opencode-workflow-guard 1.15.5 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Cross-instance tool-call claiming.
3
+ *
4
+ * OpenCode 2 merges plugin arrays, so naming the guard in both the global and
5
+ * the project config loads the same module twice under two ids. Both instances
6
+ * register `tool.execute.before`, which evaluated and audited every call twice
7
+ * and let a second instance's decision shadow the first.
8
+ *
9
+ * The first instance to reach a given (session, call, tool) owns the decision.
10
+ * A second instance for the same call defers instead of evaluating again, so a
11
+ * call produces exactly one policy decision and one decision audit record.
12
+ * Within a single instance nothing is skipped: the claim only deduplicates
13
+ * *across* instances, because one instance must still evaluate each of its own
14
+ * calls even if a host reuses call ids.
15
+ */
16
+
17
+ const MAX_CLAIMS = 4096;
18
+ // Process-global, not module-global: OpenCode can load the guard from two
19
+ // distinct paths (a cache copy plus a source checkout), and each copy
20
+ // evaluates this module separately. A `Symbol.for` slot on `globalThis` keeps
21
+ // ONE claim table so the second copy still defers to the first.
22
+ const CLAIMS_REGISTRY = Symbol.for("opencode-workflow-guard.call-claims.v1");
23
+ const globalRegistry = globalThis as unknown as Record<symbol, Map<string, string> | undefined>;
24
+ const claims: Map<string, string> = globalRegistry[CLAIMS_REGISTRY] ?? (globalRegistry[CLAIMS_REGISTRY] = new Map());
25
+
26
+ function claimKey(sessionID: string | undefined, callID: string | undefined, tool: string): string | undefined {
27
+ if (!sessionID || !callID) return undefined;
28
+ return `${sessionID}::${callID}::${tool}`;
29
+ }
30
+
31
+ /**
32
+ * Claim a tool call for `instanceKey`. Returns true when the caller owns the
33
+ * decision. Returns false when another instance already claimed it, which means
34
+ * the caller must defer (no evaluation, no audit) to keep one decision per call.
35
+ */
36
+ export function claimToolCall(
37
+ instanceKey: string,
38
+ sessionID: string | undefined,
39
+ callID: string | undefined,
40
+ tool: string,
41
+ ): boolean {
42
+ const key = claimKey(sessionID, callID, tool);
43
+ if (!key) return true;
44
+ const owner = claims.get(key);
45
+ if (owner === undefined) {
46
+ claims.set(key, instanceKey);
47
+ if (claims.size > MAX_CLAIMS) {
48
+ const oldest = claims.keys().next().value;
49
+ if (oldest !== undefined) claims.delete(oldest);
50
+ }
51
+ return true;
52
+ }
53
+ return owner === instanceKey;
54
+ }
55
+
56
+ /** Release a claim after the call settles so the map stays bounded in long sessions. */
57
+ export function releaseToolCallClaim(
58
+ instanceKey: string,
59
+ sessionID: string | undefined,
60
+ callID: string | undefined,
61
+ tool: string,
62
+ ): void {
63
+ const key = claimKey(sessionID, callID, tool);
64
+ if (!key) return;
65
+ if (claims.get(key) === instanceKey) claims.delete(key);
66
+ }
67
+
68
+ /** Test seam. */
69
+ export function resetToolCallClaimsForTesting(): void {
70
+ claims.clear();
71
+ }
@@ -0,0 +1,13 @@
1
+ /** Types for `config-writer.mjs`, the testable half of the `setup` command. */
2
+
3
+ export declare const PACKAGE_NAME: string;
4
+ export declare function pluginKeyFor(config: Record<string, unknown>, explicitMajor?: number): "plugin" | "plugins";
5
+ export declare function matchesPackageSpec(entry: unknown, spec: string): boolean;
6
+ export declare function preparePluginUpdate(
7
+ path: string,
8
+ schema: string,
9
+ spec: string,
10
+ legacySpec?: string,
11
+ explicitMajor?: number,
12
+ key?: "plugin" | "plugins",
13
+ ): string | undefined;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Config writing for `opencode-workflow-guard setup`.
3
+ *
4
+ * Split out of the bin script so the key-selection rules are testable without
5
+ * writing a live config. The bin stays a thin wrapper.
6
+ *
7
+ * Which key the target host reads:
8
+ *
9
+ * - OpenCode 1.x reads the singular `plugin` key.
10
+ * - OpenCode 2.x reads only the plural `plugins` key. The 2.0.10 config schema
11
+ * drops a singular `plugin` key without an error
12
+ * (`Schema.decodeUnknownSync(Config.Info)` returns an object with no `plugin`
13
+ * field), so writing the wrong key produces a config that looks configured
14
+ * and loads nothing.
15
+ *
16
+ * `--major` states the target explicitly. Without it, the existing config
17
+ * decides: a `plugins` key means 2.x, a `plugin` key means 1.x. A fresh config
18
+ * defaults to 2, because this package's host dependency is pinned to a 2.x
19
+ * release.
20
+ *
21
+ * `tui.json` is the exception. Its schema is singular `plugin` on both majors:
22
+ * OpenCode 2 reads `tui.json` with that key and migrates it into
23
+ * `cli.json.plugins`. Writing `plugins` there on 2.x produces a file the TUI
24
+ * schema ignores, so the TUI plugin silently never loads. Callers pass
25
+ * `key` to pin the TUI array's key independently of the server config.
26
+ *
27
+ * Evidence for that migration, since no TUI schema is vendored here: the
28
+ * installed 2.0.15 binary's cli-config migration step reads
29
+ * `join(config, "tui.json")`, decodes it with a schema whose only plugin field
30
+ * is singular `plugin`, and emits `plugins` into `cli.json`. The unit tests
31
+ * therefore pin the writer's key choice; the host behavior itself is
32
+ * host-version-dependent and is re-checked by `npm run test:install`.
33
+ */
34
+
35
+ import { existsSync, readFileSync } from "node:fs";
36
+ import { applyEdits, modify, parse } from "jsonc-parser";
37
+
38
+ export const PACKAGE_NAME = "opencode-workflow-guard";
39
+
40
+ /** Which plugin array key the target OpenCode major reads. */
41
+ export function pluginKeyFor(config, explicitMajor) {
42
+ const major = explicitMajor ?? (config.plugins !== undefined ? 2 : config.plugin !== undefined ? 1 : 2);
43
+ return major === 1 ? "plugin" : "plugins";
44
+ }
45
+
46
+ export function matchesPackageSpec(entry, spec) {
47
+ const value = Array.isArray(entry) ? entry[0] : entry;
48
+ return typeof value === "string" && (value === spec || value.startsWith(`${spec}@`));
49
+ }
50
+
51
+ /**
52
+ * Prepare an updated config document adding `spec` to the target's plugin
53
+ * array, removing a `legacySpec` entry when one is supplied.
54
+ *
55
+ * Returns `undefined` when the document already names the package, so an
56
+ * unchanged config is never rewritten. Throws on an unparseable document or a
57
+ * non-array plugin key rather than clobbering a config it does not understand.
58
+ */
59
+ export function preparePluginUpdate(path, schema, spec, legacySpec, explicitMajor, key) {
60
+ const source = existsSync(path)
61
+ ? readFileSync(path, "utf8")
62
+ : `{
63
+ "$schema": "${schema}"
64
+ }\n`;
65
+ const errors = [];
66
+ const config = parse(source, errors, { allowTrailingComma: true, disallowComments: false });
67
+ if (errors.length > 0 || !config || typeof config !== "object" || Array.isArray(config)) {
68
+ throw new Error(`Cannot update invalid config: ${path}`);
69
+ }
70
+ const targetKey = key ?? pluginKeyFor(config, explicitMajor);
71
+ if (config[targetKey] !== undefined && !Array.isArray(config[targetKey])) {
72
+ throw new Error(`Cannot update ${path}: "${targetKey}" must be an array`);
73
+ }
74
+ const plugins = config[targetKey] ?? [];
75
+ const hasSpec = plugins.some((entry) => matchesPackageSpec(entry, PACKAGE_NAME));
76
+ const updatedPlugins = legacySpec
77
+ ? plugins.filter((entry) => !matchesPackageSpec(entry, legacySpec))
78
+ : plugins;
79
+ if (updatedPlugins.length !== plugins.length) {
80
+ if (!hasSpec) updatedPlugins.push(spec);
81
+ return applyEdits(source, modify(source, [targetKey], updatedPlugins, {
82
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
83
+ }));
84
+ }
85
+ if (hasSpec) return undefined;
86
+ return applyEdits(source, modify(source, [targetKey, plugins.length], spec, {
87
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
88
+ }));
89
+ }
@@ -36,15 +36,17 @@ import {
36
36
  runWithRuntimeState,
37
37
  } from "./state.ts";
38
38
  import { getRalphOutcome } from "../policies/continuation.ts";
39
+ import { todoEvidenceReport } from "../policies/todo.ts";
39
40
  import { findGitRoot, loadProjectConfig, projectRootKey } from "./project-config.ts";
40
41
  import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
41
42
  import { buildReviewRubric } from "./review.ts";
42
- import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
43
+ import { createGitWorktree, cleanupGitWorktree, validateHostWorktreeDirectory } from "./worktree.ts";
44
+ import type { HostWorktreePort } from "./host-worktree.ts";
43
45
  import { branchHasDocumentationChange } from "../policies/docs.ts";
44
46
  import { restoreRecoveryCheckpoint } from "./checkpoint.ts";
45
47
  import { audit, getRecentAuditEntries } from "./audit.ts";
46
48
  import { guardToolCallImpl } from "./guard-dispatcher.ts";
47
- import { loadedPluginVersion } from "./version.ts";
49
+ import { loadedPluginVersion, qualifiedHostVersion } from "./version.ts";
48
50
  import { gitHygieneSnapshot } from "./hygiene.ts";
49
51
  import { discoverPlanningSources } from "../policies/planning.ts";
50
52
  import { secretIn } from "../policies/secrets.ts";
@@ -76,7 +78,7 @@ export function buildWorkflowGuardSystemGuidance(options: {
76
78
  const sections: string[] = [
77
79
  "## Workflow Guard & Operational Tools",
78
80
  "Workflow Guard enforces safe engineering discipline and provides proactive workflow tools:",
79
- "- Task Planning: Proactively call `guard_next_tasks` at session start or when planning work to discover repository roadmap, plan, and TODO items. Check `guard_status` to inspect branch protection, mutation count, and outstanding verification/review gates.",
81
+ "- Task Planning: The session's todo list is the unit of visible progress and it gates every file mutation. Call `todowrite` before starting work, keep exactly the real remaining tasks in it, and mark a task completed only after its work is actually done. A completion is checked against recorded work in that task's window, so finishing a task without doing it is blocked. Genuinely read-only tasks (review, investigation, reading) are marked `[read-only]` in their text. Proactively call `guard_next_tasks` at session start or when planning work to discover repository roadmap, plan, and TODO items, and check `guard_status` to inspect branch protection, mutation count, and outstanding verification/review gates.",
80
82
  ];
81
83
 
82
84
  if (!options.isReadOnly) {
@@ -121,8 +123,25 @@ export function createCustomTools(options: {
121
123
  portableMemoryPath: string;
122
124
  learningInterventions: Map<string, number>;
123
125
  client: unknown;
126
+ /**
127
+ * Host worktree port. When present and available, creation goes through the
128
+ * host so the worktree lands in its configured directory and inventory.
129
+ * Removal stays with the guard's snapshot-then-remove flow regardless.
130
+ */
131
+ hostWorktree?: HostWorktreePort;
132
+ /**
133
+ * Probed todo capability for this host, reported in `guard_status`.
134
+ * Omitted on V1, which has a native todo endpoint and therefore always can
135
+ * plan; V2 passes its registry probe so a fail-open gate is visible.
136
+ */
137
+ todoCapability?: () => string;
138
+ /**
139
+ * Live host version, reported next to the version this build was qualified
140
+ * against so a mismatch is visible before it becomes a silent no-op.
141
+ */
142
+ hostVersion?: () => string | undefined;
124
143
  }) {
125
- const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
144
+ const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client, hostWorktree, todoCapability, hostVersion } = options;
126
145
  // Resolve the workspace for a tool call from the CALL's own context: an
127
146
  // explicit argument first, then the host-provided per-call worktree or
128
147
  // directory (even when unusable — the call declared its target), then the
@@ -276,7 +295,8 @@ export function createCustomTools(options: {
276
295
  recommendedActions.push("All operational requirements are satisfied for task finalization and PR creation.");
277
296
  }
278
297
  const ralphOutcome = runWithRuntimeState(root, client, () => getRalphOutcome(toolContext.sessionID));
279
- return JSON.stringify({ pluginVersion: loadedPluginVersion() ?? null, workspaceRoot: root, branch, onProtectedBranch: isProtected, outstandingRequirements, recommendedActions, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, requireSubagentReview: isSubagentReviewRequired(root), recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) }, gitHygiene: gitHygieneSnapshot(root) ?? null }, null, 2);
298
+ const taskEvidence = await runWithRuntimeState(root, client, () => todoEvidenceReport(toolContext.sessionID));
299
+ return JSON.stringify({ pluginVersion: loadedPluginVersion() ?? null, qualifiedHostVersion: qualifiedHostVersion() ?? null, hostVersion: hostVersion?.() ?? null, workspaceRoot: root, branch, onProtectedBranch: isProtected, todoCapability: todoCapability ? todoCapability() : "available", taskEvidence, outstandingRequirements, recommendedActions, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, requireSubagentReview: isSubagentReviewRequired(root), recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) }, gitHygiene: gitHygieneSnapshot(root) ?? null }, null, 2);
280
300
  },
281
301
  }),
282
302
  guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard. Use to diagnose policy decisions, blocked commands, or outcome telemetry.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
@@ -374,17 +394,58 @@ export function createCustomTools(options: {
374
394
  description: "Create an isolated git worktree directory for concurrent subagent execution. Proactively call when launching parallel tasks/subagents to prevent file collision and maintain branch isolation.", args: { branch: tool.schema.string().describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"), baseBranch: tool.schema.string().optional().describe("Base branch to branch off of (defaults to HEAD)") },
375
395
  execute: async (args, toolContext) => {
376
396
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree creation with no active todo item. Break the request down with todowrite first, then create worktrees.";
377
- const toolRoot = await resolveCallWorkspace(toolContext); const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
397
+ const toolRoot = await resolveCallWorkspace(toolContext);
398
+ // Host first: it honors the operator's configured worktree directory
399
+ // and keeps the worktree in host inventory. The result is validated
400
+ // independently (registered worktree of THIS repo, not the primary
401
+ // tree); any failure falls back to the guard's git path.
402
+ //
403
+ // The host path is used only for the default base. The host create
404
+ // input's `from` is an absolute directory path, not a git ref, so an
405
+ // explicit `baseBranch` cannot be expressed there; honoring it via
406
+ // the git path is better than silently ignoring it.
407
+ let createdPath: string | undefined;
408
+ let hostNote = "";
409
+ const requestedBase = args.baseBranch ?? "HEAD";
410
+ if (hostWorktree?.available && requestedBase === "HEAD") {
411
+ const hostResult = await hostWorktree.create({ branch: args.branch });
412
+ if (hostResult.directory) {
413
+ const validation = validateHostWorktreeDirectory(hostResult.directory, toolRoot);
414
+ if (validation.ok) {
415
+ createdPath = resolve(hostResult.directory);
416
+ hostNote = " (created by the host worktree strategy)";
417
+ } else {
418
+ hostNote = ` Host worktree creation was rejected by the guard (${validation.error}); used the guard's git path instead.`;
419
+ }
420
+ } else if (hostResult.error) {
421
+ hostNote = ` Host worktree creation failed (${hostResult.error}); used the guard's git path instead.`;
422
+ }
423
+ }
424
+ if (!createdPath) {
425
+ const res = createGitWorktree(args.branch, requestedBase, toolRoot);
426
+ if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}${hostNote}`;
427
+ createdPath = res.worktreePath;
428
+ }
378
429
  const mutationRoot = await resolveCallWorkspace(toolContext);
379
430
  await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
380
- return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
431
+ return `[workflow-guard] Worktree created successfully at: ${createdPath}${hostNote}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
381
432
  },
382
433
  }),
383
434
  guard_worktree_cleanup: tool({
384
435
  description: "Commit a final snapshot and remove an isolated git worktree directory. Call to clean up worktrees created with guard_worktree_create after subagent tasks complete.", args: { worktreePath: tool.schema.string().describe("Path of the worktree directory to clean up") },
385
436
  execute: async (args, toolContext) => {
386
437
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree cleanup with no active todo item. Break the request down with todowrite first, then clean up worktrees.";
387
- const toolRoot = await resolveCallWorkspace(toolContext); const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
438
+ const toolRoot = await resolveCallWorkspace(toolContext);
439
+ // Removal never delegates to the host: its `remove` takes a `force`
440
+ // flag and has no snapshot step, so it can destroy uncommitted work
441
+ // the guard's snapshot-then-remove flow preserves. The host list is
442
+ // used only to authorize a directory outside the guard's storage.
443
+ const hostWorktrees = hostWorktree?.available ? await hostWorktree.list() : undefined;
444
+ const res = cleanupGitWorktree(args.worktreePath, toolRoot, { hostWorktrees });
445
+ if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
446
+ // Tell the host its inventory is stale, or it keeps listing the
447
+ // removed directory until something else refreshes it.
448
+ await hostWorktree?.refresh();
388
449
  const mutationRoot = await resolveCallWorkspace(toolContext);
389
450
  await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
390
451
  return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Server-side `/guard-status` command for OpenCode 2.
3
+ *
4
+ * Before this, the only way to read guard state from inside a session was the
5
+ * separately installed TUI companion, or knowing the `guard_status` tool name.
6
+ * `ctx.command.transform` registers a command that runs in every V2 client.
7
+ *
8
+ * The command computes the report from the guard's own `guard_status` tool and
9
+ * delivers it as a synthetic message. Two deliberate choices:
10
+ *
11
+ * - The report is not relayed through the model. `guard_status` is
12
+ * authoritative state; asking a model to restate it would introduce a
13
+ * paraphrase step between the operator and the evidence.
14
+ * - Delivery uses `session.synthetic`, so the command costs no model turn.
15
+ * Synthetic messages do not run the `session.prompt` hook, so this cannot
16
+ * churn recovery checkpoints.
17
+ */
18
+
19
+ import type { Plugin } from "@opencode/plugin";
20
+ import { audit } from "./audit.ts";
21
+ import { runWithRuntimeState } from "./state.ts";
22
+
23
+ /** The command id. Prefixed so it cannot shadow a user command by accident. */
24
+ export const GUARD_STATUS_COMMAND = "guard-status";
25
+
26
+ export interface GuardStatusCommandDeps {
27
+ /** Produces the same JSON report as the `guard_status` tool. */
28
+ runStatus: (sessionID: string) => Promise<string>;
29
+ effectiveRoot: string;
30
+ client: unknown;
31
+ }
32
+
33
+ interface CommandInvocation {
34
+ readonly sessionID: string;
35
+ }
36
+
37
+ /**
38
+ * Register the command. Registration is additive: a collision with an existing
39
+ * command id is the host's to resolve, and the `guard-` prefix keeps that from
40
+ * being accidental.
41
+ */
42
+ export async function registerGuardStatusCommand(
43
+ ctx: Plugin.Context,
44
+ deps: GuardStatusCommandDeps,
45
+ ): Promise<void> {
46
+ const editorHost = ctx.command as unknown as {
47
+ transform: (callback: (editor: {
48
+ add: (definition: {
49
+ name: string;
50
+ description?: string;
51
+ execute: (input: CommandInvocation) => Promise<void>;
52
+ }) => void;
53
+ }) => void) => Promise<unknown>;
54
+ };
55
+ await editorHost.transform((editor) => {
56
+ editor.add({
57
+ name: GUARD_STATUS_COMMAND,
58
+ description: "Show Workflow Guard status: branch protection, mutation count, outstanding verification/review gates, and todo capability.",
59
+ execute: async ({ sessionID }) => {
60
+ let report: string;
61
+ try {
62
+ report = await deps.runStatus(sessionID);
63
+ } catch (error) {
64
+ report = JSON.stringify({
65
+ error: `guard_status could not be computed: ${(error as Error)?.message ?? "unknown error"}`,
66
+ });
67
+ }
68
+ audit({
69
+ ts: new Date().toISOString(),
70
+ sessionID,
71
+ tool: `command.${GUARD_STATUS_COMMAND}`,
72
+ decision: "allow",
73
+ phase: "event",
74
+ reason: "guard_status_command",
75
+ });
76
+ await runWithRuntimeState(deps.effectiveRoot, deps.client, async () => {
77
+ const session = ctx.session as unknown as {
78
+ synthetic: (input: { sessionID: string; text: string }) => Promise<unknown>;
79
+ };
80
+ try {
81
+ await session.synthetic({ sessionID, text: `## Workflow Guard status\n\n\`\`\`json\n${report}\n\`\`\`` });
82
+ } catch (error) {
83
+ // A client that cannot admit synthetic messages still needs the
84
+ // report to be visible; fall back to a normal prompt.
85
+ try {
86
+ console.warn(`[workflow-guard] warn: /${GUARD_STATUS_COMMAND} synthetic delivery failed (${(error as Error)?.message ?? "unknown error"}); falling back to a prompt.`);
87
+ } catch {}
88
+ const sessionPrompt = ctx.session as unknown as {
89
+ prompt: (input: { sessionID: string; text: string }) => Promise<unknown>;
90
+ };
91
+ await sessionPrompt.prompt({ sessionID, text: `Report this guard status verbatim:\n\n${report}` });
92
+ }
93
+ });
94
+ },
95
+ });
96
+ });
97
+ }
@@ -8,6 +8,7 @@ import {
8
8
  getLiveControlPlaneRoots,
9
9
  getSdkClient,
10
10
  getSessionBoundWorkspace,
11
+ getSessionMutationCount,
11
12
  getWorkspaceRoot,
12
13
  isDocumentationRequired,
13
14
  isReviewRequired,
@@ -17,6 +18,7 @@ import {
17
18
  recordVerifyResult,
18
19
  sessionMutationTimestamps,
19
20
  sessionVerifyResults,
21
+ todoTaskStarts,
20
22
  } from "./state.ts";
21
23
  import {
22
24
  dynamicShellSyntaxIn,
@@ -74,9 +76,14 @@ import {
74
76
  effectiveTodos,
75
77
  fetchSessionTodos,
76
78
  hasActiveTodo,
79
+ readOnlyTodoCompletions,
80
+ recordTodoTaskStarts,
77
81
  subagentMutationBudgetReason,
82
+ todoCompletionBlockReason,
83
+ unverifiedTodoCompletions,
78
84
  validateTodoLifecycle,
79
85
  } from "../policies/todo.ts";
86
+ import { recordedTodoCapability, reportTodoGateFailOpen } from "./v2-todo.ts";
80
87
 
81
88
  export const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
82
89
 
@@ -192,6 +199,22 @@ export async function guardToolCallImpl(
192
199
  logPolicyBlock(`[workflow-guard] ${err}`);
193
200
  return block("todo", "todo_lifecycle", err);
194
201
  }
202
+ // Per-task completion evidence. Every task may enter `completed`
203
+ // only after the session mutated inside that task's window. The
204
+ // check is pure, so simulation reports the same verdict without
205
+ // journaling or moving any window.
206
+ const readOnlyCompleted: string[] = [];
207
+ if (context?.sessionID) {
208
+ const starts = todoTaskStarts(context.sessionID);
209
+ const total = getSessionMutationCount(context.sessionID);
210
+ const unverified = unverifiedTodoCompletions(existingTodos, newTodos, starts, total);
211
+ if (unverified.length > 0) {
212
+ const reason = todoCompletionBlockReason(unverified);
213
+ logPolicyBlock(`[workflow-guard] ${reason}`);
214
+ return block("todo", "todo_completion_unverified", reason);
215
+ }
216
+ if (!context.simulate) readOnlyCompleted.push(...readOnlyTodoCompletions(existingTodos, newTodos));
217
+ }
195
218
  const allDone = newTodos.length > 0 && newTodos.every((t) => {
196
219
  const s = String(t.status ?? "");
197
220
  return s === "completed" || s === "cancelled";
@@ -227,6 +250,22 @@ export async function guardToolCallImpl(
227
250
  }
228
251
  }
229
252
  }
253
+ // Only an accepted write becomes the task list, so task windows move
254
+ // and read-only marker use is journaled after every gate has passed.
255
+ if (context?.sessionID && !context.simulate) {
256
+ recordTodoTaskStarts(todoTaskStarts(context.sessionID), existingTodos, newTodos, getSessionMutationCount(context.sessionID));
257
+ if (readOnlyCompleted.length > 0) {
258
+ audit({
259
+ ts: new Date().toISOString(),
260
+ sessionID: context.sessionID,
261
+ tool: "todowrite",
262
+ decision: "allow",
263
+ phase: "event",
264
+ reason: "todo_completed_read_only",
265
+ input: { tasks: readOnlyCompleted, mutationCount: getSessionMutationCount(context.sessionID) },
266
+ });
267
+ }
268
+ }
230
269
  }
231
270
  return allow();
232
271
  }
@@ -309,6 +348,23 @@ export async function guardToolCallImpl(
309
348
  logPolicyBlock(`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`);
310
349
  return block("todo", "no_active_todo", "Blocked: no active todo item. First break the request down with the todowrite tool (create items with status 'pending' or 'in_progress'), then work them through, marking each completed via todowrite as you finish it. When every item is completed, create a fresh todo list before starting new work.");
311
350
  }
351
+ if (todos === undefined) {
352
+ // Fail-open, but never silent: once per session, record that the
353
+ // gate did not enforce and why. A rails policy that quietly stops
354
+ // enforcing is indistinguishable from one that was removed.
355
+ const capability = recordedTodoCapability();
356
+ if (reportTodoGateFailOpen(context?.sessionID, capability)) {
357
+ audit({
358
+ ts: new Date().toISOString(),
359
+ sessionID: context?.sessionID,
360
+ tool: "todo.gate",
361
+ decision: "allow",
362
+ phase: "event",
363
+ reason: `todo_gate_fail_open_${capability}`,
364
+ input: { [toolName]: true },
365
+ });
366
+ }
367
+ }
312
368
  if (context?.sessionID) {
313
369
  const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
314
370
  if (reason) {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Host worktree port for the V2 native worktree API.
3
+ *
4
+ * The guard hand-rolls `git worktree add` and keeps its own storage directory,
5
+ * so worktrees it creates are invisible to the host's worktree inventory. The
6
+ * V2 API exposes `ctx.worktree` with `create`, `list`, `remove`, and `refresh`.
7
+ *
8
+ * Adoption is deliberately asymmetric, because the two paths do not carry the
9
+ * same guarantees:
10
+ *
11
+ * - **create** goes through the host first, so the worktree lands where the
12
+ * operator configured `worktree.directory` and appears in host inventory.
13
+ * The result is validated before it is accepted: the directory must exist,
14
+ * must be a worktree of THIS repository, and must not be the primary tree.
15
+ * Any failure falls back to the git implementation.
16
+ * - **remove** stays with the guard. The host's `remove` takes a `force` flag
17
+ * and has no snapshot step, so it can destroy uncommitted work that the
18
+ * guard's snapshot-then-remove flow preserves. After the guard removes a
19
+ * worktree, the port calls `refresh` so host inventory does not keep a
20
+ * stale entry.
21
+ * - **list** supplies the bounded, host-declared set of directories that
22
+ * cleanup may touch. This is the same widening authority Task 3 gave
23
+ * `hostWorktree`: the host says the directory is its worktree, and the
24
+ * guard still independently requires a git registration and a non-primary
25
+ * path.
26
+ *
27
+ * Unknown or unavailable host surfaces degrade to `undefined`, never to a
28
+ * guess. The git path stays the only authority for correctness.
29
+ */
30
+
31
+ /** Narrow, duck-typed view of `ctx.worktree` so this module stays testable. */
32
+ export interface HostWorktreeApi {
33
+ create(input: { projectID: string; branch?: string; from?: string; name?: string; directory?: string }): Promise<{ directory?: string } | undefined>;
34
+ list(input: { projectID: string }): Promise<ReadonlyArray<{ directory?: string }> | undefined>;
35
+ refresh(input: { projectID: string }): Promise<unknown>;
36
+ }
37
+
38
+ export interface HostWorktreePort {
39
+ /** True when the host exposes a usable project id and the create/list calls. */
40
+ readonly available: boolean;
41
+ /** Project id used for every call, or undefined when the host did not supply one. */
42
+ readonly projectID: string | undefined;
43
+ /**
44
+ * Create a worktree. `from` is an absolute DIRECTORY path, matching the host
45
+ * schema, not a git ref; callers that need a specific base ref use the git
46
+ * path instead of passing a ref here.
47
+ */
48
+ create(input: { branch: string; from?: string }): Promise<{ directory?: string; error?: string }>;
49
+ list(): Promise<readonly string[] | undefined>;
50
+ refresh(): Promise<void>;
51
+ }
52
+
53
+ const unavailable: HostWorktreePort = {
54
+ available: false,
55
+ projectID: undefined,
56
+ create: async () => ({ error: "host worktree API unavailable" }),
57
+ list: async () => undefined,
58
+ refresh: async () => {},
59
+ };
60
+
61
+ /**
62
+ * Build the port from a V2 plugin context. A missing API, a missing project id,
63
+ * or a throwing call all downgrade to the git path instead of failing the tool.
64
+ */
65
+ export function createHostWorktreePort(ctx: unknown): HostWorktreePort {
66
+ const context = ctx as {
67
+ location?: { project?: { id?: unknown } };
68
+ worktree?: Partial<HostWorktreeApi>;
69
+ } | undefined;
70
+ const api = context?.worktree;
71
+ const rawProjectID = context?.location?.project?.id;
72
+ const projectID = typeof rawProjectID === "string" && rawProjectID.length > 0 ? rawProjectID : undefined;
73
+ if (!api || typeof api.create !== "function" || typeof api.list !== "function" || !projectID) {
74
+ return unavailable;
75
+ }
76
+ const create = api.create.bind(api);
77
+ const list = api.list.bind(api);
78
+ const refresh = typeof api.refresh === "function" ? api.refresh.bind(api) : undefined;
79
+ return {
80
+ available: true,
81
+ projectID,
82
+ create: async ({ branch, from }) => {
83
+ try {
84
+ const result = await create({ projectID, branch, ...(from ? { from } : {}), name: branch.replace(/[/\\:]/g, "-") });
85
+ const directory = typeof result?.directory === "string" && result.directory.length > 0 ? result.directory : undefined;
86
+ return directory ? { directory } : { error: "host worktree create returned no directory" };
87
+ } catch (error) {
88
+ return { error: `host worktree create failed: ${(error as Error)?.message ?? String(error)}` };
89
+ }
90
+ },
91
+ list: async () => {
92
+ try {
93
+ const result = await list({ projectID });
94
+ if (!Array.isArray(result)) return undefined;
95
+ return result
96
+ .map((entry) => (typeof entry?.directory === "string" ? entry.directory : undefined))
97
+ .filter((directory): directory is string => Boolean(directory));
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ },
102
+ refresh: async () => {
103
+ if (!refresh) return;
104
+ try {
105
+ await refresh({ projectID });
106
+ } catch {}
107
+ },
108
+ };
109
+ }