Merge pull request #4221 from heunghingwan/feat/plan-format-validator

Add plan format validator hook to detect malformed task labels
This commit is contained in:
YeonGyu-Kim
2026-05-21 12:58:01 +09:00
committed by GitHub
8 changed files with 153 additions and 1 deletions
+5 -1
View File
@@ -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
+3
View File
@@ -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]
+1
View File
@@ -57,6 +57,7 @@ export const HookNameSchema = z.enum([
"todo-description-override",
"webfetch-redirect-guard",
"fsync-skip-warning",
"plan-format-validator",
"legacy-plugin-toast",
])
+1
View File
@@ -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"
+134
View File
@@ -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-format-warning>",
`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.",
"</plan-format-warning>",
].join("\n")
}
return [
"",
"<plan-format-warning>",
`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.`)",
"</plan-format-warning>",
].join("\n")
}
function isPlanWrite(tool: string, args: Record<string, unknown>): 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<string, unknown> },
output: { title: string; output: string; metadata: unknown },
): Promise<void> => {
if (!input.args) return
if (typeof output.output !== "string") return
if (output.output.includes("<plan-format-warning>")) 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)}`
},
}
}
+1
View File
@@ -0,0 +1 @@
export { createPlanFormatValidatorHook } from "./hook"
@@ -20,6 +20,7 @@ import {
createTeamToolGating,
createFsyncSkipWarningHook,
createNotepadWriteGuardHook,
createPlanFormatValidatorHook,
} from "../../hooks"
import {
getOpenCodeVersion,
@@ -47,6 +48,7 @@ export type ToolGuardHooks = {
fsyncSkipWarning: ReturnType<typeof createFsyncSkipWarningHook> | null
teamToolGating: ReturnType<typeof createTeamToolGating> | null
notepadWriteGuard: ReturnType<typeof createNotepadWriteGuardHook> | null
planFormatValidator: ReturnType<typeof createPlanFormatValidatorHook> | 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,
}
}
+1
View File
@@ -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") {