@brainervirus/workit-core 0.6.1 → 0.7.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.
Files changed (81) hide show
  1. package/package.json +3 -7
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +19 -21
  5. package/scripts/rewrite-workspace-deps.ts +15 -9
  6. package/scripts/sync-runtime.sh +71 -19
  7. package/scripts/vendor-assets.ts +37 -0
  8. package/skills/wk-implement/SKILL.md +2 -2
  9. package/skills/wk-pr/SKILL.md +1 -1
  10. package/src/core/boundary.ts +27 -0
  11. package/src/core/branch-policy.ts +63 -0
  12. package/src/core/branch.ts +30 -16
  13. package/src/core/config.ts +193 -31
  14. package/src/core/docs-layout.ts +251 -0
  15. package/src/core/docs-migration.ts +639 -0
  16. package/src/core/docs-repo.ts +11 -9
  17. package/src/core/docs-validate.ts +18 -6
  18. package/src/core/doctor.ts +801 -0
  19. package/src/core/flow-state.ts +1579 -141
  20. package/src/core/git.ts +22 -5
  21. package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
  22. package/src/core/hygiene.ts +26 -12
  23. package/src/core/init.ts +43 -11
  24. package/src/core/logger.ts +321 -0
  25. package/src/core/package-root.ts +28 -0
  26. package/src/core/ports/init-toolkit-status.ts +1 -1
  27. package/src/core/ports/vcs-verify-token.ts +1 -1
  28. package/src/core/ports/youtrack-api.ts +1 -1
  29. package/src/core/ports/youtrack-verify-token.ts +1 -1
  30. package/src/core/pr-create.ts +116 -21
  31. package/src/core/registration.ts +215 -0
  32. package/src/core/repo-context.ts +447 -0
  33. package/src/core/repo-tools.ts +23 -0
  34. package/src/core/safe-write.ts +22 -0
  35. package/src/core/scripts.ts +3 -44
  36. package/src/core/sdd.ts +45 -28
  37. package/src/core/setup-state.ts +54 -0
  38. package/src/core/setup.ts +1216 -0
  39. package/src/core/skill-manifests.ts +95 -0
  40. package/src/core/support-matrix.ts +12 -0
  41. package/src/core/sync-runtime.ts +348 -0
  42. package/src/core/templates.ts +2 -2
  43. package/src/core/vcs-config.ts +107 -37
  44. package/src/core/verify-project.ts +181 -0
  45. package/src/core/workspaces.ts +136 -17
  46. package/src/core/youtrack-tools.ts +228 -0
  47. package/src/core/youtrack.ts +125 -67
  48. package/templates/execution-contract.md +9 -7
  49. package/templates/superpowers-doc-contract.md +4 -3
  50. package/scripts/_shared/common.sh +0 -173
  51. package/scripts/changelog-context.sh +0 -42
  52. package/scripts/docs-refresh-context.sh +0 -40
  53. package/scripts/init/apply.sh +0 -5
  54. package/scripts/init/status.sh +0 -5
  55. package/scripts/init/toolkit-status.sh +0 -5
  56. package/scripts/pr-create.sh +0 -5
  57. package/scripts/pr-ready-context.sh +0 -88
  58. package/scripts/present/ascii-wireframe.sh +0 -5
  59. package/scripts/present/flow-diagram.sh +0 -5
  60. package/scripts/release-notes-context.sh +0 -40
  61. package/scripts/vcs/config.sh +0 -5
  62. package/scripts/vcs/merged-style.sh +0 -5
  63. package/scripts/vcs/token-create-urls.sh +0 -5
  64. package/scripts/vcs/verify-token.sh +0 -5
  65. package/scripts/verify-project.sh +0 -140
  66. package/scripts/youtrack/api.sh +0 -5
  67. package/scripts/youtrack/config.sh +0 -5
  68. package/scripts/youtrack/greeting.sh +0 -5
  69. package/scripts/youtrack/parse-duration.sh +0 -5
  70. package/scripts/youtrack/token-create-url.sh +0 -5
  71. package/scripts/youtrack/verify-token.sh +0 -5
  72. package/scripts/youtrack/work-date-ms.sh +0 -5
  73. package/src/tools/docs-repo.ts +0 -51
  74. package/src/tools/flow.ts +0 -99
  75. package/src/tools/index.ts +0 -22
  76. package/src/tools/present.ts +0 -49
  77. package/src/tools/repo.ts +0 -490
  78. package/src/tools/rules.ts +0 -30
  79. package/src/tools/sdd.ts +0 -216
  80. package/src/tools/templates.ts +0 -27
  81. package/src/tools/youtrack.ts +0 -423
@@ -1,18 +1,115 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, existsSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { parseTasksFromPlan, qualitySpec, stripFences } from "./docs-validate";
3
+ import { docsValidate, parseTasksFromPlan, qualitySpec, stripFences } from "./docs-validate";
4
+ import { resolveCanonicalLayout } from "./docs-layout";
4
5
 
6
+ export type FlowHost = "opencode" | "cursor";
5
7
  export type FlowStatus = "draft" | "self_reviewed" | "approved";
6
- export type FlowDocState = { path: string; status: FlowStatus };
8
+ export type FlowRole = "coordinator" | "delegated";
9
+
10
+ /**
11
+ * Host-bound identity for every flow/product mutation (FG-05, CA-20, CA-21):
12
+ * the authoritative host workspace, the coordinator/delegated role, the host
13
+ * session, and the authenticated task identity a delegated worker carries.
14
+ * Cursor has no per-session identity, so it derives a deterministic session
15
+ * from the workspace root; OpenCode derives it from the tool context.
16
+ * Delegation is host-derived (Task 30, AR-12): callers never supply `role`.
17
+ */
18
+ export type MutationContext = {
19
+ hostWorkspace: string;
20
+ role: FlowRole;
21
+ sessionId: string;
22
+ taskIdentity?: string;
23
+ };
24
+
25
+ /** Recovery guidance surfaced on a blocked coordinator mutation (FG-07). */
26
+ export const COORDINATOR_RECOVERY_TEXT =
27
+ "A subagent-driven plan is active: coordinator product edits are blocked. " +
28
+ "Delegate product mutations (task briefs, progress, review packages) to an " +
29
+ "authenticated delegated worker via `task` / `wk-implement` instead of " +
30
+ "editing in the coordinator session.";
31
+
32
+ /**
33
+ * Cursor recovery guidance for the unsupported subagent-driven mutation path
34
+ * (CA-42): the Cursor MCP has no child sessions, so it cannot run a
35
+ * subagent-driven plan and must not enter that flow state.
36
+ */
37
+ export const CURSOR_SUBAGENT_UNSUPPORTED_TEXT =
38
+ "Cursor cannot execute subagent-driven plans: the MCP has no child-session " +
39
+ "support. Choose Inline, Handoff, or a review option in this session, or " +
40
+ "run the plan in OpenCode with `wk-implement`.";
41
+
42
+ /**
43
+ * The only acceptable approval / execution-menu evidence (FG-04, CA-19, AR-12).
44
+ * Trust comes from HOST CAPABILITIES, never from caller-supplied fields:
45
+ *
46
+ * - OpenCode: a one-use receipt the plugin records when it observes the
47
+ * answered native `question` tool (host-observed, `attested: true`). The
48
+ * approval/menu tool schemas expose no evidence argument; the receipt is
49
+ * consumed from the in-memory store bound to sessionID + callID + exact
50
+ * selected label + timestamp.
51
+ * - Cursor: a policy-only constant (`attested: false`). The MCP cannot observe
52
+ * the AskQuestion result, so it records an unauthenticated confirmation and
53
+ * never claims a host-observed answer. The constant carries no caller data.
54
+ */
55
+ export type OpenCodeChoiceEvidence = {
56
+ host: "opencode";
57
+ attested: true;
58
+ /** Host question-tool call id observed by the plugin hook. */
59
+ callID: string;
60
+ /** The exact label the user selected. */
61
+ selectedLabel: string;
62
+ recordedAt: number;
63
+ };
64
+
65
+ export type CursorConfirmation = {
66
+ host: "cursor";
67
+ attested: false;
68
+ confirmation: "contract";
69
+ };
70
+
71
+ export type NativeChoiceEvidence = OpenCodeChoiceEvidence | CursorConfirmation;
72
+
73
+ export type FlowDocState = {
74
+ path: string;
75
+ status: FlowStatus;
76
+ evidence?: NativeChoiceEvidence | null;
77
+ };
78
+
79
+ export type FlowMenuState = {
80
+ presented: boolean;
81
+ chosen: string;
82
+ evidence?: NativeChoiceEvidence | null;
83
+ };
84
+
7
85
  export type FlowState = {
8
86
  slug: string;
87
+ /** Recorded when flow preparation began (FG-01): canonical paths + activation. */
88
+ activated: boolean;
9
89
  spec: FlowDocState;
10
90
  plan: FlowDocState;
11
- menu: { presented: boolean; chosen: string };
91
+ menu: FlowMenuState;
12
92
  updated_at: number;
13
93
  };
14
94
 
15
- type Result = { ok: true } | { ok: false; error: string };
95
+ /** One shared result shape for every flow transition and mutation gate (FG-09). */
96
+ export type FlowError = { ok: false; error: string; code: string };
97
+ export type FlowGateResult = { ok: true } | FlowError;
98
+ export type EvidenceResult =
99
+ | { ok: true; evidence: NativeChoiceEvidence }
100
+ | { ok: false; error: string };
101
+ export type StatusTransition = { ok: true; next: FlowStatus } | FlowError;
102
+
103
+ export const MENU_CHOICES = [
104
+ "subagent-driven",
105
+ "inline",
106
+ "handoff",
107
+ "review-spec",
108
+ "review-plan",
109
+ ] as const;
110
+ export type MenuChoice = (typeof MENU_CHOICES)[number];
111
+
112
+ const err = (code: string, error: string): FlowError => ({ ok: false, code, error });
16
113
 
17
114
  const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
18
115
 
@@ -21,177 +118,768 @@ const flowPath = (root: string, slug: string) => {
21
118
  return path.join(root, "docs", slug, "sdd", "flow.json");
22
119
  };
23
120
 
121
+ // Resolve one spec/plan doc path under the shared contained contract (DC-01,
122
+ // DC-02): the caller-supplied slug must match the slug derived from the path.
123
+ const resolveDoc = (
124
+ root: string,
125
+ slug: string,
126
+ docPath: string,
127
+ kind: "spec" | "plan",
128
+ ): { ok: true; path: string } | { ok: false; error: string } => {
129
+ const resolved = resolveCanonicalLayout({
130
+ workspace_root: root,
131
+ ...(slug ? { slug } : {}),
132
+ [kind === "spec" ? "spec_path" : "plan_path"]: docPath,
133
+ });
134
+ if (!resolved.ok) return { ok: false, error: resolved.error };
135
+ return { ok: true, path: resolved.layout[kind === "spec" ? "spec" : "plan"] };
136
+ };
137
+
138
+ // A flow.json that exists was activated when preparation began; the field is
139
+ // kept for forward compatibility but a present file is always treated as
140
+ // activated. Missing state is NOT silently activated (FG-01).
141
+ const normalizeState = (parsed: unknown, slug: string): FlowState => {
142
+ const p = (parsed ?? {}) as Partial<FlowState>;
143
+ const spec = (p.spec ?? {}) as Partial<FlowDocState>;
144
+ const plan = (p.plan ?? {}) as Partial<FlowDocState>;
145
+ const menu = (p.menu ?? {}) as Partial<FlowMenuState>;
146
+ return {
147
+ slug: p.slug ?? slug,
148
+ activated: p.activated ?? true,
149
+ spec: {
150
+ path: spec.path ?? "",
151
+ status: spec.status ?? "draft",
152
+ evidence: spec.evidence ?? null,
153
+ },
154
+ plan: {
155
+ path: plan.path ?? "",
156
+ status: plan.status ?? "draft",
157
+ evidence: plan.evidence ?? null,
158
+ },
159
+ menu: {
160
+ presented: Boolean(menu.presented),
161
+ chosen: menu.chosen ?? "",
162
+ evidence: menu.evidence ?? null,
163
+ },
164
+ updated_at: p.updated_at ?? Date.now(),
165
+ };
166
+ };
167
+
168
+ const emptyState = (slug: string): FlowState => ({
169
+ slug,
170
+ activated: false,
171
+ spec: { path: "", status: "draft", evidence: null },
172
+ plan: { path: "", status: "draft", evidence: null },
173
+ menu: { presented: false, chosen: "", evidence: null },
174
+ updated_at: Date.now(),
175
+ });
176
+
24
177
  export const readFlowState = (root: string, slug: string): FlowState => {
178
+ const file = flowPath(root, slug);
179
+ if (!existsSync(file)) return emptyState(slug);
180
+ try {
181
+ return normalizeState(JSON.parse(readFileSync(file, "utf8")), slug);
182
+ } catch {
183
+ return emptyState(slug);
184
+ }
185
+ };
186
+
187
+ // Strict read for transitions and guards: missing or corrupt state is a
188
+ // structured error, never a silent draft fallback (CA-18).
189
+ type StrictRead = { ok: true; state: FlowState } | { ok: false; error: string; code: string };
190
+
191
+ const readFlowStrict = (root: string, slug: string): StrictRead => {
25
192
  const file = flowPath(root, slug);
26
193
  if (!existsSync(file)) {
27
- return {
28
- slug,
29
- spec: { path: "", status: "draft" },
30
- plan: { path: "", status: "draft" },
31
- menu: { presented: false, chosen: "" },
32
- updated_at: Date.now(),
33
- };
194
+ return err(
195
+ "flow_not_activated",
196
+ `flow not activated for ${slug} run workflow_flow_status first`,
197
+ );
34
198
  }
35
199
  try {
36
- const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<FlowState>;
37
- return {
38
- slug: parsed.slug ?? slug,
39
- spec: { path: parsed.spec?.path ?? "", status: parsed.spec?.status ?? "draft" },
40
- plan: { path: parsed.plan?.path ?? "", status: parsed.plan?.status ?? "draft" },
41
- menu: { presented: Boolean(parsed.menu?.presented), chosen: parsed.menu?.chosen ?? "" },
42
- updated_at: parsed.updated_at ?? Date.now(),
43
- };
44
- } catch {
45
- return {
46
- slug,
47
- spec: { path: "", status: "draft" },
48
- plan: { path: "", status: "draft" },
49
- menu: { presented: false, chosen: "" },
50
- updated_at: Date.now(),
51
- };
200
+ return { ok: true, state: normalizeState(JSON.parse(readFileSync(file, "utf8")), slug) };
201
+ } catch (error) {
202
+ return err(
203
+ "flow_corrupt",
204
+ `corrupt flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
205
+ );
52
206
  }
53
207
  };
54
208
 
209
+ // Unique per-write temporary buffer so two concurrent writers never share the
210
+ // same `<file>.tmp` (FG-08, CA-21). Pattern mirrors docs-migration.ts:597.
211
+ const uniqueTempPath = (file: string) =>
212
+ `${file}.${process.pid}-${Math.random().toString(36).slice(2)}.tmp`;
213
+
55
214
  export const writeFlowState = (root: string, state: FlowState) => {
56
215
  const file = flowPath(root, state.slug);
57
216
  mkdirSync(path.dirname(file), { recursive: true });
58
- const tmp = `${file}.tmp`;
217
+ const tmp = uniqueTempPath(file);
59
218
  writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
60
219
  renameSync(tmp, file);
61
220
  };
62
221
 
63
- const nextStatus = (
64
- current: FlowStatus,
65
- confirmed: boolean,
66
- ): { ok: false; error: string } | { ok: true; next: FlowStatus } => {
67
- if (!confirmed) return { ok: false, error: "confirmed: true required" };
68
- if (current === "draft") return { ok: true, next: "self_reviewed" };
69
- if (current === "self_reviewed") return { ok: true, next: "approved" };
70
- return { ok: false, error: "already approved; no further transitions" };
71
- };
222
+ const MAX_WRITE_ATTEMPTS = 5;
72
223
 
73
- export const transitionSpec = (
224
+ export type FlowWriteResult =
225
+ | { ok: true }
226
+ | { ok: false; conflict: true }
227
+ | { ok: false; io_error: string };
228
+
229
+ /**
230
+ * Compare-and-write (FG-08): write `next` only if the on-disk content still
231
+ * equals the version this writer read (`expected`). A stale writer gets
232
+ * `conflict` instead of clobbering a concurrent newer write; the caller re-reads
233
+ * and retries the transition (bounded). Unique per-write temp names keep the
234
+ * write buffer from being shared between writers.
235
+ *
236
+ * The first compare happens before the buffer is staged; the file is re-read
237
+ * immediately before the rename so a writer that committed between the two
238
+ * points still wins. Without the re-read, two writers holding the same expected
239
+ * text would both pass the compare and both rename — a lost update.
240
+ * ponytail: the re-read shrinks but cannot close the cross-process window (a
241
+ * writer can still commit between this re-read and the rename). Upgrade path:
242
+ * hold an O_EXCL advisory lock on `<file>.lock` across the read-modify-write,
243
+ * or move to renameat2(RENAME_EXCHANGE)/an OS-level CAS when a second
244
+ * concurrent process becomes a supported topology.
245
+ *
246
+ * A thrown error here is a real IO/permission failure (EACCES, ENOSPC, ...),
247
+ * not a conflict: it is returned as `io_error` so callers surface it instead of
248
+ * advising a pointless re-read-and-retry. Any unique `.tmp` staged by this
249
+ * writer is removed on every non-success path so crashed writers don't
250
+ * accumulate temp buffers.
251
+ */
252
+ export const writeFlowStateIfCurrent = (
74
253
  root: string,
75
- slug: string,
76
- specPath: string,
77
- confirmed: boolean,
78
- ): Result => {
79
- let state: FlowState;
254
+ expected: FlowState,
255
+ next: FlowState,
256
+ ): FlowWriteResult => {
257
+ const file = flowPath(root, next.slug);
258
+ const expectedText = JSON.stringify(expected, null, 2) + "\n";
259
+ const nextText = JSON.stringify(next, null, 2) + "\n";
260
+ if (expectedText === nextText) return { ok: true };
261
+ const tmp = uniqueTempPath(file);
80
262
  try {
81
- state = readFlowState(root, slug);
263
+ const currentText = existsSync(file) ? readFileSync(file, "utf8") : null;
264
+ if (currentText !== expectedText) return { ok: false, conflict: true };
265
+ mkdirSync(path.dirname(file), { recursive: true });
266
+ writeFileSync(tmp, nextText, "utf8");
267
+ const reRead = existsSync(file) ? readFileSync(file, "utf8") : null;
268
+ if (reRead !== expectedText) return { ok: false, conflict: true };
269
+ renameSync(tmp, file);
270
+ return { ok: true };
82
271
  } catch (error) {
83
- return { ok: false, error: error instanceof Error ? error.message : "invalid flow state" };
272
+ return { ok: false, io_error: error instanceof Error ? error.message : String(error) };
273
+ } finally {
274
+ // On success the rename moved the buffer into place; on any other exit the
275
+ // unique temp is orphaned — remove it so crashed writers don't accumulate
276
+ // `<file>.<pid>-<rand>.tmp` buffers.
277
+ try {
278
+ if (existsSync(tmp)) rmSync(tmp, { force: true });
279
+ } catch {
280
+ // best effort: a leftover temp is preferable to masking the real error
281
+ }
282
+ }
283
+ };
284
+
285
+ type MutateResult = { ok: true; next: FlowState } | FlowError;
286
+
287
+ // Unlocked read-modify-write made safe (FG-08): read strict, mutate in memory,
288
+ // then commit only if the on-disk state still matches what was read; otherwise
289
+ // re-read and retry the transition, bounded.
290
+ const readModifyWrite = (
291
+ root: string,
292
+ slug: string,
293
+ mutate: (state: FlowState) => MutateResult,
294
+ ): FlowGateResult => {
295
+ for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
296
+ const strict = readFlowStrict(root, slug);
297
+ if (!strict.ok) return strict;
298
+ const result = mutate(strict.state);
299
+ if (!result.ok) return result;
300
+ const commit = writeFlowStateIfCurrent(root, strict.state, result.next);
301
+ if (commit.ok) return { ok: true };
302
+ if ("io_error" in commit) {
303
+ return err("flow_io_error", `flow state write failed for ${slug}: ${commit.io_error}`);
304
+ }
305
+ // a concurrent writer won the race — re-read and retry the transition
306
+ }
307
+ return err(
308
+ "flow_concurrent_conflict",
309
+ `concurrent flow update detected for ${slug}: re-read the flow state and retry the transition`,
310
+ );
311
+ };
312
+
313
+ // The caller-supplied workspace must be the host workspace the context names
314
+ // (CA-21): a context built for another repo must not drive writes here.
315
+ const assertMutationWorkspace = (root: string, ctx?: MutationContext): FlowGateResult => {
316
+ if (ctx && ctx.hostWorkspace !== root) {
317
+ return err(
318
+ "workspace_mismatch",
319
+ `mutation context workspace ${JSON.stringify(ctx.hostWorkspace)} does not match flow workspace ${JSON.stringify(root)}`,
320
+ );
321
+ }
322
+ return { ok: true };
323
+ };
324
+
325
+ /**
326
+ * Coordinator boundary (FG-05, CA-20): once a plan is subagent-driven, the
327
+ * coordinator session cannot mutate product state — only authenticated
328
+ * delegated workers can. A delegated worker without a task identity is blocked.
329
+ */
330
+ export const assertCoordinatorBoundary = (
331
+ ctx: MutationContext | undefined,
332
+ menu: FlowMenuState,
333
+ ): FlowGateResult => {
334
+ if (ctx?.role === "coordinator" && menu.chosen === "subagent-driven") {
335
+ return err("coordinator_blocked", COORDINATOR_RECOVERY_TEXT);
84
336
  }
85
- if (!existsSync(path.isAbsolute(specPath) ? specPath : path.join(root, specPath))) {
86
- return { ok: false, error: `spec not found: ${specPath}` };
337
+ if (ctx?.role === "delegated" && !ctx.taskIdentity) {
338
+ return err(
339
+ "delegated_unauthenticated",
340
+ "delegated mutations require an authenticated task identity (taskIdentity) — re-run inside the delegated worker session",
341
+ );
87
342
  }
88
- if (state.spec.status === "draft" && confirmed) {
89
- const specFile = path.isAbsolute(specPath) ? specPath : path.join(root, specPath);
90
- let text: string;
91
- try {
92
- text = readFileSync(specFile, "utf8");
93
- } catch (error) {
343
+ return { ok: true };
344
+ };
345
+
346
+ /**
347
+ * The shared transition matrix (FG-09): draft -> approved in one receipt; a
348
+ * legacy self_reviewed state still advances to approved. The self-review
349
+ * validation runs automatically inside the draft transition.
350
+ */
351
+ export const nextFlowStatus = (current: FlowStatus): StatusTransition => {
352
+ if (current === "draft") return { ok: true, next: "approved" };
353
+ if (current === "self_reviewed") return { ok: true, next: "approved" };
354
+ return err("flow_already_approved", "already approved; no further transitions");
355
+ };
356
+
357
+ const MAX_CLOCK_SKEW_MS = 60_000;
358
+ const MAX_RECEIPTS_PER_SESSION = 10;
359
+
360
+ /**
361
+ * Freshness window for receipts (FINDING 2): an answer older than this can no
362
+ * longer be taken as the user's current intent. ponytail: fixed constant, not
363
+ * config — the consume path runs per approval-tool call, so a knob would buy
364
+ * surface area, not security.
365
+ */
366
+ const RECEIPT_FRESHNESS_MS = 10 * 60 * 1000;
367
+
368
+ /**
369
+ * Independent belt-and-suspenders age gate for evidence objects passed to the
370
+ * transition functions (receipts are already capped at RECEIPT_FRESHNESS_MS at
371
+ * consume time; this defends direct library callers that fabricate a shape).
372
+ */
373
+ const EVIDENCE_WINDOW_MS = 24 * 60 * 60 * 1000;
374
+
375
+ /**
376
+ * Case-insensitive negative-answer denylist (FINDING 3): a user answering
377
+ * "No"/"Reject"/"Cancel" can never be recorded as consent for an approval or
378
+ * a menu choice. The boundary closes the laundering case (negative answer ->
379
+ * approval); a prefix variant ("no, thanks") is covered too.
380
+ */
381
+ const NEGATIVE_ANSWER_LABELS = [
382
+ "no",
383
+ "nope",
384
+ "nah",
385
+ "reject",
386
+ "cancel",
387
+ "decline",
388
+ "not now",
389
+ "not yet",
390
+ "skip",
391
+ "back",
392
+ "deny",
393
+ ];
394
+
395
+ const isNegativeLabel = (label: string): boolean => {
396
+ const normalized = label.trim().toLowerCase();
397
+ return NEGATIVE_ANSWER_LABELS.some((entry) => {
398
+ const firstWord = normalized.split(/\s+/)[0] ?? "";
399
+ if (entry.includes(" ")) {
400
+ // multi-word entries ("not now", "not yet"): whole-answer match,
401
+ // punctuation-insensitive ("not yet, let me check" -> "not yet")
402
+ const plain = normalized.replace(/[^a-z ]/g, "");
403
+ return plain === entry || plain.startsWith(`${entry} `);
404
+ }
405
+ // single-word entries ("no", "reject", ...): match the first word,
406
+ // ignoring punctuation ("no, thanks" -> "no"); "notebook" stays allowed
407
+ return firstWord.replace(/[^a-z]/g, "") === entry;
408
+ });
409
+ };
410
+
411
+ /**
412
+ * One-use host-observed receipt (AR-12, CA-41): recorded by the OpenCode
413
+ * plugin when the answered `question` tool completes, bound to the session,
414
+ * the question tool call id, the exact selected label, and the timestamp.
415
+ * The model has no way to inject a receipt — `record` is only reachable from
416
+ * the plugin's `tool.execute.after` hook.
417
+ */
418
+ export type HostReceipt = {
419
+ sessionId: string;
420
+ callID: string;
421
+ selectedLabel: string;
422
+ recordedAt: number;
423
+ /** The question text the user answered (plugin-observed, best effort), so
424
+ * the consuming tool can report WHICH question authorized a transition
425
+ * (FINDING 2). */
426
+ question?: string;
427
+ };
428
+
429
+ export type ReceiptConsumeResult = { ok: true; receipt: HostReceipt } | FlowError;
430
+
431
+ /**
432
+ * In-memory per-session receipt queue. `record` simulates the host hook; the
433
+ * OpenCode plugin is the only production caller. Unconsumed receipts are
434
+ * bounded per session (oldest dropped) so a session that asks questions
435
+ * without approving cannot grow memory without limit.
436
+ *
437
+ * Correlation (FINDING 2): on a real host the model first calls the native
438
+ * `question` (user answers), THEN calls the approval/menu tool — the tools
439
+ * never run a question internally, so a before/after execution window can
440
+ * never capture the answer. Consumption therefore takes the session's MOST
441
+ * RECENT unconsumed receipt and verifies: one-use (atomic take), freshness
442
+ * (RECEIPT_FRESHNESS_MS), NOT a negative label (isNegativeLabel), and session
443
+ * match. Menu tools additionally pin the expected choice label. CallID and
444
+ * the exact selected label stay bound at record time.
445
+ *
446
+ * Residual risk (honest boundary): any recent POSITIVE host answer (e.g. a
447
+ * "proceed with stash?" -> "yes, proceed") plus the model's choice to call an
448
+ * approval tool authorizes the transition. The laundering case — a negative
449
+ * answer recorded as an approval — is closed by the negative-label denylist.
450
+ *
451
+ * ponytail: in-memory only — receipts die with the plugin process, which is
452
+ * correct: a host-observed answer cannot survive a restart. Upgrade path:
453
+ * persist to the host session store when cross-restart approvals are required.
454
+ */
455
+ export class HostReceiptStore {
456
+ #bySession = new Map<string, HostReceipt[]>();
457
+
458
+ record(
459
+ sessionId: string,
460
+ callID: string,
461
+ selectedLabel: string,
462
+ recordedAt: number = Date.now(),
463
+ question?: string,
464
+ ): void {
465
+ const label = selectedLabel.trim();
466
+ if (!label) return;
467
+ if (recordedAt > Date.now() + MAX_CLOCK_SKEW_MS) return; // forged future receipt
468
+ const queue = this.#bySession.get(sessionId) ?? [];
469
+ if (queue.length >= MAX_RECEIPTS_PER_SESSION) queue.shift();
470
+ queue.push({ sessionId, callID, selectedLabel: label, recordedAt, question });
471
+ this.#bySession.set(sessionId, queue);
472
+ }
473
+
474
+ count(sessionId: string): number {
475
+ return this.#bySession.get(sessionId)?.length ?? 0;
476
+ }
477
+
478
+ /**
479
+ * Non-destructive consume: same checks as `consume`, but a positive receipt
480
+ * stays queued. The tools no longer use peek — FINDING 5 (round 3) moved the
481
+ * approval/menu tools to consume-before-transition (the atomic take gates
482
+ * the transition and is spent on any attempt, closing the concurrent-call
483
+ * race). Peek remains for tests and read-only callers. A NEGATIVE receipt is
484
+ * the exception: it is spent by peek too (consumed-and-rejected, FINDING 3)
485
+ * so it cannot poison the top of the queue.
486
+ */
487
+ peek(sessionId: string, opts: { label?: string } = {}): ReceiptConsumeResult {
488
+ return this.#take(sessionId, opts, false);
489
+ }
490
+
491
+ /**
492
+ * One-use consumption of the session's most recent receipt (FINDING 2).
493
+ * The atomic take gates the transition at the tool layer (FINDING 5, round
494
+ * 3): a stale receipt, a wrong pinned label (menu), or a negative label
495
+ * fails the transition; the receipt is removed on take, staleness, or
496
+ * negativity (fail-closed). A wrong label (menu) is NOT spent — it stays
497
+ * queued for the choice it actually matched.
498
+ */
499
+ consume(sessionId: string, opts: { label?: string } = {}): ReceiptConsumeResult {
500
+ return this.#take(sessionId, opts, true);
501
+ }
502
+
503
+ #take(sessionId: string, opts: { label?: string }, remove: boolean): ReceiptConsumeResult {
504
+ const queue = this.#bySession.get(sessionId);
505
+ if (!queue || queue.length === 0) {
506
+ return err(
507
+ "receipt_missing",
508
+ "no host-observed native-question receipt for this session — ask the native " +
509
+ "`question` tool and have the user answer before calling this tool",
510
+ );
511
+ }
512
+ const index = queue.length - 1;
513
+ const receipt = queue[index];
514
+ if (isNegativeLabel(receipt.selectedLabel)) {
515
+ // Consumed-and-rejected: a negative answer is still an answer, and it
516
+ // can never authorize a transition (FINDING 3). The whole session queue
517
+ // is revoked too: the user's most recent intent is negative, so an
518
+ // older positive answer must not come back to life on a retry.
519
+ this.#bySession.delete(sessionId);
520
+ return err(
521
+ "receipt_rejected",
522
+ `the user's most recent answer (${JSON.stringify(receipt.selectedLabel)}) is a ` +
523
+ "negative answer — it cannot authorize an approval; ask the native question again",
524
+ );
525
+ }
526
+ if (opts.label !== undefined && !sameChoiceLabel(receipt.selectedLabel, opts.label)) {
527
+ // FINDING 6: a wrong-label answer is not spent — it stays queued for
528
+ // the choice it actually matched (or expires via freshness/bounds).
529
+ return err(
530
+ "evidence_mismatch",
531
+ `receipt selectedLabel does not match ${JSON.stringify(opts.label)} — fabricated menu choice rejected`,
532
+ );
533
+ }
534
+ if (Date.now() - receipt.recordedAt > RECEIPT_FRESHNESS_MS) {
535
+ if (remove) {
536
+ queue.splice(index, 1);
537
+ if (queue.length === 0) this.#bySession.delete(sessionId);
538
+ }
539
+ return err(
540
+ "receipt_stale",
541
+ "the question receipt is too old — ask the native question again and re-answer",
542
+ );
543
+ }
544
+ if (remove) {
545
+ queue.splice(index, 1);
546
+ if (queue.length === 0) this.#bySession.delete(sessionId);
547
+ }
548
+ return { ok: true, receipt };
549
+ }
550
+ }
551
+
552
+ /** Menu labels compare case-insensitively: the host presents "Inline", the
553
+ * enum stores "inline" (FINDING 3). */
554
+ const sameChoiceLabel = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
555
+
556
+ /** Derive the evidence record from a consumed host receipt (AR-12). */
557
+ export const createOpenCodeEvidence = (receipt: HostReceipt): OpenCodeChoiceEvidence => ({
558
+ host: "opencode",
559
+ attested: true,
560
+ callID: receipt.callID,
561
+ selectedLabel: receipt.selectedLabel,
562
+ recordedAt: receipt.recordedAt,
563
+ });
564
+
565
+ /** The Cursor policy-only constant: unauthenticated, no caller data (CA-42). */
566
+ export const createCursorConfirmation = (): EvidenceResult => ({
567
+ ok: true,
568
+ evidence: { host: "cursor", attested: false, confirmation: "contract" },
569
+ });
570
+
571
+ const CURSOR_KEYS = ["attested", "confirmation", "host"];
572
+
573
+ /**
574
+ * Strict shape validation for every flow transition (CA-41): OpenCode evidence
575
+ * must be a host-attested receipt record; Cursor evidence must be exactly the
576
+ * policy-only constant and carries no caller-supplied question data. A Cursor
577
+ * object claiming an observed answer (`attested: true`) is rejected as forged.
578
+ */
579
+ export const assertEvidenceShape = (input: unknown): EvidenceResult => {
580
+ if (typeof input !== "object" || input === null) {
581
+ return {
582
+ ok: false,
583
+ error:
584
+ "native choice evidence required — bare booleans and other primitives are not approval evidence",
585
+ };
586
+ }
587
+ const record = input as Record<string, unknown>;
588
+ if (record.host === "cursor") {
589
+ if (record.attested !== false || record.confirmation !== "contract") {
94
590
  return {
95
591
  ok: false,
96
- error: `spec self-review failed: unreadable spec: ${error instanceof Error ? error.message : String(error)}`,
592
+ error:
593
+ 'cursor confirmations are policy-only: exactly { host: "cursor", attested: false, confirmation: "contract" } — Cursor cannot attest a host-observed answer',
97
594
  };
98
595
  }
99
- const hard = qualitySpec(text).filter((f) => f.severity === "hard");
100
- const missing: string[] = [];
101
- if (!/^\s*\*+Branch:\*+/im.test(stripFences(text))) missing.push("**Branch:** header missing");
102
- if (hard.length > 0 || missing.length > 0) {
596
+ const keys = Object.keys(record).sort();
597
+ if (keys.length !== CURSOR_KEYS.length || !CURSOR_KEYS.every((key) => keys.includes(key))) {
103
598
  return {
104
599
  ok: false,
105
600
  error:
106
- "spec self-review failed: " +
107
- hard
108
- .map((f) => `${f.code} — ${f.message}`)
109
- .concat(missing)
110
- .join("; ") +
111
- " — see templates/spec-template.md for the required structure",
601
+ "cursor confirmations carry no caller-supplied question data — the attested: false constant only",
112
602
  };
113
603
  }
604
+ return { ok: true, evidence: { host: "cursor", attested: false, confirmation: "contract" } };
605
+ }
606
+ if (record.host !== "opencode") {
607
+ return {
608
+ ok: false,
609
+ error: `evidence host must be 'opencode' or 'cursor', got ${JSON.stringify(record.host)}`,
610
+ };
611
+ }
612
+ if (record.attested !== true) {
613
+ return {
614
+ ok: false,
615
+ error:
616
+ "opencode evidence requires host attestation (attested: true) — only host-observed question receipts are accepted",
617
+ };
618
+ }
619
+ const { callID, selectedLabel, recordedAt } = record;
620
+ if (typeof callID !== "string" || callID.trim() === "") {
621
+ return {
622
+ ok: false,
623
+ error: "opencode evidence callID must be a non-empty string (host question tool call)",
624
+ };
625
+ }
626
+ if (typeof selectedLabel !== "string" || selectedLabel.trim() === "") {
627
+ return {
628
+ ok: false,
629
+ error:
630
+ "opencode evidence selectedLabel must be the exact label the user selected on the native question",
631
+ };
632
+ }
633
+ if (typeof recordedAt !== "number" || !Number.isFinite(recordedAt) || recordedAt <= 0) {
634
+ return {
635
+ ok: false,
636
+ error: "opencode evidence recordedAt must be a positive epoch-ms timestamp",
637
+ };
114
638
  }
115
- const step = nextStatus(state.spec.status, confirmed);
116
- if (!step.ok) return { ok: false, error: step.error };
117
- writeFlowState(root, {
118
- ...state,
119
- spec: { path: specPath, status: step.next },
120
- updated_at: Date.now(),
639
+ const now = Date.now();
640
+ if (recordedAt > now + MAX_CLOCK_SKEW_MS) {
641
+ return {
642
+ ok: false,
643
+ error: "opencode evidence recordedAt is in the future — forged evidence is rejected",
644
+ };
645
+ }
646
+ if (now - recordedAt > EVIDENCE_WINDOW_MS) {
647
+ return {
648
+ ok: false,
649
+ error:
650
+ "opencode evidence recordedAt is too old — ask the native question again and re-record the answer",
651
+ };
652
+ }
653
+ return {
654
+ ok: true,
655
+ evidence: {
656
+ host: "opencode",
657
+ attested: true,
658
+ callID: callID.trim(),
659
+ selectedLabel: selectedLabel.trim(),
660
+ recordedAt,
661
+ },
662
+ };
663
+ };
664
+
665
+ /** Host provenance binding: OpenCode only accepts opencode evidence, and vice versa. */
666
+ export const assertHostEvidence = (host: FlowHost, evidence: unknown): FlowGateResult => {
667
+ const shaped = assertEvidenceShape(evidence);
668
+ if (!shaped.ok) return err("evidence_invalid", shaped.error);
669
+ if (shaped.evidence.host !== host) {
670
+ return err(
671
+ "evidence_host_mismatch",
672
+ `evidence was recorded on ${JSON.stringify(shaped.evidence.host)}, not ${host} — forged or misattributed evidence is rejected`,
673
+ );
674
+ }
675
+ return { ok: true };
676
+ };
677
+
678
+ /**
679
+ * Record flow activation and the canonical spec/plan paths when preparation
680
+ * begins. The flow store lives under the canonical docs/<slug>/sdd/ layout
681
+ * (Task 18 contract). Re-runs keep existing statuses while recording paths.
682
+ */
683
+ export const prepareFlowState = (
684
+ root: string,
685
+ slug: string,
686
+ opts: { spec_path?: string; plan_path?: string } = {},
687
+ ctx?: MutationContext,
688
+ ): FlowGateResult => {
689
+ const bound = assertMutationWorkspace(root, ctx);
690
+ if (!bound.ok) return bound;
691
+ const resolved = resolveCanonicalLayout({
692
+ workspace_root: root,
693
+ slug,
694
+ spec_path: opts.spec_path,
695
+ plan_path: opts.plan_path,
121
696
  });
697
+ if (!resolved.ok) return err("flow_prepare_failed", resolved.error);
698
+ const specPath = path.posix.join("docs", slug, "spec.md");
699
+ const planPath = path.posix.join("docs", slug, "plan.md");
700
+ const current = readFlowState(root, slug);
701
+ const state: FlowState = current.activated
702
+ ? {
703
+ ...current,
704
+ spec: { ...current.spec, path: specPath },
705
+ plan: { ...current.plan, path: planPath },
706
+ updated_at: Date.now(),
707
+ }
708
+ : {
709
+ slug,
710
+ activated: true,
711
+ spec: { path: specPath, status: "draft", evidence: null },
712
+ plan: { path: planPath, status: "draft", evidence: null },
713
+ menu: { presented: false, chosen: "", evidence: null },
714
+ updated_at: Date.now(),
715
+ };
716
+ writeFlowState(root, state);
122
717
  return { ok: true };
123
718
  };
124
719
 
720
+ export const transitionSpec = (
721
+ root: string,
722
+ slug: string,
723
+ specPath: string,
724
+ evidence: unknown,
725
+ ctx?: MutationContext,
726
+ ): FlowGateResult => {
727
+ const bound = assertMutationWorkspace(root, ctx);
728
+ if (!bound.ok) return bound;
729
+ const recorded = assertEvidenceShape(evidence);
730
+ if (!recorded.ok) return err("evidence_invalid", recorded.error);
731
+ const doc = resolveDoc(root, slug, specPath, "spec");
732
+ if (!doc.ok) return err("path_invalid", doc.error);
733
+ return readModifyWrite(root, slug, (state) => {
734
+ if (!existsSync(doc.path)) return err("spec_missing", `spec not found: ${specPath}`);
735
+ if (state.spec.status === "draft") {
736
+ let text: string;
737
+ try {
738
+ text = readFileSync(doc.path, "utf8");
739
+ } catch (error) {
740
+ return err(
741
+ "spec_self_review_failed",
742
+ `spec self-review failed: unreadable spec: ${error instanceof Error ? error.message : String(error)}`,
743
+ );
744
+ }
745
+ const hard = qualitySpec(text).filter((f) => f.severity === "hard");
746
+ const missing: string[] = [];
747
+ if (!/^\s*\*+Branch:\*+/im.test(stripFences(text)))
748
+ missing.push("**Branch:** header missing");
749
+ if (hard.length > 0 || missing.length > 0) {
750
+ return err(
751
+ "spec_self_review_failed",
752
+ "spec self-review failed: " +
753
+ hard
754
+ .map((f) => `${f.code} — ${f.message}`)
755
+ .concat(missing)
756
+ .join("; ") +
757
+ " — see templates/spec-template.md for the required structure",
758
+ );
759
+ }
760
+ }
761
+ const step = nextFlowStatus(state.spec.status);
762
+ if (!step.ok) return step;
763
+ return {
764
+ ok: true,
765
+ next: {
766
+ ...state,
767
+ spec: {
768
+ path: path.posix.join("docs", slug, "spec.md"),
769
+ status: step.next,
770
+ evidence: recorded.evidence,
771
+ },
772
+ updated_at: Date.now(),
773
+ },
774
+ };
775
+ });
776
+ };
777
+
125
778
  export const transitionPlan = (
126
779
  root: string,
127
780
  slug: string,
128
781
  planPath: string,
129
- confirmed: boolean,
130
- ): Result => {
131
- let state: FlowState;
132
- try {
133
- state = readFlowState(root, slug);
134
- } catch (error) {
135
- return { ok: false, error: error instanceof Error ? error.message : "invalid flow state" };
136
- }
137
- if (!existsSync(path.isAbsolute(planPath) ? planPath : path.join(root, planPath))) {
138
- return { ok: false, error: `plan not found: ${planPath}` };
139
- }
140
- if (state.spec.status !== "approved") {
141
- return { ok: false, error: "spec must be approved before the plan can be approved" };
142
- }
143
- if (state.plan.status === "draft" && confirmed) {
144
- const planFile = path.isAbsolute(planPath) ? planPath : path.join(root, planPath);
145
- let text: string;
146
- try {
147
- text = readFileSync(planFile, "utf8");
148
- } catch (error) {
149
- return {
150
- ok: false,
151
- error: `plan self-review failed: unreadable plan: ${error instanceof Error ? error.message : String(error)}`,
152
- };
782
+ evidence: unknown,
783
+ ctx?: MutationContext,
784
+ ): FlowGateResult => {
785
+ const bound = assertMutationWorkspace(root, ctx);
786
+ if (!bound.ok) return bound;
787
+ const recorded = assertEvidenceShape(evidence);
788
+ if (!recorded.ok) return err("evidence_invalid", recorded.error);
789
+ const doc = resolveDoc(root, slug, planPath, "plan");
790
+ if (!doc.ok) return err("path_invalid", doc.error);
791
+ return readModifyWrite(root, slug, (state) => {
792
+ if (!existsSync(doc.path)) return err("plan_missing", `plan not found: ${planPath}`);
793
+ if (state.spec.status !== "approved") {
794
+ return err("spec_not_approved", "spec must be approved before the plan can be approved");
153
795
  }
154
- const missing: string[] = [];
155
- const stripped = stripFences(text);
156
- if (parseTasksFromPlan(text).length === 0)
157
- missing.push("no ### Task N: sections outside fences");
158
- if (!/^\s*\*+Spec:\*+/im.test(stripped)) missing.push("**Spec:** header missing");
159
- if (!/^\s*\*+Branch:\*+/im.test(stripped)) missing.push("**Branch:** header missing");
160
- if (missing.length > 0) {
161
- return { ok: false, error: "plan self-review failed: " + missing.join("; ") };
162
- }
163
- }
164
- const step = nextStatus(state.plan.status, confirmed);
165
- if (!step.ok) return { ok: false, error: step.error };
166
- writeFlowState(root, {
167
- ...state,
168
- plan: { path: planPath, status: step.next },
169
- updated_at: Date.now(),
796
+ if (state.plan.status === "draft") {
797
+ let text: string;
798
+ try {
799
+ text = readFileSync(doc.path, "utf8");
800
+ } catch (error) {
801
+ return err(
802
+ "plan_self_review_failed",
803
+ `plan self-review failed: unreadable plan: ${error instanceof Error ? error.message : String(error)}`,
804
+ );
805
+ }
806
+ const missing: string[] = [];
807
+ const stripped = stripFences(text);
808
+ if (parseTasksFromPlan(text).length === 0)
809
+ missing.push("no ### Task N: sections outside fences");
810
+ if (!/^\s*\*+Spec:\*+/im.test(stripped)) missing.push("**Spec:** header missing");
811
+ if (!/^\s*\*+Branch:\*+/im.test(stripped)) missing.push("**Branch:** header missing");
812
+ if (missing.length > 0)
813
+ return err("plan_self_review_failed", "plan self-review failed: " + missing.join("; "));
814
+ }
815
+ const step = nextFlowStatus(state.plan.status);
816
+ if (!step.ok) return step;
817
+ return {
818
+ ok: true,
819
+ next: {
820
+ ...state,
821
+ plan: {
822
+ path: path.posix.join("docs", slug, "plan.md"),
823
+ status: step.next,
824
+ evidence: recorded.evidence,
825
+ },
826
+ updated_at: Date.now(),
827
+ },
828
+ };
170
829
  });
171
- return { ok: true };
172
830
  };
173
831
 
174
832
  export const recordMenuChoice = (
175
833
  root: string,
176
834
  slug: string,
177
835
  planPath: string,
178
- choice: string,
179
- confirmed: boolean,
180
- ): Result => {
181
- if (!confirmed) return { ok: false, error: "confirmed: true required" };
182
- let state: FlowState;
183
- try {
184
- state = readFlowState(root, slug);
185
- } catch (error) {
186
- return { ok: false, error: error instanceof Error ? error.message : "invalid flow state" };
836
+ choice: unknown,
837
+ evidence: unknown,
838
+ ctx?: MutationContext,
839
+ ): FlowGateResult => {
840
+ const bound = assertMutationWorkspace(root, ctx);
841
+ if (!bound.ok) return bound;
842
+ const recorded = assertEvidenceShape(evidence);
843
+ if (!recorded.ok) return err("evidence_invalid", recorded.error);
844
+ if (typeof choice !== "string" || !MENU_CHOICES.includes(choice as MenuChoice)) {
845
+ return err("menu_choice_invalid", `invalid menu choice: ${JSON.stringify(choice)}`);
187
846
  }
188
- writeFlowState(root, {
189
- ...state,
190
- plan: state.plan.path ? state.plan : { path: planPath, status: state.plan.status },
191
- menu: { presented: true, chosen: choice },
192
- updated_at: Date.now(),
847
+ // Cursor cannot run subagent-driven plans (no child sessions): entering that
848
+ // flow state on Cursor is rejected with recovery guidance (CA-42).
849
+ if (recorded.evidence.host === "cursor" && choice === "subagent-driven") {
850
+ return err("unsupported_mode", CURSOR_SUBAGENT_UNSUPPORTED_TEXT);
851
+ }
852
+ // The execution-menu evidence must be the label the user selected on the
853
+ // native question; a mismatched choice is fabricated (FG-04). Comparison is
854
+ // case-insensitive: the host presents "Inline", the enum stores "inline"
855
+ // (FINDING 3). Cursor evidence is the policy-only constant (no label), so
856
+ // the check applies to host-observed OpenCode receipts only.
857
+ if (
858
+ recorded.evidence.host === "opencode" &&
859
+ !sameChoiceLabel(recorded.evidence.selectedLabel, choice)
860
+ ) {
861
+ return err(
862
+ "evidence_mismatch",
863
+ `evidence selectedLabel ${JSON.stringify(recorded.evidence.selectedLabel)} does not match choice ${JSON.stringify(choice)}`,
864
+ );
865
+ }
866
+ const doc = resolveDoc(root, slug, planPath, "plan");
867
+ if (!doc.ok) return err("path_invalid", doc.error);
868
+ return readModifyWrite(root, slug, (state) => {
869
+ if (state.spec.status !== "approved")
870
+ return err("spec_not_approved", "spec must be approved before the execution menu");
871
+ if (state.plan.status !== "approved")
872
+ return err("plan_not_approved", "plan must be approved before the execution menu");
873
+ return {
874
+ ok: true,
875
+ next: {
876
+ ...state,
877
+ plan: state.plan.path ? state.plan : { path: planPath, status: state.plan.status },
878
+ menu: { presented: true, chosen: choice, evidence: recorded.evidence },
879
+ updated_at: Date.now(),
880
+ },
881
+ };
193
882
  });
194
- return { ok: true };
195
883
  };
196
884
 
197
885
  export const slugFromPath = (p: string) => {
@@ -199,31 +887,781 @@ export const slugFromPath = (p: string) => {
199
887
  return dirName === "." || dirName === "/" || dirName === "" ? "" : dirName;
200
888
  };
201
889
 
890
+ /** Derive a slug from a canonical docs/<slug>/sdd/... path (SDD write gates). */
891
+ export const slugFromSddPath = (p: string): string => {
892
+ const match = p
893
+ .split(path.sep)
894
+ .join("/")
895
+ // The sdd dir name may be followed by a separator, end-of-string, or a
896
+ // quote char part of a quote-bearing dir name; `sdd-attack` (hyphen/letter
897
+ // continuation) is still rejected.
898
+ .match(/^docs\/([^/]+)\/sdd(\/|$|['"])/);
899
+ return match?.[1] ?? "";
900
+ };
901
+
202
902
  export const assertFlowGates = (
203
903
  root: string,
204
904
  planPath: string,
205
905
  opts: { requireMenu?: boolean } = {},
206
- ): Result => {
906
+ ): FlowGateResult => {
907
+ const doc = resolveDoc(root, "", planPath, "plan");
908
+ if (!doc.ok) return err("path_invalid", doc.error);
207
909
  const slug = slugFromPath(planPath);
208
910
  const state = readFlowState(root, slug);
209
911
  if (state.spec.status !== "approved") {
210
- return {
211
- ok: false,
212
- error: `spec not approved (status: ${state.spec.status}). Run workflow_spec_approve after the user's approval.`,
213
- };
912
+ return err(
913
+ "spec_not_approved",
914
+ `spec not approved (status: ${state.spec.status}). Run workflow_spec_approve after the user's approval.`,
915
+ );
214
916
  }
215
917
  if (state.plan.status !== "approved") {
216
- return {
217
- ok: false,
218
- error: `plan not approved (status: ${state.plan.status}). Run workflow_plan_approve after the user's approval.`,
219
- };
918
+ return err(
919
+ "plan_not_approved",
920
+ `plan not approved (status: ${state.plan.status}). Run workflow_plan_approve after the user's approval.`,
921
+ );
220
922
  }
221
923
  if (opts.requireMenu && !state.menu.presented) {
222
- return {
223
- ok: false,
224
- error:
225
- "post-plan menu not presented. Ask the native question menu (Subagent-driven/Inline/Handoff/Review spec/Review plan) and record the answer with workflow_plan_menu.",
226
- };
924
+ return err(
925
+ "menu_not_presented",
926
+ "post-plan menu not presented. Ask the native question menu (Subagent-driven/Inline/Handoff/Review spec/Review plan) and record the answer with workflow_plan_menu.",
927
+ );
928
+ }
929
+ return { ok: true };
930
+ };
931
+
932
+ /**
933
+ * Shared mutation guard for non-document product writes (FG-03, CA-18): a write
934
+ * is blocked until the spec is approved, the plan is approved, the execution
935
+ * menu has been recorded (when required), and the canonical docs validate.
936
+ * The optional MutationContext adds the coordinator boundary (FG-05, CA-20).
937
+ */
938
+ export const assertProductGates = (
939
+ root: string,
940
+ slug: string,
941
+ opts: { requireMenu?: boolean; requireDocs?: boolean } = {},
942
+ ctx?: MutationContext,
943
+ ): FlowGateResult => {
944
+ const bound = assertMutationWorkspace(root, ctx);
945
+ if (!bound.ok) return bound;
946
+ // Strict read (CA-18): missing or corrupt state surfaces flow_not_activated /
947
+ // flow_corrupt, never a misleading spec_not_approved from a silent draft
948
+ // fallback. Fail-closed is preserved — no gate ever passes on absent state.
949
+ const strict = readFlowStrict(root, slug);
950
+ if (!strict.ok) return strict;
951
+ const state = strict.state;
952
+ if (state.spec.status !== "approved") {
953
+ return err(
954
+ "spec_not_approved",
955
+ `spec not approved (status: ${state.spec.status}). Run workflow_spec_approve after the user's approval.`,
956
+ );
957
+ }
958
+ if (state.plan.status !== "approved") {
959
+ return err(
960
+ "plan_not_approved",
961
+ `plan not approved (status: ${state.plan.status}). Run workflow_plan_approve after the user's approval.`,
962
+ );
963
+ }
964
+ if (opts.requireMenu && !state.menu.presented) {
965
+ return err(
966
+ "menu_not_presented",
967
+ "post-plan menu not presented. Record the native question answer with workflow_plan_menu.",
968
+ );
969
+ }
970
+ if (opts.requireDocs) {
971
+ // Canonical relative form of the docs pair (DC-01/DC-02): docsValidate
972
+ // resolves the contained paths itself.
973
+ const validated = docsValidate({
974
+ spec_path: path.posix.join("docs", slug, "spec.md"),
975
+ plan_path: path.posix.join("docs", slug, "plan.md"),
976
+ workspace_root: root,
977
+ });
978
+ if (validated.ok === false) return err("docs_invalid", validated.error);
979
+ }
980
+ return assertCoordinatorBoundary(ctx, state.menu);
981
+ };
982
+
983
+ /**
984
+ * Delegated status derives from host session parentage (AR-12, CA-20): a
985
+ * session whose host record has a parent is a child (delegated worker); a root
986
+ * session (no parent) is the coordinator. Caller-supplied role fields are
987
+ * removed from every tool schema — this pure function is the only source.
988
+ */
989
+ export const roleFromParentage = (parentID?: string | null): FlowRole =>
990
+ parentID ? "delegated" : "coordinator";
991
+
992
+ /**
993
+ * Root-session write interception while a subagent-driven plan is active
994
+ * (CA-18, AR-13): known mutation tools are denied outright. Host-native write
995
+ * tools (write/edit/apply_patch/patch/rename/delete/…) plus the workit
996
+ * mutation tools are listed here so the plugin hook can deny them before any
997
+ * file is touched. Read-only host tools (read/grep/glob/list/question/task/…)
998
+ * are intentionally absent. The list is the audited boundary: adding a write
999
+ * tool here without a test is the escape hatch the audit checks for.
1000
+ */
1001
+ export const COORDINATOR_WRITE_TOOLS: readonly string[] = [
1002
+ // host-native file/command mutation tools
1003
+ "write",
1004
+ "edit",
1005
+ "apply_patch",
1006
+ "patch",
1007
+ "rename",
1008
+ "delete",
1009
+ "mkdir",
1010
+ "mv",
1011
+ "cp",
1012
+ "rm",
1013
+ "touch",
1014
+ "chmod",
1015
+ "chown",
1016
+ // workit product/config/external mutation tools
1017
+ "workflow_commit",
1018
+ "workflow_pr_create",
1019
+ "workflow_rule_edit",
1020
+ "workflow_template_edit",
1021
+ "workflow_changelog_apply",
1022
+ "workflow_branch_setup",
1023
+ "workflow_toolkit_init_apply",
1024
+ "workflow_docs_promote",
1025
+ "workflow_docs_layout",
1026
+ "workflow_docs_repo_link",
1027
+ "workflow_sdd_task_brief",
1028
+ "workflow_sdd_review_package",
1029
+ "workflow_sdd_append_progress",
1030
+ "workflow_youtrack_post",
1031
+ "workflow_youtrack_log_time",
1032
+ ];
1033
+
1034
+ /**
1035
+ * Bounded coordinator shell allowlist (CA-18, AR-13): while a subagent-driven
1036
+ * plan is active, the coordinator may run ONLY read/review/test/verify
1037
+ * commands — never anything that mutates files, git state, the system clock,
1038
+ * or the network.
1039
+ *
1040
+ * DENY matrix (every check below is asserted by the adversarial test table):
1041
+ * - Denied fragments, checked on the raw command before tokenizing:
1042
+ * `>` `>>` `2>` `&>` `<>` (any `>` — redirection), `|` (pipes, incl. `2>|`),
1043
+ * `&` (backgrounding), `;` (chains), `$(` (command substitution),
1044
+ * `${` (parameter expansion — `${IFS}` can smuggle whitespace past the
1045
+ * tokenizer), `$'` (ANSI-C quoting — can embed `\t`/`\n` escapes that are
1046
+ * real whitespace to the shell), `` ` `` (backticks), newline (multi-line
1047
+ * scripts), `<(` (process substitution input — `>(` dies on `>` already).
1048
+ * Heredocs `<<` are stdin-only and allowed (a heredoc cannot write without
1049
+ * a `>`). Literal `\t`/`\n` backslash escapes OUTSIDE `$'...'` are plain
1050
+ * `t`/`n` characters to the shell — they cannot create whitespace
1051
+ * (documented, FINDING 4).
1052
+ * - `(` `)` are denied per-token (process substitution `<(`, `>(`, subshells
1053
+ * `(cmd)`, and `awk system(...)` all need them) — EXCEPT as git `--format`
1054
+ * placeholders (`--format='%(refname)'`): a `--format` value is display
1055
+ * text (the shell already consumed the quotes) and `$(`/`<(`/`>`/backticks
1056
+ * are denied raw regardless (FINDING 3, round 4). Multi-token format
1057
+ * values (a space inside the quoted format) stay denied — fail-closed.
1058
+ * Pure-stdout verbs (`echo printf jq`) may print parens as display text:
1059
+ * a shell-quote-state scan allows the command iff every paren lies inside
1060
+ * a quoted region — any unquoted paren (subshell syntax, bash-verified
1061
+ * syntax error) denies the whole command, fail-closed (FINDING 3, round 5).
1062
+ * - Denied command heads: `curl`, `sudo`, `tee`, `wget` (privilege/network/
1063
+ * tee writes). These words are ONLY denied as the first token — as argument
1064
+ * text (`grep curl README.md`, `cat sudo-config.txt`) they pass (FINDING 5).
1065
+ * - Every other first token must be one of the allowlisted sets below.
1066
+ * - Tokens are UNQUOTED (every `'`/`"` character stripped — the shell's word
1067
+ * parsing removes quote characters entirely, so `--out'put=x'` IS
1068
+ * `--output=x`, `-de'lete'` IS `-delete`, `cu'rl'` IS `curl`, `awk -'f x'`
1069
+ * IS `awk -f x`) before every check (FINDING 2, round 5).
1070
+ * - `--output` and `--output=` (git log/diff and any other verb) are denied
1071
+ * on every command: both forms write a file.
1072
+ * - Write-capable `-o`/attached `-oFILE`/`--output`/`--output=FILE` are
1073
+ * denied on `sort`, `tree`, `comm`, `diff`, `jq` (grep/rg keep `-o` — it
1074
+ * only prints the matching part, read-only; find's `-o` is the logical-OR
1075
+ * operator and stays allowed).
1076
+ * - `--compress-program` (any form, every verb): GNU sort EXECUTES the given
1077
+ * program with the sorted data on its stdin — `sh` runs that data as a
1078
+ * script (bash-verified, FINDING 1, round 6). Only sort has the flag, but
1079
+ * the deny is global so no flag surface needs tracking.
1080
+ * - `date -s`/`--set` (any attached/separate/`=` form): mutates the system
1081
+ * clock (bash-verified setter, FINDING 3, round 6). `date -d`/`--date`
1082
+ * (display) stays allowed.
1083
+ * - `sort -T`/`--temporary-directory` (any form): writes sort's own temp
1084
+ * files into an arbitrary directory (bash/strace-verified, FINDING 4,
1085
+ * round 6). `sort -t:` (field separator) stays allowed.
1086
+ * - Read-only tool heads (`cat head tail less more grep rg ag find ls stat wc
1087
+ * file diff sort uniq cut tr fold printf echo pwd date which type du df tree
1088
+ * jq basename dirname realpath readlink rev comm paste nl od xxd awk gawk
1089
+ * mawk test [`):
1090
+ * `find` is denied every destructive/file-writing form: `-delete -exec
1091
+ * -execdir -ok -okdir` and `-fprint* -fls` (prefix).
1092
+ * `sed` is NOT allowlisted at all (round 5, decision: deny outright). GNU
1093
+ * sed 4.9 executes arbitrary commands through the `e` command (`sed 'e
1094
+ * touch x' f`) and the `s///e` flag (bash-verified: both ran `touch` —
1095
+ * e.g. `sed 's/.+/touch x/e' f`); closing the class needs a full sed script
1096
+ * grammar, and five review rounds of sed escapes (`w`/`W`/`-f`/attached
1097
+ * forms/quote joins) show a token parser cannot close it. sed reads are a
1098
+ * nice-to-have — `cat`/`grep`/`awk` cover them.
1099
+ * `awk`/`gawk`/`mawk` are denied every script file form (`-f`/`--file`,
1100
+ * attached or separate — the script may contain `system(...)`/file
1101
+ * redirects); `-F` (field separator, read-only) stays allowed.
1102
+ * - `tsc` with `--noEmit` (bare `tsc` can emit build artifacts).
1103
+ * - `git` with a read-only subcommand (`status log diff show branch rev-parse
1104
+ * merge-base remote ls-files blame shortlog describe check-ignore name-rev
1105
+ * stash grep tag`); `git stash` only as `git stash list`; the mutable
1106
+ * listing subcommands (`branch tag remote`) are bare or one of their
1107
+ * whitelisted read flags only — `branch` `-a -r -v -vv --all --remotes
1108
+ * --verbose --show-current -l --list --merged --no-merged --contains
1109
+ * --points-at --format --sort`, `tag` `-l --list --sort --contains
1110
+ * --points-at --merged --no-merged --format --column`, `remote` `-v
1111
+ * --verbose`. The value-taking flags (`--contains --points-at --merged
1112
+ * --no-merged --sort --format`) accept AT MOST ONE following value token
1113
+ * (a commit/tag name, a sort key, a format string — or glued
1114
+ * `--flag=value`; verified read-only in bash). Every other flag
1115
+ * (`-d -D -m -c -f -a -s ...`) is denied, a trailing NAME after a value
1116
+ * is denied (it would CREATE a branch/tag), and non-listed subcommands
1117
+ * (`config`, `var`, `push`, `commit`, `checkout`, `stash push`, ...) are
1118
+ * denied outright.
1119
+ * - git exec-trigger flags are denied on every allowlisted subcommand
1120
+ * (FINDING 2, round 6): `grep --open-files-in-pager[=<pager>]`/`-O[<pager>]`
1121
+ * executes the pager with each matched file (`sh` executes the file —
1122
+ * bash-verified), `log/diff/show --ext-diff` runs repo gitattributes
1123
+ * external diff drivers, `log/diff/show/blame/grep --textconv` runs
1124
+ * repo-configured textconv drivers, `--show-signature` runs gpg
1125
+ * (core.gpg.program), `--remerge-diff` runs the merge machinery
1126
+ * (external merge drivers). `-O` on log/diff/show is `--diff-order`
1127
+ * (a read flag) and stays allowed; `--no-ext-diff`/`--no-textconv`
1128
+ * disable the drivers and stay allowed. Global `-p`/`--paginate` (before
1129
+ * the subcommand) are already denied by the subcommand-position rule;
1130
+ * `git log -p` is `--patch` (read-only) and stays allowed.
1131
+ * - `git --no-pager <sub>` (global pager-disable, BEFORE the subcommand) is
1132
+ * allowed and behaves exactly like `git <sub>` for every rule below — it
1133
+ * never lifts a mutable/exec deny (FINDING 3, round 7). Combined
1134
+ * read-only short flags (`-av`, `-ar`, `-avv` on `branch` — every char
1135
+ * from the per-subcommand read set `branch: a r v l`, `tag: l`, `remote:
1136
+ * v`) are allowed on the mutable listing subcommands; a combined form
1137
+ * containing any write char (`git tag -av` creates an annotated tag,
1138
+ * `git branch -adv` deletes) is denied (FINDING 4, round 7).
1139
+ * - A test runner head (`bun|npm|pnpm|yarn|npx`) with one of the test/check/
1140
+ * lint/typecheck/verify/validate verbs (`vitest jest mocha` are runner
1141
+ * verbs too, e.g. `npx jest`; `tsc` as a runner verb requires `--noEmit`).
1142
+ * The FULL enumerated runner-write surface is denied on every runner verb
1143
+ * (FINDING 1, round 7, bash-verified): `--fix*` (lint autofix), `--write*`
1144
+ * (prettier), `--update*`/`-u` (snapshot updates — `bun test -u` rewrote
1145
+ * the snapshot, verified), `-w` (write-capable short form in some runners;
1146
+ * vitest/mocha watch is over-denied — the `--watch` long form stays
1147
+ * allowed), `--coverage*` plus camelCase `--collectCoverage*` AND kebab
1148
+ * `--collect-coverage*` (all write coverage/), `--outputFile`/`=` (jest
1149
+ * JSON report — bash-verified, also
1150
+ * denied globally as an output flag), `--cache*` (eslint/jest cache files;
1151
+ * `--cache=false` is read-only but over-denied — the coordinator never
1152
+ * needs cache control), and the tsc build-info flags (`-b`/`--build`,
1153
+ * `--incremental`, `--tsBuildInfoFile`, `--composite` — they write
1154
+ * .tsbuildinfo/outputs even with `--noEmit`). Matching is
1155
+ * case-insensitive-prefix on lowercased tokens (camelCase cannot dodge
1156
+ * the prefixes). Direct-head tools (`jest vitest eslint prettier oxlint
1157
+ * oxfmt mocha ...`) are NOT allowlisted at all — head denial, so their
1158
+ * write flags never reach the runner rules.
1159
+ * - `command` is allowed ONLY as `command -v <name>` / `command -V <name>`
1160
+ * (path lookup — read-only); bare `command` EXECUTES and is denied.
1161
+ * `test`/`[` evaluate expressions only and are read-only (FINDING 3,
1162
+ * round 4).
1163
+ *
1164
+ * Test runner flags write nothing — exact statement (FINDING 1, round 7 +
1165
+ * round 8): the allowed verbs are `test check lint typecheck verify validate`
1166
+ * (plus the `vitest jest mocha` runner verbs and `tsc --noEmit`), and every
1167
+ * write-capable runner flag family is denied (snapshot updates
1168
+ * `-u`/`--update*`, autofix `--fix*`, `--write*`, `-w`, coverage
1169
+ * `--coverage*`/`--collectCoverage*`/`--collect-coverage*` (camel AND kebab),
1170
+ * jest JSON reports `--outputFile`, caches `--cache*`, tsc build-info
1171
+ * `-b`/`--incremental`/`--tsBuildInfoFile`/`--composite`), so no RUNNER
1172
+ * WRITE FLAG inside the boundary can write a file — that is the exact scope
1173
+ * of this claim. A test run itself can still write by design (inherent
1174
+ * allowance, stated here as the documented boundary): a first-run jest
1175
+ * creates new `__snapshots__` WITHOUT `-u`, and test code runs with
1176
+ * coordinator permissions — the test runner is admitted to the boundary as
1177
+ * a runner, not sandboxed. `bun run format` (writes) and `bun run build`
1178
+ * (dist) are NOT allowed. This allowlist is an audited security boundary (asserted by the
1179
+ * adversarial table). The EXACT deny statement (FINDING 3, round 6 — the old
1180
+ * "every WRITE form of the listed verbs" claim was false while `date -s` and
1181
+ * `sort -T` were unguarded): redirections, pipes, chains, substitution,
1182
+ * parens; `sed` denied outright (round 5 — its `e`/`s///e` commands execute);
1183
+ * awk/gawk/mawk `-f`/`--file` script files; `--output`/`--output-file`/`-o`
1184
+ * on output-flag verbs; `--compress-program` on EVERY verb (executes PROG
1185
+ * with data on stdin — `sh` runs sorted data as a script, bash-verified,
1186
+ * round 6); git mutable-subcommand mutations AND git exec-trigger flags
1187
+ * (`--open-files-in-pager`/`-O` on grep — executes the pager on matched
1188
+ * files, bash-verified; `--ext-diff`; `--textconv`; `--show-signature`;
1189
+ * `--remerge-diff`; global `-p`/`--paginate` denied by the subcommand-
1190
+ * position rule); `date -s`/`--set` (system-clock mutation, round 6);
1191
+ * `sort -T`/`--temporary-directory` (writes sort's temporaries into the
1192
+ * given directory, round 6); find's delete/exec/fprint family; runner
1193
+ * mutating flags (round 7: `-u`/`-w`/`--cache*`/`--collectCoverage*`/
1194
+ * `--outputFile`/tsc build-info added to the `--fix`/`--write`/`--coverage`/
1195
+ * `--update` families); `command` without `-v`/`-V` — and every head not
1196
+ * listed above is denied outright (unlisted write/exec/network commands
1197
+ * never enter the allowlist at all).
1198
+ */
1199
+ const BASH_READ_TOKENS = new Set([
1200
+ "cat",
1201
+ "head",
1202
+ "tail",
1203
+ "less",
1204
+ "more",
1205
+ "grep",
1206
+ "rg",
1207
+ "ag",
1208
+ "find",
1209
+ "ls",
1210
+ "stat",
1211
+ "wc",
1212
+ "file",
1213
+ "diff",
1214
+ "sort",
1215
+ "uniq",
1216
+ "cut",
1217
+ "tr",
1218
+ "fold",
1219
+ "printf",
1220
+ "echo",
1221
+ "pwd",
1222
+ "date",
1223
+ "which",
1224
+ "type",
1225
+ "du",
1226
+ "df",
1227
+ "tree",
1228
+ "jq",
1229
+ "basename",
1230
+ "dirname",
1231
+ "realpath",
1232
+ "readlink",
1233
+ "rev",
1234
+ "comm",
1235
+ "paste",
1236
+ "nl",
1237
+ "od",
1238
+ "xxd",
1239
+ "awk",
1240
+ "gawk",
1241
+ "mawk",
1242
+ "test",
1243
+ "[",
1244
+ ]);
1245
+
1246
+ const BASH_GIT_READ_SUBCOMMANDS = new Set([
1247
+ "status",
1248
+ "log",
1249
+ "diff",
1250
+ "show",
1251
+ "branch",
1252
+ "rev-parse",
1253
+ "merge-base",
1254
+ "remote",
1255
+ "ls-files",
1256
+ "blame",
1257
+ "shortlog",
1258
+ "describe",
1259
+ "check-ignore",
1260
+ "name-rev",
1261
+ "stash",
1262
+ "grep",
1263
+ "tag",
1264
+ ]);
1265
+
1266
+ const BASH_GIT_MUTABLE_SUBCOMMANDS = new Set(["branch", "remote", "tag"]);
1267
+
1268
+ // Exact read-only flag forms per mutable git subcommand: every other flag or
1269
+ // argument (a branch/tag name, `-d -D -m -c -f -a -s ...`) is a write and is
1270
+ // denied (FINDING 1).
1271
+ const BASH_GIT_READ_FLAGS: Record<string, Set<string>> = {
1272
+ branch: new Set([
1273
+ "-a",
1274
+ "--all",
1275
+ "-r",
1276
+ "--remotes",
1277
+ "-v",
1278
+ "--verbose",
1279
+ "-vv",
1280
+ "--show-current",
1281
+ "-l",
1282
+ "--list",
1283
+ "--merged",
1284
+ "--no-merged",
1285
+ "--contains",
1286
+ "--points-at",
1287
+ "--format",
1288
+ "--sort",
1289
+ ]),
1290
+ tag: new Set([
1291
+ "-l",
1292
+ "--list",
1293
+ "--sort",
1294
+ "--contains",
1295
+ "--points-at",
1296
+ "--merged",
1297
+ "--no-merged",
1298
+ "--format",
1299
+ "--column",
1300
+ ]),
1301
+ remote: new Set(["-v", "--verbose"]),
1302
+ };
1303
+
1304
+ // Read-only SHORT flags per mutable git subcommand (FINDING 4, round 7):
1305
+ // `branch` `-a -r -v -l` (+ `-vv` = `-v -v`), `tag` `-l` ONLY (`-a` creates
1306
+ // an annotated tag), `remote` `-v` ONLY. Git combines short flags into one
1307
+ // token (`-av` = `-a -v`), so a single-dash all-letter token is allowed iff
1308
+ // EVERY character is a read char for that subcommand — any write char
1309
+ // (`git tag -av` → `-a` creates; `git branch -adv` → `-d` deletes) denies.
1310
+ const BASH_GIT_READ_SHORT_FLAGS: Record<string, string> = {
1311
+ branch: "arvl",
1312
+ tag: "l",
1313
+ remote: "v",
1314
+ };
1315
+
1316
+ const isCombinedReadShortFlag = (sub: string, token: string): boolean => {
1317
+ if (!/^-[a-z]+$/.test(token)) return false;
1318
+ const allowed = BASH_GIT_READ_SHORT_FLAGS[sub] ?? "";
1319
+ for (let i = 1; i < token.length; i++) {
1320
+ if (!allowed.includes(token[i])) return false;
1321
+ }
1322
+ return true;
1323
+ };
1324
+
1325
+ // find's destructive and file-writing forms: `-delete` deletes, `-exec/
1326
+ // -execdir/-ok/-okdir` execute arbitrary commands, `-fprint/-fprintf/
1327
+ // -fprint0/-fls` write files (FINDING 1).
1328
+ const BASH_FIND_DENIED_FLAGS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir"]);
1329
+
1330
+ const BASH_FIND_DENIED_PREFIXES = ["-fprint", "-fls"];
1331
+
1332
+ const BASH_TEST_RUNNERS = ["bun", "npm", "pnpm", "yarn", "npx"];
1333
+
1334
+ const BASH_TEST_VERBS = new Set([
1335
+ "test",
1336
+ "check",
1337
+ "lint",
1338
+ "typecheck",
1339
+ "verify",
1340
+ "validate",
1341
+ "vitest",
1342
+ "jest",
1343
+ "mocha",
1344
+ ]);
1345
+
1346
+ // Mutating flag families on test/lint verbs — the FULL enumerated
1347
+ // runner-write surface (FINDING 1, round 7): `--fix*` (lint autofix),
1348
+ // `--write*` (prettier), `--update*`/`-u` (snapshot updates — `bun test -u`,
1349
+ // `npm test -u`, `jest -u`/`--updateSnapshot`), `-w` (write-capable short
1350
+ // form in some runners; vitest/mocha watch is over-denied — the `--watch`
1351
+ // long form stays allowed), `--coverage*` plus the camelCase
1352
+ // `--collectCoverage*` AND the kebab `--collect-coverage*` (all write
1353
+ // coverage/ — the kebab form escapes the camelCase entry because the dash
1354
+ // breaks the prefix match, FINDING 1, round 8), `--cache*` (eslint/jest
1355
+ // cache files; `--cache=false` is read-only but over-denied — the
1356
+ // coordinator never needs cache control). Matching is
1357
+ // case-insensitive-prefix on lowercased tokens so camelCase spellings cannot
1358
+ // dodge the prefixes.
1359
+ const BASH_MUTATING_TEST_FLAGS = [
1360
+ "--fix",
1361
+ "--write",
1362
+ "--update",
1363
+ "-u",
1364
+ "-w",
1365
+ "--coverage",
1366
+ "--collectcoverage",
1367
+ "--collect-coverage",
1368
+ "--cache",
1369
+ ];
1370
+
1371
+ // Privilege/network/tee-write heads: denied ONLY as the first token, so the
1372
+ // words themselves stay legal as argument text (FINDING 5).
1373
+ const BASH_DENIED_HEADS = new Set(["curl", "sudo", "tee", "wget"]);
1374
+
1375
+ // Write-capable -o/-oFILE/--output/--output=FILE on allowlisted read verbs
1376
+ // (FINDING 1). grep/rg keep `-o` (read-only match printing); find keeps `-o`
1377
+ // (logical OR). `--output` is denied globally for every command. jq `-o` is
1378
+ // `--output-file` (round 3 audit — same write class as sort -o).
1379
+ const BASH_OUTPUT_FLAG_VERBS = new Set(["sort", "tree", "comm", "diff", "jq"]);
1380
+
1381
+ const BASH_FORBIDDEN_FRAGMENTS = [">", "|", "&", ";", "$(", "${", "$'", "`", "\n", "<("];
1382
+
1383
+ // Pure-stdout verbs exempt from the per-token paren denial, but only for
1384
+ // parens inside a fully-quoted token (display text). Unquoted parens stay
1385
+ // denied. jq qualifies: its only write paths (`-o`/`--output-file`) are
1386
+ // denied separately. awk is NOT exempt (`system(...)` executes). (FINDING 3,
1387
+ // round 5)
1388
+ const BASH_PAREN_EXEMPT_HEADS = new Set(["echo", "printf", "jq"]);
1389
+
1390
+ // Value-taking READ flags on the mutable git listing subcommands (FINDING 3,
1391
+ // round 4): `--contains|--points-at|--merged|--no-merged [<commit>]`,
1392
+ // `--sort <key>`, `--format <format>` — each takes AT MOST ONE following
1393
+ // value token (the value may also be glued: `--sort=-x`, `--format='%(x)'`).
1394
+ // The value is display/list filtering only — verified read-only in bash. A
1395
+ // trailing NAME after a value would create a branch/tag (`git branch --sort=
1396
+ // -x y`, `git tag --format=x y` both create), so anything that is neither a
1397
+ // whitelisted flag nor the single value of a value flag is denied.
1398
+ const BASH_GIT_VALUE_FLAGS = new Set([
1399
+ "--contains",
1400
+ "--points-at",
1401
+ "--merged",
1402
+ "--no-merged",
1403
+ "--sort",
1404
+ "--format",
1405
+ ]);
1406
+
1407
+ const gitGluedValueFlag = (token: string): boolean =>
1408
+ /^--(contains|points-at|merged|no-merged|sort|format)=.+/.test(token);
1409
+
1410
+ const hasMutatingTestFlag = (tokens: string[]): boolean => {
1411
+ const lower = tokens.map((t) => t.toLowerCase());
1412
+ return lower.some((t) => BASH_MUTATING_TEST_FLAGS.some((flag) => t.startsWith(flag)));
1413
+ };
1414
+
1415
+ // Script FILE forms for awk/gawk/mawk (FINDING 2/3, round 3): the option may
1416
+ // carry its value ATTACHED (`-fscript.awk`, `-f/tmp/evil.awk`, `--file=x`) —
1417
+ // GNU awk accepts the attached short-option form, so any token starting with
1418
+ // `-f`/`--file` is a script file. The script may contain `system(...)`/
1419
+ // redirections. `-F` (awk field separator, read-only, uppercase) is NOT
1420
+ // matched. sed is not allowlisted at all (round 5), so no sed -f rule exists.
1421
+ const scriptFileForm = (token: string): boolean =>
1422
+ token === "-f" || token.startsWith("-f") || token.startsWith("--file");
1423
+
1424
+ const findDenied = (token: string): boolean =>
1425
+ BASH_FIND_DENIED_FLAGS.has(token) ||
1426
+ BASH_FIND_DENIED_PREFIXES.some((prefix) => token.startsWith(prefix));
1427
+
1428
+ // Any command may write via --output/--output=FILE (git log/diff, sort, ...)
1429
+ // or --output-file/--output-file=FILE (jq). `--outputFile`/`--outputFile=`
1430
+ // is jest's JSON-report flag (writes the report file — bash-verified,
1431
+ // FINDING 1, round 7: `npx jest --json --outputFile=out.json` created the
1432
+ // file); it is denied globally for the same reason as `--output-file`.
1433
+ const outputFlagDenied = (token: string): boolean =>
1434
+ token === "--output" ||
1435
+ token.startsWith("--output=") ||
1436
+ token === "--output-file" ||
1437
+ token.startsWith("--output-file=") ||
1438
+ token === "--outputFile" ||
1439
+ token.startsWith("--outputFile=");
1440
+
1441
+ // `--compress-program` (GNU sort; any verb — global deny) EXECUTES PROG with
1442
+ // the sorted data on its stdin: `sh` runs the data as a script (bash-verified,
1443
+ // FINDING 1, round 6: `sort --buffer-size=1M --compress-program=sh` created
1444
+ // PWNED_COMPRESS). The space form dies at the flag token; the `=` form here.
1445
+ const compressProgramDenied = (token: string): boolean =>
1446
+ token === "--compress-program" || token.startsWith("--compress-program=");
1447
+
1448
+ // git flags that TRIGGER external program execution (FINDING 2, round 6):
1449
+ // `grep --open-files-in-pager[=<pager>]` and its short form `-O[<pager>]`
1450
+ // open each matched file with a pager — `sh` executes the file (bash-verified:
1451
+ // `git grep --open-files-in-pager=sh -e x -- f` and `git grep -Osh` both
1452
+ // created GITPWNED files); `log/diff/show --ext-diff` runs repo gitattributes
1453
+ // external diff drivers; `log/diff/show/blame/grep --textconv` runs
1454
+ // repo-configured textconv drivers; `--show-signature` runs gpg
1455
+ // (core.gpg.program); `--remerge-diff` runs the merge machinery on merge
1456
+ // commits (external merge drivers) — same driver-execution class, denied
1457
+ // fail-closed. `-O` on grep is open-files-in-pager, but `-O` on log/diff/show
1458
+ // is `--diff-order=<orderfile>` (a read flag) — the short form is scoped to
1459
+ // grep. `--no-ext-diff`/`--no-textconv` DISABLE the drivers and stay allowed.
1460
+ // Global `-p`/`--paginate` (before the subcommand) never reach this check —
1461
+ // the subcommand-position rule already denies them (pinned in the matrix).
1462
+ const gitExecFlagDenied = (sub: string, token: string): boolean => {
1463
+ if (sub === "grep" && (token === "-O" || token.startsWith("-O"))) return true;
1464
+ if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) return true;
1465
+ if (token === "--ext-diff" || token.startsWith("--ext-diff=")) return true;
1466
+ if (token === "--textconv" || token.startsWith("--textconv=")) return true;
1467
+ if (token === "--show-signature" || token.startsWith("--show-signature=")) return true;
1468
+ if (token === "--remerge-diff" || token.startsWith("--remerge-diff=")) return true;
1469
+ return false;
1470
+ };
1471
+
1472
+ // `date -s`/`--set` (and attached `-sVALUE`, `--set=VALUE`) MUTATE the system
1473
+ // clock (bash-verified: `date -s` attempts the set — "cannot set date:
1474
+ // Operation not permitted", FINDING 3, round 6). No other GNU date flag
1475
+ // starts with `-s`; `-d`/`--date` (display) stays allowed.
1476
+ const dateSetDenied = (token: string): boolean =>
1477
+ token === "-s" || token.startsWith("-s") || token === "--set" || token.startsWith("--set=");
1478
+
1479
+ // `sort -T`/`--temporary-directory` writes sort's own temp files into an
1480
+ // arbitrary directory (bash/strace-verified: `sort -T <dir>` created
1481
+ // sortGdvlHf, sortV2VyNF, ..., FINDING 4, round 6). `-t:` (field separator,
1482
+ // lowercase) is NOT matched. The space form dies at the flag token.
1483
+ const sortTempDirDenied = (token: string): boolean =>
1484
+ token === "-T" ||
1485
+ token.startsWith("-T") ||
1486
+ token === "--temporary-directory" ||
1487
+ token.startsWith("--temporary-directory=");
1488
+
1489
+ // tsc build-info flags (FINDING 1, round 7): `-b`/`--build` (build mode
1490
+ // writes outputs), and `--incremental`/`--tsBuildInfoFile`/`--composite`
1491
+ // write `.tsbuildinfo` even WITH `--noEmit` — so `--noEmit` alone is not a
1492
+ // sufficient read guarantee. Denied on BOTH the direct `tsc` head and the
1493
+ // runner verb (`bun run tsc`); `tsc --noEmit` remains the only admitted form.
1494
+ const tsBuildDenied = (token: string): boolean =>
1495
+ token === "-b" ||
1496
+ token.startsWith("-b") ||
1497
+ token === "--build" ||
1498
+ token.startsWith("--build=") ||
1499
+ token === "--incremental" ||
1500
+ token.startsWith("--incremental=") ||
1501
+ token === "--tsBuildInfoFile" ||
1502
+ token.startsWith("--tsBuildInfoFile=") ||
1503
+ token === "--composite" ||
1504
+ token.startsWith("--composite=");
1505
+
1506
+ export const isCoordinatorBashAllowed = (command: string): boolean => {
1507
+ const trimmed = command.trim();
1508
+ if (!trimmed) return false;
1509
+ for (const fragment of BASH_FORBIDDEN_FRAGMENTS) {
1510
+ if (trimmed.includes(fragment)) return false;
1511
+ }
1512
+ // FINDING 2 (round 5): the shell's word parsing REMOVES every quote
1513
+ // character when building argv — `'w'out` IS `wout`, `--out'put=x'` IS
1514
+ // `--output=x`, `-de'lete'` IS `-delete`, `awk -'f x'` IS `awk -f x`.
1515
+ // Strip ALL `'`/`"` from each token before every check so mid-token
1516
+ // quote joins cannot smuggle a deny-listed flag past the rules. Stripping
1517
+ // only removes characters, so a deny rule can never be evaded by it.
1518
+ const unquote = (token: string): string => token.replace(/['"]/g, "");
1519
+ const rawTokens = trimmed.split(/\s+/);
1520
+ const tokens = rawTokens.map(unquote);
1521
+ const head = tokens[0] ?? "";
1522
+ // `(`/`)` are denied per-token (process substitution `<(`, `>(`, subshells
1523
+ // `(cmd)`, and `awk system(...)` all need them) — EXCEPT as git `--format`
1524
+ // placeholders (`--format='%(refname)'`): the shell has already consumed
1525
+ // the quotes, so a `--format` value is display text, and `$(`/`<(`/`>`/
1526
+ // backticks are denied raw regardless (FINDING 3, round 4). A value token
1527
+ // AFTER a bare `--format` is likewise display text. Pure-stdout verbs
1528
+ // (`echo printf jq`) may print parens as display text: a shell-quote-state
1529
+ // scan of the RAW command allows the command iff every paren lies inside a
1530
+ // quoted region; any unquoted paren (subshell/syntax forms — bash-verified
1531
+ // syntax errors) denies the whole command, fail-closed. jq's only write
1532
+ // paths (`-o`/`--output-file`) are denied separately (FINDING 3, round 5).
1533
+ const parenExempt = BASH_PAREN_EXEMPT_HEADS.has(head);
1534
+ let parensSafe = true;
1535
+ if (parenExempt) {
1536
+ let state = 0; // 0 = unquoted, 1 = '...', 2 = "..."
1537
+ for (let i = 0; i < trimmed.length; i++) {
1538
+ const ch = trimmed[i];
1539
+ if (state === 0) {
1540
+ if (ch === "'") state = 1;
1541
+ else if (ch === '"') state = 2;
1542
+ else if (ch === "(" || ch === ")") parensSafe = false;
1543
+ } else if (state === 1) {
1544
+ if (ch === "'") state = 0;
1545
+ } else if (ch === "\\") {
1546
+ i++; // escaped char inside "..."
1547
+ } else if (ch === '"') {
1548
+ state = 0;
1549
+ }
1550
+ }
1551
+ }
1552
+ let formatValue = false;
1553
+ for (let i = 0; i < tokens.length; i++) {
1554
+ const t = tokens[i];
1555
+ if (formatValue) {
1556
+ formatValue = false;
1557
+ continue;
1558
+ }
1559
+ if (t.includes("(") || t.includes(")")) {
1560
+ if (parenExempt && parensSafe) continue;
1561
+ if (t.startsWith("--format=") && !t.includes("<(")) continue;
1562
+ return false;
1563
+ }
1564
+ formatValue = t === "--format";
1565
+ }
1566
+ if (BASH_DENIED_HEADS.has(head)) return false;
1567
+ if (tokens.some(outputFlagDenied) || tokens.some(compressProgramDenied)) return false;
1568
+ if (head === "git") {
1569
+ // FINDING 3 (round 7): `--no-pager` is a GLOBAL pager-disable that sits
1570
+ // BEFORE the subcommand (`git --no-pager log ...`) — read-only, the
1571
+ // exact counterpart of the already-allowed post-subcommand form. It only
1572
+ // shifts the subcommand position; every rule below (exec flags, mutable
1573
+ // subcommands, stash list, value walk) still applies to the real
1574
+ // subcommand. `git --no-pager` alone (no subcommand) falls through to
1575
+ // the subcommand-position deny.
1576
+ let subIndex = 1;
1577
+ if (tokens[1] === "--no-pager") subIndex = 2;
1578
+ const sub = tokens[subIndex] ?? "";
1579
+ const flagTokens = tokens.slice(subIndex + 1);
1580
+ if (flagTokens.some((t) => gitExecFlagDenied(sub, t))) return false;
1581
+ if (sub === "stash") return flagTokens[0] === "list";
1582
+ if (!BASH_GIT_READ_SUBCOMMANDS.has(sub)) return false;
1583
+ if (!BASH_GIT_MUTABLE_SUBCOMMANDS.has(sub)) return true;
1584
+ if (flagTokens.length === 0) return true; // bare listing (`git branch`)
1585
+ const flags = BASH_GIT_READ_FLAGS[sub];
1586
+ if (!flags) return false;
1587
+ // exact read flags, plus AT MOST ONE value after each value-taking flag,
1588
+ // plus combined read-only short flags (FINDING 4, round 7)
1589
+ let valuePending = false;
1590
+ for (const t of flagTokens) {
1591
+ if (flags.has(t) || gitGluedValueFlag(t)) {
1592
+ valuePending = BASH_GIT_VALUE_FLAGS.has(t);
1593
+ continue;
1594
+ }
1595
+ if (valuePending) {
1596
+ valuePending = false;
1597
+ continue;
1598
+ }
1599
+ if (isCombinedReadShortFlag(sub, t)) continue;
1600
+ return false;
1601
+ }
1602
+ return true;
1603
+ }
1604
+ if (head === "command") {
1605
+ // `command` EXECUTES its argument; only `command -v`/`-V` (lookup) is
1606
+ // read-only — exactly one name, no more (FINDING 3, round 4).
1607
+ return (tokens[1] === "-v" || tokens[1] === "-V") && tokens.length === 3;
1608
+ }
1609
+ if (head === "tsc") return tokens.includes("--noEmit") && !tokens.some(tsBuildDenied);
1610
+ if (BASH_READ_TOKENS.has(head)) {
1611
+ if (head === "find") return !tokens.some(findDenied);
1612
+ // awk/gawk/mawk: only the script-file form is denied (`-f`/`--file`,
1613
+ // attached or separate); `-F` (field separator) and reads stay allowed.
1614
+ if (head === "awk" || head === "gawk" || head === "mawk") {
1615
+ return !tokens.some(scriptFileForm);
1616
+ }
1617
+ // date: only `-s`/`--set` (clock mutation) is denied (FINDING 3, round 6).
1618
+ if (head === "date") return !tokens.some(dateSetDenied);
1619
+ // sort: `-T`/`--temporary-directory` (temp files in arbitrary dirs) is
1620
+ // denied; the `-o`/`--output` write forms are denied by the output-flag
1621
+ // check below (FINDING 4, round 6).
1622
+ if (head === "sort" && tokens.some(sortTempDirDenied)) return false;
1623
+ if (BASH_OUTPUT_FLAG_VERBS.has(head)) {
1624
+ return !tokens.some((t) => t === "-o" || t.startsWith("-o") || outputFlagDenied(t));
1625
+ }
1626
+ return true;
1627
+ }
1628
+ if (BASH_TEST_RUNNERS.includes(head)) {
1629
+ if (hasMutatingTestFlag(tokens)) return false;
1630
+ const verbIndex = tokens[1] === "run" ? 2 : 1;
1631
+ const verb = tokens[verbIndex] ?? "";
1632
+ if (verb === "tsc") return tokens.includes("--noEmit") && !tokens.some(tsBuildDenied);
1633
+ return BASH_TEST_VERBS.has(verb);
1634
+ }
1635
+ return false;
1636
+ };
1637
+
1638
+ export const COORDINATOR_SHELL_DENIED_TEXT =
1639
+ "Coordinator shell commands are restricted while a subagent-driven plan is " +
1640
+ "active: only bounded read/test/review commands are allowed (the exact " +
1641
+ "allowlist is in flow-state.ts, isCoordinatorBashAllowed). " +
1642
+ COORDINATOR_RECOVERY_TEXT;
1643
+
1644
+ /**
1645
+ * The plugin hook's decision function (AR-13): a delegated child session
1646
+ * (host parentage) is never intercepted; the root session is intercepted only
1647
+ * while at least one subagent-driven plan is active in its workspace. Returns
1648
+ * the denial error to throw from `tool.execute.before`, or `{ ok: true }`.
1649
+ */
1650
+ export const subagentDrivenInterception = (input: {
1651
+ tool: string;
1652
+ command?: string;
1653
+ parentID?: string | null;
1654
+ active: boolean;
1655
+ }): FlowGateResult => {
1656
+ if (input.parentID) return { ok: true }; // delegated child — the worker
1657
+ if (!input.active) return { ok: true };
1658
+ if (COORDINATOR_WRITE_TOOLS.includes(input.tool)) {
1659
+ return err("coordinator_write_denied", COORDINATOR_RECOVERY_TEXT);
1660
+ }
1661
+ if (input.tool === "bash") {
1662
+ if (!input.command || !isCoordinatorBashAllowed(input.command)) {
1663
+ return err("coordinator_shell_denied", COORDINATOR_SHELL_DENIED_TEXT);
1664
+ }
227
1665
  }
228
1666
  return { ok: true };
229
1667
  };