diff --git a/src/agents/prometheus/plan-generation.ts b/src/agents/prometheus/plan-generation.ts index 152de8472..b93e2d8c5 100644 --- a/src/agents/prometheus/plan-generation.ts +++ b/src/agents/prometheus/plan-generation.ts @@ -95,7 +95,9 @@ task( 4. Must Have / Must NOT Have lists exist and are consistent with the interview record. 5. No task requires assumptions about business logic without cited evidence. 6. Plan path is .omo/plans/, not docs/ or plans/. - Return: \\\`CHECK [N/6] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, file:line citations for each blocking issue.\` + 7. All TODO task labels use bare-number format ("1. xxx"), NOT "T1.", "Phase 1:", "Task-1." etc. + All Final Wave labels use bare-number format with "F" prefix: "F1. xxx", "F2. xxx", NOT "T-F1.", "F-1.", "Final-1." etc. + Return: \\\`CHECK [N/7] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, file:line citations for each blocking issue.\` ) \`\`\` @@ -201,6 +203,8 @@ Before presenting summary, verify: □ QA scenarios include BOTH happy-path AND negative/error scenarios? □ Zero acceptance criteria require human intervention? □ QA scenarios use specific selectors/data, not vague descriptions? +□ All TODO labels use bare-number format ("1. ", "2. ")? NO T1./Phase 1:/Task-1. etc. +□ All Final Wave labels use "F" + number format ("F1. ", "F2. ")? NO T-F1./F-1./Final-1. etc. \`\`\` ### Gap Handling Protocol diff --git a/src/agents/prometheus/plan-template.ts b/src/agents/prometheus/plan-template.ts index 452d62592..fe715e38d 100644 --- a/src/agents/prometheus/plan-template.ts +++ b/src/agents/prometheus/plan-template.ts @@ -163,6 +163,9 @@ Max Concurrent: 7 (Waves 1 & 2) > Implementation + Test = ONE Task. Never separate. > EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios. > **A task WITHOUT QA Scenarios is INCOMPLETE. No exceptions.** +> **FORMAT**: Task labels MUST use bare numbers: \`1.\`, \`2.\`, \`3.\` — NOT \`T1.\`, \`Task 1.\`, \`Phase 1:\`. +> The /start-work progress counter requires exact format. Deviation = progress shows 0/0. +> Final Verification Wave labels MUST use \`F1.\`, \`F2.\`, etc. — NOT \`T-F1.\`, \`F-1.\`, \`Final 1.\`. - [ ] 1. [Task Title] diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index bdee70718..72a43722a 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -57,6 +57,7 @@ export const HookNameSchema = z.enum([ "todo-description-override", "webfetch-redirect-guard", "fsync-skip-warning", + "plan-format-validator", "legacy-plugin-toast", ]) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 3dc503077..493414d34 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -67,3 +67,4 @@ export { createWebFetchRedirectGuardHook } from "./webfetch-redirect-guard" export { createLegacyPluginToastHook } from "./legacy-plugin-toast" export { createFsyncSkipWarningHook } from "./fsync-skip-warning" export { createNotepadWriteGuardHook } from "./notepad-write-guard" +export { createPlanFormatValidatorHook } from "./plan-format-validator" diff --git a/src/hooks/plan-format-validator/hook.ts b/src/hooks/plan-format-validator/hook.ts new file mode 100644 index 000000000..db708a870 --- /dev/null +++ b/src/hooks/plan-format-validator/hook.ts @@ -0,0 +1,134 @@ +import { existsSync, readFileSync } from "node:fs" +import { resolve } from "node:path" + +import type { PluginInput } from "@opencode-ai/plugin" + +import { getPlanProgress } from "../../features/boulder-state/storage" +import { log } from "../../shared/logger" + +const WRITE_TOOLS = new Set(["Write", "Edit", "write", "edit"]) + +const CHECKBOX_PATTERN = /^[-*]\s*\[[ xX]\]/m + +const HEADING_SECOND_LEVEL = /^##\s+/ +const HEADING_TODOS = /^##\s+TODOs\b/i +const HEADING_FINAL_WAVE = /^##\s+Final Verification Wave\b/i +const TOPLEVEL_CHECKBOX = /^[-*]\s*\[[ xX]?\]/ + +function countRawTopLevelCheckboxes(content: string): number { + const lines = content.split(/\r?\n/) + let section: "todo" | "final-wave" | "other" = "other" + let count = 0 + + for (const line of lines) { + if (HEADING_SECOND_LEVEL.test(line)) { + section = HEADING_TODOS.test(line) + ? "todo" + : HEADING_FINAL_WAVE.test(line) + ? "final-wave" + : "other" + continue + } + + if (section === "other") continue + if (!TOPLEVEL_CHECKBOX.test(line)) continue + + count++ + } + + return count +} + +function buildWarning(rawCount: number, parsedCount: number): string { + const skipped = rawCount - parsedCount + + if (parsedCount === 0) { + return [ + "", + "", + `Plan has **${rawCount} task checkbox(es)** but \`getPlanProgress()\` parsed **0**.`, + "This means `/start-work` will show **\"Progress: 0/0\"** for this plan.", + "", + "**Fix**: Every task checkbox under `## TODOs` MUST start with a bare number", + "followed by dot + space: `1.`, `2.`, `3.` — NOT `T1.`, `Phase 1:`, `Task-1.` etc.", + "Every Final Verification Wave checkbox MUST start with `F` + number:", + "`F1.`, `F2.` — NOT `T-F1.`, `F-1.`, `Final-1.` etc.", + "", + ].join("\n") + } + + return [ + "", + "", + `Plan has **${rawCount} task checkbox(es)** but \`getPlanProgress()\` only parsed **${parsedCount}**. `, + `**${skipped} task(s)** have malformed labels and will be SKIPPED by the progress counter.`, + `\`/start-work\` will show \"Progress: ${parsedCount} tasks\" — missing ${skipped} task(s).`, + "", + "**Fix**: Ensure every skipped task checkbox uses bare-number format:", + " `## TODOs` → `1.`, `2.`, `3.` (NOT `T1.`, `Phase 1:`, `Task-1.`)", + " `## Final Verification Wave` → `F1.`, `F2.`, `F3.` (NOT `T-F1.`, `F-1.`, `Final-1.`)", + "", + ].join("\n") +} + +function isPlanWrite(tool: string, args: Record): string | null { + if (!WRITE_TOOLS.has(tool)) return null + + const filePath = (args.filePath ?? args.path ?? args.file) as string | undefined + if (!filePath) return null + + return filePath +} + +function isPlanFilePath(filePath: string): boolean { + const normalized = filePath.toLowerCase().replace(/\\/g, "/") + return normalized.includes(".omo/plans/") && normalized.endsWith(".md") +} + +/** + * Programmatic plan format validator. + * + * After any agent writes to a `.omo/plans/*.md` file, compares the + * raw top-level checkbox count against `getPlanProgress()` to detect + * malformed task labels. Warns the agent when some or all tasks + * will be skipped by the progress counter. + */ +export function createPlanFormatValidatorHook(_ctx: PluginInput) { + return { + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string; args?: Record }, + output: { title: string; output: string; metadata: unknown }, + ): Promise => { + if (!input.args) return + if (typeof output.output !== "string") return + if (output.output.includes("")) return + + const filePath = isPlanWrite(input.tool, input.args) + if (!filePath) return + if (!isPlanFilePath(filePath)) return + + const resolvedPath = resolve(_ctx.directory, filePath) + if (!existsSync(resolvedPath)) return + + const content = readFileSync(resolvedPath, "utf-8") + if (!CHECKBOX_PATTERN.test(content)) return + + const rawCount = countRawTopLevelCheckboxes(content) + if (rawCount === 0) return + + const progress = getPlanProgress(resolvedPath) + const parsedCount = progress.total + + if (rawCount === parsedCount) return + + log(`[plan-format-validator] Plan ${filePath}: ${parsedCount}/${rawCount} tasks parsed`, { + sessionID: input.sessionID, + filePath, + rawCount, + parsedCount, + }) + + output.output = `${output.output}${buildWarning(rawCount, parsedCount)}` + }, + } +} diff --git a/src/hooks/plan-format-validator/index.ts b/src/hooks/plan-format-validator/index.ts new file mode 100644 index 000000000..0363ee2d8 --- /dev/null +++ b/src/hooks/plan-format-validator/index.ts @@ -0,0 +1 @@ +export { createPlanFormatValidatorHook } from "./hook" diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index d2f06146e..0ddfea118 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -20,6 +20,7 @@ import { createTeamToolGating, createFsyncSkipWarningHook, createNotepadWriteGuardHook, + createPlanFormatValidatorHook, } from "../../hooks" import { getOpenCodeVersion, @@ -47,6 +48,7 @@ export type ToolGuardHooks = { fsyncSkipWarning: ReturnType | null teamToolGating: ReturnType | null notepadWriteGuard: ReturnType | null + planFormatValidator: ReturnType | null } export function createToolGuardHooks(args: { @@ -147,6 +149,10 @@ export function createToolGuardHooks(args: { ? safeHook("fsync-skip-warning", () => createFsyncSkipWarningHook()) : null + const planFormatValidator = isHookEnabled("plan-format-validator") + ? safeHook("plan-format-validator", () => createPlanFormatValidatorHook(ctx)) + : null + const notepadWriteGuard = isHookEnabled("notepad-write-guard") ? safeHook("notepad-write-guard", () => createNotepadWriteGuardHook()) : null @@ -169,5 +175,6 @@ export function createToolGuardHooks(args: { fsyncSkipWarning, teamToolGating, notepadWriteGuard, + planFormatValidator, } } diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 104cd6fd5..859bbb065 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -170,6 +170,7 @@ export function createToolExecuteAfterHandler(args: { await hooks.webfetchRedirectGuard?.["tool.execute.after"]?.(hookInput, output) await hooks.fsyncSkipWarning?.["tool.execute.after"]?.(hookInput, output) await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(hookInput, output) + await hooks.planFormatValidator?.["tool.execute.after"]?.(hookInput, output) } if (input.tool === "extract" || input.tool === "discard") {