Skip to content

Commit 7c5d312

Browse files
jwaldripclaude
andauthored
fix(intent): require explicit title and auto-link unit inputs (#222)
* fix(intent): require explicit title and auto-link unit inputs `haiku_intent_create` previously took only `description` and derived the title by truncating it to 80 chars with an ellipsis — producing mid-sentence fragments like `"Add archivable intents to H·AI·K·U. Users need a way to soft-hide completed,…"` instead of real titles. The tool now requires both `title` (3–8 word summary, validated single line ≤80 chars) and `description` (full body) as distinct inputs. Lazy titles are rejected at the schema level. Skills (`/haiku:start`, `/haiku:quick`, `/haiku:reset`) are updated with explicit guidance and good/bad examples so the agent writes deliberate titles. Repair stops auto-truncating bad titles. Mechanical truncation produced the same broken titles as the original bug, so the title fix path now flags the issue as a remaining error with rewrite instructions, leaving it for the agent to handle interactively. Repair also auto-links missing unit `inputs:`. Hundreds of legacy units were blocking execution because the scanner had already resolved upstream artifact paths but never wrote them. The new pass parses the fix message ("upstream paths: X, Y, Z") and writes those paths into the unit frontmatter. First-stage units with no upstream fall back to `intent.md` plus any existing `knowledge/*.md`. `haiku_intent_reset` now preserves and returns title and description as distinct fields, with a recreate-instruction message that warns the agent to rewrite an auto-truncated preserved title rather than resave a broken one. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(intent): address PR #222 review comments - state-tools.ts: drop redundant `&& true` in the unit-inputs guard clause. The extra conjunct had no effect on behavior. - orchestrator.ts: reject newlines on raw `title` input before the `\s+` normalization collapses them. Previously, `intentTitleNeedsRepair`'s newline guard was dead code here because `\s+` had already flattened the input, so a multi-line title like "short\ntitle" would silently become "short title" instead of being rejected. Now a multi-line input returns a dedicated "single line" error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(intent): address PR #222 review feedback - Drop redundant `&& true` in the inputs repair guard (state-tools.ts). - Reject multi-line `title` before whitespace normalization so the error is explicit instead of silently collapsing newlines to spaces (orchestrator.ts). Split the overlong/empty error message into its own branch now that the newline path is handled separately. - Add handler-level tests for `haiku_intent_create`: missing title, empty title, overlong title, multi-line title, and the happy path asserting that frontmatter `title` and body description are distinct. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(orchestrator): make test() wrapper async-aware so handler tests run The sync `test()` helper silently passed async callbacks — it called `fn()` but never awaited the returned promise, so rejected assertions never surfaced as failures. The five `haiku_intent_create` handler contract tests added in 2e6ace9 were therefore not actually being verified. Make `test()` detect thenable return values and chain pass/fail onto the promise. Callers for async tests use `await test(...)` so the loop sequences correctly and the cleanup block fires after all awaits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * merge: resolve test file post-merge with main * docs(repair): clarify fixedHere=true semantics in title branch In the title repair block, `fixedHere = true` doesn't mean the issue was fixed — the rewritten issue was already pushed to `remaining` above. The flag just suppresses the end-of-loop fallthrough that would re-push the original (unmodified) issue. All other branches in this loop genuinely fix things. Add a comment explaining the distinction. Addresses final nit from PR #222 review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(repair): remove dead deriveIntentTitle helper The function was only used for mechanical title truncation during repair and at intent creation. Both call sites were removed when repair switched to flagging bad titles for agent rewrite and intent creation started requiring an explicit title. The helper is unreferenced now (confirmed via grep across src/ and test/), so delete it rather than leave it as an attractive nuisance for future truncation-based fixes. Addresses follow-up from PR #222 review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f5d0755 commit 7c5d312

File tree

8 files changed

+1085
-751
lines changed

8 files changed

+1085
-751
lines changed

packages/haiku/src/orchestrator.ts

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ import { validateIdentifier } from "./prompts/helpers.js"
3838
import { reportError } from "./sentry.js"
3939
import { getSessionIntent, logSessionEvent } from "./session-metadata.js"
4040
import {
41-
deriveIntentTitle,
4241
findHaikuRoot,
4342
gitCommitState,
4443
intentDir,
44+
intentTitleNeedsRepair,
4545
isGitRepo,
4646
parseFrontmatter,
4747
readJson,
@@ -3390,18 +3390,24 @@ export const orchestratorToolDefs = [
33903390
{
33913391
name: "haiku_intent_create",
33923392
description:
3393-
"Create a new H·AI·K·U intent. Studio selection happens separately via haiku_select_studio.",
3393+
'Create a new H·AI·K·U intent. Studio selection happens separately via haiku_select_studio. You must provide BOTH a crisp `title` (3–8 words, ≤80 chars, single line, no trailing punctuation — e.g. "Add archivable intents") AND a richer `description` (2–5 sentences covering scope, motivation, and constraints). The title is NOT derived from the description — write it deliberately as a human-readable summary.',
33943394
inputSchema: {
33953395
type: "object" as const,
33963396
properties: {
3397+
title: {
3398+
type: "string",
3399+
description:
3400+
'Short human-readable title (3–8 words, max 80 chars, single line, no trailing period). Must be a deliberate summary — NOT the first 80 chars of the description. Good: "Add archivable intents". Bad: "Add archivable intents to H·AI·K·U. Users need a way to soft-hide…".',
3401+
},
33973402
description: {
33983403
type: "string",
3399-
description: "What the intent is about",
3404+
description:
3405+
"Full description of what the intent is about (2–5 sentences covering scope, motivation, and constraints). Stored verbatim in the intent body.",
34003406
},
34013407
slug: {
34023408
type: "string",
34033409
description:
3404-
"URL-friendly slug for the intent (auto-generated from description if not provided)",
3410+
"URL-friendly slug for the intent (auto-generated from title if not provided)",
34053411
},
34063412
context: {
34073413
type: "string",
@@ -3421,7 +3427,7 @@ export const orchestratorToolDefs = [
34213427
"Explicit stage list — overrides the studio's default stages. Use to run a subset of stages (e.g. just ['development'] for quick tasks).",
34223428
},
34233429
},
3424-
required: ["description"],
3430+
required: ["title", "description"],
34253431
},
34263432
},
34273433
{
@@ -4052,11 +4058,45 @@ export async function handleOrchestratorTool(
40524058

40534059
if (name === "haiku_intent_create") {
40544060
const description = args.description as string
4061+
const titleInput = args.title as string | undefined
40554062
let slug = args.slug as string | undefined
40564063

4057-
// Generate slug from description if not provided
4064+
// Title is required: must be a crisp, human-readable summary the agent
4065+
// writes deliberately. We do NOT derive it by truncating the description.
4066+
if (!titleInput || typeof titleInput !== "string") {
4067+
return text(
4068+
JSON.stringify({
4069+
error: "missing_title",
4070+
message:
4071+
'haiku_intent_create requires a `title` parameter — a crisp 3–8 word summary (≤80 chars, single line, no trailing period). Write it deliberately; do NOT pass a truncated description. Example: title: "Add archivable intents".',
4072+
}),
4073+
)
4074+
}
4075+
// Reject newlines explicitly before normalization — otherwise `\s+` would
4076+
// collapse them to spaces and hide the intent (a multi-line title input
4077+
// is a sign the agent pasted a paragraph, not wrote a title).
4078+
if (/[\r\n]/.test(titleInput)) {
4079+
return text(
4080+
JSON.stringify({
4081+
error: "invalid_title",
4082+
message:
4083+
"`title` must be a single line — got newlines. Rewrite as a crisp 3–8 word summary (≤80 chars) and call again.",
4084+
}),
4085+
)
4086+
}
4087+
const title = titleInput.trim().replace(/\s+/g, " ")
4088+
if (intentTitleNeedsRepair(title)) {
4089+
return text(
4090+
JSON.stringify({
4091+
error: "invalid_title",
4092+
message: `\`title\` must be non-empty and ≤80 chars after trimming. Got ${title.length} chars. Rewrite as a 3–8 word summary and call again.`,
4093+
}),
4094+
)
4095+
}
4096+
4097+
// Generate slug from title if not provided
40584098
if (!slug) {
4059-
slug = description
4099+
slug = title
40604100
.toLowerCase()
40614101
.replace(/[^a-z0-9\s-]/g, "")
40624102
.replace(/\s+/g, "-")
@@ -4102,14 +4142,13 @@ export async function handleOrchestratorTool(
41024142
mkdirSync(join(iDir, "knowledge"), { recursive: true })
41034143
mkdirSync(join(iDir, "stages"), { recursive: true })
41044144

4105-
// Build intent.md with frontmatter + body (no studio — selected separately)
4106-
// Title is derived as a short one-liner; the full description lives in the body.
4145+
// Build intent.md with frontmatter + body (no studio — selected separately).
4146+
// Title and description are distinct: title is a short human-readable summary
4147+
// the agent wrote deliberately; description is the full narrative body.
41074148
const context = args.context as string | undefined
41084149
const mode = (args.mode as string) || "continuous"
41094150
const stagesOverride = args.stages as string[] | undefined
4110-
const title = deriveIntentTitle(description)
4111-
const descriptionBody = description.trim()
4112-
const bodyHasDescription = descriptionBody && descriptionBody !== title
4151+
const descriptionBody = (description || "").trim()
41134152
const intentContent = [
41144153
"---",
41154154
`title: "${title.replace(/"/g, '\\"')}"`,
@@ -4124,7 +4163,7 @@ export async function handleOrchestratorTool(
41244163
"",
41254164
`# ${title}`,
41264165
"",
4127-
...(bodyHasDescription ? [descriptionBody, ""] : []),
4166+
...(descriptionBody ? [descriptionBody, ""] : []),
41284167
...(context ? [context, ""] : []),
41294168
].join("\n")
41304169

@@ -4460,11 +4499,12 @@ export async function handleOrchestratorTool(
44604499
}
44614500
}
44624501

4463-
// Read the description before deleting
4502+
// Read the title and description before deleting
44644503
const raw = readFileSync(intentFile, "utf8")
44654504
const { data, body } = parseFrontmatter(raw)
44664505
const title = (data.title as string) || ""
4467-
const description = title || body.replace(/^#\s+.*\n/, "").trim()
4506+
// Description = body minus the H1 heading, trimmed
4507+
const description = body.replace(/^#\s+.*\n+/, "").trim() || title
44684508

44694509
// Ask for confirmation via elicitation
44704510
if (_elicitInput) {
@@ -4526,9 +4566,10 @@ export async function handleOrchestratorTool(
45264566
{
45274567
action: "intent_reset",
45284568
slug,
4569+
title,
45294570
description,
45304571
context: conversationContext,
4531-
message: `Intent '${slug}' has been reset. Call haiku_intent_create { description: "${description.replace(/"/g, '\\"')}", slug: "${slug}"${conversationContext ? ', context: "<preserved context>"' : ""} } to recreate it.`,
4572+
message: `Intent '${slug}' has been reset. Call haiku_intent_create { title: "${title.replace(/"/g, '\\"')}", description: "${description.replace(/"/g, '\\"').replace(/\n/g, "\\n")}", slug: "${slug}"${conversationContext ? ', context: "<preserved context>"' : ""} } to recreate it.`,
45324573
},
45334574
null,
45344575
2,

packages/haiku/src/state-tools.ts

Lines changed: 96 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -47,38 +47,6 @@ import { MCP_VERSION, getPluginVersion } from "./version.js"
4747
* description that needs summarizing. */
4848
export const INTENT_TITLE_MAX_LENGTH = 80
4949

50-
/** Derive a short, one-line title from an arbitrary input string.
51-
* - Collapses whitespace and newlines to single spaces
52-
* - If it fits in the limit, returns the collapsed input verbatim
53-
* - Otherwise truncates at a word boundary and appends an ellipsis
54-
* - Strips trailing periods (titles don't end in a period)
55-
*
56-
* Intentionally does NOT try to detect sentence boundaries: regex-based
57-
* detection false-truncates on abbreviation periods ("e.g.", "i.e.", "Mr.",
58-
* version strings like "v1.0", etc.). Word-boundary truncation with an
59-
* ellipsis is deterministic and correct for all inputs.
60-
*
61-
* Exported so both intent creation and repair use the same logic. */
62-
export function deriveIntentTitle(input: string): string {
63-
if (!input) return ""
64-
const collapsed = input.replace(/\s+/g, " ").trim()
65-
if (!collapsed) return ""
66-
67-
if (collapsed.length <= INTENT_TITLE_MAX_LENGTH) {
68-
return collapsed.replace(/\.$/, "")
69-
}
70-
71-
// Too long — truncate at a word boundary and append ellipsis
72-
const hardLimit = INTENT_TITLE_MAX_LENGTH - 1 // leave room for ellipsis
73-
const truncated = collapsed.slice(0, hardLimit)
74-
const lastSpace = truncated.lastIndexOf(" ")
75-
return `${
76-
lastSpace > INTENT_TITLE_MAX_LENGTH / 2
77-
? truncated.slice(0, lastSpace)
78-
: truncated
79-
}…`
80-
}
81-
8250
/** Whether a title value needs repair (too long, multiline, or empty). */
8351
export function intentTitleNeedsRepair(title: unknown): boolean {
8452
if (typeof title !== "string") return true
@@ -121,42 +89,38 @@ export function applyAutoFixes(
12189
const raw = readFileSync(intentPath, "utf8")
12290
const parsed = matter(raw)
12391
const data = parsed.data
124-
let body = parsed.content
92+
const body = parsed.content
12593
let changed = false
12694
const applied: AppliedFix[] = []
12795
const remaining: RepairIssue[] = []
12896

12997
for (const issue of issues) {
13098
let fixedHere = false
13199

132-
// Title: overlong, multiline, or otherwise non-conforming
100+
// Title: overlong, multiline, or otherwise non-conforming.
101+
// We do NOT auto-truncate — mechanical truncation produces mid-sentence
102+
// fragments that aren't real titles. Instead we flag it for agent rewrite
103+
// with instructions to produce a crisp 3–8 word summary. The full
104+
// original is preserved as-is so the agent has it to work from.
133105
if (
134106
issue.field === "title" &&
135107
typeof data.title === "string" &&
136108
intentTitleNeedsRepair(data.title)
137109
) {
138-
const oldTitle = data.title as string
139-
const newTitle = deriveIntentTitle(oldTitle)
140-
data.title = newTitle
141-
// Update H1 in body if it matches the old title; otherwise prepend
142-
const h1Re = /^#\s+(.+?)\s*$/m
143-
const h1Match = body.match(h1Re)
144-
const oldDescription = oldTitle.replace(/\s+/g, " ").trim()
145-
if (
146-
h1Match &&
147-
h1Match[1].replace(/\s+/g, " ").trim() === oldDescription
148-
) {
149-
body = body.replace(h1Re, `# ${newTitle}\n\n${oldDescription}`)
150-
} else if (!h1Match) {
151-
body = `${`# ${newTitle}\n\n${oldDescription}\n\n${body}`.trimEnd()}\n`
152-
}
153-
applied.push({
110+
const oldTitle = (data.title as string).replace(/\s+/g, " ").trim()
111+
const preview =
112+
oldTitle.length > 120 ? `${oldTitle.slice(0, 117)}...` : oldTitle
113+
remaining.push({
154114
intent: slug,
155115
field: "title",
156-
description: `Trimmed title from ${oldTitle.length} chars to ${newTitle.length} chars; full description preserved in body`,
116+
severity: "error",
117+
message: `Title is ${oldTitle.length} chars — looks auto-truncated or is a full description, not a title`,
118+
fix: `Rewrite as a crisp 3–8 word summary (≤80 chars, single line, no trailing period). Preserve the current text as a paragraph in the body under the H1 if it isn't there already. Original: "${preview}"`,
157119
})
120+
// Not "fixed" here — the rewritten issue was already pushed to `remaining` above.
121+
// This flag just suppresses the end-of-loop fallthrough that would re-push the
122+
// original (unmodified) issue. All other branches in this loop genuinely fix things.
158123
fixedHere = true
159-
changed = true
160124
}
161125

162126
// Legacy `created` field → `created_at`
@@ -306,9 +270,85 @@ export function applyAutoFixes(
306270
}
307271
}
308272

309-
// Second pass: fix stage-level state.json issues (completion synthesis)
310-
const stageRemaining: RepairIssue[] = []
273+
// Second pass: auto-apply unit `inputs:` from the fix instructions.
274+
// The scanner has already resolved upstream artifact paths per stage; we
275+
// just write them into each unit's frontmatter. For first-stage units with
276+
// no upstream (the "intent doc and discovery docs" fallback), we link the
277+
// intent.md and any existing knowledge/*.md as a sensible default.
278+
const inputsRemaining: RepairIssue[] = []
279+
const unitInputsRe = /^stages\/([^/]+)\/units\/([^/]+):inputs$/
311280
for (const issue of remaining) {
281+
const m = issue.field.match(unitInputsRe)
282+
if (
283+
!m ||
284+
!issue.message.includes("Unit has no `inputs:`") ||
285+
typeof issue.fix !== "string"
286+
) {
287+
inputsRemaining.push(issue)
288+
continue
289+
}
290+
const stageName = m[1]
291+
const unitFile = m[2]
292+
const unitPath = join(
293+
intentRoot,
294+
slug,
295+
"stages",
296+
stageName,
297+
"units",
298+
unitFile,
299+
)
300+
if (!existsSync(unitPath)) {
301+
inputsRemaining.push(issue)
302+
continue
303+
}
304+
305+
// Resolve the inputs to write
306+
let inputsToWrite: string[] = []
307+
const upstreamMatch = issue.fix.match(/upstream paths:\s*(.+?)\s*$/)
308+
if (upstreamMatch) {
309+
inputsToWrite = upstreamMatch[1]
310+
.split(",")
311+
.map((s) => s.trim())
312+
.filter(Boolean)
313+
} else {
314+
// Fallback: link intent.md and any discoverable knowledge/*.md
315+
const fallback: string[] = ["intent.md"]
316+
const knowledgeDir = join(intentRoot, slug, "knowledge")
317+
if (existsSync(knowledgeDir)) {
318+
for (const f of readdirSync(knowledgeDir)) {
319+
if (f.endsWith(".md")) fallback.push(`knowledge/${f}`)
320+
}
321+
}
322+
inputsToWrite = fallback
323+
}
324+
325+
if (inputsToWrite.length === 0) {
326+
inputsRemaining.push(issue)
327+
continue
328+
}
329+
330+
const unitRaw = readFileSync(unitPath, "utf8")
331+
const unitParsed = matter(unitRaw)
332+
const existing = (unitParsed.data.inputs as string[]) || []
333+
if (existing.length > 0) {
334+
// Already has inputs (race or stale issue list) — drop the issue
335+
continue
336+
}
337+
unitParsed.data.inputs = inputsToWrite
338+
writeFileSync(
339+
unitPath,
340+
matter.stringify(unitParsed.content, unitParsed.data),
341+
)
342+
applied.push({
343+
intent: slug,
344+
field: issue.field,
345+
description: `Linked ${inputsToWrite.length} input(s): ${inputsToWrite.join(", ")}`,
346+
})
347+
}
348+
349+
// Third pass: fix stage-level state.json issues (completion synthesis)
350+
const stageRemaining: RepairIssue[] = []
351+
for (const issue of inputsRemaining) {
312352
let fixedHere = false
313353

314354
// Synthesize or update stage completion records for stages before active_stage
@@ -466,16 +506,15 @@ function scanOneIntent(
466506
intentTitleNeedsRepair(repairData.title)
467507
) {
468508
const current = repairData.title as string
469-
const derived = deriveIntentTitle(current)
470509
const reason = /\n/.test(current)
471510
? "title contains newlines"
472511
: `title is ${current.length} chars (max ${INTENT_TITLE_MAX_LENGTH})`
473512
issues.push({
474513
intent: slug,
475514
field: "title",
476-
severity: "warning",
515+
severity: "error",
477516
message: `Title should be a short one-liner — ${reason}`,
478-
fix: `Replace \`title\` with: "${derived.replace(/"/g, '\\"')}". Move the full description into the intent body as a paragraph under the H1.`,
517+
fix: "Rewrite `title` as a crisp 3–8 word summary (≤80 chars, single line, no trailing period). Do NOT truncate the current value — write a deliberate human-readable summary. Preserve the original text as a paragraph in the body under the H1 if it isn't there already.",
479518
})
480519
}
481520

0 commit comments

Comments
 (0)