diff --git a/.changeset/fn-424-plan-only-no-commit.md b/.changeset/fn-424-plan-only-no-commit.md new file mode 100644 index 0000000000..2f4f8d771c --- /dev/null +++ b/.changeset/fn-424-plan-only-no-commit.md @@ -0,0 +1,5 @@ +--- +"@fusion/engine": patch +--- + +Allow narrowly scoped plan-only operational tasks to complete without source commits when their prompt or metadata explicitly declares no-source/no-code intent and their recorded evidence satisfies the task. The commit guard still rejects missing commits for normal implementation tasks and still enforces worktree and branch invariants before applying the no-commit exemption. diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index cd12bc7d5e..18069407c5 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -376,14 +376,16 @@ When ALL of the following are true, include this metadata line in the header blo - Add this exact line: **No commits expected:** true Set it only when all of these conditions hold: -- Title/mission starts with decision verbs like "Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", or "Investigate and report" -- Acceptance criteria are strictly observational (record findings, log a decision, update task log/docs) with no required code/config/file mutations -- Task description explicitly says things like "no code changes expected" or "the deliverable is the recorded decision" +- Title/mission starts with decision verbs like "Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", or "Investigate and report", OR is an operational routing/coordination task whose only outcome is assigning/routing existing work or recording an intentional no-route/no-owner decision +- Acceptance criteria are strictly observational (record findings, routing evidence, no-route/no-owner state, log a decision, update task log/docs) with no required code/config/file mutations +- Task description explicitly says things like "no code changes expected", "no source files expected", "no product-source changes", or "the deliverable is the recorded decision" Anti-heuristics (bias to false-negative when ambiguous): - SET: Decide whether FN-XYZ needs a fix +- SET: Assign ready implementation task to active owner, or record no-route state (no source files expected) - LEAVE UNSET: Investigate FN-XYZ - LEAVE UNSET: Investigate FN-XYZ and fix if needed +- LEAVE UNSET: Investigate and fix routing if needed ## Guidelines - Read relevant source files before writing the spec @@ -397,7 +399,7 @@ Anti-heuristics (bias to false-negative when ambiguous): When the user prompt includes explicit test/build commands, use those exact commands in the generated spec. ## Workflow Routing -Call \`fn_workflow_list\` and use workflow descriptions as the routing signal. For investigation/audit/research or decision-only tasks that meet the no-commits criteria above, include \`**No commits expected:** true\` in the PROMPT.md header and prefer \`builtin:quick-fix\` or a custom investigation workflow; standard coding tasks can stay on the default \`builtin:coding\`. Use \`fn_workflow_select\` for the current task or pass \`workflow_id\` to \`fn_task_create\` for subtasks. +Call \`fn_workflow_list\` and use workflow descriptions as the routing signal. For investigation/audit/research, operational routing/coordination, or decision-only tasks that meet the no-commits criteria above, include \`**No commits expected:** true\` in the PROMPT.md header and prefer \`builtin:quick-fix\` or a custom investigation workflow; standard coding tasks can stay on the default \`builtin:coding\`. Use \`fn_workflow_select\` for the current task or pass \`workflow_id\` to \`fn_task_create\` for subtasks. ## Task Artifact Location for Forensic / Reconciliation Tasks @@ -657,14 +659,16 @@ When ALL of the following are true, include this metadata line in the header blo - Add this exact line: **No commits expected:** true Set it only when all of these conditions hold: -- Title/mission starts with decision verbs like {{triageNoCommitsDecisionVerbs}} -- Acceptance criteria are strictly observational (record findings, log a decision, update task log/docs) with no required code/config/file mutations -- Task description explicitly says things like "no code changes expected" or "the deliverable is the recorded decision" +- Title/mission starts with decision verbs like {{triageNoCommitsDecisionVerbs}}, OR is an operational routing/coordination task whose only outcome is assigning/routing existing work or recording an intentional no-route/no-owner decision +- Acceptance criteria are strictly observational (record findings, routing evidence, no-route/no-owner state, log a decision, update task log/docs) with no required code/config/file mutations +- Task description explicitly says things like "no code changes expected", "no source files expected", "no product-source changes", or "the deliverable is the recorded decision" Anti-heuristics (bias to false-negative when ambiguous): - SET: Decide whether FN-XYZ needs a fix +- SET: Assign ready implementation task to active owner, or record no-route state (no source files expected) - LEAVE UNSET: Investigate FN-XYZ - LEAVE UNSET: Investigate FN-XYZ and fix if needed +- LEAVE UNSET: Investigate and fix routing if needed ## Guidelines - Read the project structure and relevant source files to understand context BEFORE writing @@ -687,7 +691,7 @@ package.json when explicit commands are provided. ## Workflow Routing - Call \`fn_workflow_list\` to discover available workflows before selecting a routing path, and read each workflow description as the routing signal. -- For investigation, audit, research, or decision-only tasks that produce no code changes, set \`**No commits expected:** true\` in the PROMPT.md header when the no-commits criteria above are met, then select an appropriate lightweight workflow. +- For investigation, audit, research, operational routing/coordination, or decision-only tasks that produce no code/config/file changes, set \`**No commits expected:** true\` in the PROMPT.md header when the no-commits criteria above are met, then select an appropriate lightweight workflow. - For decision-only tasks ({{triageNoCommitsDecisionVerbs}}), prefer \`{{triageDecisionOnlyWorkflowId}}\` or a custom investigation workflow when one is available. - For standard coding tasks, \`{{triageDefaultWorkflowId}}\` is the default and is usually appropriate. - Use \`fn_workflow_select\` to set the workflow on the current task, or pass \`workflow_id\` to \`fn_task_create\` when creating subtasks. diff --git a/packages/engine/src/__tests__/executor-task-done-invariant.test.ts b/packages/engine/src/__tests__/executor-task-done-invariant.test.ts index dcf3710d2b..a939af9e56 100644 --- a/packages/engine/src/__tests__/executor-task-done-invariant.test.ts +++ b/packages/engine/src/__tests__/executor-task-done-invariant.test.ts @@ -9,6 +9,51 @@ import * as worktreePool from "../worktree-pool.js"; import { TaskStore } from "@fusion/core"; import { createMockStore, mockedCreateFnAgent, mockedExec, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js"; +const fn416Prompt = `# Task: FN-416 - Assign ready implementation task to active owner + +**Created:** 2026-06-12 +**Size:** S + +## Review Level: 1 (Plan Only) + +**Assessment:** This is an operational routing task with no expected product-source changes. + +## Mission +Assign or route exactly one ready implementation task to an eligible active owner, or record an intentional no-route state. No source files expected. + +## File Scope + +- FN-416 task document docs via fn_task_document_write +- .fusion/tasks/FN-416/ task log evidence only + +## Steps + +### Step 0: Preflight +- [x] Check board state + +### Step 1: Route exactly one existing ready task or record no-route +- [x] Record evidence in task documents/logs +`; + +const sourceChangingPlanOnlyPrompt = `# Task: FN-999 - Implement source fix + +**Size:** S + +## Review Level: 1 (Plan Only) + +## Mission +Implement a source-changing bug-fix in the executor. + +## File Scope + +- packages/engine/src/executor.ts + +## Steps + +### Step 1: Implement +- [ ] Change source +`; + function baseTask(overrides: Record = {}) { return { id: "FN-4114", @@ -48,7 +93,7 @@ async function setup(overrides: Record = {}) { }); const executor = new TaskExecutor(store as any, "/repo"); - await executor.execute(baseTask() as any); + await executor.execute(task as any); return { store, tool, setTask: (next: any) => (task = { ...task, ...next }) }; } @@ -124,10 +169,30 @@ describe("FN-4114 fn_task_done invariants", () => { const result = await tool.execute("id", {}); expect(result.content[0].text).toContain("Task marked complete"); expect(store.updateStep).toHaveBeenCalled(); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-4114", + expect.stringContaining("noCommitsExpected=true"), + undefined, + undefined, + ); const revListCalled = mockedExecSync.mock.calls.some(([cmd]) => String(cmd).includes("rev-list --count")); expect(revListCalled).toBe(false); }); + it("FN-4114 still refuses wrong_toplevel even when noCommitsExpected is true", async () => { + const { store, tool } = await setup({ noCommitsExpected: true }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4114\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("fn_task_done refused: wrong_toplevel"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + }); + it("FN-4114 still refuses wrong_branch even when noCommitsExpected is true", async () => { const { store, tool } = await setup({ noCommitsExpected: true }); mockedExecSync.mockImplementation((cmd: string) => { @@ -141,6 +206,179 @@ describe("FN-4114 fn_task_done invariants", () => { expect(result.content[0].text).toContain("fn_task_done refused: wrong_branch"); expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); }); + it("FN-4114 allows no-commit completion when noCommitsExpected audit logging fails", async () => { + const { store, tool } = await setup({ noCommitsExpected: true }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4114\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + store.logEntry.mockImplementation(async (_id: string, message: string) => { + if (message.includes("no_commits guard skipped")) throw new Error("audit unavailable"); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("Task marked complete"); + expect(store.updateStep).toHaveBeenCalled(); + }); + + + it("FN-416 allows plan-only operational no-source completion with zero commits when the explicit flag is missing", async () => { + const { store, tool } = await setup({ + id: "FN-416", + branch: "fusion/fn-416", + title: "Assign ready implementation task to active owner", + description: "Operational routing task with no expected product-source changes; record routing evidence or no-route state.", + reviewLevel: 1, + prompt: fn416Prompt, + sourceMetadata: { fileScope: ["FN-416 task document docs via fn_task_document_write"] }, + log: [{ timestamp: new Date().toISOString(), action: "Routing evidence recorded", outcome: "No-route state documented in task docs" }], + steps: [ + { name: "Preflight", status: "done" as const }, + { name: "Route or record no-route", status: "done" as const }, + ], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-416\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("Task marked complete"); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-416", "todo", { preserveProgress: true }); + expect(store.handoffToReview).not.toHaveBeenCalledWith("FN-416", expect.objectContaining({ + evidence: expect.objectContaining({ reason: "invariant-check-failed" }), + })); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-416", + expect.stringContaining("prompt/source metadata derived operational no-commit contract"), + undefined, + undefined, + ); + const revListCalled = mockedExecSync.mock.calls.some(([cmd]) => String(cmd).includes("rev-list --count")); + expect(revListCalled).toBe(false); + }); + it("FN-416 refuses plan-only operational no-source completion when File Scope is missing", async () => { + const promptWithoutFileScope = `# Task: FN-417 - Assign ready implementation task to active owner + +## Review Level: 1 (Plan Only) + +**Assessment:** This is an operational routing task with no expected product-source changes. + +## Mission +Assign or route exactly one ready implementation task to an eligible active owner, or record an intentional no-route state. No source files expected. + +## Steps + +### Step 1: Route exactly one existing ready task or record no-route +- [x] Record evidence in task documents/logs +`; + const { store, tool } = await setup({ + id: "FN-417", + branch: "fusion/fn-417", + title: "Assign ready implementation task to active owner", + description: "Operational routing task with no expected product-source changes; record routing evidence or no-route state.", + reviewLevel: 1, + prompt: promptWithoutFileScope, + sourceMetadata: {}, + log: [{ timestamp: new Date().toISOString(), action: "Routing evidence recorded", outcome: "No-route state documented in task docs" }], + steps: [{ name: "Route or record no-route", status: "done" as const }], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-417\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-417", "todo", { preserveProgress: true }); + }); + + it("FN-416 refuses prompt-only evidence text when steps are incomplete and logs are empty", async () => { + const { store, tool } = await setup({ + id: "FN-418", + branch: "fusion/fn-418", + title: "Assign ready implementation task to active owner", + description: "Operational routing task with no expected product-source changes; record routing evidence or no-route state.", + reviewLevel: 1, + prompt: fn416Prompt.replace("# Task: FN-416", "# Task: FN-418"), + sourceMetadata: { fileScope: ["FN-418 task document docs via fn_task_document_write"] }, + log: [], + steps: [{ name: "Route or record no-route", status: "in-progress" as const }], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-418\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-418", "todo", { preserveProgress: true }); + }); + + it("FN-416 refuses mixed no-source text with source-changing scope entries", async () => { + const mixedScopePrompt = fn416Prompt + .replace("# Task: FN-416", "# Task: FN-419") + .replace( + "- FN-416 task document docs via fn_task_document_write", + "- No source changes expected, but inspect packages/engine/src/executor.ts", + ); + const { store, tool } = await setup({ + id: "FN-419", + branch: "fusion/fn-419", + title: "Assign ready implementation task to active owner", + description: "Operational routing task with no expected product-source changes; record routing evidence or no-route state.", + reviewLevel: 1, + prompt: mixedScopePrompt, + sourceMetadata: { fileScope: ["No source changes expected, but inspect packages/engine/src/executor.ts"] }, + log: [{ timestamp: new Date().toISOString(), action: "Routing evidence recorded", outcome: "No-route state documented in task docs" }], + steps: [{ name: "Route or record no-route", status: "done" as const }], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-419\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-419", "todo", { preserveProgress: true }); + }); + + it("FN-416 keeps the missing-commit guard for source-changing plan-only tasks without an explicit contract", async () => { + const { store, tool } = await setup({ + title: "Implement executor fix", + description: "Plan Only but requires source-changing implementation work.", + reviewLevel: 1, + prompt: sourceChangingPlanOnlyPrompt, + sourceMetadata: { fileScope: ["packages/engine/src/executor.ts"] }, + steps: [{ name: "Implement", status: "done" as const }], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4114\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + }); it("FN-4114 allows fn_task_done on valid worktree/branch/commit state", async () => { const { store, tool } = await setup(); diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index f0fb927f02..18b9e4b7ed 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -1788,8 +1788,12 @@ describe("approved triage recovery", () => { it("includes decision-only noCommitsExpected heuristic instructions in system prompts", () => { expect(TRIAGE_POLICY_PROMPT).toContain("**No commits expected:** true"); expect(TRIAGE_POLICY_PROMPT).toContain("Decide whether FN-XYZ needs a fix"); + expect(TRIAGE_POLICY_PROMPT).toContain("Assign ready implementation task to active owner, or record no-route state"); + expect(TRIAGE_POLICY_PROMPT).toContain("operational routing/coordination"); expect(TRIAGE_POLICY_PROMPT).toContain("Investigate FN-XYZ and fix if needed"); + expect(TRIAGE_POLICY_PROMPT).toContain("Investigate and fix routing if needed"); expect(FAST_PLANNING_PROMPT).toContain("**No commits expected:** true"); + expect(FAST_PLANNING_PROMPT).toContain("operational routing/coordination"); }); it("preserves imported GitHub issue titles during planning recovery", async () => { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index f564cea9b4..115594cdc7 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -588,6 +588,111 @@ export function parseReviewLevelFromPrompt(prompt: string): number { return reviewMatch ? parseInt(reviewMatch[1], 10) : 0; } +function extractPromptSection(prompt: string, heading: string): string { + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headingPattern = new RegExp(`^##\\s+${escaped}\\s*:?\\s*$`, "i"); + const nextHeadingPattern = /^##\s+/; + const lines = prompt.split(/\r?\n/); + const start = lines.findIndex((line) => headingPattern.test(line.trim())); + if (start === -1) return ""; + + const sectionLines: string[] = []; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (nextHeadingPattern.test(line.trim())) break; + sectionLines.push(line); + } + return sectionLines.join("\n").trim(); +} + +function extractPromptListEntries(section: string): string[] { + return section + .split(/\r?\n/) + .map((line) => line.trim()) + .map((line) => line.replace(/^[-*]\s+/, "").replace(/^`([^`]+)`.*$/, "$1").trim()) + .filter(Boolean); +} + +function isNoSourceScopeEntry(entry: string): boolean { + const normalized = entry.toLowerCase(); + return ( + normalized.includes("no source") || + normalized.includes("no product-source") || + normalized.includes("no code") || + normalized.includes("no file mutations") || + normalized.includes("task document") || + normalized.includes("task log") || + normalized.includes("agent log") || + normalized.includes("read-only evidence") || + normalized.startsWith(".fusion/tasks/") || + normalized.startsWith("/.fusion/tasks/") + ); +} + +function hasSourceChangingScopeEntry(entry: string): boolean { + const normalized = entry.toLowerCase(); + if (!normalized) return false; + if (normalized.startsWith(".fusion/tasks/") || normalized.startsWith("/.fusion/tasks/")) return false; + if (/\b(source|sources|packages|tests|src|app|scripts|\.changeset)\b/.test(normalized)) return true; + if (/\.(ts|tsx|js|jsx|mjs|cjs|swift|kt|java|py|rs|go|rb|md|json|ya?ml|toml|css|scss|html)\b/.test(normalized)) return true; + if (normalized.includes("read-only") || isNoSourceScopeEntry(normalized)) return false; + return false; +} + +function getTaskTextForNoCommitEligibility(task: Task, promptContent: string): string { + const logText = (task.log ?? []) + .map((entry) => `${entry.action ?? ""}\n${entry.outcome ?? ""}`) + .join("\n"); + const sourceMetadata = task.sourceMetadata ? JSON.stringify(task.sourceMetadata) : ""; + return [task.title, task.description, promptContent, sourceMetadata, logText] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join("\n"); +} + +function evaluatePromptDerivedNoCommitEligibility(task: Task, promptContent: string): { eligible: boolean; reason?: string } { + const combined = getTaskTextForNoCommitEligibility(task, promptContent).toLowerCase(); + const reviewLevel = typeof task.reviewLevel === "number" ? task.reviewLevel : parseReviewLevelFromPrompt(promptContent); + const isPlanOnly = reviewLevel === 1 && (/plan\s*only/.test(combined) || combined.includes("plan-only")); + if (!isPlanOnly) return { eligible: false }; + + const explicitNoSourceIntent = [ + "no expected product-source changes", + "no product-source changes", + "no source changes expected", + "no source files expected", + "no code changes expected", + "no expected source changes", + "no file mutations", + "no source/config/file mutations", + ].some((phrase) => combined.includes(phrase)); + if (!explicitNoSourceIntent) return { eligible: false }; + + const excludedImplementationIntent = /\b(investigate and fix|fix if needed|implement|source-changing|code change|docs\/tests changes|documentation change|bug[- ]fix|feature)\b/.test(combined); + const operationalIntent = /\b(operational|routing|route|assign|assignment|owner|handoff|coordination|coordinate|no-route|triage)\b/.test(combined); + if (!operationalIntent || excludedImplementationIntent) return { eligible: false }; + + const promptScopeEntries = extractPromptListEntries(extractPromptSection(promptContent, "File Scope")); + const metadataScope = Array.isArray(task.sourceMetadata?.fileScope) + ? task.sourceMetadata.fileScope.filter((entry): entry is string => typeof entry === "string") + : []; + const declaredScope = [...promptScopeEntries, ...metadataScope]; + if (declaredScope.length === 0) return { eligible: false }; + if (declaredScope.some(hasSourceChangingScopeEntry)) return { eligible: false }; + if (!declaredScope.every(isNoSourceScopeEntry)) return { eligible: false }; + + const stepsComplete = Array.isArray(task.steps) && task.steps.length > 0 + ? task.steps.every((step) => step.status === "done" || step.status === "skipped") + : false; + const logText = (task.log ?? []) + .map((entry) => `${entry.action ?? ""}\n${entry.outcome ?? ""}`) + .join("\n") + .toLowerCase(); + const hasOperationalEvidence = /\b(evidence|recorded|documented|no-route|routed|assigned|handoff|decision)\b/.test(logText); + if (!stepsComplete && !hasOperationalEvidence) return { eligible: false }; + + return { eligible: true, reason: "prompt/source metadata derived operational no-commit contract" }; +} + export function partitionWorkflowRevisionFeedback( feedback: string, declaredFileScope: readonly string[], @@ -9476,8 +9581,24 @@ export class TaskExecutor { }; } - if (task.noCommitsExpected === true) { - executorLog.log(`${task.id}: fn_task_done no_commits guard skipped (noCommitsExpected=true)`); + const promptContent = (task as Task & { prompt?: unknown }).prompt; + const noCommitEligibility = task.noCommitsExpected === true + ? { eligible: true, reason: "noCommitsExpected=true" } + : evaluatePromptDerivedNoCommitEligibility(task, typeof promptContent === "string" ? promptContent : ""); + if (noCommitEligibility.eligible) { + executorLog.log(`${task.id}: fn_task_done no_commits guard skipped (${noCommitEligibility.reason})`); + try { + await this.store.logEntry( + task.id, + `fn_task_done no_commits guard skipped (${noCommitEligibility.reason})`, + undefined, + this.getRunContextFor(task.id), + ); + } catch (error) { + executorLog.warn( + `${task.id}: failed to write no_commits guard skip audit log: ${error instanceof Error ? error.message : String(error)}`, + ); + } return { ok: true }; }