@brainervirus/workit-core 0.10.0 → 1.0.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 (150) hide show
  1. package/README.md +5 -3
  2. package/package.json +8 -3
  3. package/scripts/analyze-release-scope.ts +4 -1
  4. package/scripts/doctor-check.ts +3 -1
  5. package/scripts/install-codex-plugin.sh +28 -0
  6. package/scripts/install-cursor-plugin.sh +1 -0
  7. package/scripts/install-opencode-plugin.sh +1 -0
  8. package/scripts/install-pi-package.sh +56 -0
  9. package/scripts/rewrite-workspace-deps.ts +22 -5
  10. package/scripts/sync-release-manifests.ts +8 -5
  11. package/scripts/sync-runtime.sh +7 -3
  12. package/scripts/validate-cursor-marketplace.ts +16 -62
  13. package/skills/workit-babysit/SKILL.md +33 -0
  14. package/skills/workit-behavioral-tdd/SKILL.md +53 -0
  15. package/skills/workit-blast-radius/SKILL.md +31 -0
  16. package/skills/workit-challenge/SKILL.md +62 -0
  17. package/skills/workit-debug/SKILL.md +61 -0
  18. package/skills/workit-deslop/SKILL.md +36 -0
  19. package/skills/workit-diagram/SKILL.md +32 -0
  20. package/skills/workit-green-run/SKILL.md +29 -0
  21. package/skills/workit-handoff/SKILL.md +43 -0
  22. package/skills/workit-implement/SKILL.md +46 -0
  23. package/skills/workit-mockup/SKILL.md +28 -0
  24. package/skills/workit-plan/SKILL.md +66 -0
  25. package/skills/workit-review/SKILL.md +60 -0
  26. package/skills/workit-steer/SKILL.md +32 -0
  27. package/src/core/authority.ts +918 -0
  28. package/src/core/boundary.ts +29 -0
  29. package/src/core/branch.ts +26 -255
  30. package/src/core/changelog.ts +95 -18
  31. package/src/core/commit-flavors.ts +65 -0
  32. package/src/core/config-conversion.ts +183 -0
  33. package/src/core/config.ts +42 -0
  34. package/src/core/cutover.ts +721 -0
  35. package/src/core/detect-hosts.ts +107 -0
  36. package/src/core/doctor.ts +428 -18
  37. package/src/core/external-action-effects.ts +1171 -0
  38. package/src/core/external-action.ts +541 -0
  39. package/src/core/init.ts +83 -32
  40. package/src/core/methods.ts +125 -0
  41. package/src/core/policy-resolver.ts +523 -0
  42. package/src/core/pr-create.ts +7 -1
  43. package/src/core/registration.ts +69 -4
  44. package/src/core/repo-context.ts +59 -1
  45. package/src/core/setup.ts +28 -0
  46. package/src/core/skill-manifests.ts +40 -42
  47. package/src/core/support-matrix.ts +8 -3
  48. package/src/core/sync-runtime.ts +15 -3
  49. package/src/core/task-context.ts +179 -0
  50. package/src/core/task-contract.ts +1136 -0
  51. package/src/core/task-engine.ts +2562 -0
  52. package/src/core/task-evaluation.ts +679 -0
  53. package/src/core/task-store.ts +1071 -0
  54. package/src/core/tracker-issues.ts +237 -0
  55. package/src/core/uninstall.ts +67 -3
  56. package/src/core/vcs-config.ts +53 -22
  57. package/src/core/workers.ts +166 -0
  58. package/src/core/workspaces.ts +3 -1
  59. package/src/core/youtrack-tools.ts +2 -0
  60. package/src/core/youtrack.ts +145 -10
  61. package/src/core.ts +126 -0
  62. package/templates/execution-contract.md +17 -50
  63. package/templates/plan-template.md +3 -3
  64. package/templates/spec-template.md +15 -3
  65. package/templates/workit-contract.md +12 -0
  66. package/commands/wk-changelog.md +0 -2
  67. package/commands/wk-commit.md +0 -2
  68. package/commands/wk-docs-refresh.md +0 -2
  69. package/commands/wk-handoff.md +0 -2
  70. package/commands/wk-implement.md +0 -2
  71. package/commands/wk-init.md +0 -2
  72. package/commands/wk-issue-update.md +0 -2
  73. package/commands/wk-meetings.md +0 -2
  74. package/commands/wk-pr.md +0 -2
  75. package/commands/wk-release-notes.md +0 -2
  76. package/commands/wk-status.md +0 -2
  77. package/commands/wk-verify.md +0 -2
  78. package/scripts/update-superpowers.sh +0 -82
  79. package/scripts/vendor-assets.ts +0 -37
  80. package/skills/wk-changelog/SKILL.md +0 -15
  81. package/skills/wk-commit/SKILL.md +0 -16
  82. package/skills/wk-docs-refresh/SKILL.md +0 -15
  83. package/skills/wk-handoff/SKILL.md +0 -20
  84. package/skills/wk-implement/SKILL.md +0 -47
  85. package/skills/wk-init/SKILL.md +0 -31
  86. package/skills/wk-issue-update/SKILL.md +0 -27
  87. package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
  88. package/skills/wk-meetings/SKILL.md +0 -17
  89. package/skills/wk-pr/SKILL.md +0 -27
  90. package/skills/wk-release-notes/SKILL.md +0 -15
  91. package/skills/wk-status/SKILL.md +0 -16
  92. package/skills/wk-verify/SKILL.md +0 -16
  93. package/src/core/detector.ts +0 -239
  94. package/src/core/flow-state.ts +0 -3048
  95. package/src/core/handoff-context.ts +0 -136
  96. package/src/core/handoff-tools.ts +0 -133
  97. package/src/core/menu.ts +0 -70
  98. package/src/core/plan-tasks.ts +0 -43
  99. package/src/core/reminder.ts +0 -124
  100. package/src/core/sdd.ts +0 -353
  101. package/src/state.ts +0 -22
  102. package/templates/superpowers-doc-contract.md +0 -75
  103. package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
  104. package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
  105. package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
  106. package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
  107. package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
  108. package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
  109. package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
  110. package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
  111. package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
  112. package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
  113. package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
  114. package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
  115. package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
  116. package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
  117. package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -426
  118. package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
  119. package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
  120. package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
  121. package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
  122. package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
  123. package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
  124. package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
  125. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
  126. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
  127. package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
  128. package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
  129. package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
  130. package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
  131. package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
  132. package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
  133. package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
  134. package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
  135. package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
  136. package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
  137. package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
  138. package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
  139. package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
  140. package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
  141. package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
  142. package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
  143. package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
  144. package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
  145. package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
  146. package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
  147. package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
  148. package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
  149. package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
  150. package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +0 -384
@@ -1,3048 +0,0 @@
1
- import {
2
- closeSync,
3
- existsSync,
4
- fstatSync,
5
- fsyncSync,
6
- mkdirSync,
7
- openSync,
8
- readdirSync,
9
- readFileSync,
10
- renameSync,
11
- rmSync,
12
- statSync,
13
- unlinkSync,
14
- writeFileSync,
15
- } from "node:fs";
16
- import { createHash } from "node:crypto";
17
- import path from "node:path";
18
- import { docsValidate, parseTasksFromPlan, qualitySpec, stripFences } from "./docs-validate";
19
- import { resolveCanonicalLayout } from "./docs-layout";
20
- import { ledgerCompletion } from "./sdd";
21
- import { runVerifyProject } from "./verify-project";
22
-
23
- export type FlowHost = "opencode" | "cursor";
24
- export type FlowStatus = "draft" | "self_reviewed" | "approved";
25
- export type FlowRole = "coordinator" | "delegated";
26
-
27
- /** The canonical document kinds a flow binds approvals to (CA-01). */
28
- export type FlowDocument = "spec" | "plan";
29
-
30
- /** Structured approval-drift reasons (CA-04). */
31
- export type FlowDriftCode =
32
- | "digest_missing"
33
- | "document_missing"
34
- | "document_unreadable"
35
- | "digest_mismatch";
36
-
37
- export type FlowDriftReason = {
38
- document: FlowDocument;
39
- code: FlowDriftCode;
40
- path: string;
41
- };
42
-
43
- /** Execution lifecycle (CA-11): only these four states exist; no cancellation. */
44
- export type ExecutionStatus = "pending" | "active" | "paused" | "completed";
45
- export type ExecutionMode = "subagent-driven" | "inline";
46
-
47
- /** CLI confirmation evidence (CA-19, CA-21): policy-only, no attestation. */
48
- export type CliConfirmation = {
49
- host: "cli";
50
- attested: false;
51
- confirmation: "flag" | "tty";
52
- };
53
-
54
- export type LifecycleEvidence = NativeChoiceEvidence | CliConfirmation;
55
-
56
- export type FlowExecutionState = {
57
- status: ExecutionStatus;
58
- mode: ExecutionMode | null;
59
- evidence: LifecycleEvidence | null;
60
- /**
61
- * The activating OpenCode coordinator session (CA-12): recorded when an
62
- * accepted `subagent-driven` menu choice starts the execution; preserved
63
- * across pause/resume; cleared on completion and approval drift; null for
64
- * every non-subagent-driven path and for legacy states without the field.
65
- */
66
- coordinator_session_id: string | null;
67
- };
68
-
69
- /**
70
- * Host-bound identity for every flow/product mutation (FG-05, CA-20, CA-21):
71
- * the authoritative host workspace, the coordinator/delegated role, the host
72
- * session, and the authenticated task identity a delegated worker carries.
73
- * Cursor has no per-session identity, so it derives a deterministic session
74
- * from the workspace root; OpenCode derives it from the tool context.
75
- * Delegation is host-derived (Task 30, AR-12): callers never supply `role`.
76
- */
77
- export type MutationContext = {
78
- hostWorkspace: string;
79
- role: FlowRole;
80
- sessionId: string;
81
- /**
82
- * The host-attested parent session id (OpenCode only): present exactly when
83
- * the host reports a parent for this session, i.e. the session is a child.
84
- * Delegated authority requires this to equal the persisted
85
- * `execution.coordinator_session_id` (CA-13) — fail closed otherwise.
86
- */
87
- parentSessionId?: string;
88
- taskIdentity?: string;
89
- };
90
-
91
- /** Recovery guidance surfaced on a blocked coordinator mutation (FG-07). */
92
- export const COORDINATOR_RECOVERY_TEXT =
93
- "A subagent-driven plan is active: coordinator product edits are blocked. " +
94
- "Delegate product mutations to an authenticated delegated worker via `task` / `wk-implement` instead of " +
95
- "editing in the coordinator session.";
96
-
97
- /**
98
- * Cursor recovery guidance for the unsupported subagent-driven mutation path
99
- * (CA-42): the Cursor MCP has no child sessions, so it cannot run a
100
- * subagent-driven plan and must not enter that flow state.
101
- */
102
- export const CURSOR_SUBAGENT_UNSUPPORTED_TEXT =
103
- "Cursor cannot execute subagent-driven plans: the MCP has no child-session " +
104
- "support. Choose Inline, Handoff, or a review option in this session, or " +
105
- "run the plan in OpenCode with `wk-implement`.";
106
-
107
- /**
108
- * The only acceptable approval / execution-menu evidence (FG-04, CA-19, AR-12).
109
- * Trust comes from HOST CAPABILITIES, never from caller-supplied fields:
110
- *
111
- * - OpenCode: a one-use receipt the plugin records when it observes the
112
- * answered native `question` tool (host-observed, `attested: true`). The
113
- * approval/menu tool schemas expose no evidence argument; the receipt is
114
- * consumed from the in-memory store bound to sessionID + callID + exact
115
- * selected label + timestamp.
116
- * - Cursor: a policy-only constant (`attested: false`). The MCP cannot observe
117
- * the AskQuestion result, so it records an unauthenticated confirmation and
118
- * never claims a host-observed answer. The constant carries no caller data.
119
- */
120
- export type OpenCodeChoiceEvidence = {
121
- host: "opencode";
122
- attested: true;
123
- /** Host question-tool call id observed by the plugin hook. */
124
- callID: string;
125
- /** The exact label the user selected. */
126
- selectedLabel: string;
127
- recordedAt: number;
128
- };
129
-
130
- export type CursorConfirmation = {
131
- host: "cursor";
132
- attested: false;
133
- confirmation: "contract";
134
- };
135
-
136
- export type NativeChoiceEvidence = OpenCodeChoiceEvidence | CursorConfirmation;
137
-
138
- export type FlowDocState = {
139
- path: string;
140
- status: FlowStatus;
141
- evidence?: NativeChoiceEvidence | null;
142
- /** SHA-256 (lowercase hex) of the canonical document's exact bytes (CA-01). */
143
- approved_digest: string | null;
144
- };
145
-
146
- export type FlowMenuState = {
147
- presented: boolean;
148
- chosen: string;
149
- evidence?: NativeChoiceEvidence | null;
150
- };
151
-
152
- export type FlowState = {
153
- slug: string;
154
- /** Recorded when flow preparation began (FG-01): canonical paths + activation. */
155
- activated: boolean;
156
- spec: FlowDocState;
157
- plan: FlowDocState;
158
- menu: FlowMenuState;
159
- execution: FlowExecutionState;
160
- handoff_destination: boolean;
161
- updated_at: number;
162
- };
163
-
164
- /** One shared result shape for every flow transition and mutation gate (FG-09). */
165
- export type FlowError = {
166
- ok: false;
167
- error: string;
168
- code: string;
169
- details?: Record<string, unknown>;
170
- };
171
- export type FlowGateResult = { ok: true } | FlowError;
172
- export type EvidenceResult =
173
- | { ok: true; evidence: NativeChoiceEvidence }
174
- | { ok: false; error: string };
175
- export type StatusTransition = { ok: true; next: FlowStatus } | FlowError;
176
-
177
- /** Persisted state after legacy normalization and approval-integrity reconciliation (CA-02). */
178
- export type EffectiveFlowState = {
179
- state: FlowState;
180
- drift: FlowDriftReason[];
181
- };
182
-
183
- /** Structured result of an effective (reconciled) flow-state read (CA-04). */
184
- export type FlowReadResult = ({ ok: true } & EffectiveFlowState) | FlowError;
185
-
186
- export const MENU_CHOICES = [
187
- "subagent-driven",
188
- "inline",
189
- "handoff",
190
- "review-spec",
191
- "review-plan",
192
- ] as const;
193
- export type MenuChoice = (typeof MENU_CHOICES)[number];
194
-
195
- /**
196
- * The source post-plan menu (CA-08): the full five-way choice set the source
197
- * session presents after the plan is approved. `DESTINATION_MENU_CHOICES` is
198
- * the same tuple without `handoff` — a marked destination never re-offers the
199
- * originating handoff choice.
200
- */
201
- export const SOURCE_MENU_CHOICES = MENU_CHOICES;
202
- export const DESTINATION_MENU_CHOICES = [
203
- "subagent-driven",
204
- "inline",
205
- "review-spec",
206
- "review-plan",
207
- ] as const;
208
- export type DestinationMenuChoice = (typeof DESTINATION_MENU_CHOICES)[number];
209
-
210
- // The source/destination menu labels and the destination marker live in the
211
- // import-light menu module (CA-07/CA-08) so session-start hooks select reminder
212
- // wording without pulling in the full flow-state graph; flow-state re-exports
213
- // them so every existing consumer keeps the same import site.
214
- export { DESTINATION_MENU_LABELS, HANDOFF_DESTINATION_MARKER, SOURCE_MENU_LABELS } from "./menu";
215
-
216
- const err = (code: string, error: string, details?: Record<string, unknown>): FlowError => ({
217
- ok: false,
218
- code,
219
- error,
220
- ...(details ? { details } : {}),
221
- });
222
-
223
- const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
224
-
225
- const flowPath = (root: string, slug: string) => {
226
- if (!SLUG_RE.test(slug)) throw new Error(`invalid slug: ${JSON.stringify(slug)}`);
227
- return path.join(root, "docs", slug, "sdd", "flow.json");
228
- };
229
-
230
- // Resolve one spec/plan doc path under the shared contained contract (DC-01,
231
- // DC-02): the caller-supplied slug must match the slug derived from the path.
232
- const resolveDoc = (
233
- root: string,
234
- slug: string,
235
- docPath: string,
236
- kind: "spec" | "plan",
237
- ): { ok: true; path: string } | { ok: false; error: string } => {
238
- const resolved = resolveCanonicalLayout({
239
- workspace_root: root,
240
- ...(slug ? { slug } : {}),
241
- [kind === "spec" ? "spec_path" : "plan_path"]: docPath,
242
- });
243
- if (!resolved.ok) return { ok: false, error: resolved.error };
244
- return { ok: true, path: resolved.layout[kind === "spec" ? "spec" : "plan"] };
245
- };
246
-
247
- // A flow.json that exists was activated when preparation began; the field is
248
- // kept for forward compatibility but a present file is always treated as
249
- // activated. Missing state is NOT silently activated (FG-01).
250
- const normalizeState = (parsed: unknown, slug: string): FlowState => {
251
- const p = (parsed ?? {}) as Partial<FlowState>;
252
- const spec = (p.spec ?? {}) as Partial<FlowDocState>;
253
- const plan = (p.plan ?? {}) as Partial<FlowDocState>;
254
- const menu = (p.menu ?? {}) as Partial<FlowMenuState>;
255
- const execution = (p.execution ?? {}) as Partial<FlowExecutionState>;
256
- return {
257
- slug: p.slug ?? slug,
258
- activated: p.activated ?? true,
259
- spec: {
260
- path: spec.path ?? "",
261
- status: spec.status ?? "draft",
262
- evidence: spec.evidence ?? null,
263
- approved_digest: spec.approved_digest ?? null,
264
- },
265
- plan: {
266
- path: plan.path ?? "",
267
- status: plan.status ?? "draft",
268
- evidence: plan.evidence ?? null,
269
- approved_digest: plan.approved_digest ?? null,
270
- },
271
- menu: {
272
- presented: Boolean(menu.presented),
273
- chosen: menu.chosen ?? "",
274
- evidence: menu.evidence ?? null,
275
- },
276
- execution: {
277
- status: (execution.status ?? "pending") as ExecutionStatus,
278
- mode: (execution.mode ?? null) as ExecutionMode | null,
279
- evidence: (execution.evidence ?? null) as LifecycleEvidence | null,
280
- coordinator_session_id: execution.coordinator_session_id ?? null,
281
- },
282
- handoff_destination: p.handoff_destination ?? false,
283
- updated_at: p.updated_at ?? Date.now(),
284
- };
285
- };
286
-
287
- const emptyState = (slug: string): FlowState => ({
288
- slug,
289
- activated: false,
290
- spec: { path: "", status: "draft", evidence: null, approved_digest: null },
291
- plan: { path: "", status: "draft", evidence: null, approved_digest: null },
292
- menu: { presented: false, chosen: "", evidence: null },
293
- execution: { status: "pending", mode: null, evidence: null, coordinator_session_id: null },
294
- handoff_destination: false,
295
- updated_at: Date.now(),
296
- });
297
-
298
- export const readFlowState = (root: string, slug: string): FlowState => {
299
- const file = flowPath(root, slug);
300
- if (!existsSync(file)) return emptyState(slug);
301
- try {
302
- return normalizeState(JSON.parse(readFileSync(file, "utf8")), slug);
303
- } catch {
304
- return emptyState(slug);
305
- }
306
- };
307
-
308
- const HEX64_RE = /^[0-9a-f]{64}$/;
309
- const FLOW_STATUSES: readonly FlowStatus[] = ["draft", "self_reviewed", "approved"];
310
- const EXECUTION_STATUSES: readonly ExecutionStatus[] = ["pending", "active", "paused", "completed"];
311
-
312
- const isRecord = (v: unknown): v is Record<string, unknown> =>
313
- typeof v === "object" && v !== null && !Array.isArray(v);
314
-
315
- /**
316
- * Structural validation of persisted choice evidence (CA-18). Approved
317
- * lifecycle/approval evidence is data, not a host hook: we validate the shape
318
- * (host + required fields) but never re-check freshness here — freshness is a
319
- * consume-time property of the host receipt store.
320
- */
321
- const validateEvidenceValue = (v: unknown, allowCli: boolean): boolean => {
322
- if (v === null) return true;
323
- if (!isRecord(v)) return false;
324
- if (v.host === "opencode") {
325
- return (
326
- v.attested === true &&
327
- typeof v.callID === "string" &&
328
- typeof v.selectedLabel === "string" &&
329
- typeof v.recordedAt === "number"
330
- );
331
- }
332
- if (v.host === "cursor") return v.attested === false && v.confirmation === "contract";
333
- if (allowCli && v.host === "cli") {
334
- return v.attested === false && (v.confirmation === "flag" || v.confirmation === "tty");
335
- }
336
- return false;
337
- };
338
-
339
- /**
340
- * Strict validation + documented normalization of parsed flow.json (CA-18):
341
- * unsupported field values are rejected (flow_state_invalid) instead of being
342
- * coerced; missing OPTIONAL fields are normalized only by the documented rules.
343
- */
344
- const validateState = (
345
- parsed: unknown,
346
- slug: string,
347
- ): { ok: true; state: FlowState } | { ok: false; error: string } => {
348
- if (!isRecord(parsed)) return { ok: false, error: "flow state must be a JSON object" };
349
- if (parsed.slug !== undefined && (typeof parsed.slug !== "string" || parsed.slug !== slug)) {
350
- return { ok: false, error: `flow state slug must be ${JSON.stringify(slug)}` };
351
- }
352
- if (parsed.activated !== undefined && typeof parsed.activated !== "boolean") {
353
- return { ok: false, error: "flow state activated must be a boolean" };
354
- }
355
- if (parsed.handoff_destination !== undefined && typeof parsed.handoff_destination !== "boolean") {
356
- return { ok: false, error: "flow state handoff_destination must be a boolean" };
357
- }
358
- if (
359
- parsed.updated_at !== undefined &&
360
- (typeof parsed.updated_at !== "number" || !Number.isFinite(parsed.updated_at))
361
- ) {
362
- return { ok: false, error: "flow state updated_at must be a finite number" };
363
- }
364
- const doc = (value: unknown, name: FlowDocument): FlowDocState | string => {
365
- const p = isRecord(value) ? value : {};
366
- if (!isRecord(value) && value !== undefined) {
367
- return `flow state ${name} must be an object`;
368
- }
369
- if (p.status !== undefined && !FLOW_STATUSES.includes(p.status as FlowStatus)) {
370
- return `flow state ${name}.status must be draft, self_reviewed, or approved`;
371
- }
372
- if (p.path !== undefined && typeof p.path !== "string") {
373
- return `flow state ${name}.path must be a string`;
374
- }
375
- if (
376
- p.approved_digest !== undefined &&
377
- p.approved_digest !== null &&
378
- (typeof p.approved_digest !== "string" || !HEX64_RE.test(p.approved_digest))
379
- ) {
380
- return `flow state ${name}.approved_digest must be 64-char lowercase hex or null`;
381
- }
382
- if (p.evidence !== undefined && !validateEvidenceValue(p.evidence, false)) {
383
- return `flow state ${name}.evidence has an unsupported shape`;
384
- }
385
- return {
386
- path: (p.path as string | undefined) ?? "",
387
- status: (p.status as FlowStatus | undefined) ?? "draft",
388
- evidence: (p.evidence as NativeChoiceEvidence | null | undefined) ?? null,
389
- approved_digest: (p.approved_digest as string | null | undefined) ?? null,
390
- };
391
- };
392
- const spec = doc(parsed.spec, "spec");
393
- if (typeof spec === "string") return { ok: false, error: spec };
394
- const plan = doc(parsed.plan, "plan");
395
- if (typeof plan === "string") return { ok: false, error: plan };
396
-
397
- const menuRaw = isRecord(parsed.menu) ? parsed.menu : undefined;
398
- if (parsed.menu !== undefined && !isRecord(parsed.menu)) {
399
- return { ok: false, error: "flow state menu must be an object" };
400
- }
401
- if (menuRaw?.presented !== undefined && typeof menuRaw.presented !== "boolean") {
402
- return { ok: false, error: "flow state menu.presented must be a boolean" };
403
- }
404
- if (menuRaw?.chosen !== undefined && typeof menuRaw.chosen !== "string") {
405
- return { ok: false, error: "flow state menu.chosen must be a string" };
406
- }
407
- // The only persisted `chosen` values are the MENU_CHOICES plus the empty
408
- // string ("" marks an unpresented/reset menu — markHandoffDestination and the
409
- // drift resets persist it). Anything else is a bogus/legacy value and fails
410
- // closed (CA-18).
411
- if (
412
- menuRaw?.chosen !== undefined &&
413
- menuRaw.chosen !== "" &&
414
- !MENU_CHOICES.includes(menuRaw.chosen as MenuChoice)
415
- ) {
416
- return {
417
- ok: false,
418
- error: `flow state menu.chosen must be one of: ${MENU_CHOICES.join(", ")} (or an empty string when the menu is unpresented)`,
419
- };
420
- }
421
- if (menuRaw?.evidence !== undefined && !validateEvidenceValue(menuRaw.evidence, false)) {
422
- return { ok: false, error: "flow state menu.evidence has an unsupported shape" };
423
- }
424
-
425
- const execRaw = isRecord(parsed.execution) ? parsed.execution : undefined;
426
- if (parsed.execution !== undefined && !isRecord(parsed.execution)) {
427
- return { ok: false, error: "flow state execution must be an object" };
428
- }
429
- if (
430
- execRaw?.status !== undefined &&
431
- !EXECUTION_STATUSES.includes(execRaw.status as ExecutionStatus)
432
- ) {
433
- return {
434
- ok: false,
435
- error: "flow state execution.status must be pending, active, paused, or completed",
436
- };
437
- }
438
- if (
439
- execRaw?.mode !== undefined &&
440
- execRaw.mode !== null &&
441
- execRaw.mode !== "subagent-driven" &&
442
- execRaw.mode !== "inline"
443
- ) {
444
- return {
445
- ok: false,
446
- error: "flow state execution.mode must be subagent-driven, inline, or null",
447
- };
448
- }
449
- if (execRaw?.evidence !== undefined && !validateEvidenceValue(execRaw.evidence, true)) {
450
- return { ok: false, error: "flow state execution.evidence has an unsupported shape" };
451
- }
452
- if (
453
- execRaw?.coordinator_session_id !== undefined &&
454
- execRaw.coordinator_session_id !== null &&
455
- typeof execRaw.coordinator_session_id !== "string"
456
- ) {
457
- return {
458
- ok: false,
459
- error: "flow state execution.coordinator_session_id must be a string or null",
460
- };
461
- }
462
-
463
- return {
464
- ok: true,
465
- state: {
466
- slug,
467
- activated: parsed.activated ?? true,
468
- spec,
469
- plan,
470
- menu: {
471
- presented: menuRaw?.presented ?? false,
472
- chosen: (menuRaw?.chosen as string | undefined) ?? "",
473
- evidence: (menuRaw?.evidence as NativeChoiceEvidence | null | undefined) ?? null,
474
- },
475
- execution: {
476
- status: (execRaw?.status as ExecutionStatus | undefined) ?? "pending",
477
- mode: (execRaw?.mode as ExecutionMode | null | undefined) ?? null,
478
- evidence: (execRaw?.evidence as LifecycleEvidence | null | undefined) ?? null,
479
- coordinator_session_id:
480
- (execRaw?.coordinator_session_id as string | null | undefined) ?? null,
481
- },
482
- handoff_destination: parsed.handoff_destination ?? false,
483
- updated_at: parsed.updated_at ?? Date.now(),
484
- },
485
- };
486
- };
487
-
488
- // Strict read for transitions and guards: missing or corrupt state is a
489
- // structured error, never a silent draft fallback (CA-18). The raw readFlowState
490
- // above stays a lenient compatibility helper for controlled tests and mutation
491
- // internals; status, gates, and host adapters use the effective path. The raw
492
- // parsed JSON is carried so compatibility normalization can distinguish a
493
- // genuinely missing `execution` key from an explicit persisted state (CA-16).
494
- type StrictRead =
495
- | { ok: true; state: FlowState; raw: unknown }
496
- | { ok: false; error: string; code: string };
497
-
498
- const readFlowStrict = (root: string, slug: string): StrictRead => {
499
- const file = flowPath(root, slug);
500
- const rel = path.posix.join("docs", slug, "sdd", "flow.json");
501
- if (!existsSync(file)) {
502
- return err(
503
- "flow_not_activated",
504
- `flow not activated for ${slug} — run workit_flow_status first`,
505
- );
506
- }
507
- let text: string;
508
- try {
509
- text = readFileSync(file, "utf8");
510
- } catch (error) {
511
- return err(
512
- "flow_io_error",
513
- `cannot read flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
514
- );
515
- }
516
- let parsed: unknown;
517
- try {
518
- parsed = JSON.parse(text);
519
- } catch (error) {
520
- return err(
521
- "flow_state_invalid",
522
- `invalid flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
523
- { path: rel, original_bytes_preserved: true },
524
- );
525
- }
526
- const validated = validateState(parsed, slug);
527
- if (!validated.ok) {
528
- return err("flow_state_invalid", `invalid flow state at ${file}: ${validated.error}`, {
529
- path: rel,
530
- original_bytes_preserved: true,
531
- });
532
- }
533
- return { ok: true, state: validated.state, raw: parsed };
534
- };
535
-
536
- // Unique per-write temporary buffer so two concurrent writers never share the
537
- // same `<file>.tmp` (FG-08, CA-21). Pattern mirrors docs-migration.ts:597.
538
- const uniqueTempPath = (file: string) =>
539
- `${file}.${process.pid}-${Math.random().toString(36).slice(2)}.tmp`;
540
-
541
- /**
542
- * Same-directory atomic replacement (CA-19): write a unique temp file, fsync its
543
- * descriptor, close it, and rename it into place. The temp shares the target's
544
- * directory so rename is atomic on the same filesystem; a reader never observes
545
- * partial JSON. Best-effort removal of the temp on every exit path.
546
- */
547
- const writeFlowFileAtomic = (file: string, state: FlowState): void => {
548
- const text = JSON.stringify(state, null, 2) + "\n";
549
- const tmp = uniqueTempPath(file);
550
- mkdirSync(path.dirname(file), { recursive: true });
551
- let fd: number | null = null;
552
- try {
553
- fd = openSync(tmp, "w");
554
- writeFileSync(fd, text, "utf8");
555
- fsyncSync(fd);
556
- closeSync(fd);
557
- fd = null;
558
- renameSync(tmp, file);
559
- } finally {
560
- try {
561
- if (fd !== null) closeSync(fd);
562
- } catch {
563
- // best effort
564
- }
565
- try {
566
- if (existsSync(tmp)) rmSync(tmp, { force: true });
567
- } catch {
568
- // best effort: a leftover temp is preferable to masking the real error
569
- }
570
- }
571
- };
572
-
573
- export const writeFlowState = (root: string, state: FlowState) => {
574
- writeFlowFileAtomic(flowPath(root, state.slug), state);
575
- };
576
-
577
- const MAX_WRITE_ATTEMPTS = 5;
578
-
579
- /**
580
- * Age threshold for stale-lock recovery (CA-19): a crash between
581
- * `openSync(lock, "wx")` and `rmSync(lock)` leaves `<flow.json>.lock` forever.
582
- * A lock file older than this is treated as abandoned and removed before a
583
- * fresh acquisition attempt, so a crash never wedges every later operation.
584
- *
585
- * ponytail: age-based recovery has two documented ceilings. (1) A very slow
586
- * writer still legitimately holding the lock (or clock skew) can have its lock
587
- * reclaimed; the CAS below still protects data, but that writer's critical
588
- * section is no longer mutually exclusive with the new acquirer's. (2)
589
- * Recovery renames by PATH, not by inode: two simultaneous reclaimers of the
590
- * same stale lock can still move a freshly re-acquired winner's lock (one
591
- * reclaimer's rename lands after the other's re-acquisition). No data is lost —
592
- * `writeFlowStateIfCurrent`'s CAS is the integrity backstop — but mutual
593
- * exclusion is not absolute. Upgrade path: write PID/host-session into the
594
- * lock and verify liveness, or lease-renew, when writers that legitimately
595
- * exceed the threshold matter.
596
- */
597
- const STALE_LOCK_MS = 1000;
598
-
599
- // The lock's mtime, or null when it vanished between the EEXIST and the stat
600
- // (a concurrent writer removed it) — either way the caller retries acquisition.
601
- const lockMtimeMs = (lock: string): number | null => {
602
- try {
603
- return statSync(lock).mtimeMs;
604
- } catch {
605
- return null;
606
- }
607
- };
608
-
609
- // Whether the lock at `lock` is still the inode `fd` opened (CA-19): release
610
- // must never unlink a successor's fresh lock, only the file this writer owns.
611
- // ponytail: this is a stat-then-rmSync window — a successor that replaces the
612
- // path between the stat and the release rmSync (a concurrent recovery of a
613
- // >1s-held lock) can still lose its fresh lock. Microsecond window, documented
614
- // ceiling; the CAS backstops data integrity.
615
- const lockOwnedBy = (fd: number, lock: string): boolean => {
616
- try {
617
- return fstatSync(fd).ino === statSync(lock).ino;
618
- } catch {
619
- return false;
620
- }
621
- };
622
-
623
- export type FlowWriteResult =
624
- | { ok: true }
625
- | { ok: false; conflict: true }
626
- | { ok: false; io_error: string };
627
-
628
- /**
629
- * Compare-and-write (FG-08, CA-19): write `next` only if the on-disk content
630
- * still equals the version this writer read (`expected`). A stale writer gets
631
- * `conflict` instead of clobbering a concurrent newer write; the caller re-reads
632
- * and retries the transition (bounded). Unique per-write temp names keep the
633
- * write buffer from being shared between writers.
634
- *
635
- * The first compare happens before the buffer is staged; the file is re-read
636
- * immediately before the rename so a writer that committed between the two
637
- * points still wins. Without the re-read, two writers holding the same expected
638
- * text would both pass the compare and both rename — a lost update. This CAS
639
- * stays as the second safety net under the per-flow `flow.json.lock` (CA-19):
640
- * cooperating writers are serialized by the lock; the CAS catches any writer
641
- * that bypasses it.
642
- *
643
- * A thrown error here is a real IO/permission failure (EACCES, ENOSPC, ...),
644
- * not a conflict: it is returned as `io_error` so callers surface it instead of
645
- * advising a pointless re-read-and-retry. Any unique `.tmp` staged by this
646
- * writer is removed on every non-success path so crashed writers don't
647
- * accumulate temp buffers.
648
- */
649
- export const writeFlowStateIfCurrent = (
650
- root: string,
651
- expected: FlowState,
652
- next: FlowState,
653
- ): FlowWriteResult => {
654
- const file = flowPath(root, next.slug);
655
- const expectedText = JSON.stringify(expected, null, 2) + "\n";
656
- const nextText = JSON.stringify(next, null, 2) + "\n";
657
- if (expectedText === nextText) return { ok: true };
658
- const tmp = uniqueTempPath(file);
659
- let fd: number | null = null;
660
- try {
661
- const currentText = existsSync(file) ? readFileSync(file, "utf8") : null;
662
- if (currentText !== expectedText) return { ok: false, conflict: true };
663
- mkdirSync(path.dirname(file), { recursive: true });
664
- fd = openSync(tmp, "w");
665
- writeFileSync(fd, nextText, "utf8");
666
- fsyncSync(fd);
667
- closeSync(fd);
668
- fd = null;
669
- const reRead = existsSync(file) ? readFileSync(file, "utf8") : null;
670
- if (reRead !== expectedText) return { ok: false, conflict: true };
671
- renameSync(tmp, file);
672
- return { ok: true };
673
- } catch (error) {
674
- return { ok: false, io_error: error instanceof Error ? error.message : String(error) };
675
- } finally {
676
- // On success the rename moved the buffer into place; on any other exit the
677
- // unique temp is orphaned — remove it so crashed writers don't accumulate
678
- // `<file>.<pid>-<rand>.tmp` buffers.
679
- try {
680
- if (fd !== null) closeSync(fd);
681
- } catch {
682
- // best effort
683
- }
684
- try {
685
- if (existsSync(tmp)) rmSync(tmp, { force: true });
686
- } catch {
687
- // best effort: a leftover temp is preferable to masking the real error
688
- }
689
- }
690
- };
691
-
692
- /**
693
- * One internal strict-byte helper (CA-01, CA-06): resolve the canonical
694
- * document, read it as a Buffer, validate it with a fatal TextDecoder, and
695
- * return both the decoded text and the SHA-256 of the exact bytes. Line endings
696
- * and Unicode are never normalized — any byte change invalidates the approval.
697
- */
698
- type CanonicalDigestResult =
699
- | { ok: true; text: string; digest: string }
700
- | { ok: false; code: "document_missing" | "document_unreadable" };
701
-
702
- const readCanonicalDigest = (root: string, rel: string): CanonicalDigestResult => {
703
- const abs = path.join(root, ...rel.split("/"));
704
- let bytes: Buffer;
705
- try {
706
- bytes = readFileSync(abs);
707
- } catch (error) {
708
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
709
- return { ok: false, code: "document_missing" };
710
- }
711
- return { ok: false, code: "document_unreadable" };
712
- }
713
- let text: string;
714
- try {
715
- text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
716
- } catch {
717
- return { ok: false, code: "document_unreadable" };
718
- }
719
- return { ok: true, text, digest: createHash("sha256").update(bytes).digest("hex") };
720
- };
721
-
722
- /**
723
- * Approval-integrity reconciliation (CA-02, CA-03): recompute approved
724
- * document digests in spec-before-plan order and return the reset state plus
725
- * the structured drift reasons. Spec drift resets the whole approval chain;
726
- * plan drift (spec valid) preserves the spec approval/digest and the execution
727
- * lifecycle, resetting only the plan's approval digest.
728
- */
729
- const resetForSpecDrift = (state: FlowState): FlowState => ({
730
- ...state,
731
- spec: { ...state.spec, status: "draft", evidence: null, approved_digest: null },
732
- plan: { ...state.plan, status: "draft", evidence: null, approved_digest: null },
733
- menu: { presented: false, chosen: "", evidence: null },
734
- execution: { status: "pending", mode: null, evidence: null, coordinator_session_id: null },
735
- handoff_destination: false,
736
- updated_at: Date.now(),
737
- });
738
-
739
- const resetForPlanDrift = (state: FlowState): FlowState => ({
740
- ...state,
741
- plan: { ...state.plan, status: "draft", evidence: null, approved_digest: null },
742
- // A plan edit resets only the plan's approval digest (fresh re-approval is
743
- // required before any plan-gated transition). The execution lifecycle, the
744
- // recorded menu choice, and the handoff context are lifecycle facts, not
745
- // plan-approval facts: an in-progress or completed run must not be rewound
746
- // to pending by a doc edit made during or after implementation. The
747
- // coordinator identity is likewise a lifecycle fact (CA-12): plan drift
748
- // preserves it alongside the active status; only completion and spec
749
- // approval drift clear it.
750
- updated_at: Date.now(),
751
- });
752
-
753
- const driftCodeFor = (
754
- root: string,
755
- relPath: string,
756
- storedDigest: string | null,
757
- ): FlowDriftCode | null => {
758
- if (storedDigest === null) return "digest_missing";
759
- const current = readCanonicalDigest(root, relPath);
760
- if (!current.ok) return current.code;
761
- return current.digest !== storedDigest ? "digest_mismatch" : null;
762
- };
763
-
764
- const reconcileState = (
765
- root: string,
766
- slug: string,
767
- state: FlowState,
768
- ): { state: FlowState; drift: FlowDriftReason[] } => {
769
- const specPath = path.posix.join("docs", slug, "spec.md");
770
- const planPath = path.posix.join("docs", slug, "plan.md");
771
- if (state.spec.status === "approved") {
772
- const code = driftCodeFor(root, specPath, state.spec.approved_digest);
773
- if (code) {
774
- return {
775
- state: resetForSpecDrift(state),
776
- drift: [{ document: "spec", code, path: specPath }],
777
- };
778
- }
779
- }
780
- if (state.plan.status === "approved") {
781
- const code = driftCodeFor(root, planPath, state.plan.approved_digest);
782
- if (code) {
783
- return {
784
- state: resetForPlanDrift(state),
785
- drift: [{ document: "plan", code, path: planPath }],
786
- };
787
- }
788
- }
789
- return { state, drift: [] };
790
- };
791
-
792
- /**
793
- * Compatibility normalization for legacy persisted shapes (CA-16): a flow.json
794
- * written before the execution lifecycle has NO `execution` key. Only then is
795
- * execution derived — active exactly when the persisted plan approval, a
796
- * subagent-driven menu choice, and an in-progress SDD ledger prove a legacy
797
- * execution is running; every other combination (and any explicit persisted
798
- * execution) stays pending/fail-closed. Runs BEFORE digest reconciliation
799
- * (CA-17) so a drift reset can still pull a derived active state back to
800
- * pending. Migration evidence is null by design: a legacy flow has no
801
- * host-observed lifecycle receipt to cite.
802
- */
803
- const deriveLegacyExecution = (
804
- root: string,
805
- slug: string,
806
- state: FlowState,
807
- ): FlowExecutionState => {
808
- const ledger = ledgerCompletion(root, slug);
809
- if (
810
- state.plan.status === "approved" &&
811
- state.menu.chosen === "subagent-driven" &&
812
- ledger.started &&
813
- !ledger.complete
814
- ) {
815
- // A legacy flow has no persisted coordinator identity: the field stays
816
- // null and every lineage check fails closed (CA-12/CA-13).
817
- return {
818
- status: "active",
819
- mode: "subagent-driven",
820
- evidence: null,
821
- coordinator_session_id: null,
822
- };
823
- }
824
- return { status: "pending", mode: null, evidence: null, coordinator_session_id: null };
825
- };
826
-
827
- type CompatibilityResult = { state: FlowState; changed: boolean };
828
-
829
- const normalizeCompatibility = (
830
- root: string,
831
- slug: string,
832
- parsed: unknown,
833
- state: FlowState,
834
- ): CompatibilityResult => {
835
- if (!isRecord(parsed) || !("execution" in parsed)) {
836
- const derived = deriveLegacyExecution(root, slug, state);
837
- const current = state.execution;
838
- if (derived.status !== current.status || derived.mode !== current.mode) {
839
- return { state: { ...state, execution: derived, updated_at: Date.now() }, changed: true };
840
- }
841
- return { state, changed: false };
842
- }
843
- // Legacy states written before coordinator_session_id (CA-12) carry an
844
- // execution object without the key; validation defaults it to null, so the
845
- // migration must be persisted under the lock or every read-modify-write
846
- // would CAS-conflict forever (baseline bytes would never match disk).
847
- const execRaw = isRecord(parsed.execution) ? parsed.execution : undefined;
848
- if (execRaw && !("coordinator_session_id" in execRaw)) {
849
- return { state: { ...state, updated_at: Date.now() }, changed: true };
850
- }
851
- return { state, changed: false };
852
- };
853
-
854
- type MutateResult = { ok: true; next: FlowState } | FlowError;
855
-
856
- /**
857
- * Per-flow critical section (CA-19): acquire `<flow.json>.lock` exclusively
858
- * (openSync "wx"); on contention retry with a bounded 10ms backoff; a lock
859
- * older than STALE_LOCK_MS (a crashed writer) is removed and acquisition is
860
- * retried; run the critical section; release the lock and best-effort remove
861
- * it in `finally`. A never-activated flow (no `docs/<slug>/sdd/` dir) runs
862
- * without a lock: there is no flow.json to serialize and no filesystem side
863
- * effect is created — the write helpers create the dir on the first actual
864
- * write. No lock module and no adapter-side lock: every host shares this one
865
- * core contract. ponytail: stale recovery and release are path-based with
866
- * documented TOCTOU ceilings (see STALE_LOCK_MS and lockOwnedBy); data
867
- * integrity is guaranteed by the CAS, not by absolute mutual exclusion.
868
- */
869
- type Locked<T> = { locked: true; value: T } | { locked: false; error: FlowError };
870
-
871
- const withFlowLock = <T>(file: string, fn: () => T): Locked<T> => {
872
- const lock = `${file}.lock`;
873
- // An activated flow's `docs/<slug>/sdd/` dir always exists (a file implies
874
- // its parent dir); a never-activated flow has neither. Skip the lock for the
875
- // never-activated case so a failed flow_not_activated gate/status read
876
- // leaves no filesystem side effect. The skip window is benign: a concurrent
877
- // first activation writes byte-equivalent initial state through unique temp
878
- // names + atomic rename, and the CAS serializes every later mutation.
879
- if (!existsSync(path.dirname(file))) return { locked: true, value: fn() };
880
- // Best-effort cleanup of a leftover `<file>.lock.stale` from a crashed
881
- // recovery (a crash between the recovery rename and the unlink strands it).
882
- // It is never a live lock path — the live lock is always `<file>.lock` — so
883
- // removing it here is safe. ponytail: on-acquisition best-effort only; an
884
- // unremovable `.stale` falls through and never blocks the live lock path.
885
- try {
886
- if (existsSync(`${lock}.stale`)) rmSync(`${lock}.stale`, { force: true });
887
- } catch {
888
- // best effort: a leftover .stale is harmless and cannot wedge the lock
889
- }
890
- const wait = new Int32Array(new SharedArrayBuffer(4));
891
- let fd: number | null = null;
892
- for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
893
- try {
894
- fd = openSync(lock, "wx");
895
- break;
896
- } catch (error) {
897
- const code = (error as NodeJS.ErrnoException).code;
898
- if (code !== "EEXIST") {
899
- return {
900
- locked: false,
901
- error: err(
902
- "flow_io_error",
903
- `flow lock failed for ${file}: ${error instanceof Error ? error.message : String(error)}`,
904
- ),
905
- };
906
- }
907
- // Stale-lock recovery (CA-19): the lock is older than STALE_LOCK_MS, so
908
- // its writer crashed after acquiring it. Reclaim it via a PATH-based
909
- // atomic rename to `<file>.lock.stale`, unlink the stale inode, then
910
- // re-attempt acquisition inline so the final attempt still acquires
911
- // instead of falling out of the loop unlocked. ponytail: the rename is
912
- // NOT inode-conditional — a concurrent reclaimer of the same stale lock
913
- // can move a freshly re-acquired winner's lock at the same path
914
- // (documented TOCTOU ceiling; the CAS backstops integrity). A crash
915
- // between the rename and the unlink strands only `<file>.lock.stale`,
916
- // which is never a live lock path and is best-effort removed on the next
917
- // acquisition.
918
- const mtime = lockMtimeMs(lock);
919
- if (mtime !== null && Date.now() - mtime > STALE_LOCK_MS) {
920
- try {
921
- renameSync(lock, `${lock}.stale`);
922
- unlinkSync(`${lock}.stale`);
923
- } catch {
924
- // best effort: an unremovable or concurrently-reclaimed stale lock
925
- // falls through to the bounded retries and, ultimately,
926
- // flow_concurrent_conflict — never past the lock
927
- }
928
- try {
929
- fd = openSync(lock, "wx");
930
- break;
931
- } catch (innerError) {
932
- const innerCode = (innerError as NodeJS.ErrnoException).code;
933
- if (innerCode !== "EEXIST") {
934
- return {
935
- locked: false,
936
- error: err(
937
- "flow_io_error",
938
- `flow lock failed for ${file}: ${innerError instanceof Error ? innerError.message : String(innerError)}`,
939
- ),
940
- };
941
- }
942
- // another writer won the reclaimed lock — fall through to backoff
943
- }
944
- }
945
- if (attempt === MAX_WRITE_ATTEMPTS - 1) {
946
- return {
947
- locked: false,
948
- error: err(
949
- "flow_concurrent_conflict",
950
- `concurrent flow update detected for ${path.dirname(file)}: re-read the flow state and retry the transition`,
951
- ),
952
- };
953
- }
954
- Atomics.wait(wait, 0, 0, 10);
955
- }
956
- }
957
- if (fd === null) {
958
- // Every acquisition attempt failed without granting the lock: never run the
959
- // critical section unlocked.
960
- return {
961
- locked: false,
962
- error: err(
963
- "flow_concurrent_conflict",
964
- `concurrent flow update detected for ${path.dirname(file)}: re-read the flow state and retry the transition`,
965
- ),
966
- };
967
- }
968
- try {
969
- return { locked: true, value: fn() };
970
- } finally {
971
- try {
972
- if (fd !== null && lockOwnedBy(fd, lock)) rmSync(lock, { force: true });
973
- } catch {
974
- // best effort: a leftover lock is preferable to masking the real error
975
- }
976
- try {
977
- if (fd !== null) closeSync(fd);
978
- } catch {
979
- // best effort
980
- }
981
- }
982
- };
983
-
984
- /**
985
- * Effective flow-state read (CA-02, CA-04): under the per-flow lock, validate
986
- * persisted state, normalize legacy compatibility (missing execution) first,
987
- * reconcile approval digests in spec-before-plan order, and persist any reset
988
- * or migration atomically. Status reads and gates operate ONLY on this
989
- * reconciled state; drift is reported structurally.
990
- */
991
- export const readEffectiveFlowState = (root: string, slug: string): FlowReadResult => {
992
- const file = flowPath(root, slug);
993
- const rel = path.posix.join("docs", slug, "sdd", "flow.json");
994
- const locked = withFlowLock<FlowReadResult>(file, () => {
995
- const strict = readFlowStrict(root, slug);
996
- if (!strict.ok) return strict;
997
- const normalized = normalizeCompatibility(root, slug, strict.raw, strict.state);
998
- const { state, drift } = reconcileState(root, slug, normalized.state);
999
- if (normalized.changed || drift.length > 0) {
1000
- try {
1001
- writeFlowFileAtomic(file, state);
1002
- } catch (error) {
1003
- // A read-path persist failure (EACCES, ENOSPC, EROFS) must never throw
1004
- // through the lock: the FlowReadResult contract is structured (CA-04),
1005
- // and every gate/status read now writes on drift. The original
1006
- // flow.json bytes are untouched — the atomic write never got far enough
1007
- // to swap the file.
1008
- return err(
1009
- "flow_io_error",
1010
- `cannot persist reconciled flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
1011
- { path: rel, original_bytes_preserved: true },
1012
- );
1013
- }
1014
- }
1015
- return { ok: true, state, drift };
1016
- });
1017
- if (!locked.locked) return locked.error;
1018
- return locked.value;
1019
- };
1020
-
1021
- /**
1022
- * Locked read-modify-write (FG-08, CA-19): under the per-flow lock, read strict,
1023
- * normalize legacy compatibility, reconcile approval digests first, mutate on
1024
- * the reconciled state, then commit only if the on-disk state still matches
1025
- * what was read (CAS); otherwise re-read and retry the transition, bounded.
1026
- * A compatibility migration is persisted under the lock first so the CAS
1027
- * baseline matches the on-disk bytes. Reconciliation runs inside this same
1028
- * critical section before every transition (CA-02).
1029
- */
1030
- const readModifyWrite = (
1031
- root: string,
1032
- slug: string,
1033
- mutate: (state: FlowState) => MutateResult,
1034
- ): FlowGateResult => {
1035
- const file = flowPath(root, slug);
1036
- const locked = withFlowLock<FlowGateResult>(file, () => {
1037
- for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
1038
- const strict = readFlowStrict(root, slug);
1039
- if (!strict.ok) return strict;
1040
- const normalized = normalizeCompatibility(root, slug, strict.raw, strict.state);
1041
- const reconciled = reconcileState(root, slug, normalized.state);
1042
- const result = mutate(reconciled.state);
1043
- if (!result.ok) return result;
1044
- let baseline = strict.state;
1045
- if (normalized.changed) {
1046
- try {
1047
- writeFlowFileAtomic(file, normalized.state);
1048
- } catch (error) {
1049
- return err(
1050
- "flow_io_error",
1051
- `cannot persist normalized flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
1052
- );
1053
- }
1054
- baseline = normalized.state;
1055
- }
1056
- const commit = writeFlowStateIfCurrent(root, baseline, result.next);
1057
- if (commit.ok) return { ok: true };
1058
- if ("io_error" in commit) {
1059
- return err("flow_io_error", `flow state write failed for ${slug}: ${commit.io_error}`);
1060
- }
1061
- // a non-cooperating writer won the race — re-read and retry the transition
1062
- }
1063
- return err(
1064
- "flow_concurrent_conflict",
1065
- `concurrent flow update detected for ${slug}: re-read the flow state and retry the transition`,
1066
- );
1067
- });
1068
- if (!locked.locked) return locked.error;
1069
- return locked.value;
1070
- };
1071
-
1072
- // The caller-supplied workspace must be the host workspace the context names
1073
- // (CA-21): a context built for another repo must not drive writes here.
1074
- const assertMutationWorkspace = (root: string, ctx?: MutationContext): FlowGateResult => {
1075
- if (ctx && ctx.hostWorkspace !== root) {
1076
- return err(
1077
- "workspace_mismatch",
1078
- `mutation context workspace ${JSON.stringify(ctx.hostWorkspace)} does not match flow workspace ${JSON.stringify(root)}`,
1079
- );
1080
- }
1081
- return { ok: true };
1082
- };
1083
-
1084
- /**
1085
- * Coordinator boundary (FG-05, CA-20): while a plan's execution is ACTIVE and
1086
- * subagent-driven, the coordinator session cannot mutate product state — only
1087
- * authenticated delegated workers can. A historical subagent-driven menu choice
1088
- * alone is not a boundary: a pending/paused/completed/inline execution leaves
1089
- * the coordinator unblocked. A delegated worker is bound to the recorded
1090
- * activating coordinator lineage (CA-13) and without a task identity is
1091
- * blocked.
1092
- */
1093
- export const assertCoordinatorBoundary = (
1094
- ctx: MutationContext | undefined,
1095
- state: FlowState,
1096
- ): FlowGateResult => {
1097
- if (
1098
- ctx?.role === "coordinator" &&
1099
- state.execution.status === "active" &&
1100
- state.execution.mode === "subagent-driven"
1101
- ) {
1102
- return err("coordinator_blocked", COORDINATOR_RECOVERY_TEXT);
1103
- }
1104
- if (ctx?.role === "delegated") {
1105
- const activeSubagent =
1106
- state.execution.status === "active" && state.execution.mode === "subagent-driven";
1107
- if (activeSubagent) {
1108
- const parent =
1109
- typeof ctx.parentSessionId === "string" && ctx.parentSessionId !== ""
1110
- ? ctx.parentSessionId
1111
- : null;
1112
- const recorded = state.execution.coordinator_session_id;
1113
- // A present-but-mismatched lineage fails closed (CA-13): re-rooted
1114
- // lineage laundering denied.
1115
- if (parent !== null && recorded !== null && parent !== recorded) {
1116
- return err(
1117
- "delegation_lineage_denied",
1118
- "delegated mutations require an exact direct-parent match to the activating coordinator session",
1119
- );
1120
- }
1121
- if (!ctx.taskIdentity) {
1122
- // Unverifiable lineage (no parent reported, or no recorded coordinator
1123
- // id — CA-12/CA-13) fails closed; a verified lineage without a task
1124
- // identity stays unauthenticated.
1125
- if (parent === null || recorded === null) {
1126
- return err(
1127
- "delegation_lineage_denied",
1128
- "delegated mutations require an exact direct-parent match to the activating coordinator session",
1129
- );
1130
- }
1131
- return err(
1132
- "delegated_unauthenticated",
1133
- "delegated mutations require an authenticated task identity (taskIdentity) — re-run inside the delegated worker session",
1134
- );
1135
- }
1136
- } else if (!ctx.taskIdentity) {
1137
- return err(
1138
- "delegated_unauthenticated",
1139
- "delegated mutations require an authenticated task identity (taskIdentity) — re-run inside the delegated worker session",
1140
- );
1141
- }
1142
- }
1143
- return { ok: true };
1144
- };
1145
-
1146
- /**
1147
- * The shared transition matrix (FG-09): draft -> approved in one receipt; a
1148
- * legacy self_reviewed state still advances to approved. The self-review
1149
- * validation runs automatically inside the draft transition.
1150
- *
1151
- * @deprecated public compat — production transitions (transitionSpec /
1152
- * transitionPlan) hardcode "approved"; this matrix is retained only as the
1153
- * documented single-source transition contract for tests and external
1154
- * consumers of the exported API.
1155
- */
1156
- export const nextFlowStatus = (current: FlowStatus): StatusTransition => {
1157
- if (current === "draft") return { ok: true, next: "approved" };
1158
- if (current === "self_reviewed") return { ok: true, next: "approved" };
1159
- return err("flow_already_approved", "already approved; no further transitions");
1160
- };
1161
-
1162
- const MAX_CLOCK_SKEW_MS = 60_000;
1163
- const MAX_RECEIPTS_PER_SESSION = 10;
1164
-
1165
- /**
1166
- * Freshness window for receipts (FINDING 2): an answer older than this can no
1167
- * longer be taken as the user's current intent. ponytail: fixed constant, not
1168
- * config — the consume path runs per approval-tool call, so a knob would buy
1169
- * surface area, not security.
1170
- */
1171
- const RECEIPT_FRESHNESS_MS = 10 * 60 * 1000;
1172
-
1173
- /**
1174
- * Independent belt-and-suspenders age gate for evidence objects passed to the
1175
- * transition functions (receipts are already capped at RECEIPT_FRESHNESS_MS at
1176
- * consume time; this defends direct library callers that fabricate a shape).
1177
- */
1178
- const EVIDENCE_WINDOW_MS = 24 * 60 * 60 * 1000;
1179
-
1180
- /**
1181
- * Case-insensitive negative-answer denylist (FINDING 3): a user answering
1182
- * "No"/"Reject"/"Cancel" can never be recorded as consent for an approval or
1183
- * a menu choice. The boundary closes the laundering case (negative answer ->
1184
- * approval); a prefix variant ("no, thanks") is covered too.
1185
- */
1186
- const NEGATIVE_ANSWER_LABELS = [
1187
- "no",
1188
- "nope",
1189
- "nah",
1190
- "reject",
1191
- "cancel",
1192
- "decline",
1193
- "not now",
1194
- "not yet",
1195
- "skip",
1196
- "back",
1197
- "deny",
1198
- ];
1199
-
1200
- export const isNegativeLabel = (label: string): boolean => {
1201
- const normalized = label.trim().toLowerCase();
1202
- return NEGATIVE_ANSWER_LABELS.some((entry) => {
1203
- const firstWord = normalized.split(/\s+/)[0] ?? "";
1204
- if (entry.includes(" ")) {
1205
- // multi-word entries ("not now", "not yet"): whole-answer match,
1206
- // punctuation-insensitive ("not yet, let me check" -> "not yet")
1207
- const plain = normalized.replace(/[^a-z ]/g, "");
1208
- return plain === entry || plain.startsWith(`${entry} `);
1209
- }
1210
- // single-word entries ("no", "reject", ...): match the first word,
1211
- // ignoring punctuation ("no, thanks" -> "no"); "notebook" stays allowed
1212
- return firstWord.replace(/[^a-z]/g, "") === entry;
1213
- });
1214
- };
1215
-
1216
- export type ReceiptPurpose =
1217
- | "spec-approval"
1218
- | "plan-approval"
1219
- | "execution-menu"
1220
- | "plan-pause"
1221
- | "plan-resume"
1222
- | "plan-complete";
1223
-
1224
- export const receiptPurposeForLabel = (label: string): ReceiptPurpose | undefined => {
1225
- const n = normalizeLabel(label);
1226
- if (n === "approve spec" || n === "approve spec recommended") return "spec-approval";
1227
- if (n === "approve plan" || n === "approve plan recommended") return "plan-approval";
1228
- if (n === "approve") return undefined;
1229
- if (n === "pause plan") return "plan-pause";
1230
- if (n === "resume plan") return "plan-resume";
1231
- if (n === "complete plan") return "plan-complete";
1232
- const exec = new Set([
1233
- "subagent driven",
1234
- "inline",
1235
- "handoff",
1236
- "review spec",
1237
- "review plan",
1238
- "change model",
1239
- ]);
1240
- // Decorated execution labels: "(Recommended)" is stripped by normalizeLabel,
1241
- // so "Subagent-driven (Recommended)" normalizes to "subagent driven".
1242
- if (exec.has(n)) return "execution-menu";
1243
- return undefined;
1244
- };
1245
-
1246
- /**
1247
- * One-use host-observed receipt (AR-12, CA-41): recorded by the OpenCode
1248
- * plugin when the answered `question` tool completes, bound to the session,
1249
- * the question tool call id, the exact selected label, the timestamp, and
1250
- * the workflow purpose.
1251
- * The model has no way to inject a receipt — `record` is only reachable from
1252
- * the plugin's `tool.execute.after` hook.
1253
- */
1254
- export type HostReceipt = {
1255
- sessionId: string;
1256
- callID: string;
1257
- selectedLabel: string;
1258
- recordedAt: number;
1259
- /** The question text the user answered (plugin-observed, best effort), so
1260
- * the consuming tool can report WHICH question authorized a transition
1261
- * (FINDING 2). */
1262
- question: string;
1263
- purpose: ReceiptPurpose;
1264
- };
1265
-
1266
- export type ReceiptConsumeResult = { ok: true; receipt: HostReceipt } | FlowError;
1267
-
1268
- /**
1269
- * In-memory per-session receipt queue. `record` simulates the host hook; the
1270
- * OpenCode plugin is the only production caller. Unconsumed receipts are
1271
- * bounded per session (oldest dropped) so a session that asks questions
1272
- * without approving cannot grow memory without limit.
1273
- *
1274
- * Correlation (FINDING 2): on a real host the model first calls the native
1275
- * `question` (user answers), THEN calls the approval/menu tool — the tools
1276
- * never run a question internally, so a before/after execution window can
1277
- * never capture the answer. Consumption therefore takes the session's MOST
1278
- * RECENT unconsumed receipt FOR THE EXACT PURPOSE and verifies: one-use (atomic take), freshness
1279
- * (RECEIPT_FRESHNESS_MS), NOT a negative label (isNegativeLabel), and session
1280
- * match. Menu tools additionally pin the expected choice label. CallID and
1281
- * the exact selected label stay bound at record time. Unrelated purpose
1282
- * receipts never mask the target purpose.
1283
- *
1284
- * Residual risk (honest boundary): any recent POSITIVE host answer FOR THAT PURPOSE plus the model's choice to call an
1285
- * approval tool authorizes the transition. The laundering case — a negative
1286
- * answer recorded as an approval — is closed by the negative-label denylist
1287
- * per purpose.
1288
- *
1289
- * ponytail: in-memory only — receipts die with the plugin process, which is
1290
- * correct: a host-observed answer cannot survive a restart. Upgrade path:
1291
- * persist to the host session store when cross-restart approvals are required.
1292
- */
1293
- export class HostReceiptStore {
1294
- #bySession = new Map<string, HostReceipt[]>();
1295
-
1296
- record(
1297
- sessionId: string,
1298
- callID: string,
1299
- selectedLabel: string,
1300
- recordedAt: number = Date.now(),
1301
- question: string = "",
1302
- purpose?: ReceiptPurpose,
1303
- ): void {
1304
- const trimmed = selectedLabel.trim();
1305
- if (!trimmed) return;
1306
- if (recordedAt > Date.now() + MAX_CLOCK_SKEW_MS) return; // forged future receipt
1307
- const derived = purpose ?? receiptPurposeForLabel(selectedLabel);
1308
- if (derived === undefined) return; // unrelated question produces no flow receipt (CA-01)
1309
- const q = question ?? "";
1310
- const queue = this.#bySession.get(sessionId) ?? [];
1311
- if (queue.length >= MAX_RECEIPTS_PER_SESSION) queue.shift();
1312
- queue.push({ sessionId, callID, selectedLabel, recordedAt, question: q, purpose: derived });
1313
- this.#bySession.set(sessionId, queue);
1314
- }
1315
-
1316
- count(sessionId: string): number {
1317
- return this.#bySession.get(sessionId)?.length ?? 0;
1318
- }
1319
-
1320
- /**
1321
- * Non-destructive consume: same checks as `consume`, but a positive receipt
1322
- * stays queued. The tools no longer use peek — FINDING 5 (round 3) moved the
1323
- * approval/menu tools to consume-before-transition (the atomic take gates
1324
- * the transition and is spent on any attempt, closing the concurrent-call
1325
- * race). Peek remains for tests and read-only callers. A NEGATIVE receipt is
1326
- * the exception: it is spent by peek too (consumed-and-rejected, FINDING 3)
1327
- * so it cannot poison the top of the queue. Negative revocation is per-purpose.
1328
- */
1329
- peek(
1330
- sessionId: string,
1331
- opts: { purpose?: ReceiptPurpose; label?: string } = {},
1332
- ): ReceiptConsumeResult {
1333
- return this.#take(sessionId, opts, false);
1334
- }
1335
-
1336
- /**
1337
- * One-use consumption of the session's most recent receipt FOR THE EXACT PURPOSE (FINDING 2).
1338
- * The atomic take gates the transition at the tool layer (FINDING 5, round
1339
- * 3): a stale receipt, a wrong pinned label (menu), or a negative label
1340
- * fails the transition; the receipt is removed on take, staleness, or
1341
- * negativity (fail-closed). A wrong label (menu) is NOT spent — it stays
1342
- * queued for the choice it actually matched. Negative revocation removes only older receipts of that purpose.
1343
- */
1344
- consume(
1345
- sessionId: string,
1346
- opts: { purpose?: ReceiptPurpose; label?: string } = {},
1347
- ): ReceiptConsumeResult {
1348
- return this.#take(sessionId, opts, true);
1349
- }
1350
-
1351
- #take(
1352
- sessionId: string,
1353
- opts: { purpose?: ReceiptPurpose; label?: string },
1354
- remove: boolean,
1355
- ): ReceiptConsumeResult {
1356
- const queue = this.#bySession.get(sessionId);
1357
- if (!queue || queue.length === 0) {
1358
- return err(
1359
- "receipt_missing",
1360
- "no host-observed native-question receipt for this session — ask the native " +
1361
- "`question` tool and have the user answer before calling this tool",
1362
- );
1363
- }
1364
- let index = -1;
1365
- if (opts.purpose !== undefined) {
1366
- // A top negative blocks only its own purpose; an unrelated purpose's
1367
- // typed receipt is untouched (CA-02 per-purpose revocation). Purposeless
1368
- // negatives no longer exist: `record` drops unclassified questions, so
1369
- // branch/stash/No/Cancel never block typed purposes globally.
1370
- const top = queue[queue.length - 1];
1371
- if (top && top.purpose === opts.purpose && isNegativeLabel(top.selectedLabel)) {
1372
- const filtered = queue.filter((r) => r.purpose !== opts.purpose);
1373
- if (filtered.length === 0) this.#bySession.delete(sessionId);
1374
- else this.#bySession.set(sessionId, filtered);
1375
- return err(
1376
- "receipt_rejected",
1377
- `the user's most recent answer (${JSON.stringify(top.selectedLabel)}) is a ` +
1378
- "negative answer — it cannot authorize an approval; ask the native question again",
1379
- );
1380
- }
1381
- for (let i = queue.length - 1; i >= 0; i--) {
1382
- if (queue[i].purpose === opts.purpose) {
1383
- index = i;
1384
- break;
1385
- }
1386
- }
1387
- if (index === -1) {
1388
- return err(
1389
- "receipt_missing",
1390
- `no host-observed receipt for purpose ${JSON.stringify(opts.purpose)} — ask the native question for that purpose`,
1391
- );
1392
- }
1393
- } else {
1394
- index = queue.length - 1;
1395
- }
1396
- const receipt = queue[index];
1397
- if (isNegativeLabel(receipt.selectedLabel)) {
1398
- if (opts.purpose !== undefined) {
1399
- const filtered = queue.filter((r) => r.purpose !== opts.purpose);
1400
- if (filtered.length === 0) this.#bySession.delete(sessionId);
1401
- else this.#bySession.set(sessionId, filtered);
1402
- } else {
1403
- this.#bySession.delete(sessionId);
1404
- }
1405
- return err(
1406
- "receipt_rejected",
1407
- `the user's most recent answer (${JSON.stringify(receipt.selectedLabel)}) is a ` +
1408
- "negative answer — it cannot authorize an approval; ask the native question again",
1409
- );
1410
- }
1411
- if (opts.label !== undefined && !sameChoiceLabel(receipt.selectedLabel, opts.label)) {
1412
- // FINDING 6: a wrong-label answer is not spent — it stays queued for
1413
- // the choice it actually matched (or expires via freshness/bounds).
1414
- return err(
1415
- "evidence_mismatch",
1416
- `receipt selectedLabel does not match ${JSON.stringify(opts.label)} — fabricated menu choice rejected`,
1417
- );
1418
- }
1419
- if (Date.now() - receipt.recordedAt > RECEIPT_FRESHNESS_MS) {
1420
- if (remove) {
1421
- queue.splice(index, 1);
1422
- if (queue.length === 0) this.#bySession.delete(sessionId);
1423
- }
1424
- return err(
1425
- "receipt_stale",
1426
- "the question receipt is too old — ask the native question again and re-answer",
1427
- );
1428
- }
1429
- if (remove) {
1430
- queue.splice(index, 1);
1431
- if (queue.length === 0) this.#bySession.delete(sessionId);
1432
- }
1433
- return { ok: true, receipt };
1434
- }
1435
- }
1436
-
1437
- /** Menu labels compare semantically: hosts decorate choices with
1438
- * parenthesized qualifiers ("Handoff (new session only)") that the enum does
1439
- * not carry, so we strip them, trim, collapse whitespace, and lowercase both
1440
- * sides before comparing. Only the comparison normalizes — the stored label
1441
- * and evidence bytes are preserved verbatim. */
1442
- const sameChoiceLabel = (a: string, b: string): boolean => normalizeLabel(a) === normalizeLabel(b);
1443
-
1444
- const normalizeLabel = (s: string): string =>
1445
- s
1446
- .replace(/\s*\([^)]*\)/g, " ")
1447
- .replace(/\s*\bfirst\b\s*$/i, " ")
1448
- .replace(/[^a-z0-9]+/gi, " ")
1449
- .trim()
1450
- .toLowerCase();
1451
-
1452
- /** Derive the evidence record from a consumed host receipt (AR-12). */
1453
- export const createOpenCodeEvidence = (receipt: HostReceipt): OpenCodeChoiceEvidence => ({
1454
- host: "opencode",
1455
- attested: true,
1456
- callID: receipt.callID,
1457
- selectedLabel: receipt.selectedLabel,
1458
- recordedAt: receipt.recordedAt,
1459
- });
1460
-
1461
- /** The Cursor policy-only constant: unauthenticated, no caller data (CA-42). */
1462
- export const createCursorConfirmation = (): EvidenceResult => ({
1463
- ok: true,
1464
- evidence: { host: "cursor", attested: false, confirmation: "contract" },
1465
- });
1466
-
1467
- const CURSOR_KEYS = ["attested", "confirmation", "host"];
1468
-
1469
- /**
1470
- * Strict shape validation for every flow transition (CA-41): OpenCode evidence
1471
- * must be a host-attested receipt record; Cursor evidence must be exactly the
1472
- * policy-only constant and carries no caller-supplied question data. A Cursor
1473
- * object claiming an observed answer (`attested: true`) is rejected as forged.
1474
- */
1475
- export const assertEvidenceShape = (input: unknown): EvidenceResult => {
1476
- if (typeof input !== "object" || input === null) {
1477
- return {
1478
- ok: false,
1479
- error:
1480
- "native choice evidence required — bare booleans and other primitives are not approval evidence",
1481
- };
1482
- }
1483
- const record = input as Record<string, unknown>;
1484
- if (record.host === "cursor") {
1485
- if (record.attested !== false || record.confirmation !== "contract") {
1486
- return {
1487
- ok: false,
1488
- error:
1489
- 'cursor confirmations are policy-only: exactly { host: "cursor", attested: false, confirmation: "contract" } — Cursor cannot attest a host-observed answer',
1490
- };
1491
- }
1492
- const keys = Object.keys(record).sort();
1493
- if (keys.length !== CURSOR_KEYS.length || !CURSOR_KEYS.every((key) => keys.includes(key))) {
1494
- return {
1495
- ok: false,
1496
- error:
1497
- "cursor confirmations carry no caller-supplied question data — the attested: false constant only",
1498
- };
1499
- }
1500
- return { ok: true, evidence: { host: "cursor", attested: false, confirmation: "contract" } };
1501
- }
1502
- if (record.host !== "opencode") {
1503
- return {
1504
- ok: false,
1505
- error: `evidence host must be 'opencode' or 'cursor', got ${JSON.stringify(record.host)}`,
1506
- };
1507
- }
1508
- if (record.attested !== true) {
1509
- return {
1510
- ok: false,
1511
- error:
1512
- "opencode evidence requires host attestation (attested: true) — only host-observed question receipts are accepted",
1513
- };
1514
- }
1515
- const { callID, selectedLabel, recordedAt } = record;
1516
- if (typeof callID !== "string" || callID.trim() === "") {
1517
- return {
1518
- ok: false,
1519
- error: "opencode evidence callID must be a non-empty string (host question tool call)",
1520
- };
1521
- }
1522
- if (typeof selectedLabel !== "string" || selectedLabel.trim() === "") {
1523
- return {
1524
- ok: false,
1525
- error:
1526
- "opencode evidence selectedLabel must be the exact label the user selected on the native question",
1527
- };
1528
- }
1529
- if (typeof recordedAt !== "number" || !Number.isFinite(recordedAt) || recordedAt <= 0) {
1530
- return {
1531
- ok: false,
1532
- error: "opencode evidence recordedAt must be a positive epoch-ms timestamp",
1533
- };
1534
- }
1535
- const now = Date.now();
1536
- if (recordedAt > now + MAX_CLOCK_SKEW_MS) {
1537
- return {
1538
- ok: false,
1539
- error: "opencode evidence recordedAt is in the future — forged evidence is rejected",
1540
- };
1541
- }
1542
- if (now - recordedAt > EVIDENCE_WINDOW_MS) {
1543
- return {
1544
- ok: false,
1545
- error:
1546
- "opencode evidence recordedAt is too old — ask the native question again and re-record the answer",
1547
- };
1548
- }
1549
- return {
1550
- ok: true,
1551
- evidence: {
1552
- host: "opencode",
1553
- attested: true,
1554
- callID: callID.trim(),
1555
- selectedLabel: selectedLabel.trim(),
1556
- recordedAt,
1557
- },
1558
- };
1559
- };
1560
-
1561
- /** Host provenance binding: OpenCode only accepts opencode evidence, and vice versa. */
1562
- export const assertHostEvidence = (host: FlowHost, evidence: unknown): FlowGateResult => {
1563
- const shaped = assertEvidenceShape(evidence);
1564
- if (!shaped.ok) return err("evidence_invalid", shaped.error);
1565
- if (shaped.evidence.host !== host) {
1566
- return err(
1567
- "evidence_host_mismatch",
1568
- `evidence was recorded on ${JSON.stringify(shaped.evidence.host)}, not ${host} — forged or misattributed evidence is rejected`,
1569
- );
1570
- }
1571
- return { ok: true };
1572
- };
1573
-
1574
- /**
1575
- * Record flow activation and the canonical spec/plan paths when preparation
1576
- * begins. The flow store lives under the canonical docs/<slug>/sdd/ layout
1577
- * (Task 18 contract). Re-runs keep existing statuses while recording paths.
1578
- * Activation is a locked critical section (CA-19): existing state is validated
1579
- * and reconciled before being trusted, and malformed state fails closed without
1580
- * overwriting the original file (CA-18).
1581
- */
1582
- export const prepareFlowState = (
1583
- root: string,
1584
- slug: string,
1585
- opts: { spec_path?: string; plan_path?: string } = {},
1586
- ctx?: MutationContext,
1587
- ): FlowGateResult => {
1588
- const bound = assertMutationWorkspace(root, ctx);
1589
- if (!bound.ok) return bound;
1590
- const resolved = resolveCanonicalLayout({
1591
- workspace_root: root,
1592
- slug,
1593
- spec_path: opts.spec_path,
1594
- plan_path: opts.plan_path,
1595
- });
1596
- if (!resolved.ok) return err("flow_prepare_failed", resolved.error);
1597
- const specPath = path.posix.join("docs", slug, "spec.md");
1598
- const planPath = path.posix.join("docs", slug, "plan.md");
1599
- const file = flowPath(root, slug);
1600
- const locked = withFlowLock<FlowGateResult>(file, () => {
1601
- if (!existsSync(file)) {
1602
- writeFlowFileAtomic(file, {
1603
- slug,
1604
- activated: true,
1605
- spec: { path: specPath, status: "draft", evidence: null, approved_digest: null },
1606
- plan: { path: planPath, status: "draft", evidence: null, approved_digest: null },
1607
- menu: { presented: false, chosen: "", evidence: null },
1608
- execution: { status: "pending", mode: null, evidence: null, coordinator_session_id: null },
1609
- handoff_destination: false,
1610
- updated_at: Date.now(),
1611
- });
1612
- return { ok: true };
1613
- }
1614
- const strict = readFlowStrict(root, slug);
1615
- if (!strict.ok) return strict;
1616
- const reconciled = reconcileState(root, slug, strict.state);
1617
- writeFlowFileAtomic(file, {
1618
- ...reconciled.state,
1619
- spec: { ...reconciled.state.spec, path: specPath },
1620
- plan: { ...reconciled.state.plan, path: planPath },
1621
- updated_at: Date.now(),
1622
- });
1623
- return { ok: true };
1624
- });
1625
- if (!locked.locked) return locked.error;
1626
- return locked.value;
1627
- };
1628
-
1629
- /**
1630
- * Approve the canonical spec (CA-01): under the locked read/reconcile/mutate
1631
- * critical section, reset any stale approval first, then read the exact bytes,
1632
- * run the self-review on the decoded text, and atomically store the approval
1633
- * evidence TOGETHER WITH the SHA-256 digest of those bytes.
1634
- */
1635
- export const transitionSpec = (
1636
- root: string,
1637
- slug: string,
1638
- specPath: string,
1639
- evidence: unknown,
1640
- ctx?: MutationContext,
1641
- ): FlowGateResult => {
1642
- const bound = assertMutationWorkspace(root, ctx);
1643
- if (!bound.ok) return bound;
1644
- const recorded = assertEvidenceShape(evidence);
1645
- if (!recorded.ok) return err("evidence_invalid", recorded.error);
1646
- const doc = resolveDoc(root, slug, specPath, "spec");
1647
- if (!doc.ok) return err("path_invalid", doc.error);
1648
- const relPath = path.posix.join("docs", slug, "spec.md");
1649
- return readModifyWrite(root, slug, (state) => {
1650
- if (!existsSync(doc.path)) return err("spec_missing", `spec not found: ${specPath}`);
1651
- if (state.spec.status === "draft" || state.spec.status === "self_reviewed") {
1652
- const digest = readCanonicalDigest(root, relPath);
1653
- if (!digest.ok) {
1654
- return err(
1655
- "spec_self_review_failed",
1656
- `spec self-review failed: unreadable or invalid UTF-8 canonical spec: ${specPath}`,
1657
- );
1658
- }
1659
- if (state.spec.status === "draft") {
1660
- const hard = qualitySpec(digest.text).filter((f) => f.severity === "hard");
1661
- const missing: string[] = [];
1662
- if (!/^\s*\*+Branch:\*+/im.test(stripFences(digest.text)))
1663
- missing.push("**Branch:** header missing");
1664
- if (hard.length > 0 || missing.length > 0) {
1665
- return err(
1666
- "spec_self_review_failed",
1667
- "spec self-review failed: " +
1668
- hard
1669
- .map((f) => `${f.code} — ${f.message}`)
1670
- .concat(missing)
1671
- .join("; ") +
1672
- " — see templates/spec-template.md for the required structure",
1673
- );
1674
- }
1675
- }
1676
- return {
1677
- ok: true,
1678
- next: {
1679
- ...state,
1680
- spec: {
1681
- path: relPath,
1682
- status: "approved",
1683
- evidence: recorded.evidence,
1684
- approved_digest: digest.digest,
1685
- },
1686
- updated_at: Date.now(),
1687
- },
1688
- };
1689
- }
1690
- return err("flow_already_approved", "already approved; no further transitions");
1691
- });
1692
- };
1693
-
1694
- /**
1695
- * Approve the canonical plan (CA-01): requires a currently valid spec approval;
1696
- * under the locked read/reconcile/mutate critical section, reset any stale plan
1697
- * approval first, then read the exact bytes, run the self-review on the decoded
1698
- * text, and atomically store the approval evidence TOGETHER WITH the SHA-256
1699
- * digest of those bytes.
1700
- */
1701
- export const transitionPlan = (
1702
- root: string,
1703
- slug: string,
1704
- planPath: string,
1705
- evidence: unknown,
1706
- ctx?: MutationContext,
1707
- ): FlowGateResult => {
1708
- const bound = assertMutationWorkspace(root, ctx);
1709
- if (!bound.ok) return bound;
1710
- const recorded = assertEvidenceShape(evidence);
1711
- if (!recorded.ok) return err("evidence_invalid", recorded.error);
1712
- const doc = resolveDoc(root, slug, planPath, "plan");
1713
- if (!doc.ok) return err("path_invalid", doc.error);
1714
- const relPath = path.posix.join("docs", slug, "plan.md");
1715
- return readModifyWrite(root, slug, (state) => {
1716
- if (!existsSync(doc.path)) return err("plan_missing", `plan not found: ${planPath}`);
1717
- if (state.spec.status !== "approved") {
1718
- return err("spec_not_approved", "spec must be approved before the plan can be approved");
1719
- }
1720
- if (state.plan.status === "draft" || state.plan.status === "self_reviewed") {
1721
- const digest = readCanonicalDigest(root, relPath);
1722
- if (!digest.ok) {
1723
- return err(
1724
- "plan_self_review_failed",
1725
- `plan self-review failed: unreadable or invalid UTF-8 canonical plan: ${planPath}`,
1726
- );
1727
- }
1728
- if (state.plan.status === "draft") {
1729
- const missing: string[] = [];
1730
- const stripped = stripFences(digest.text);
1731
- if (parseTasksFromPlan(digest.text).length === 0)
1732
- missing.push("no ### Task N: sections outside fences");
1733
- if (!/^\s*\*+Spec:\*+/im.test(stripped)) missing.push("**Spec:** header missing");
1734
- if (!/^\s*\*+Branch:\*+/im.test(stripped)) missing.push("**Branch:** header missing");
1735
- if (missing.length > 0)
1736
- return err("plan_self_review_failed", "plan self-review failed: " + missing.join("; "));
1737
- }
1738
- return {
1739
- ok: true,
1740
- next: {
1741
- ...state,
1742
- plan: {
1743
- path: relPath,
1744
- status: "approved",
1745
- evidence: recorded.evidence,
1746
- approved_digest: digest.digest,
1747
- },
1748
- updated_at: Date.now(),
1749
- },
1750
- };
1751
- }
1752
- return err("flow_already_approved", "already approved; no further transitions");
1753
- });
1754
- };
1755
-
1756
- export const recordMenuChoice = (
1757
- root: string,
1758
- slug: string,
1759
- planPath: string,
1760
- choice: unknown,
1761
- evidence: unknown,
1762
- ctx?: MutationContext,
1763
- ): FlowGateResult => {
1764
- const bound = assertMutationWorkspace(root, ctx);
1765
- if (!bound.ok) return bound;
1766
- const recorded = assertEvidenceShape(evidence);
1767
- if (!recorded.ok) return err("evidence_invalid", recorded.error);
1768
- if (typeof choice !== "string" || !MENU_CHOICES.includes(choice as MenuChoice)) {
1769
- return err("menu_choice_invalid", `invalid menu choice: ${JSON.stringify(choice)}`);
1770
- }
1771
- // Cursor cannot run subagent-driven plans (no child sessions): entering that
1772
- // flow state on Cursor is rejected with recovery guidance (CA-42).
1773
- if (recorded.evidence.host === "cursor" && choice === "subagent-driven") {
1774
- return err("unsupported_mode", CURSOR_SUBAGENT_UNSUPPORTED_TEXT);
1775
- }
1776
- // The execution-menu evidence must be the label the user selected on the
1777
- // native question; a mismatched choice is fabricated (FG-04). Comparison is
1778
- // case-insensitive: the host presents "Inline", the enum stores "inline"
1779
- // (FINDING 3). Cursor evidence is the policy-only constant (no label), so
1780
- // the check applies to host-observed OpenCode receipts only.
1781
- if (
1782
- recorded.evidence.host === "opencode" &&
1783
- !sameChoiceLabel(recorded.evidence.selectedLabel, choice)
1784
- ) {
1785
- return err(
1786
- "evidence_mismatch",
1787
- `evidence selectedLabel ${JSON.stringify(recorded.evidence.selectedLabel)} does not match choice ${JSON.stringify(choice)}`,
1788
- );
1789
- }
1790
- const doc = resolveDoc(root, slug, planPath, "plan");
1791
- if (!doc.ok) return err("path_invalid", doc.error);
1792
- return readModifyWrite(root, slug, (state) => {
1793
- if (state.spec.status !== "approved")
1794
- return err("spec_not_approved", "spec must be approved before the execution menu");
1795
- if (state.plan.status !== "approved")
1796
- return err("plan_not_approved", "plan must be approved before the execution menu");
1797
- // Recursive-handoff rejection (CA-09): a marked destination never re-offers
1798
- // the originating handoff choice, even when an adapter or CLI caller
1799
- // bypasses the destination prompt's four-choice wording.
1800
- if (state.handoff_destination && choice === "handoff") {
1801
- return err(
1802
- "recursive_handoff",
1803
- "this flow is already a handoff destination — a second handoff is rejected",
1804
- );
1805
- }
1806
- // Lifecycle is set ATOMICALLY with the menu evidence (CA-11/CA-13): an
1807
- // executing choice starts the plan; a review/handoff choice leaves it
1808
- // pending. The menu evidence IS the lifecycle evidence — the choice the
1809
- // user selected on the native question. The activating OpenCode
1810
- // coordinator session (CA-12) is persisted ONLY for an accepted
1811
- // subagent-driven activation; inline/handoff/review choices and Cursor's
1812
- // rejected subagent path keep it null.
1813
- const executing = choice === "subagent-driven" || choice === "inline";
1814
- const coordinatorSessionId =
1815
- choice === "subagent-driven" && recorded.evidence.host === "opencode"
1816
- ? (ctx?.sessionId ?? null)
1817
- : null;
1818
- return {
1819
- ok: true,
1820
- next: {
1821
- ...state,
1822
- // Legacy fixup (CA-16): a hand-crafted legacy flow.json with an empty
1823
- // plan.path keeps it empty through menu recording unless restored to
1824
- // the canonical path here.
1825
- plan: { ...state.plan, path: state.plan.path || `docs/${slug}/plan.md` },
1826
- menu: { presented: true, chosen: choice, evidence: recorded.evidence },
1827
- execution: executing
1828
- ? {
1829
- status: "active",
1830
- mode: choice as ExecutionMode,
1831
- evidence: recorded.evidence,
1832
- coordinator_session_id: coordinatorSessionId,
1833
- }
1834
- : {
1835
- status: "pending",
1836
- mode: null,
1837
- evidence: recorded.evidence,
1838
- coordinator_session_id: null,
1839
- },
1840
- updated_at: Date.now(),
1841
- },
1842
- };
1843
- });
1844
- };
1845
-
1846
- /**
1847
- * Atomically mark a flow as a handoff destination (CA-07, CA-09): one effective
1848
- * state mutation under the existing lock/CAS writer. Requires approved spec and
1849
- * plan plus the source menu choice `handoff`; rejects an already marked
1850
- * destination (recursive_handoff). Sets `handoff_destination: true`, resets the
1851
- * menu presentation/evidence, and keeps execution pending. Host-neutral
1852
- * (CA-10): OpenCode, Cursor, and the CLI all reach this single core mutation.
1853
- */
1854
- export const markHandoffDestination = (
1855
- root: string,
1856
- slug: string,
1857
- planPath: string,
1858
- ): FlowGateResult => {
1859
- const doc = resolveDoc(root, slug, planPath, "plan");
1860
- if (!doc.ok) return err("path_invalid", doc.error);
1861
- return readModifyWrite(root, slug, (state) => {
1862
- if (state.spec.status !== "approved")
1863
- return err("spec_not_approved", "spec must be approved before marking a handoff destination");
1864
- if (state.plan.status !== "approved")
1865
- return err("plan_not_approved", "plan must be approved before marking a handoff destination");
1866
- if (state.handoff_destination) {
1867
- return err(
1868
- "recursive_handoff",
1869
- "this flow is already a handoff destination — a second handoff is rejected",
1870
- );
1871
- }
1872
- if (state.menu.chosen !== "handoff") {
1873
- return err(
1874
- "handoff_not_chosen",
1875
- `source menu choice must be "handoff" to mark a handoff destination (chosen: ${JSON.stringify(state.menu.chosen)})`,
1876
- );
1877
- }
1878
- return {
1879
- ok: true,
1880
- next: {
1881
- ...state,
1882
- handoff_destination: true,
1883
- menu: { presented: false, chosen: "", evidence: null },
1884
- updated_at: Date.now(),
1885
- },
1886
- };
1887
- });
1888
- };
1889
-
1890
- const CLI_CONFIRMATION_KEYS = ["attested", "confirmation", "host"];
1891
-
1892
- /**
1893
- * Strict shape validation for lifecycle evidence (CA-19, CA-21): OpenCode and
1894
- * Cursor use the existing native-choice validation; CLI evidence accepts ONLY
1895
- * the exact `{ host: "cli", attested: false, confirmation: "flag" | "tty" }`
1896
- * constant — no caller data, no attestation.
1897
- */
1898
- const validateLifecycleEvidence = (
1899
- input: unknown,
1900
- ): { ok: true; evidence: LifecycleEvidence } | { ok: false; error: string } => {
1901
- if (typeof input !== "object" || input === null) {
1902
- return {
1903
- ok: false,
1904
- error: "lifecycle evidence required — native choice evidence or an exact CLI confirmation",
1905
- };
1906
- }
1907
- const record = input as Record<string, unknown>;
1908
- if (record.host === "cli") {
1909
- const validValue =
1910
- record.attested === false &&
1911
- (record.confirmation === "flag" || record.confirmation === "tty");
1912
- const keys = Object.keys(record).sort();
1913
- const exactShape =
1914
- keys.length === CLI_CONFIRMATION_KEYS.length &&
1915
- CLI_CONFIRMATION_KEYS.every((key) => keys.includes(key));
1916
- if (validValue && exactShape) {
1917
- return {
1918
- ok: true,
1919
- evidence: {
1920
- host: "cli",
1921
- attested: false,
1922
- confirmation: record.confirmation as "flag" | "tty",
1923
- },
1924
- };
1925
- }
1926
- return {
1927
- ok: false,
1928
- error:
1929
- 'cli confirmations accept only the exact { host: "cli", attested: false, confirmation: "flag" | "tty" } shape',
1930
- };
1931
- }
1932
- return assertEvidenceShape(input);
1933
- };
1934
-
1935
- const errPendingFlow = (action: string): FlowError =>
1936
- err("flow_not_active", `cannot ${action} a pending flow — the execution menu has not started it`);
1937
-
1938
- const errCompletedFlow = (action: string): FlowError =>
1939
- err("flow_already_completed", `cannot ${action} a completed flow`);
1940
-
1941
- /**
1942
- * Completion (CA-23): acquire/read/reconcile/validate and capture the exact
1943
- * effective state plus the ledger result; RELEASE the lock; run repository
1944
- * verification outside the lock (no expensive command ever runs while a flow
1945
- * lock is held); stop on nonzero verification; reacquire and compare-and-swap
1946
- * the completed state against the captured state — a concurrent mutation during
1947
- * verification returns flow_concurrent_conflict rather than rerunning
1948
- * verification or overwriting the newer state.
1949
- */
1950
- const completeExecution = (
1951
- root: string,
1952
- slug: string,
1953
- deps?: { verifyProject?: typeof runVerifyProject },
1954
- ): FlowGateResult => {
1955
- const file = flowPath(root, slug);
1956
- const captured = readEffectiveFlowState(root, slug);
1957
- if (!captured.ok) return captured;
1958
- const exec = captured.state.execution;
1959
- if (exec.status === "pending") return errPendingFlow("complete");
1960
- if (exec.status === "completed") return errCompletedFlow("complete");
1961
- const ledger = ledgerCompletion(root, slug);
1962
- if (!ledger.complete) {
1963
- return err(
1964
- "execution_incomplete",
1965
- `execution ledger incomplete for ${slug}: missing tasks ${ledger.missing.join(", ")}`,
1966
- { required: ledger.required, completed: ledger.completed, missing: ledger.missing },
1967
- );
1968
- }
1969
- const verifier = deps?.verifyProject ?? runVerifyProject;
1970
- const verify = verifier(root, false);
1971
- if (verify.exitCode !== 0) {
1972
- return err(
1973
- "verification_failed",
1974
- `repository verification failed for ${slug} (exit ${verify.exitCode}) — see the verification output`,
1975
- { exitCode: verify.exitCode },
1976
- );
1977
- }
1978
- const locked = withFlowLock<FlowGateResult>(file, () => {
1979
- const strict = readFlowStrict(root, slug);
1980
- if (!strict.ok) return strict;
1981
- const reconciled = reconcileState(root, slug, strict.state);
1982
- const currentExec = reconciled.state.execution;
1983
- if (currentExec.status !== exec.status || currentExec.mode !== exec.mode) {
1984
- return err(
1985
- "flow_concurrent_conflict",
1986
- `concurrent execution state change detected for ${slug}: re-read the flow state and retry completion`,
1987
- );
1988
- }
1989
- const next: FlowState = {
1990
- ...reconciled.state,
1991
- // Completion clears the activating coordinator identity (CA-12): a
1992
- // completed flow has no delegated workers left to authorize.
1993
- execution: { ...exec, status: "completed", coordinator_session_id: null },
1994
- // A completed flow is never a destination: clear the context so the next
1995
- // ordinary session gets the source five-choice reminder, not the stale
1996
- // four-choice destination wording (CA-08). Both approval-drift resets
1997
- // (resetForSpecDrift/resetForPlanDrift) and completion clear
1998
- // handoff_destination; only a new-flow prepareFlowState initializes it.
1999
- handoff_destination: false,
2000
- updated_at: Date.now(),
2001
- };
2002
- const commit = writeFlowStateIfCurrent(root, captured.state, next);
2003
- if (commit.ok) return { ok: true };
2004
- if ("io_error" in commit) {
2005
- return err("flow_io_error", `flow state write failed for ${slug}: ${commit.io_error}`);
2006
- }
2007
- return err(
2008
- "flow_concurrent_conflict",
2009
- `concurrent flow update detected for ${slug}: re-read the flow state and retry completion`,
2010
- );
2011
- });
2012
- if (!locked.locked) return locked.error;
2013
- return locked.value;
2014
- };
2015
-
2016
- /**
2017
- * Execution lifecycle transitions (CA-11, CA-14, CA-23): pause, resume, and
2018
- * complete move the plan between the only four states — pending, active,
2019
- * paused, completed. Pause/resume run under the per-flow critical section and
2020
- * preserve the retained mode and original lifecycle evidence; every SDD
2021
- * artifact (briefs, reviews, ledger) is untouched. Completion is orchestrated
2022
- * by completeExecution (ledger check -> verification outside the lock -> CAS).
2023
- */
2024
- export const transitionExecution = (
2025
- root: string,
2026
- slug: string,
2027
- planPath: string,
2028
- action: "pause" | "resume" | "complete",
2029
- evidence: LifecycleEvidence,
2030
- ctx?: MutationContext,
2031
- deps?: { verifyProject?: typeof runVerifyProject },
2032
- ): FlowGateResult => {
2033
- const bound = assertMutationWorkspace(root, ctx);
2034
- if (!bound.ok) return bound;
2035
- const validated = validateLifecycleEvidence(evidence);
2036
- if (!validated.ok) return err("evidence_invalid", validated.error);
2037
- const doc = resolveDoc(root, slug, planPath, "plan");
2038
- if (!doc.ok) return err("path_invalid", doc.error);
2039
-
2040
- if (action === "complete") return completeExecution(root, slug, deps);
2041
-
2042
- if (action === "pause") {
2043
- return readModifyWrite(root, slug, (state) => {
2044
- const exec = state.execution;
2045
- if (exec.status === "pending") return errPendingFlow("pause");
2046
- if (exec.status === "completed") return errCompletedFlow("pause");
2047
- if (exec.status === "paused") return err("flow_already_paused", "flow is already paused");
2048
- return {
2049
- ok: true,
2050
- next: { ...state, execution: { ...exec, status: "paused" }, updated_at: Date.now() },
2051
- };
2052
- });
2053
- }
2054
- return readModifyWrite(root, slug, (state) => {
2055
- const exec = state.execution;
2056
- if (exec.status === "completed") return errCompletedFlow("resume");
2057
- if (exec.status !== "paused") {
2058
- return err(
2059
- "flow_not_paused",
2060
- exec.status === "active"
2061
- ? "flow is already active — cannot resume"
2062
- : "cannot resume a pending flow — the execution menu has not started it",
2063
- );
2064
- }
2065
- return {
2066
- ok: true,
2067
- next: { ...state, execution: { ...exec, status: "active" }, updated_at: Date.now() },
2068
- };
2069
- });
2070
- };
2071
-
2072
- export const slugFromPath = (p: string) => {
2073
- const dirName = path.basename(path.dirname(p));
2074
- return dirName === "." || dirName === "/" || dirName === "" ? "" : dirName;
2075
- };
2076
-
2077
- /** Derive a slug from a canonical docs/<slug>/sdd/... path (SDD write gates). */
2078
- export const slugFromSddPath = (p: string): string => {
2079
- const match = p
2080
- .split(path.sep)
2081
- .join("/")
2082
- // The sdd dir name may be followed by a separator, end-of-string, or a
2083
- // quote char part of a quote-bearing dir name; `sdd-attack` (hyphen/letter
2084
- // continuation) is still rejected.
2085
- .match(/^docs\/([^/]+)\/sdd(\/|$|['"])/);
2086
- return match?.[1] ?? "";
2087
- };
2088
-
2089
- /**
2090
- * Handoff readiness (CA-06..CA-08): the source flow must be approved, valid,
2091
- * not already a destination, and have menu.presented === true with
2092
- * menu.chosen === "handoff" before ANY session is created. A logical preflight
2093
- * failure creates no session (orphan-free). Uses the effective reconciled
2094
- * state so digest drift is observed.
2095
- */
2096
- export const assertHandoffReady = (root: string, planPath: string): FlowGateResult => {
2097
- const doc = resolveDoc(root, "", planPath, "plan");
2098
- if (!doc.ok) return err("path_invalid", doc.error);
2099
- const slug = slugFromPath(planPath);
2100
- const effective = readEffectiveFlowState(root, slug);
2101
- if (!effective.ok) return effective;
2102
- const state = effective.state;
2103
- if (state.spec.status !== "approved") {
2104
- return err(
2105
- "spec_not_approved",
2106
- `spec not approved (status: ${state.spec.status}). Run workit_spec_approve after the user's approval.`,
2107
- );
2108
- }
2109
- if (state.plan.status !== "approved") {
2110
- return err(
2111
- "plan_not_approved",
2112
- `plan not approved (status: ${state.plan.status}). Run workit_plan_approve after the user's approval.`,
2113
- );
2114
- }
2115
- if (state.handoff_destination) {
2116
- return err(
2117
- "recursive_handoff",
2118
- "this flow is already a handoff destination — a second handoff is rejected",
2119
- );
2120
- }
2121
- if (!state.menu.presented || state.menu.chosen !== "handoff") {
2122
- return err(
2123
- "handoff_not_chosen",
2124
- `handoff requires the execution menu choice "handoff" (chosen: ${JSON.stringify(state.menu.chosen)}, presented: ${state.menu.presented})`,
2125
- );
2126
- }
2127
- return { ok: true };
2128
- };
2129
-
2130
- export const assertFlowGates = (
2131
- root: string,
2132
- planPath: string,
2133
- opts: { requireMenu?: boolean } = {},
2134
- ): FlowGateResult => {
2135
- const doc = resolveDoc(root, "", planPath, "plan");
2136
- if (!doc.ok) return err("path_invalid", doc.error);
2137
- const slug = slugFromPath(planPath);
2138
- // Effective read (CA-02): digest reconciliation runs before the gate trusts
2139
- // persisted approvals; drift resets are persisted before gating.
2140
- const effective = readEffectiveFlowState(root, slug);
2141
- if (!effective.ok) return effective;
2142
- const state = effective.state;
2143
- if (state.spec.status !== "approved") {
2144
- return err(
2145
- "spec_not_approved",
2146
- `spec not approved (status: ${state.spec.status}). Run workit_spec_approve after the user's approval.`,
2147
- );
2148
- }
2149
- if (state.plan.status !== "approved") {
2150
- return err(
2151
- "plan_not_approved",
2152
- `plan not approved (status: ${state.plan.status}). Run workit_plan_approve after the user's approval.`,
2153
- );
2154
- }
2155
- if (opts.requireMenu && !state.menu.presented) {
2156
- return err(
2157
- "menu_not_presented",
2158
- "post-plan menu not presented. Ask the native question menu (Subagent-driven/Inline/Handoff/Review spec/Review plan) and record the answer with workit_plan_menu.",
2159
- );
2160
- }
2161
- return { ok: true };
2162
- };
2163
-
2164
- /**
2165
- * Shared mutation guard for non-document product writes (FG-03, CA-18): a write
2166
- * is blocked until the spec is approved, the plan is approved, the execution
2167
- * menu has been recorded (when required), and the canonical docs validate.
2168
- * The optional MutationContext adds the coordinator boundary (FG-05, CA-20).
2169
- * The gate reconciles approval digests before trusting persisted approvals
2170
- * (CA-02); drift resets are persisted before gating.
2171
- */
2172
- export const assertProductGates = (
2173
- root: string,
2174
- slug: string,
2175
- opts: { requireMenu?: boolean; requireDocs?: boolean } = {},
2176
- ctx?: MutationContext,
2177
- ): FlowGateResult => {
2178
- const bound = assertMutationWorkspace(root, ctx);
2179
- if (!bound.ok) return bound;
2180
- // Effective strict read (CA-18): missing state surfaces flow_not_activated,
2181
- // malformed state flow_state_invalid — never a misleading spec_not_approved
2182
- // from a silent draft fallback. Fail-closed is preserved — no gate ever
2183
- // passes on absent state.
2184
- const effective = readEffectiveFlowState(root, slug);
2185
- if (!effective.ok) return effective;
2186
- const state = effective.state;
2187
- if (state.spec.status !== "approved") {
2188
- return err(
2189
- "spec_not_approved",
2190
- `spec not approved (status: ${state.spec.status}). Run workit_spec_approve after the user's approval.`,
2191
- );
2192
- }
2193
- if (state.plan.status !== "approved") {
2194
- return err(
2195
- "plan_not_approved",
2196
- `plan not approved (status: ${state.plan.status}). Run workit_plan_approve after the user's approval.`,
2197
- );
2198
- }
2199
- if (opts.requireMenu && !state.menu.presented) {
2200
- return err(
2201
- "menu_not_presented",
2202
- "post-plan menu not presented. Record the native question answer with workit_plan_menu.",
2203
- );
2204
- }
2205
- if (opts.requireDocs) {
2206
- // Canonical relative form of the docs pair (DC-01/DC-02): docsValidate
2207
- // resolves the contained paths itself.
2208
- const validated = docsValidate({
2209
- spec_path: path.posix.join("docs", slug, "spec.md"),
2210
- plan_path: path.posix.join("docs", slug, "plan.md"),
2211
- workspace_root: root,
2212
- });
2213
- if (validated.ok === false) return err("docs_invalid", validated.error);
2214
- }
2215
- return assertCoordinatorBoundary(ctx, state);
2216
- };
2217
-
2218
- /**
2219
- * Coordinator-only SDD control gate (CA-10): validated gitignored control
2220
- * metadata under docs/<slug>/sdd/ — task briefs, review packages, progress,
2221
- * and advisories. Requirements match assertProductGates' workspace/approval/
2222
- * menu/docs/path checks, but when execution is active subagent-driven the
2223
- * call must be the coordinator (root session); a delegated worker cannot
2224
- * mutate coordinator bookkeeping. Inactive flows are not gated on role.
2225
- */
2226
- export const assertSddControlGates = (
2227
- root: string,
2228
- slug: string,
2229
- opts: { requireMenu?: boolean; requireDocs?: boolean } = {},
2230
- ctx?: MutationContext,
2231
- ): FlowGateResult => {
2232
- const bound = assertMutationWorkspace(root, ctx);
2233
- if (!bound.ok) return bound;
2234
- const effective = readEffectiveFlowState(root, slug);
2235
- if (!effective.ok) return effective;
2236
- const state = effective.state;
2237
- if (state.spec.status !== "approved") {
2238
- return err(
2239
- "spec_not_approved",
2240
- `spec not approved (status: ${state.spec.status}). Run workit_spec_approve after the user's approval.`,
2241
- );
2242
- }
2243
- if (state.plan.status !== "approved") {
2244
- return err(
2245
- "plan_not_approved",
2246
- `plan not approved (status: ${state.plan.status}). Run workit_plan_approve after the user's approval.`,
2247
- );
2248
- }
2249
- if (opts.requireMenu && !state.menu.presented) {
2250
- return err(
2251
- "menu_not_presented",
2252
- "post-plan menu not presented. Record the native question answer with workit_plan_menu.",
2253
- );
2254
- }
2255
- if (opts.requireDocs) {
2256
- const validated = docsValidate({
2257
- spec_path: path.posix.join("docs", slug, "spec.md"),
2258
- plan_path: path.posix.join("docs", slug, "plan.md"),
2259
- workspace_root: root,
2260
- });
2261
- if (validated.ok === false) return err("docs_invalid", validated.error);
2262
- }
2263
- if (
2264
- state.execution.status === "active" &&
2265
- state.execution.mode === "subagent-driven" &&
2266
- // Lineage binding (CA-13): the adapter derives the role before the slug
2267
- // resolves, so delegation is re-derived here from the host-attested parent
2268
- // against the persisted activating coordinator id.
2269
- roleFromParentage(ctx?.parentSessionId, state.execution.coordinator_session_id) === "delegated"
2270
- ) {
2271
- return err(
2272
- "sdd_control_denied",
2273
- "SDD control metadata is coordinator-owned while a subagent-driven plan is active — delegated workers cannot mutate task briefs, review packages, progress, or advisories",
2274
- );
2275
- }
2276
- return { ok: true };
2277
- };
2278
-
2279
- /**
2280
- * Delegated status derives from host session parentage bound to the persisted
2281
- * coordinator identity (AR-12, CA-13): a session with a parent is delegated
2282
- * ONLY when that parent id equals the flow's recorded activating coordinator
2283
- * session; any other parentage (or a missing/null coordinator id) is a
2284
- * coordinator. Caller-supplied role fields are removed from every tool schema
2285
- * — this pure function is the only source.
2286
- */
2287
- export const roleFromParentage = (
2288
- parentID?: string | null,
2289
- coordinatorSessionId?: string | null,
2290
- ): FlowRole =>
2291
- typeof parentID === "string" && parentID !== "" && parentID === coordinatorSessionId
2292
- ? "delegated"
2293
- : "coordinator";
2294
-
2295
- /**
2296
- * Root-session write interception while a subagent-driven plan is active
2297
- * (CA-18, AR-13): known mutation tools are denied outright. Host-native write
2298
- * tools (write/edit/apply_patch/patch/rename/delete/…) plus the workit
2299
- * mutation tools are listed here so the plugin hook can deny them before any
2300
- * file is touched. Read-only host tools (read/grep/glob/list/question/task/…)
2301
- * are intentionally absent. The list is the audited boundary: adding a write
2302
- * tool here without a test is the escape hatch the audit checks for.
2303
- */
2304
- export const COORDINATOR_WRITE_TOOLS: readonly string[] = [
2305
- // host-native file/command mutation tools
2306
- "write",
2307
- "edit",
2308
- "apply_patch",
2309
- "patch",
2310
- "rename",
2311
- "delete",
2312
- "mkdir",
2313
- "mv",
2314
- "cp",
2315
- "rm",
2316
- "touch",
2317
- "chmod",
2318
- "chown",
2319
- // workit product/config/external mutation tools (SDD control tools are
2320
- // coordinator-owned and routed through assertSddControlGates, not this set)
2321
- "workit_commit",
2322
- "workit_pr_create",
2323
- "workit_rule_edit",
2324
- "workit_template_edit",
2325
- "workit_changelog_apply",
2326
- "workit_branch_setup",
2327
- "workit_init_apply",
2328
- "workit_docs_promote",
2329
- "workit_docs_layout",
2330
- "workit_docs_repo_link",
2331
- "workit_youtrack_post",
2332
- "workit_youtrack_log_time",
2333
- ];
2334
-
2335
- /**
2336
- * Bounded coordinator shell allowlist (CA-18, AR-13): while a subagent-driven
2337
- * plan is active, the coordinator may run ONLY read/review/test/verify
2338
- * commands — never anything that mutates files, git state, the system clock,
2339
- * or the network.
2340
- *
2341
- * DENY matrix (every check below is asserted by the adversarial test table):
2342
- * - Denied fragments, checked on the raw command before tokenizing:
2343
- * `>` `>>` `2>` `&>` `<>` (any `>` — redirection), `|` (pipes, incl. `2>|`),
2344
- * `&` (backgrounding), `;` (chains), `$(` (command substitution),
2345
- * `${` (parameter expansion — `${IFS}` can smuggle whitespace past the
2346
- * tokenizer), `$'` (ANSI-C quoting — can embed `\t`/`\n` escapes that are
2347
- * real whitespace to the shell), `` ` `` (backticks), newline (multi-line
2348
- * scripts), `<(` (process substitution input — `>(` dies on `>` already).
2349
- * Heredocs `<<` are stdin-only and allowed (a heredoc cannot write without
2350
- * a `>`). Literal `\t`/`\n` backslash escapes OUTSIDE `$'...'` are plain
2351
- * `t`/`n` characters to the shell — they cannot create whitespace
2352
- * (documented, FINDING 4).
2353
- * - `(` `)` are denied per-token (process substitution `<(`, `>(`, subshells
2354
- * `(cmd)`, and `awk system(...)` all need them) — EXCEPT as git `--format`
2355
- * placeholders (`--format='%(refname)'`): a `--format` value is display
2356
- * text (the shell already consumed the quotes) and `$(`/`<(`/`>`/backticks
2357
- * are denied raw regardless (FINDING 3, round 4). Multi-token format
2358
- * values (a space inside the quoted format) stay denied — fail-closed.
2359
- * Pure-stdout verbs (`echo printf jq`) may print parens as display text:
2360
- * a shell-quote-state scan allows the command iff every paren lies inside
2361
- * a quoted region — any unquoted paren (subshell syntax, bash-verified
2362
- * syntax error) denies the whole command, fail-closed (FINDING 3, round 5).
2363
- * - Denied command heads: `curl`, `sudo`, `tee`, `wget` (privilege/network/
2364
- * tee writes). These words are ONLY denied as the first token — as argument
2365
- * text (`grep curl README.md`, `cat sudo-config.txt`) they pass (FINDING 5).
2366
- * - Every other first token must be one of the allowlisted sets below.
2367
- * - Tokens are UNQUOTED (every `'`/`"` character stripped — the shell's word
2368
- * parsing removes quote characters entirely, so `--out'put=x'` IS
2369
- * `--output=x`, `-de'lete'` IS `-delete`, `cu'rl'` IS `curl`, `awk -'f x'`
2370
- * IS `awk -f x`) before every check (FINDING 2, round 5).
2371
- * - `--output` and `--output=` (git log/diff and any other verb) are denied
2372
- * on every command: both forms write a file.
2373
- * - Write-capable `-o`/attached `-oFILE`/`--output`/`--output=FILE` are
2374
- * denied on `sort`, `tree`, `comm`, `diff`, `jq` (grep/rg keep `-o` — it
2375
- * only prints the matching part, read-only; find's `-o` is the logical-OR
2376
- * operator and stays allowed).
2377
- * - `--compress-program` (any form, every verb): GNU sort EXECUTES the given
2378
- * program with the sorted data on its stdin — `sh` runs that data as a
2379
- * script (bash-verified, FINDING 1, round 6). Only sort has the flag, but
2380
- * the deny is global so no flag surface needs tracking.
2381
- * - `date -s`/`--set` (any attached/separate/`=` form): mutates the system
2382
- * clock (bash-verified setter, FINDING 3, round 6). `date -d`/`--date`
2383
- * (display) stays allowed.
2384
- * - `sort -T`/`--temporary-directory` (any form): writes sort's own temp
2385
- * files into an arbitrary directory (bash/strace-verified, FINDING 4,
2386
- * round 6). `sort -t:` (field separator) stays allowed.
2387
- * - Read-only tool heads (`cat head tail less more grep rg ag find ls stat wc
2388
- * file diff sort uniq cut tr fold printf echo pwd date which type du df tree
2389
- * jq basename dirname realpath readlink rev comm paste nl od xxd awk gawk
2390
- * mawk test [`):
2391
- * `find` is denied every destructive/file-writing form: `-delete -exec
2392
- * -execdir -ok -okdir` and `-fprint* -fls` (prefix).
2393
- * `sed` is NOT allowlisted at all (round 5, decision: deny outright). GNU
2394
- * sed 4.9 executes arbitrary commands through the `e` command (`sed 'e
2395
- * touch x' f`) and the `s///e` flag (bash-verified: both ran `touch` —
2396
- * e.g. `sed 's/.+/touch x/e' f`); closing the class needs a full sed script
2397
- * grammar, and five review rounds of sed escapes (`w`/`W`/`-f`/attached
2398
- * forms/quote joins) show a token parser cannot close it. sed reads are a
2399
- * nice-to-have — `cat`/`grep`/`awk` cover them.
2400
- * `awk`/`gawk`/`mawk` are denied every script file form (`-f`/`--file`,
2401
- * attached or separate — the script may contain `system(...)`/file
2402
- * redirects); `-F` (field separator, read-only) stays allowed.
2403
- * - `tsc` with `--noEmit` (bare `tsc` can emit build artifacts).
2404
- * - `git` with a read-only subcommand (`status log diff show branch rev-parse
2405
- * merge-base remote ls-files blame shortlog describe check-ignore name-rev
2406
- * stash grep tag`); `git stash` only as `git stash list`; the mutable
2407
- * listing subcommands (`branch tag remote`) are bare or one of their
2408
- * whitelisted read flags only — `branch` `-a -r -v -vv --all --remotes
2409
- * --verbose --show-current -l --list --merged --no-merged --contains
2410
- * --points-at --format --sort`, `tag` `-l --list --sort --contains
2411
- * --points-at --merged --no-merged --format --column`, `remote` `-v
2412
- * --verbose`. The value-taking flags (`--contains --points-at --merged
2413
- * --no-merged --sort --format`) accept AT MOST ONE following value token
2414
- * (a commit/tag name, a sort key, a format string — or glued
2415
- * `--flag=value`; verified read-only in bash). Every other flag
2416
- * (`-d -D -m -c -f -a -s ...`) is denied, a trailing NAME after a value
2417
- * is denied (it would CREATE a branch/tag), and non-listed subcommands
2418
- * (`config`, `var`, `push`, `commit`, `checkout`, `stash push`, ...) are
2419
- * denied outright.
2420
- * - git exec-trigger flags are denied on every allowlisted subcommand
2421
- * (FINDING 2, round 6): `grep --open-files-in-pager[=<pager>]`/`-O[<pager>]`
2422
- * executes the pager with each matched file (`sh` executes the file —
2423
- * bash-verified), `log/diff/show --ext-diff` runs repo gitattributes
2424
- * external diff drivers, `log/diff/show/blame/grep --textconv` runs
2425
- * repo-configured textconv drivers, `--show-signature` runs gpg
2426
- * (core.gpg.program), `--remerge-diff` runs the merge machinery
2427
- * (external merge drivers). `-O` on log/diff/show is `--diff-order`
2428
- * (a read flag) and stays allowed; `--no-ext-diff`/`--no-textconv`
2429
- * disable the drivers and stay allowed. Global `-p`/`--paginate` (before
2430
- * the subcommand) are already denied by the subcommand-position rule;
2431
- * `git log -p` is `--patch` (read-only) and stays allowed.
2432
- * - `git --no-pager <sub>` (global pager-disable, BEFORE the subcommand) is
2433
- * allowed and behaves exactly like `git <sub>` for every rule below — it
2434
- * never lifts a mutable/exec deny (FINDING 3, round 7). Combined
2435
- * read-only short flags (`-av`, `-ar`, `-avv` on `branch` — every char
2436
- * from the per-subcommand read set `branch: a r v l`, `tag: l`, `remote:
2437
- * v`) are allowed on the mutable listing subcommands; a combined form
2438
- * containing any write char (`git tag -av` creates an annotated tag,
2439
- * `git branch -adv` deletes) is denied (FINDING 4, round 7).
2440
- * - A test runner head (`bun|npm|pnpm|yarn|npx`) with one of the test/check/
2441
- * lint/typecheck/verify/validate verbs (`vitest jest mocha` are runner
2442
- * verbs too, e.g. `npx jest`; `tsc` as a runner verb requires `--noEmit`).
2443
- * The FULL enumerated runner-write surface is denied on every runner verb
2444
- * (FINDING 1, round 7, bash-verified): `--fix*` (lint autofix), `--write*`
2445
- * (prettier), `--update*`/`-u` (snapshot updates — `bun test -u` rewrote
2446
- * the snapshot, verified), `-w` (write-capable short form in some runners;
2447
- * vitest/mocha watch is over-denied — the `--watch` long form stays
2448
- * allowed), `--coverage*` plus camelCase `--collectCoverage*` AND kebab
2449
- * `--collect-coverage*` (all write coverage/), `--outputFile`/`=` (jest
2450
- * JSON report — bash-verified, also
2451
- * denied globally as an output flag), `--cache*` (eslint/jest cache files;
2452
- * `--cache=false` is read-only but over-denied — the coordinator never
2453
- * needs cache control), and the tsc build-info flags (`-b`/`--build`,
2454
- * `--incremental`, `--tsBuildInfoFile`, `--composite` — they write
2455
- * .tsbuildinfo/outputs even with `--noEmit`). Matching is
2456
- * case-insensitive-prefix on lowercased tokens (camelCase cannot dodge
2457
- * the prefixes). Direct-head tools (`jest vitest eslint prettier oxlint
2458
- * oxfmt mocha ...`) are NOT allowlisted at all — head denial, so their
2459
- * write flags never reach the runner rules.
2460
- * - `command` is allowed ONLY as `command -v <name>` / `command -V <name>`
2461
- * (path lookup — read-only); bare `command` EXECUTES and is denied.
2462
- * `test`/`[` evaluate expressions only and are read-only (FINDING 3,
2463
- * round 4).
2464
- *
2465
- * Test runner flags write nothing — exact statement (FINDING 1, round 7 +
2466
- * round 8): the allowed verbs are `test check lint typecheck verify validate`
2467
- * (plus the `vitest jest mocha` runner verbs and `tsc --noEmit`), and every
2468
- * write-capable runner flag family is denied (snapshot updates
2469
- * `-u`/`--update*`, autofix `--fix*`, `--write*`, `-w`, coverage
2470
- * `--coverage*`/`--collectCoverage*`/`--collect-coverage*` (camel AND kebab),
2471
- * jest JSON reports `--outputFile`, caches `--cache*`, tsc build-info
2472
- * `-b`/`--incremental`/`--tsBuildInfoFile`/`--composite`), so no RUNNER
2473
- * WRITE FLAG inside the boundary can write a file — that is the exact scope
2474
- * of this claim. A test run itself can still write by design (inherent
2475
- * allowance, stated here as the documented boundary): a first-run jest
2476
- * creates new `__snapshots__` WITHOUT `-u`, and test code runs with
2477
- * coordinator permissions — the test runner is admitted to the boundary as
2478
- * a runner, not sandboxed. `bun run format` (writes) and `bun run build`
2479
- * (dist) are NOT allowed. This allowlist is an audited security boundary (asserted by the
2480
- * adversarial table). The EXACT deny statement (FINDING 3, round 6 — the old
2481
- * "every WRITE form of the listed verbs" claim was false while `date -s` and
2482
- * `sort -T` were unguarded): redirections, pipes, chains, substitution,
2483
- * parens; `sed` denied outright (round 5 — its `e`/`s///e` commands execute);
2484
- * awk/gawk/mawk `-f`/`--file` script files; `--output`/`--output-file`/`-o`
2485
- * on output-flag verbs; `--compress-program` on EVERY verb (executes PROG
2486
- * with data on stdin — `sh` runs sorted data as a script, bash-verified,
2487
- * round 6); git mutable-subcommand mutations AND git exec-trigger flags
2488
- * (`--open-files-in-pager`/`-O` on grep — executes the pager on matched
2489
- * files, bash-verified; `--ext-diff`; `--textconv`; `--show-signature`;
2490
- * `--remerge-diff`; global `-p`/`--paginate` denied by the subcommand-
2491
- * position rule); `date -s`/`--set` (system-clock mutation, round 6);
2492
- * `sort -T`/`--temporary-directory` (writes sort's temporaries into the
2493
- * given directory, round 6); find's delete/exec/fprint family; runner
2494
- * mutating flags (round 7: `-u`/`-w`/`--cache*`/`--collectCoverage*`/
2495
- * `--outputFile`/tsc build-info added to the `--fix`/`--write`/`--coverage`/
2496
- * `--update` families); `command` without `-v`/`-V` — and every head not
2497
- * listed above is denied outright (unlisted write/exec/network commands
2498
- * never enter the allowlist at all).
2499
- */
2500
- const BASH_READ_TOKENS = new Set([
2501
- "cat",
2502
- "head",
2503
- "tail",
2504
- "less",
2505
- "more",
2506
- "grep",
2507
- "rg",
2508
- "ag",
2509
- "find",
2510
- "ls",
2511
- "stat",
2512
- "wc",
2513
- "file",
2514
- "diff",
2515
- "sort",
2516
- "uniq",
2517
- "cut",
2518
- "tr",
2519
- "fold",
2520
- "printf",
2521
- "echo",
2522
- "pwd",
2523
- "date",
2524
- "which",
2525
- "type",
2526
- "du",
2527
- "df",
2528
- "tree",
2529
- "jq",
2530
- "basename",
2531
- "dirname",
2532
- "realpath",
2533
- "readlink",
2534
- "rev",
2535
- "comm",
2536
- "paste",
2537
- "nl",
2538
- "od",
2539
- "xxd",
2540
- "awk",
2541
- "gawk",
2542
- "mawk",
2543
- "test",
2544
- "[",
2545
- ]);
2546
-
2547
- const BASH_GIT_READ_SUBCOMMANDS = new Set([
2548
- "status",
2549
- "log",
2550
- "diff",
2551
- "show",
2552
- "branch",
2553
- "rev-parse",
2554
- "merge-base",
2555
- "remote",
2556
- "ls-files",
2557
- "blame",
2558
- "shortlog",
2559
- "describe",
2560
- "check-ignore",
2561
- "name-rev",
2562
- "stash",
2563
- "grep",
2564
- "tag",
2565
- ]);
2566
-
2567
- const BASH_GIT_MUTABLE_SUBCOMMANDS = new Set(["branch", "remote", "tag"]);
2568
-
2569
- // Exact read-only flag forms per mutable git subcommand: every other flag or
2570
- // argument (a branch/tag name, `-d -D -m -c -f -a -s ...`) is a write and is
2571
- // denied (FINDING 1).
2572
- const BASH_GIT_READ_FLAGS: Record<string, Set<string>> = {
2573
- branch: new Set([
2574
- "-a",
2575
- "--all",
2576
- "-r",
2577
- "--remotes",
2578
- "-v",
2579
- "--verbose",
2580
- "-vv",
2581
- "--show-current",
2582
- "-l",
2583
- "--list",
2584
- "--merged",
2585
- "--no-merged",
2586
- "--contains",
2587
- "--points-at",
2588
- "--format",
2589
- "--sort",
2590
- ]),
2591
- tag: new Set([
2592
- "-l",
2593
- "--list",
2594
- "--sort",
2595
- "--contains",
2596
- "--points-at",
2597
- "--merged",
2598
- "--no-merged",
2599
- "--format",
2600
- "--column",
2601
- ]),
2602
- remote: new Set(["-v", "--verbose"]),
2603
- };
2604
-
2605
- // Read-only SHORT flags per mutable git subcommand (FINDING 4, round 7):
2606
- // `branch` `-a -r -v -l` (+ `-vv` = `-v -v`), `tag` `-l` ONLY (`-a` creates
2607
- // an annotated tag), `remote` `-v` ONLY. Git combines short flags into one
2608
- // token (`-av` = `-a -v`), so a single-dash all-letter token is allowed iff
2609
- // EVERY character is a read char for that subcommand — any write char
2610
- // (`git tag -av` → `-a` creates; `git branch -adv` → `-d` deletes) denies.
2611
- const BASH_GIT_READ_SHORT_FLAGS: Record<string, string> = {
2612
- branch: "arvl",
2613
- tag: "l",
2614
- remote: "v",
2615
- };
2616
-
2617
- const isCombinedReadShortFlag = (sub: string, token: string): boolean => {
2618
- if (!/^-[a-z]+$/.test(token)) return false;
2619
- const allowed = BASH_GIT_READ_SHORT_FLAGS[sub] ?? "";
2620
- for (let i = 1; i < token.length; i++) {
2621
- if (!allowed.includes(token[i])) return false;
2622
- }
2623
- return true;
2624
- };
2625
-
2626
- // find's destructive and file-writing forms: `-delete` deletes, `-exec/
2627
- // -execdir/-ok/-okdir` execute arbitrary commands, `-fprint/-fprintf/
2628
- // -fprint0/-fls` write files (FINDING 1).
2629
- const BASH_FIND_DENIED_FLAGS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir"]);
2630
-
2631
- const BASH_FIND_DENIED_PREFIXES = ["-fprint", "-fls"];
2632
-
2633
- const BASH_TEST_RUNNERS = ["bun", "npm", "pnpm", "yarn", "npx"];
2634
-
2635
- const BASH_TEST_VERBS = new Set([
2636
- "test",
2637
- "check",
2638
- "lint",
2639
- "typecheck",
2640
- "verify",
2641
- "validate",
2642
- "vitest",
2643
- "jest",
2644
- "mocha",
2645
- ]);
2646
-
2647
- // Mutating flag families on test/lint verbs — the FULL enumerated
2648
- // runner-write surface (FINDING 1, round 7): `--fix*` (lint autofix),
2649
- // `--write*` (prettier), `--update*`/`-u` (snapshot updates — `bun test -u`,
2650
- // `npm test -u`, `jest -u`/`--updateSnapshot`), `-w` (write-capable short
2651
- // form in some runners; vitest/mocha watch is over-denied — the `--watch`
2652
- // long form stays allowed), `--coverage*` plus the camelCase
2653
- // `--collectCoverage*` AND the kebab `--collect-coverage*` (all write
2654
- // coverage/ — the kebab form escapes the camelCase entry because the dash
2655
- // breaks the prefix match, FINDING 1, round 8), `--cache*` (eslint/jest
2656
- // cache files; `--cache=false` is read-only but over-denied — the
2657
- // coordinator never needs cache control). Matching is
2658
- // case-insensitive-prefix on lowercased tokens so camelCase spellings cannot
2659
- // dodge the prefixes.
2660
- const BASH_MUTATING_TEST_FLAGS = [
2661
- "--fix",
2662
- "--write",
2663
- "--update",
2664
- "-u",
2665
- "-w",
2666
- "--coverage",
2667
- "--collectcoverage",
2668
- "--collect-coverage",
2669
- "--cache",
2670
- ];
2671
-
2672
- // Privilege/network/tee-write heads: denied ONLY as the first token, so the
2673
- // words themselves stay legal as argument text (FINDING 5).
2674
- const BASH_DENIED_HEADS = new Set(["curl", "sudo", "tee", "wget"]);
2675
-
2676
- // Write-capable -o/-oFILE/--output/--output=FILE on allowlisted read verbs
2677
- // (FINDING 1). grep/rg keep `-o` (read-only match printing); find keeps `-o`
2678
- // (logical OR). `--output` is denied globally for every command. jq `-o` is
2679
- // `--output-file` (round 3 audit — same write class as sort -o).
2680
- const BASH_OUTPUT_FLAG_VERBS = new Set(["sort", "tree", "comm", "diff", "jq"]);
2681
-
2682
- const BASH_FORBIDDEN_FRAGMENTS = [">", "|", "&", ";", "$(", "${", "$'", "`", "\n", "<("];
2683
-
2684
- // Pure-stdout verbs exempt from the per-token paren denial, but only for
2685
- // parens inside a fully-quoted token (display text). Unquoted parens stay
2686
- // denied. jq qualifies: its only write paths (`-o`/`--output-file`) are
2687
- // denied separately. awk is NOT exempt (`system(...)` executes). (FINDING 3,
2688
- // round 5)
2689
- const BASH_PAREN_EXEMPT_HEADS = new Set(["echo", "printf", "jq"]);
2690
-
2691
- // Value-taking READ flags on the mutable git listing subcommands (FINDING 3,
2692
- // round 4): `--contains|--points-at|--merged|--no-merged [<commit>]`,
2693
- // `--sort <key>`, `--format <format>` — each takes AT MOST ONE following
2694
- // value token (the value may also be glued: `--sort=-x`, `--format='%(x)'`).
2695
- // The value is display/list filtering only — verified read-only in bash. A
2696
- // trailing NAME after a value would create a branch/tag (`git branch --sort=
2697
- // -x y`, `git tag --format=x y` both create), so anything that is neither a
2698
- // whitelisted flag nor the single value of a value flag is denied.
2699
- const BASH_GIT_VALUE_FLAGS = new Set([
2700
- "--contains",
2701
- "--points-at",
2702
- "--merged",
2703
- "--no-merged",
2704
- "--sort",
2705
- "--format",
2706
- ]);
2707
-
2708
- const gitGluedValueFlag = (token: string): boolean =>
2709
- /^--(contains|points-at|merged|no-merged|sort|format)=.+/.test(token);
2710
-
2711
- const hasMutatingTestFlag = (tokens: string[]): boolean => {
2712
- const lower = tokens.map((t) => t.toLowerCase());
2713
- return lower.some((t) => BASH_MUTATING_TEST_FLAGS.some((flag) => t.startsWith(flag)));
2714
- };
2715
-
2716
- // Script FILE forms for awk/gawk/mawk (FINDING 2/3, round 3): the option may
2717
- // carry its value ATTACHED (`-fscript.awk`, `-f/tmp/evil.awk`, `--file=x`) —
2718
- // GNU awk accepts the attached short-option form, so any token starting with
2719
- // `-f`/`--file` is a script file. The script may contain `system(...)`/
2720
- // redirections. `-F` (awk field separator, read-only, uppercase) is NOT
2721
- // matched. sed is not allowlisted at all (round 5), so no sed -f rule exists.
2722
- const scriptFileForm = (token: string): boolean =>
2723
- token === "-f" || token.startsWith("-f") || token.startsWith("--file");
2724
-
2725
- const findDenied = (token: string): boolean =>
2726
- BASH_FIND_DENIED_FLAGS.has(token) ||
2727
- BASH_FIND_DENIED_PREFIXES.some((prefix) => token.startsWith(prefix));
2728
-
2729
- // Any command may write via --output/--output=FILE (git log/diff, sort, ...)
2730
- // or --output-file/--output-file=FILE (jq). `--outputFile`/`--outputFile=`
2731
- // is jest's JSON-report flag (writes the report file — bash-verified,
2732
- // FINDING 1, round 7: `npx jest --json --outputFile=out.json` created the
2733
- // file); it is denied globally for the same reason as `--output-file`.
2734
- const outputFlagDenied = (token: string): boolean =>
2735
- token === "--output" ||
2736
- token.startsWith("--output=") ||
2737
- token === "--output-file" ||
2738
- token.startsWith("--output-file=") ||
2739
- token === "--outputFile" ||
2740
- token.startsWith("--outputFile=");
2741
-
2742
- // `--compress-program` (GNU sort; any verb — global deny) EXECUTES PROG with
2743
- // the sorted data on its stdin: `sh` runs the data as a script (bash-verified,
2744
- // FINDING 1, round 6: `sort --buffer-size=1M --compress-program=sh` created
2745
- // PWNED_COMPRESS). The space form dies at the flag token; the `=` form here.
2746
- const compressProgramDenied = (token: string): boolean =>
2747
- token === "--compress-program" || token.startsWith("--compress-program=");
2748
-
2749
- // git flags that TRIGGER external program execution (FINDING 2, round 6):
2750
- // `grep --open-files-in-pager[=<pager>]` and its short form `-O[<pager>]`
2751
- // open each matched file with a pager — `sh` executes the file (bash-verified:
2752
- // `git grep --open-files-in-pager=sh -e x -- f` and `git grep -Osh` both
2753
- // created GITPWNED files); `log/diff/show --ext-diff` runs repo gitattributes
2754
- // external diff drivers; `log/diff/show/blame/grep --textconv` runs
2755
- // repo-configured textconv drivers; `--show-signature` runs gpg
2756
- // (core.gpg.program); `--remerge-diff` runs the merge machinery on merge
2757
- // commits (external merge drivers) — same driver-execution class, denied
2758
- // fail-closed. `-O` on grep is open-files-in-pager, but `-O` on log/diff/show
2759
- // is `--diff-order=<orderfile>` (a read flag) — the short form is scoped to
2760
- // grep. `--no-ext-diff`/`--no-textconv` DISABLE the drivers and stay allowed.
2761
- // Global `-p`/`--paginate` (before the subcommand) never reach this check —
2762
- // the subcommand-position rule already denies them (pinned in the matrix).
2763
- const gitExecFlagDenied = (sub: string, token: string): boolean => {
2764
- if (sub === "grep" && (token === "-O" || token.startsWith("-O"))) return true;
2765
- if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) return true;
2766
- if (token === "--ext-diff" || token.startsWith("--ext-diff=")) return true;
2767
- if (token === "--textconv" || token.startsWith("--textconv=")) return true;
2768
- if (token === "--show-signature" || token.startsWith("--show-signature=")) return true;
2769
- if (token === "--remerge-diff" || token.startsWith("--remerge-diff=")) return true;
2770
- return false;
2771
- };
2772
-
2773
- // `date -s`/`--set` (and attached `-sVALUE`, `--set=VALUE`) MUTATE the system
2774
- // clock (bash-verified: `date -s` attempts the set — "cannot set date:
2775
- // Operation not permitted", FINDING 3, round 6). No other GNU date flag
2776
- // starts with `-s`; `-d`/`--date` (display) stays allowed.
2777
- const dateSetDenied = (token: string): boolean =>
2778
- token === "-s" || token.startsWith("-s") || token === "--set" || token.startsWith("--set=");
2779
-
2780
- // `sort -T`/`--temporary-directory` writes sort's own temp files into an
2781
- // arbitrary directory (bash/strace-verified: `sort -T <dir>` created
2782
- // sortGdvlHf, sortV2VyNF, ..., FINDING 4, round 6). `-t:` (field separator,
2783
- // lowercase) is NOT matched. The space form dies at the flag token.
2784
- const sortTempDirDenied = (token: string): boolean =>
2785
- token === "-T" ||
2786
- token.startsWith("-T") ||
2787
- token === "--temporary-directory" ||
2788
- token.startsWith("--temporary-directory=");
2789
-
2790
- // tsc build-info flags (FINDING 1, round 7): `-b`/`--build` (build mode
2791
- // writes outputs), and `--incremental`/`--tsBuildInfoFile`/`--composite`
2792
- // write `.tsbuildinfo` even WITH `--noEmit` — so `--noEmit` alone is not a
2793
- // sufficient read guarantee. Denied on BOTH the direct `tsc` head and the
2794
- // runner verb (`bun run tsc`); `tsc --noEmit` remains the only admitted form.
2795
- const tsBuildDenied = (token: string): boolean =>
2796
- token === "-b" ||
2797
- token.startsWith("-b") ||
2798
- token === "--build" ||
2799
- token.startsWith("--build=") ||
2800
- token === "--incremental" ||
2801
- token.startsWith("--incremental=") ||
2802
- token === "--tsBuildInfoFile" ||
2803
- token.startsWith("--tsBuildInfoFile=") ||
2804
- token === "--composite" ||
2805
- token.startsWith("--composite=");
2806
-
2807
- export const isCoordinatorBashAllowed = (command: string): boolean => {
2808
- const trimmed = command.trim();
2809
- if (!trimmed) return false;
2810
- for (const fragment of BASH_FORBIDDEN_FRAGMENTS) {
2811
- if (trimmed.includes(fragment)) return false;
2812
- }
2813
- // FINDING 2 (round 5): the shell's word parsing REMOVES every quote
2814
- // character when building argv — `'w'out` IS `wout`, `--out'put=x'` IS
2815
- // `--output=x`, `-de'lete'` IS `-delete`, `awk -'f x'` IS `awk -f x`.
2816
- // Strip ALL `'`/`"` from each token before every check so mid-token
2817
- // quote joins cannot smuggle a deny-listed flag past the rules. Stripping
2818
- // only removes characters, so a deny rule can never be evaded by it.
2819
- const unquote = (token: string): string => token.replace(/['"]/g, "");
2820
- const rawTokens = trimmed.split(/\s+/);
2821
- const tokens = rawTokens.map(unquote);
2822
- const head = tokens[0] ?? "";
2823
- // `(`/`)` are denied per-token (process substitution `<(`, `>(`, subshells
2824
- // `(cmd)`, and `awk system(...)` all need them) — EXCEPT as git `--format`
2825
- // placeholders (`--format='%(refname)'`): the shell has already consumed
2826
- // the quotes, so a `--format` value is display text, and `$(`/`<(`/`>`/
2827
- // backticks are denied raw regardless (FINDING 3, round 4). A value token
2828
- // AFTER a bare `--format` is likewise display text. Pure-stdout verbs
2829
- // (`echo printf jq`) may print parens as display text: a shell-quote-state
2830
- // scan of the RAW command allows the command iff every paren lies inside a
2831
- // quoted region; any unquoted paren (subshell/syntax forms — bash-verified
2832
- // syntax errors) denies the whole command, fail-closed. jq's only write
2833
- // paths (`-o`/`--output-file`) are denied separately (FINDING 3, round 5).
2834
- const parenExempt = BASH_PAREN_EXEMPT_HEADS.has(head);
2835
- let parensSafe = true;
2836
- if (parenExempt) {
2837
- let state = 0; // 0 = unquoted, 1 = '...', 2 = "..."
2838
- for (let i = 0; i < trimmed.length; i++) {
2839
- const ch = trimmed[i];
2840
- if (state === 0) {
2841
- if (ch === "'") state = 1;
2842
- else if (ch === '"') state = 2;
2843
- else if (ch === "(" || ch === ")") parensSafe = false;
2844
- } else if (state === 1) {
2845
- if (ch === "'") state = 0;
2846
- } else if (ch === "\\") {
2847
- i++; // escaped char inside "..."
2848
- } else if (ch === '"') {
2849
- state = 0;
2850
- }
2851
- }
2852
- }
2853
- let formatValue = false;
2854
- for (let i = 0; i < tokens.length; i++) {
2855
- const t = tokens[i];
2856
- if (formatValue) {
2857
- formatValue = false;
2858
- continue;
2859
- }
2860
- if (t.includes("(") || t.includes(")")) {
2861
- if (parenExempt && parensSafe) continue;
2862
- if (t.startsWith("--format=") && !t.includes("<(")) continue;
2863
- return false;
2864
- }
2865
- formatValue = t === "--format";
2866
- }
2867
- if (BASH_DENIED_HEADS.has(head)) return false;
2868
- if (tokens.some(outputFlagDenied) || tokens.some(compressProgramDenied)) return false;
2869
- if (head === "git") {
2870
- // FINDING 3 (round 7): `--no-pager` is a GLOBAL pager-disable that sits
2871
- // BEFORE the subcommand (`git --no-pager log ...`) — read-only, the
2872
- // exact counterpart of the already-allowed post-subcommand form. It only
2873
- // shifts the subcommand position; every rule below (exec flags, mutable
2874
- // subcommands, stash list, value walk) still applies to the real
2875
- // subcommand. `git --no-pager` alone (no subcommand) falls through to
2876
- // the subcommand-position deny.
2877
- let subIndex = 1;
2878
- if (tokens[1] === "--no-pager") subIndex = 2;
2879
- const sub = tokens[subIndex] ?? "";
2880
- const flagTokens = tokens.slice(subIndex + 1);
2881
- if (flagTokens.some((t) => gitExecFlagDenied(sub, t))) return false;
2882
- if (sub === "stash") return flagTokens[0] === "list";
2883
- if (!BASH_GIT_READ_SUBCOMMANDS.has(sub)) return false;
2884
- if (!BASH_GIT_MUTABLE_SUBCOMMANDS.has(sub)) return true;
2885
- if (flagTokens.length === 0) return true; // bare listing (`git branch`)
2886
- const flags = BASH_GIT_READ_FLAGS[sub];
2887
- if (!flags) return false;
2888
- // exact read flags, plus AT MOST ONE value after each value-taking flag,
2889
- // plus combined read-only short flags (FINDING 4, round 7)
2890
- let valuePending = false;
2891
- for (const t of flagTokens) {
2892
- if (flags.has(t) || gitGluedValueFlag(t)) {
2893
- valuePending = BASH_GIT_VALUE_FLAGS.has(t);
2894
- continue;
2895
- }
2896
- if (valuePending) {
2897
- valuePending = false;
2898
- continue;
2899
- }
2900
- if (isCombinedReadShortFlag(sub, t)) continue;
2901
- return false;
2902
- }
2903
- return true;
2904
- }
2905
- if (head === "command") {
2906
- // `command` EXECUTES its argument; only `command -v`/`-V` (lookup) is
2907
- // read-only — exactly one name, no more (FINDING 3, round 4).
2908
- return (tokens[1] === "-v" || tokens[1] === "-V") && tokens.length === 3;
2909
- }
2910
- if (head === "tsc") return tokens.includes("--noEmit") && !tokens.some(tsBuildDenied);
2911
- if (BASH_READ_TOKENS.has(head)) {
2912
- if (head === "find") return !tokens.some(findDenied);
2913
- // awk/gawk/mawk: only the script-file form is denied (`-f`/`--file`,
2914
- // attached or separate); `-F` (field separator) and reads stay allowed.
2915
- if (head === "awk" || head === "gawk" || head === "mawk") {
2916
- return !tokens.some(scriptFileForm);
2917
- }
2918
- // date: only `-s`/`--set` (clock mutation) is denied (FINDING 3, round 6).
2919
- if (head === "date") return !tokens.some(dateSetDenied);
2920
- // sort: `-T`/`--temporary-directory` (temp files in arbitrary dirs) is
2921
- // denied; the `-o`/`--output` write forms are denied by the output-flag
2922
- // check below (FINDING 4, round 6).
2923
- if (head === "sort" && tokens.some(sortTempDirDenied)) return false;
2924
- if (BASH_OUTPUT_FLAG_VERBS.has(head)) {
2925
- return !tokens.some((t) => t === "-o" || t.startsWith("-o") || outputFlagDenied(t));
2926
- }
2927
- return true;
2928
- }
2929
- if (BASH_TEST_RUNNERS.includes(head)) {
2930
- if (hasMutatingTestFlag(tokens)) return false;
2931
- const verbIndex = tokens[1] === "run" ? 2 : 1;
2932
- const verb = tokens[verbIndex] ?? "";
2933
- if (verb === "tsc") return tokens.includes("--noEmit") && !tokens.some(tsBuildDenied);
2934
- return BASH_TEST_VERBS.has(verb);
2935
- }
2936
- return false;
2937
- };
2938
-
2939
- export const COORDINATOR_SHELL_DENIED_TEXT =
2940
- "Coordinator shell commands are restricted while a subagent-driven plan is " +
2941
- "active: only bounded read/test/review commands are allowed (the exact " +
2942
- "allowlist is in flow-state.ts, isCoordinatorBashAllowed). " +
2943
- COORDINATOR_RECOVERY_TEXT;
2944
-
2945
- /**
2946
- * The plugin hook's decision function (AR-13): only the exact direct child of
2947
- * the single recorded activating coordinator escapes interception while a
2948
- * subagent-driven plan is active; a re-rooted lineage, an unrelated child, or
2949
- * the root coordinator itself is intercepted. Returns the denial error to
2950
- * throw from `tool.execute.before`, or `{ ok: true }`.
2951
- */
2952
- export const subagentDrivenInterception = (input: {
2953
- tool: string;
2954
- command?: string;
2955
- parentID?: string | null;
2956
- activeCoordinatorIds?: string[];
2957
- active?: boolean;
2958
- }): FlowGateResult => {
2959
- // Distinct owners only: the same coordinator recorded on several active
2960
- // plans is still ONE owner (CA-13 denies multiple DISTINCT owners).
2961
- const ids = Array.from(
2962
- new Set((input.activeCoordinatorIds ?? []).filter((id) => typeof id === "string" && id !== "")),
2963
- );
2964
- const parent =
2965
- typeof input.parentID === "string" && input.parentID !== "" ? input.parentID : null;
2966
- const legacyActive = input.active === true;
2967
- if (!legacyActive && ids.length === 0) return { ok: true };
2968
- // Authorized direct child: exactly one recorded coordinator and this session
2969
- // is its exact direct child.
2970
- if (parent !== null && ids.length === 1 && ids[0] === parent) {
2971
- if (input.tool === "bash") {
2972
- // Nested-launch denial (CA-14): an authorized worker cannot launch
2973
- // opencode recursively while the plan is active. Any token whose
2974
- // basename is exactly `opencode` denies — head, path-suffixed
2975
- // (`./node_modules/.bin/opencode`), or runner-carried
2976
- // (`bun x opencode`). ponytail: argument text containing the bare word
2977
- // (`grep opencode file`) is over-denied — a documented ceiling; a
2978
- // parser that distinguishes argument positions is the upgrade path.
2979
- const tokens = (input.command ?? "").split(/[\s'"]+/).filter(Boolean);
2980
- const launchesOpencode = tokens.some((t) => t.split("/").pop() === "opencode");
2981
- if (launchesOpencode) {
2982
- return err(
2983
- "delegation_lineage_denied",
2984
- "nested opencode launch is denied while a subagent-driven plan is active",
2985
- );
2986
- }
2987
- }
2988
- if (
2989
- [
2990
- "workit_sdd_task_brief",
2991
- "workit_sdd_review_package",
2992
- "workit_sdd_append_progress",
2993
- "workit_sdd_append_advisory",
2994
- ].includes(input.tool)
2995
- ) {
2996
- return err(
2997
- "delegation_lineage_denied",
2998
- "SDD control metadata is coordinator-owned — workers execute briefs, not bookkeeping",
2999
- );
3000
- }
3001
- return { ok: true };
3002
- }
3003
- if (parent !== null) {
3004
- // A non-empty parentID that does not exactly match the single recorded
3005
- // coordinator fails closed (CA-13): re-rooted lineage laundering denied.
3006
- if (!legacyActive || ids.length > 0) {
3007
- return err(
3008
- "delegation_lineage_denied",
3009
- "delegated writes require an exact direct-parent match to the activating coordinator",
3010
- );
3011
- }
3012
- }
3013
- // Coordinator (root) path: existing restrictions while active.
3014
- if (COORDINATOR_WRITE_TOOLS.includes(input.tool)) {
3015
- return err("coordinator_write_denied", COORDINATOR_RECOVERY_TEXT);
3016
- }
3017
- if (input.tool === "bash") {
3018
- if (!input.command || !isCoordinatorBashAllowed(input.command)) {
3019
- return err("coordinator_shell_denied", COORDINATOR_SHELL_DENIED_TEXT);
3020
- }
3021
- }
3022
- return { ok: true };
3023
- };
3024
-
3025
- export const findActiveSubagentDrivenContexts = (
3026
- root: string,
3027
- ): Array<{ slug: string; coordinator_session_id: string | null }> => {
3028
- let entries: string[] = [];
3029
- try {
3030
- entries = readdirSync(path.join(root, "docs"), { withFileTypes: true })
3031
- .filter((e) => e.isDirectory())
3032
- .map((e) => e.name);
3033
- } catch {
3034
- return [];
3035
- }
3036
- const out: Array<{ slug: string; coordinator_session_id: string | null }> = [];
3037
- for (const slug of entries) {
3038
- try {
3039
- const state = readFlowState(root, slug);
3040
- if (state.execution.status === "active" && state.execution.mode === "subagent-driven") {
3041
- out.push({ slug, coordinator_session_id: state.execution.coordinator_session_id });
3042
- }
3043
- } catch {
3044
- // unreadable flow state: skip, never throw from discovery
3045
- }
3046
- }
3047
- return out;
3048
- };