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