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