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:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const HookNameSchema = z.enum([
|
||||
"todo-description-override",
|
||||
"webfetch-redirect-guard",
|
||||
"fsync-skip-warning",
|
||||
"plan-format-validator",
|
||||
"legacy-plugin-toast",
|
||||
])
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}`
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user