@brainervirus/workit-core 0.8.0 → 0.8.2
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/package.json +1 -1
- package/scripts/run-cursor-mcp.sh +1 -1
- package/src/core/detector.ts +65 -44
- package/src/core/docs-layout.ts +38 -10
- package/src/core/doctor.ts +32 -2
- package/src/core/flow-state.ts +1133 -132
- package/src/core/handoff-context.ts +8 -0
- package/src/core/handoff-tools.ts +21 -1
- package/src/core/menu.ts +68 -0
- package/src/core/registration.ts +9 -7
- package/src/core/reminder.ts +31 -1
- package/src/core/sdd.ts +57 -0
- package/templates/execution-contract.md +11 -0
- package/templates/superpowers-doc-contract.md +2 -0
package/src/core/flow-state.ts
CHANGED
|
@@ -1,12 +1,63 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
fstatSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
2
16
|
import path from "node:path";
|
|
3
17
|
import { docsValidate, parseTasksFromPlan, qualitySpec, stripFences } from "./docs-validate";
|
|
4
18
|
import { resolveCanonicalLayout } from "./docs-layout";
|
|
19
|
+
import { ledgerCompletion } from "./sdd";
|
|
20
|
+
import { runVerifyProject } from "./verify-project";
|
|
5
21
|
|
|
6
22
|
export type FlowHost = "opencode" | "cursor";
|
|
7
23
|
export type FlowStatus = "draft" | "self_reviewed" | "approved";
|
|
8
24
|
export type FlowRole = "coordinator" | "delegated";
|
|
9
25
|
|
|
26
|
+
/** The canonical document kinds a flow binds approvals to (CA-01). */
|
|
27
|
+
export type FlowDocument = "spec" | "plan";
|
|
28
|
+
|
|
29
|
+
/** Structured approval-drift reasons (CA-04). */
|
|
30
|
+
export type FlowDriftCode =
|
|
31
|
+
| "digest_missing"
|
|
32
|
+
| "document_missing"
|
|
33
|
+
| "document_unreadable"
|
|
34
|
+
| "digest_mismatch";
|
|
35
|
+
|
|
36
|
+
export type FlowDriftReason = {
|
|
37
|
+
document: FlowDocument;
|
|
38
|
+
code: FlowDriftCode;
|
|
39
|
+
path: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Execution lifecycle (CA-11): only these four states exist; no cancellation. */
|
|
43
|
+
export type ExecutionStatus = "pending" | "active" | "paused" | "completed";
|
|
44
|
+
export type ExecutionMode = "subagent-driven" | "inline";
|
|
45
|
+
|
|
46
|
+
/** CLI confirmation evidence (CA-19, CA-21): policy-only, no attestation. */
|
|
47
|
+
export type CliConfirmation = {
|
|
48
|
+
host: "cli";
|
|
49
|
+
attested: false;
|
|
50
|
+
confirmation: "flag" | "tty";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type LifecycleEvidence = NativeChoiceEvidence | CliConfirmation;
|
|
54
|
+
|
|
55
|
+
export type FlowExecutionState = {
|
|
56
|
+
status: ExecutionStatus;
|
|
57
|
+
mode: ExecutionMode | null;
|
|
58
|
+
evidence: LifecycleEvidence | null;
|
|
59
|
+
};
|
|
60
|
+
|
|
10
61
|
/**
|
|
11
62
|
* Host-bound identity for every flow/product mutation (FG-05, CA-20, CA-21):
|
|
12
63
|
* the authoritative host workspace, the coordinator/delegated role, the host
|
|
@@ -74,6 +125,8 @@ export type FlowDocState = {
|
|
|
74
125
|
path: string;
|
|
75
126
|
status: FlowStatus;
|
|
76
127
|
evidence?: NativeChoiceEvidence | null;
|
|
128
|
+
/** SHA-256 (lowercase hex) of the canonical document's exact bytes (CA-01). */
|
|
129
|
+
approved_digest: string | null;
|
|
77
130
|
};
|
|
78
131
|
|
|
79
132
|
export type FlowMenuState = {
|
|
@@ -89,17 +142,33 @@ export type FlowState = {
|
|
|
89
142
|
spec: FlowDocState;
|
|
90
143
|
plan: FlowDocState;
|
|
91
144
|
menu: FlowMenuState;
|
|
145
|
+
execution: FlowExecutionState;
|
|
146
|
+
handoff_destination: boolean;
|
|
92
147
|
updated_at: number;
|
|
93
148
|
};
|
|
94
149
|
|
|
95
150
|
/** One shared result shape for every flow transition and mutation gate (FG-09). */
|
|
96
|
-
export type FlowError = {
|
|
151
|
+
export type FlowError = {
|
|
152
|
+
ok: false;
|
|
153
|
+
error: string;
|
|
154
|
+
code: string;
|
|
155
|
+
details?: Record<string, unknown>;
|
|
156
|
+
};
|
|
97
157
|
export type FlowGateResult = { ok: true } | FlowError;
|
|
98
158
|
export type EvidenceResult =
|
|
99
159
|
| { ok: true; evidence: NativeChoiceEvidence }
|
|
100
160
|
| { ok: false; error: string };
|
|
101
161
|
export type StatusTransition = { ok: true; next: FlowStatus } | FlowError;
|
|
102
162
|
|
|
163
|
+
/** Persisted state after legacy normalization and approval-integrity reconciliation (CA-02). */
|
|
164
|
+
export type EffectiveFlowState = {
|
|
165
|
+
state: FlowState;
|
|
166
|
+
drift: FlowDriftReason[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** Structured result of an effective (reconciled) flow-state read (CA-04). */
|
|
170
|
+
export type FlowReadResult = ({ ok: true } & EffectiveFlowState) | FlowError;
|
|
171
|
+
|
|
103
172
|
export const MENU_CHOICES = [
|
|
104
173
|
"subagent-driven",
|
|
105
174
|
"inline",
|
|
@@ -109,7 +178,33 @@ export const MENU_CHOICES = [
|
|
|
109
178
|
] as const;
|
|
110
179
|
export type MenuChoice = (typeof MENU_CHOICES)[number];
|
|
111
180
|
|
|
112
|
-
|
|
181
|
+
/**
|
|
182
|
+
* The source post-plan menu (CA-08): the full five-way choice set the source
|
|
183
|
+
* session presents after the plan is approved. `DESTINATION_MENU_CHOICES` is
|
|
184
|
+
* the same tuple without `handoff` — a marked destination never re-offers the
|
|
185
|
+
* originating handoff choice.
|
|
186
|
+
*/
|
|
187
|
+
export const SOURCE_MENU_CHOICES = MENU_CHOICES;
|
|
188
|
+
export const DESTINATION_MENU_CHOICES = [
|
|
189
|
+
"subagent-driven",
|
|
190
|
+
"inline",
|
|
191
|
+
"review-spec",
|
|
192
|
+
"review-plan",
|
|
193
|
+
] as const;
|
|
194
|
+
export type DestinationMenuChoice = (typeof DESTINATION_MENU_CHOICES)[number];
|
|
195
|
+
|
|
196
|
+
// The source/destination menu labels and the destination marker live in the
|
|
197
|
+
// import-light menu module (CA-07/CA-08) so session-start hooks select reminder
|
|
198
|
+
// wording without pulling in the full flow-state graph; flow-state re-exports
|
|
199
|
+
// them so every existing consumer keeps the same import site.
|
|
200
|
+
export { DESTINATION_MENU_LABELS, HANDOFF_DESTINATION_MARKER, SOURCE_MENU_LABELS } from "./menu";
|
|
201
|
+
|
|
202
|
+
const err = (code: string, error: string, details?: Record<string, unknown>): FlowError => ({
|
|
203
|
+
ok: false,
|
|
204
|
+
code,
|
|
205
|
+
error,
|
|
206
|
+
...(details ? { details } : {}),
|
|
207
|
+
});
|
|
113
208
|
|
|
114
209
|
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
115
210
|
|
|
@@ -143,6 +238,7 @@ const normalizeState = (parsed: unknown, slug: string): FlowState => {
|
|
|
143
238
|
const spec = (p.spec ?? {}) as Partial<FlowDocState>;
|
|
144
239
|
const plan = (p.plan ?? {}) as Partial<FlowDocState>;
|
|
145
240
|
const menu = (p.menu ?? {}) as Partial<FlowMenuState>;
|
|
241
|
+
const execution = (p.execution ?? {}) as Partial<FlowExecutionState>;
|
|
146
242
|
return {
|
|
147
243
|
slug: p.slug ?? slug,
|
|
148
244
|
activated: p.activated ?? true,
|
|
@@ -150,17 +246,25 @@ const normalizeState = (parsed: unknown, slug: string): FlowState => {
|
|
|
150
246
|
path: spec.path ?? "",
|
|
151
247
|
status: spec.status ?? "draft",
|
|
152
248
|
evidence: spec.evidence ?? null,
|
|
249
|
+
approved_digest: spec.approved_digest ?? null,
|
|
153
250
|
},
|
|
154
251
|
plan: {
|
|
155
252
|
path: plan.path ?? "",
|
|
156
253
|
status: plan.status ?? "draft",
|
|
157
254
|
evidence: plan.evidence ?? null,
|
|
255
|
+
approved_digest: plan.approved_digest ?? null,
|
|
158
256
|
},
|
|
159
257
|
menu: {
|
|
160
258
|
presented: Boolean(menu.presented),
|
|
161
259
|
chosen: menu.chosen ?? "",
|
|
162
260
|
evidence: menu.evidence ?? null,
|
|
163
261
|
},
|
|
262
|
+
execution: {
|
|
263
|
+
status: (execution.status ?? "pending") as ExecutionStatus,
|
|
264
|
+
mode: (execution.mode ?? null) as ExecutionMode | null,
|
|
265
|
+
evidence: (execution.evidence ?? null) as LifecycleEvidence | null,
|
|
266
|
+
},
|
|
267
|
+
handoff_destination: p.handoff_destination ?? false,
|
|
164
268
|
updated_at: p.updated_at ?? Date.now(),
|
|
165
269
|
};
|
|
166
270
|
};
|
|
@@ -168,9 +272,11 @@ const normalizeState = (parsed: unknown, slug: string): FlowState => {
|
|
|
168
272
|
const emptyState = (slug: string): FlowState => ({
|
|
169
273
|
slug,
|
|
170
274
|
activated: false,
|
|
171
|
-
spec: { path: "", status: "draft", evidence: null },
|
|
172
|
-
plan: { path: "", status: "draft", evidence: null },
|
|
275
|
+
spec: { path: "", status: "draft", evidence: null, approved_digest: null },
|
|
276
|
+
plan: { path: "", status: "draft", evidence: null, approved_digest: null },
|
|
173
277
|
menu: { presented: false, chosen: "", evidence: null },
|
|
278
|
+
execution: { status: "pending", mode: null, evidence: null },
|
|
279
|
+
handoff_destination: false,
|
|
174
280
|
updated_at: Date.now(),
|
|
175
281
|
});
|
|
176
282
|
|
|
@@ -184,26 +290,220 @@ export const readFlowState = (root: string, slug: string): FlowState => {
|
|
|
184
290
|
}
|
|
185
291
|
};
|
|
186
292
|
|
|
293
|
+
const HEX64_RE = /^[0-9a-f]{64}$/;
|
|
294
|
+
const FLOW_STATUSES: readonly FlowStatus[] = ["draft", "self_reviewed", "approved"];
|
|
295
|
+
const EXECUTION_STATUSES: readonly ExecutionStatus[] = ["pending", "active", "paused", "completed"];
|
|
296
|
+
|
|
297
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
298
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Structural validation of persisted choice evidence (CA-18). Approved
|
|
302
|
+
* lifecycle/approval evidence is data, not a host hook: we validate the shape
|
|
303
|
+
* (host + required fields) but never re-check freshness here — freshness is a
|
|
304
|
+
* consume-time property of the host receipt store.
|
|
305
|
+
*/
|
|
306
|
+
const validateEvidenceValue = (v: unknown, allowCli: boolean): boolean => {
|
|
307
|
+
if (v === null) return true;
|
|
308
|
+
if (!isRecord(v)) return false;
|
|
309
|
+
if (v.host === "opencode") {
|
|
310
|
+
return (
|
|
311
|
+
v.attested === true &&
|
|
312
|
+
typeof v.callID === "string" &&
|
|
313
|
+
typeof v.selectedLabel === "string" &&
|
|
314
|
+
typeof v.recordedAt === "number"
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (v.host === "cursor") return v.attested === false && v.confirmation === "contract";
|
|
318
|
+
if (allowCli && v.host === "cli") {
|
|
319
|
+
return v.attested === false && (v.confirmation === "flag" || v.confirmation === "tty");
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Strict validation + documented normalization of parsed flow.json (CA-18):
|
|
326
|
+
* unsupported field values are rejected (flow_state_invalid) instead of being
|
|
327
|
+
* coerced; missing OPTIONAL fields are normalized only by the documented rules.
|
|
328
|
+
*/
|
|
329
|
+
const validateState = (
|
|
330
|
+
parsed: unknown,
|
|
331
|
+
slug: string,
|
|
332
|
+
): { ok: true; state: FlowState } | { ok: false; error: string } => {
|
|
333
|
+
if (!isRecord(parsed)) return { ok: false, error: "flow state must be a JSON object" };
|
|
334
|
+
if (parsed.slug !== undefined && (typeof parsed.slug !== "string" || parsed.slug !== slug)) {
|
|
335
|
+
return { ok: false, error: `flow state slug must be ${JSON.stringify(slug)}` };
|
|
336
|
+
}
|
|
337
|
+
if (parsed.activated !== undefined && typeof parsed.activated !== "boolean") {
|
|
338
|
+
return { ok: false, error: "flow state activated must be a boolean" };
|
|
339
|
+
}
|
|
340
|
+
if (parsed.handoff_destination !== undefined && typeof parsed.handoff_destination !== "boolean") {
|
|
341
|
+
return { ok: false, error: "flow state handoff_destination must be a boolean" };
|
|
342
|
+
}
|
|
343
|
+
if (
|
|
344
|
+
parsed.updated_at !== undefined &&
|
|
345
|
+
(typeof parsed.updated_at !== "number" || !Number.isFinite(parsed.updated_at))
|
|
346
|
+
) {
|
|
347
|
+
return { ok: false, error: "flow state updated_at must be a finite number" };
|
|
348
|
+
}
|
|
349
|
+
const doc = (value: unknown, name: FlowDocument): FlowDocState | string => {
|
|
350
|
+
const p = isRecord(value) ? value : {};
|
|
351
|
+
if (!isRecord(value) && value !== undefined) {
|
|
352
|
+
return `flow state ${name} must be an object`;
|
|
353
|
+
}
|
|
354
|
+
if (p.status !== undefined && !FLOW_STATUSES.includes(p.status as FlowStatus)) {
|
|
355
|
+
return `flow state ${name}.status must be draft, self_reviewed, or approved`;
|
|
356
|
+
}
|
|
357
|
+
if (p.path !== undefined && typeof p.path !== "string") {
|
|
358
|
+
return `flow state ${name}.path must be a string`;
|
|
359
|
+
}
|
|
360
|
+
if (
|
|
361
|
+
p.approved_digest !== undefined &&
|
|
362
|
+
p.approved_digest !== null &&
|
|
363
|
+
(typeof p.approved_digest !== "string" || !HEX64_RE.test(p.approved_digest))
|
|
364
|
+
) {
|
|
365
|
+
return `flow state ${name}.approved_digest must be 64-char lowercase hex or null`;
|
|
366
|
+
}
|
|
367
|
+
if (p.evidence !== undefined && !validateEvidenceValue(p.evidence, false)) {
|
|
368
|
+
return `flow state ${name}.evidence has an unsupported shape`;
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
path: (p.path as string | undefined) ?? "",
|
|
372
|
+
status: (p.status as FlowStatus | undefined) ?? "draft",
|
|
373
|
+
evidence: (p.evidence as NativeChoiceEvidence | null | undefined) ?? null,
|
|
374
|
+
approved_digest: (p.approved_digest as string | null | undefined) ?? null,
|
|
375
|
+
};
|
|
376
|
+
};
|
|
377
|
+
const spec = doc(parsed.spec, "spec");
|
|
378
|
+
if (typeof spec === "string") return { ok: false, error: spec };
|
|
379
|
+
const plan = doc(parsed.plan, "plan");
|
|
380
|
+
if (typeof plan === "string") return { ok: false, error: plan };
|
|
381
|
+
|
|
382
|
+
const menuRaw = isRecord(parsed.menu) ? parsed.menu : undefined;
|
|
383
|
+
if (parsed.menu !== undefined && !isRecord(parsed.menu)) {
|
|
384
|
+
return { ok: false, error: "flow state menu must be an object" };
|
|
385
|
+
}
|
|
386
|
+
if (menuRaw?.presented !== undefined && typeof menuRaw.presented !== "boolean") {
|
|
387
|
+
return { ok: false, error: "flow state menu.presented must be a boolean" };
|
|
388
|
+
}
|
|
389
|
+
if (menuRaw?.chosen !== undefined && typeof menuRaw.chosen !== "string") {
|
|
390
|
+
return { ok: false, error: "flow state menu.chosen must be a string" };
|
|
391
|
+
}
|
|
392
|
+
// The only persisted `chosen` values are the MENU_CHOICES plus the empty
|
|
393
|
+
// string ("" marks an unpresented/reset menu — markHandoffDestination and the
|
|
394
|
+
// drift resets persist it). Anything else is a bogus/legacy value and fails
|
|
395
|
+
// closed (CA-18).
|
|
396
|
+
if (
|
|
397
|
+
menuRaw?.chosen !== undefined &&
|
|
398
|
+
menuRaw.chosen !== "" &&
|
|
399
|
+
!MENU_CHOICES.includes(menuRaw.chosen as MenuChoice)
|
|
400
|
+
) {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
error: `flow state menu.chosen must be one of: ${MENU_CHOICES.join(", ")} (or an empty string when the menu is unpresented)`,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (menuRaw?.evidence !== undefined && !validateEvidenceValue(menuRaw.evidence, false)) {
|
|
407
|
+
return { ok: false, error: "flow state menu.evidence has an unsupported shape" };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const execRaw = isRecord(parsed.execution) ? parsed.execution : undefined;
|
|
411
|
+
if (parsed.execution !== undefined && !isRecord(parsed.execution)) {
|
|
412
|
+
return { ok: false, error: "flow state execution must be an object" };
|
|
413
|
+
}
|
|
414
|
+
if (
|
|
415
|
+
execRaw?.status !== undefined &&
|
|
416
|
+
!EXECUTION_STATUSES.includes(execRaw.status as ExecutionStatus)
|
|
417
|
+
) {
|
|
418
|
+
return {
|
|
419
|
+
ok: false,
|
|
420
|
+
error: "flow state execution.status must be pending, active, paused, or completed",
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (
|
|
424
|
+
execRaw?.mode !== undefined &&
|
|
425
|
+
execRaw.mode !== null &&
|
|
426
|
+
execRaw.mode !== "subagent-driven" &&
|
|
427
|
+
execRaw.mode !== "inline"
|
|
428
|
+
) {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
error: "flow state execution.mode must be subagent-driven, inline, or null",
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (execRaw?.evidence !== undefined && !validateEvidenceValue(execRaw.evidence, true)) {
|
|
435
|
+
return { ok: false, error: "flow state execution.evidence has an unsupported shape" };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
ok: true,
|
|
440
|
+
state: {
|
|
441
|
+
slug,
|
|
442
|
+
activated: parsed.activated ?? true,
|
|
443
|
+
spec,
|
|
444
|
+
plan,
|
|
445
|
+
menu: {
|
|
446
|
+
presented: menuRaw?.presented ?? false,
|
|
447
|
+
chosen: (menuRaw?.chosen as string | undefined) ?? "",
|
|
448
|
+
evidence: (menuRaw?.evidence as NativeChoiceEvidence | null | undefined) ?? null,
|
|
449
|
+
},
|
|
450
|
+
execution: {
|
|
451
|
+
status: (execRaw?.status as ExecutionStatus | undefined) ?? "pending",
|
|
452
|
+
mode: (execRaw?.mode as ExecutionMode | null | undefined) ?? null,
|
|
453
|
+
evidence: (execRaw?.evidence as LifecycleEvidence | null | undefined) ?? null,
|
|
454
|
+
},
|
|
455
|
+
handoff_destination: parsed.handoff_destination ?? false,
|
|
456
|
+
updated_at: parsed.updated_at ?? Date.now(),
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
|
|
187
461
|
// Strict read for transitions and guards: missing or corrupt state is a
|
|
188
|
-
// structured error, never a silent draft fallback (CA-18).
|
|
189
|
-
|
|
462
|
+
// structured error, never a silent draft fallback (CA-18). The raw readFlowState
|
|
463
|
+
// above stays a lenient compatibility helper for controlled tests and mutation
|
|
464
|
+
// internals; status, gates, and host adapters use the effective path. The raw
|
|
465
|
+
// parsed JSON is carried so compatibility normalization can distinguish a
|
|
466
|
+
// genuinely missing `execution` key from an explicit persisted state (CA-16).
|
|
467
|
+
type StrictRead =
|
|
468
|
+
| { ok: true; state: FlowState; raw: unknown }
|
|
469
|
+
| { ok: false; error: string; code: string };
|
|
190
470
|
|
|
191
471
|
const readFlowStrict = (root: string, slug: string): StrictRead => {
|
|
192
472
|
const file = flowPath(root, slug);
|
|
473
|
+
const rel = path.posix.join("docs", slug, "sdd", "flow.json");
|
|
193
474
|
if (!existsSync(file)) {
|
|
194
475
|
return err(
|
|
195
476
|
"flow_not_activated",
|
|
196
477
|
`flow not activated for ${slug} — run workflow_flow_status first`,
|
|
197
478
|
);
|
|
198
479
|
}
|
|
480
|
+
let text: string;
|
|
481
|
+
try {
|
|
482
|
+
text = readFileSync(file, "utf8");
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return err(
|
|
485
|
+
"flow_io_error",
|
|
486
|
+
`cannot read flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
let parsed: unknown;
|
|
199
490
|
try {
|
|
200
|
-
|
|
491
|
+
parsed = JSON.parse(text);
|
|
201
492
|
} catch (error) {
|
|
202
493
|
return err(
|
|
203
|
-
"
|
|
204
|
-
`
|
|
494
|
+
"flow_state_invalid",
|
|
495
|
+
`invalid flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
496
|
+
{ path: rel, original_bytes_preserved: true },
|
|
205
497
|
);
|
|
206
498
|
}
|
|
499
|
+
const validated = validateState(parsed, slug);
|
|
500
|
+
if (!validated.ok) {
|
|
501
|
+
return err("flow_state_invalid", `invalid flow state at ${file}: ${validated.error}`, {
|
|
502
|
+
path: rel,
|
|
503
|
+
original_bytes_preserved: true,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return { ok: true, state: validated.state, raw: parsed };
|
|
207
507
|
};
|
|
208
508
|
|
|
209
509
|
// Unique per-write temporary buffer so two concurrent writers never share the
|
|
@@ -211,24 +511,96 @@ const readFlowStrict = (root: string, slug: string): StrictRead => {
|
|
|
211
511
|
const uniqueTempPath = (file: string) =>
|
|
212
512
|
`${file}.${process.pid}-${Math.random().toString(36).slice(2)}.tmp`;
|
|
213
513
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
514
|
+
/**
|
|
515
|
+
* Same-directory atomic replacement (CA-19): write a unique temp file, fsync its
|
|
516
|
+
* descriptor, close it, and rename it into place. The temp shares the target's
|
|
517
|
+
* directory so rename is atomic on the same filesystem; a reader never observes
|
|
518
|
+
* partial JSON. Best-effort removal of the temp on every exit path.
|
|
519
|
+
*/
|
|
520
|
+
const writeFlowFileAtomic = (file: string, state: FlowState): void => {
|
|
521
|
+
const text = JSON.stringify(state, null, 2) + "\n";
|
|
217
522
|
const tmp = uniqueTempPath(file);
|
|
218
|
-
|
|
219
|
-
|
|
523
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
524
|
+
let fd: number | null = null;
|
|
525
|
+
try {
|
|
526
|
+
fd = openSync(tmp, "w");
|
|
527
|
+
writeFileSync(fd, text, "utf8");
|
|
528
|
+
fsyncSync(fd);
|
|
529
|
+
closeSync(fd);
|
|
530
|
+
fd = null;
|
|
531
|
+
renameSync(tmp, file);
|
|
532
|
+
} finally {
|
|
533
|
+
try {
|
|
534
|
+
if (fd !== null) closeSync(fd);
|
|
535
|
+
} catch {
|
|
536
|
+
// best effort
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
if (existsSync(tmp)) rmSync(tmp, { force: true });
|
|
540
|
+
} catch {
|
|
541
|
+
// best effort: a leftover temp is preferable to masking the real error
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
export const writeFlowState = (root: string, state: FlowState) => {
|
|
547
|
+
writeFlowFileAtomic(flowPath(root, state.slug), state);
|
|
220
548
|
};
|
|
221
549
|
|
|
222
550
|
const MAX_WRITE_ATTEMPTS = 5;
|
|
223
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Age threshold for stale-lock recovery (CA-19): a crash between
|
|
554
|
+
* `openSync(lock, "wx")` and `rmSync(lock)` leaves `<flow.json>.lock` forever.
|
|
555
|
+
* A lock file older than this is treated as abandoned and removed before a
|
|
556
|
+
* fresh acquisition attempt, so a crash never wedges every later operation.
|
|
557
|
+
*
|
|
558
|
+
* ponytail: age-based recovery has two documented ceilings. (1) A very slow
|
|
559
|
+
* writer still legitimately holding the lock (or clock skew) can have its lock
|
|
560
|
+
* reclaimed; the CAS below still protects data, but that writer's critical
|
|
561
|
+
* section is no longer mutually exclusive with the new acquirer's. (2)
|
|
562
|
+
* Recovery renames by PATH, not by inode: two simultaneous reclaimers of the
|
|
563
|
+
* same stale lock can still move a freshly re-acquired winner's lock (one
|
|
564
|
+
* reclaimer's rename lands after the other's re-acquisition). No data is lost —
|
|
565
|
+
* `writeFlowStateIfCurrent`'s CAS is the integrity backstop — but mutual
|
|
566
|
+
* exclusion is not absolute. Upgrade path: write PID/host-session into the
|
|
567
|
+
* lock and verify liveness, or lease-renew, when writers that legitimately
|
|
568
|
+
* exceed the threshold matter.
|
|
569
|
+
*/
|
|
570
|
+
const STALE_LOCK_MS = 1000;
|
|
571
|
+
|
|
572
|
+
// The lock's mtime, or null when it vanished between the EEXIST and the stat
|
|
573
|
+
// (a concurrent writer removed it) — either way the caller retries acquisition.
|
|
574
|
+
const lockMtimeMs = (lock: string): number | null => {
|
|
575
|
+
try {
|
|
576
|
+
return statSync(lock).mtimeMs;
|
|
577
|
+
} catch {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// Whether the lock at `lock` is still the inode `fd` opened (CA-19): release
|
|
583
|
+
// must never unlink a successor's fresh lock, only the file this writer owns.
|
|
584
|
+
// ponytail: this is a stat-then-rmSync window — a successor that replaces the
|
|
585
|
+
// path between the stat and the release rmSync (a concurrent recovery of a
|
|
586
|
+
// >1s-held lock) can still lose its fresh lock. Microsecond window, documented
|
|
587
|
+
// ceiling; the CAS backstops data integrity.
|
|
588
|
+
const lockOwnedBy = (fd: number, lock: string): boolean => {
|
|
589
|
+
try {
|
|
590
|
+
return fstatSync(fd).ino === statSync(lock).ino;
|
|
591
|
+
} catch {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
224
596
|
export type FlowWriteResult =
|
|
225
597
|
| { ok: true }
|
|
226
598
|
| { ok: false; conflict: true }
|
|
227
599
|
| { ok: false; io_error: string };
|
|
228
600
|
|
|
229
601
|
/**
|
|
230
|
-
* Compare-and-write (FG-08): write `next` only if the on-disk content
|
|
231
|
-
* equals the version this writer read (`expected`). A stale writer gets
|
|
602
|
+
* Compare-and-write (FG-08, CA-19): write `next` only if the on-disk content
|
|
603
|
+
* still equals the version this writer read (`expected`). A stale writer gets
|
|
232
604
|
* `conflict` instead of clobbering a concurrent newer write; the caller re-reads
|
|
233
605
|
* and retries the transition (bounded). Unique per-write temp names keep the
|
|
234
606
|
* write buffer from being shared between writers.
|
|
@@ -236,12 +608,10 @@ export type FlowWriteResult =
|
|
|
236
608
|
* The first compare happens before the buffer is staged; the file is re-read
|
|
237
609
|
* immediately before the rename so a writer that committed between the two
|
|
238
610
|
* points still wins. Without the re-read, two writers holding the same expected
|
|
239
|
-
* text would both pass the compare and both rename — a lost update.
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* or move to renameat2(RENAME_EXCHANGE)/an OS-level CAS when a second
|
|
244
|
-
* concurrent process becomes a supported topology.
|
|
611
|
+
* text would both pass the compare and both rename — a lost update. This CAS
|
|
612
|
+
* stays as the second safety net under the per-flow `flow.json.lock` (CA-19):
|
|
613
|
+
* cooperating writers are serialized by the lock; the CAS catches any writer
|
|
614
|
+
* that bypasses it.
|
|
245
615
|
*
|
|
246
616
|
* A thrown error here is a real IO/permission failure (EACCES, ENOSPC, ...),
|
|
247
617
|
* not a conflict: it is returned as `io_error` so callers surface it instead of
|
|
@@ -259,11 +629,16 @@ export const writeFlowStateIfCurrent = (
|
|
|
259
629
|
const nextText = JSON.stringify(next, null, 2) + "\n";
|
|
260
630
|
if (expectedText === nextText) return { ok: true };
|
|
261
631
|
const tmp = uniqueTempPath(file);
|
|
632
|
+
let fd: number | null = null;
|
|
262
633
|
try {
|
|
263
634
|
const currentText = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
264
635
|
if (currentText !== expectedText) return { ok: false, conflict: true };
|
|
265
636
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
266
|
-
|
|
637
|
+
fd = openSync(tmp, "w");
|
|
638
|
+
writeFileSync(fd, nextText, "utf8");
|
|
639
|
+
fsyncSync(fd);
|
|
640
|
+
closeSync(fd);
|
|
641
|
+
fd = null;
|
|
267
642
|
const reRead = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
268
643
|
if (reRead !== expectedText) return { ok: false, conflict: true };
|
|
269
644
|
renameSync(tmp, file);
|
|
@@ -274,6 +649,11 @@ export const writeFlowStateIfCurrent = (
|
|
|
274
649
|
// On success the rename moved the buffer into place; on any other exit the
|
|
275
650
|
// unique temp is orphaned — remove it so crashed writers don't accumulate
|
|
276
651
|
// `<file>.<pid>-<rand>.tmp` buffers.
|
|
652
|
+
try {
|
|
653
|
+
if (fd !== null) closeSync(fd);
|
|
654
|
+
} catch {
|
|
655
|
+
// best effort
|
|
656
|
+
}
|
|
277
657
|
try {
|
|
278
658
|
if (existsSync(tmp)) rmSync(tmp, { force: true });
|
|
279
659
|
} catch {
|
|
@@ -282,32 +662,362 @@ export const writeFlowStateIfCurrent = (
|
|
|
282
662
|
}
|
|
283
663
|
};
|
|
284
664
|
|
|
665
|
+
/**
|
|
666
|
+
* One internal strict-byte helper (CA-01, CA-06): resolve the canonical
|
|
667
|
+
* document, read it as a Buffer, validate it with a fatal TextDecoder, and
|
|
668
|
+
* return both the decoded text and the SHA-256 of the exact bytes. Line endings
|
|
669
|
+
* and Unicode are never normalized — any byte change invalidates the approval.
|
|
670
|
+
*/
|
|
671
|
+
type CanonicalDigestResult =
|
|
672
|
+
| { ok: true; text: string; digest: string }
|
|
673
|
+
| { ok: false; code: "document_missing" | "document_unreadable" };
|
|
674
|
+
|
|
675
|
+
const readCanonicalDigest = (root: string, rel: string): CanonicalDigestResult => {
|
|
676
|
+
const abs = path.join(root, ...rel.split("/"));
|
|
677
|
+
let bytes: Buffer;
|
|
678
|
+
try {
|
|
679
|
+
bytes = readFileSync(abs);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
682
|
+
return { ok: false, code: "document_missing" };
|
|
683
|
+
}
|
|
684
|
+
return { ok: false, code: "document_unreadable" };
|
|
685
|
+
}
|
|
686
|
+
let text: string;
|
|
687
|
+
try {
|
|
688
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
689
|
+
} catch {
|
|
690
|
+
return { ok: false, code: "document_unreadable" };
|
|
691
|
+
}
|
|
692
|
+
return { ok: true, text, digest: createHash("sha256").update(bytes).digest("hex") };
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Approval-integrity reconciliation (CA-02, CA-03): recompute approved
|
|
697
|
+
* document digests in spec-before-plan order and return the reset state plus
|
|
698
|
+
* the structured drift reasons. Spec drift resets the whole approval chain;
|
|
699
|
+
* plan drift (spec valid) preserves the spec approval and digest.
|
|
700
|
+
*/
|
|
701
|
+
const resetForSpecDrift = (state: FlowState): FlowState => ({
|
|
702
|
+
...state,
|
|
703
|
+
spec: { ...state.spec, status: "draft", evidence: null, approved_digest: null },
|
|
704
|
+
plan: { ...state.plan, status: "draft", evidence: null, approved_digest: null },
|
|
705
|
+
menu: { presented: false, chosen: "", evidence: null },
|
|
706
|
+
execution: { status: "pending", mode: null, evidence: null },
|
|
707
|
+
handoff_destination: false,
|
|
708
|
+
updated_at: Date.now(),
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const resetForPlanDrift = (state: FlowState): FlowState => ({
|
|
712
|
+
...state,
|
|
713
|
+
plan: { ...state.plan, status: "draft", evidence: null, approved_digest: null },
|
|
714
|
+
menu: { presented: false, chosen: "", evidence: null },
|
|
715
|
+
execution: { status: "pending", mode: null, evidence: null },
|
|
716
|
+
handoff_destination: false,
|
|
717
|
+
updated_at: Date.now(),
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const driftCodeFor = (
|
|
721
|
+
root: string,
|
|
722
|
+
relPath: string,
|
|
723
|
+
storedDigest: string | null,
|
|
724
|
+
): FlowDriftCode | null => {
|
|
725
|
+
if (storedDigest === null) return "digest_missing";
|
|
726
|
+
const current = readCanonicalDigest(root, relPath);
|
|
727
|
+
if (!current.ok) return current.code;
|
|
728
|
+
return current.digest !== storedDigest ? "digest_mismatch" : null;
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const reconcileState = (
|
|
732
|
+
root: string,
|
|
733
|
+
slug: string,
|
|
734
|
+
state: FlowState,
|
|
735
|
+
): { state: FlowState; drift: FlowDriftReason[] } => {
|
|
736
|
+
const specPath = path.posix.join("docs", slug, "spec.md");
|
|
737
|
+
const planPath = path.posix.join("docs", slug, "plan.md");
|
|
738
|
+
if (state.spec.status === "approved") {
|
|
739
|
+
const code = driftCodeFor(root, specPath, state.spec.approved_digest);
|
|
740
|
+
if (code) {
|
|
741
|
+
return {
|
|
742
|
+
state: resetForSpecDrift(state),
|
|
743
|
+
drift: [{ document: "spec", code, path: specPath }],
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (state.plan.status === "approved") {
|
|
748
|
+
const code = driftCodeFor(root, planPath, state.plan.approved_digest);
|
|
749
|
+
if (code) {
|
|
750
|
+
return {
|
|
751
|
+
state: resetForPlanDrift(state),
|
|
752
|
+
drift: [{ document: "plan", code, path: planPath }],
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return { state, drift: [] };
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Compatibility normalization for legacy persisted shapes (CA-16): a flow.json
|
|
761
|
+
* written before the execution lifecycle has NO `execution` key. Only then is
|
|
762
|
+
* execution derived — active exactly when the persisted plan approval, a
|
|
763
|
+
* subagent-driven menu choice, and an in-progress SDD ledger prove a legacy
|
|
764
|
+
* execution is running; every other combination (and any explicit persisted
|
|
765
|
+
* execution) stays pending/fail-closed. Runs BEFORE digest reconciliation
|
|
766
|
+
* (CA-17) so a drift reset can still pull a derived active state back to
|
|
767
|
+
* pending. Migration evidence is null by design: a legacy flow has no
|
|
768
|
+
* host-observed lifecycle receipt to cite.
|
|
769
|
+
*/
|
|
770
|
+
const deriveLegacyExecution = (
|
|
771
|
+
root: string,
|
|
772
|
+
slug: string,
|
|
773
|
+
state: FlowState,
|
|
774
|
+
): FlowExecutionState => {
|
|
775
|
+
const ledger = ledgerCompletion(root, slug);
|
|
776
|
+
if (
|
|
777
|
+
state.plan.status === "approved" &&
|
|
778
|
+
state.menu.chosen === "subagent-driven" &&
|
|
779
|
+
ledger.started &&
|
|
780
|
+
!ledger.complete
|
|
781
|
+
) {
|
|
782
|
+
return { status: "active", mode: "subagent-driven", evidence: null };
|
|
783
|
+
}
|
|
784
|
+
return { status: "pending", mode: null, evidence: null };
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
type CompatibilityResult = { state: FlowState; changed: boolean };
|
|
788
|
+
|
|
789
|
+
const normalizeCompatibility = (
|
|
790
|
+
root: string,
|
|
791
|
+
slug: string,
|
|
792
|
+
parsed: unknown,
|
|
793
|
+
state: FlowState,
|
|
794
|
+
): CompatibilityResult => {
|
|
795
|
+
if (!isRecord(parsed) || !("execution" in parsed)) {
|
|
796
|
+
const derived = deriveLegacyExecution(root, slug, state);
|
|
797
|
+
const current = state.execution;
|
|
798
|
+
if (derived.status !== current.status || derived.mode !== current.mode) {
|
|
799
|
+
return { state: { ...state, execution: derived, updated_at: Date.now() }, changed: true };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return { state, changed: false };
|
|
803
|
+
};
|
|
804
|
+
|
|
285
805
|
type MutateResult = { ok: true; next: FlowState } | FlowError;
|
|
286
806
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
807
|
+
/**
|
|
808
|
+
* Per-flow critical section (CA-19): acquire `<flow.json>.lock` exclusively
|
|
809
|
+
* (openSync "wx"); on contention retry with a bounded 10ms backoff; a lock
|
|
810
|
+
* older than STALE_LOCK_MS (a crashed writer) is removed and acquisition is
|
|
811
|
+
* retried; run the critical section; release the lock and best-effort remove
|
|
812
|
+
* it in `finally`. A never-activated flow (no `docs/<slug>/sdd/` dir) runs
|
|
813
|
+
* without a lock: there is no flow.json to serialize and no filesystem side
|
|
814
|
+
* effect is created — the write helpers create the dir on the first actual
|
|
815
|
+
* write. No lock module and no adapter-side lock: every host shares this one
|
|
816
|
+
* core contract. ponytail: stale recovery and release are path-based with
|
|
817
|
+
* documented TOCTOU ceilings (see STALE_LOCK_MS and lockOwnedBy); data
|
|
818
|
+
* integrity is guaranteed by the CAS, not by absolute mutual exclusion.
|
|
819
|
+
*/
|
|
820
|
+
type Locked<T> = { locked: true; value: T } | { locked: false; error: FlowError };
|
|
821
|
+
|
|
822
|
+
const withFlowLock = <T>(file: string, fn: () => T): Locked<T> => {
|
|
823
|
+
const lock = `${file}.lock`;
|
|
824
|
+
// An activated flow's `docs/<slug>/sdd/` dir always exists (a file implies
|
|
825
|
+
// its parent dir); a never-activated flow has neither. Skip the lock for the
|
|
826
|
+
// never-activated case so a failed flow_not_activated gate/status read
|
|
827
|
+
// leaves no filesystem side effect. The skip window is benign: a concurrent
|
|
828
|
+
// first activation writes byte-equivalent initial state through unique temp
|
|
829
|
+
// names + atomic rename, and the CAS serializes every later mutation.
|
|
830
|
+
if (!existsSync(path.dirname(file))) return { locked: true, value: fn() };
|
|
831
|
+
// Best-effort cleanup of a leftover `<file>.lock.stale` from a crashed
|
|
832
|
+
// recovery (a crash between the recovery rename and the unlink strands it).
|
|
833
|
+
// It is never a live lock path — the live lock is always `<file>.lock` — so
|
|
834
|
+
// removing it here is safe. ponytail: on-acquisition best-effort only; an
|
|
835
|
+
// unremovable `.stale` falls through and never blocks the live lock path.
|
|
836
|
+
try {
|
|
837
|
+
if (existsSync(`${lock}.stale`)) rmSync(`${lock}.stale`, { force: true });
|
|
838
|
+
} catch {
|
|
839
|
+
// best effort: a leftover .stale is harmless and cannot wedge the lock
|
|
840
|
+
}
|
|
841
|
+
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
842
|
+
let fd: number | null = null;
|
|
843
|
+
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
|
|
844
|
+
try {
|
|
845
|
+
fd = openSync(lock, "wx");
|
|
846
|
+
break;
|
|
847
|
+
} catch (error) {
|
|
848
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
849
|
+
if (code !== "EEXIST") {
|
|
850
|
+
return {
|
|
851
|
+
locked: false,
|
|
852
|
+
error: err(
|
|
853
|
+
"flow_io_error",
|
|
854
|
+
`flow lock failed for ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
855
|
+
),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
// Stale-lock recovery (CA-19): the lock is older than STALE_LOCK_MS, so
|
|
859
|
+
// its writer crashed after acquiring it. Reclaim it via a PATH-based
|
|
860
|
+
// atomic rename to `<file>.lock.stale`, unlink the stale inode, then
|
|
861
|
+
// re-attempt acquisition inline so the final attempt still acquires
|
|
862
|
+
// instead of falling out of the loop unlocked. ponytail: the rename is
|
|
863
|
+
// NOT inode-conditional — a concurrent reclaimer of the same stale lock
|
|
864
|
+
// can move a freshly re-acquired winner's lock at the same path
|
|
865
|
+
// (documented TOCTOU ceiling; the CAS backstops integrity). A crash
|
|
866
|
+
// between the rename and the unlink strands only `<file>.lock.stale`,
|
|
867
|
+
// which is never a live lock path and is best-effort removed on the next
|
|
868
|
+
// acquisition.
|
|
869
|
+
const mtime = lockMtimeMs(lock);
|
|
870
|
+
if (mtime !== null && Date.now() - mtime > STALE_LOCK_MS) {
|
|
871
|
+
try {
|
|
872
|
+
renameSync(lock, `${lock}.stale`);
|
|
873
|
+
unlinkSync(`${lock}.stale`);
|
|
874
|
+
} catch {
|
|
875
|
+
// best effort: an unremovable or concurrently-reclaimed stale lock
|
|
876
|
+
// falls through to the bounded retries and, ultimately,
|
|
877
|
+
// flow_concurrent_conflict — never past the lock
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
fd = openSync(lock, "wx");
|
|
881
|
+
break;
|
|
882
|
+
} catch (innerError) {
|
|
883
|
+
const innerCode = (innerError as NodeJS.ErrnoException).code;
|
|
884
|
+
if (innerCode !== "EEXIST") {
|
|
885
|
+
return {
|
|
886
|
+
locked: false,
|
|
887
|
+
error: err(
|
|
888
|
+
"flow_io_error",
|
|
889
|
+
`flow lock failed for ${file}: ${innerError instanceof Error ? innerError.message : String(innerError)}`,
|
|
890
|
+
),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
// another writer won the reclaimed lock — fall through to backoff
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (attempt === MAX_WRITE_ATTEMPTS - 1) {
|
|
897
|
+
return {
|
|
898
|
+
locked: false,
|
|
899
|
+
error: err(
|
|
900
|
+
"flow_concurrent_conflict",
|
|
901
|
+
`concurrent flow update detected for ${path.dirname(file)}: re-read the flow state and retry the transition`,
|
|
902
|
+
),
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
Atomics.wait(wait, 0, 0, 10);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (fd === null) {
|
|
909
|
+
// Every acquisition attempt failed without granting the lock: never run the
|
|
910
|
+
// critical section unlocked.
|
|
911
|
+
return {
|
|
912
|
+
locked: false,
|
|
913
|
+
error: err(
|
|
914
|
+
"flow_concurrent_conflict",
|
|
915
|
+
`concurrent flow update detected for ${path.dirname(file)}: re-read the flow state and retry the transition`,
|
|
916
|
+
),
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
return { locked: true, value: fn() };
|
|
921
|
+
} finally {
|
|
922
|
+
try {
|
|
923
|
+
if (fd !== null && lockOwnedBy(fd, lock)) rmSync(lock, { force: true });
|
|
924
|
+
} catch {
|
|
925
|
+
// best effort: a leftover lock is preferable to masking the real error
|
|
926
|
+
}
|
|
927
|
+
try {
|
|
928
|
+
if (fd !== null) closeSync(fd);
|
|
929
|
+
} catch {
|
|
930
|
+
// best effort
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Effective flow-state read (CA-02, CA-04): under the per-flow lock, validate
|
|
937
|
+
* persisted state, normalize legacy compatibility (missing execution) first,
|
|
938
|
+
* reconcile approval digests in spec-before-plan order, and persist any reset
|
|
939
|
+
* or migration atomically. Status reads and gates operate ONLY on this
|
|
940
|
+
* reconciled state; drift is reported structurally.
|
|
941
|
+
*/
|
|
942
|
+
export const readEffectiveFlowState = (root: string, slug: string): FlowReadResult => {
|
|
943
|
+
const file = flowPath(root, slug);
|
|
944
|
+
const rel = path.posix.join("docs", slug, "sdd", "flow.json");
|
|
945
|
+
const locked = withFlowLock<FlowReadResult>(file, () => {
|
|
946
|
+
const strict = readFlowStrict(root, slug);
|
|
947
|
+
if (!strict.ok) return strict;
|
|
948
|
+
const normalized = normalizeCompatibility(root, slug, strict.raw, strict.state);
|
|
949
|
+
const { state, drift } = reconcileState(root, slug, normalized.state);
|
|
950
|
+
if (normalized.changed || drift.length > 0) {
|
|
951
|
+
try {
|
|
952
|
+
writeFlowFileAtomic(file, state);
|
|
953
|
+
} catch (error) {
|
|
954
|
+
// A read-path persist failure (EACCES, ENOSPC, EROFS) must never throw
|
|
955
|
+
// through the lock: the FlowReadResult contract is structured (CA-04),
|
|
956
|
+
// and every gate/status read now writes on drift. The original
|
|
957
|
+
// flow.json bytes are untouched — the atomic write never got far enough
|
|
958
|
+
// to swap the file.
|
|
959
|
+
return err(
|
|
960
|
+
"flow_io_error",
|
|
961
|
+
`cannot persist reconciled flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
962
|
+
{ path: rel, original_bytes_preserved: true },
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return { ok: true, state, drift };
|
|
967
|
+
});
|
|
968
|
+
if (!locked.locked) return locked.error;
|
|
969
|
+
return locked.value;
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Locked read-modify-write (FG-08, CA-19): under the per-flow lock, read strict,
|
|
974
|
+
* normalize legacy compatibility, reconcile approval digests first, mutate on
|
|
975
|
+
* the reconciled state, then commit only if the on-disk state still matches
|
|
976
|
+
* what was read (CAS); otherwise re-read and retry the transition, bounded.
|
|
977
|
+
* A compatibility migration is persisted under the lock first so the CAS
|
|
978
|
+
* baseline matches the on-disk bytes. Reconciliation runs inside this same
|
|
979
|
+
* critical section before every transition (CA-02).
|
|
980
|
+
*/
|
|
290
981
|
const readModifyWrite = (
|
|
291
982
|
root: string,
|
|
292
983
|
slug: string,
|
|
293
984
|
mutate: (state: FlowState) => MutateResult,
|
|
294
985
|
): FlowGateResult => {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
986
|
+
const file = flowPath(root, slug);
|
|
987
|
+
const locked = withFlowLock<FlowGateResult>(file, () => {
|
|
988
|
+
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
|
|
989
|
+
const strict = readFlowStrict(root, slug);
|
|
990
|
+
if (!strict.ok) return strict;
|
|
991
|
+
const normalized = normalizeCompatibility(root, slug, strict.raw, strict.state);
|
|
992
|
+
const reconciled = reconcileState(root, slug, normalized.state);
|
|
993
|
+
const result = mutate(reconciled.state);
|
|
994
|
+
if (!result.ok) return result;
|
|
995
|
+
let baseline = strict.state;
|
|
996
|
+
if (normalized.changed) {
|
|
997
|
+
try {
|
|
998
|
+
writeFlowFileAtomic(file, normalized.state);
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
return err(
|
|
1001
|
+
"flow_io_error",
|
|
1002
|
+
`cannot persist normalized flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
baseline = normalized.state;
|
|
1006
|
+
}
|
|
1007
|
+
const commit = writeFlowStateIfCurrent(root, baseline, result.next);
|
|
1008
|
+
if (commit.ok) return { ok: true };
|
|
1009
|
+
if ("io_error" in commit) {
|
|
1010
|
+
return err("flow_io_error", `flow state write failed for ${slug}: ${commit.io_error}`);
|
|
1011
|
+
}
|
|
1012
|
+
// a non-cooperating writer won the race — re-read and retry the transition
|
|
304
1013
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
);
|
|
1014
|
+
return err(
|
|
1015
|
+
"flow_concurrent_conflict",
|
|
1016
|
+
`concurrent flow update detected for ${slug}: re-read the flow state and retry the transition`,
|
|
1017
|
+
);
|
|
1018
|
+
});
|
|
1019
|
+
if (!locked.locked) return locked.error;
|
|
1020
|
+
return locked.value;
|
|
311
1021
|
};
|
|
312
1022
|
|
|
313
1023
|
// The caller-supplied workspace must be the host workspace the context names
|
|
@@ -323,15 +1033,22 @@ const assertMutationWorkspace = (root: string, ctx?: MutationContext): FlowGateR
|
|
|
323
1033
|
};
|
|
324
1034
|
|
|
325
1035
|
/**
|
|
326
|
-
* Coordinator boundary (FG-05, CA-20):
|
|
327
|
-
* coordinator session cannot mutate product state — only
|
|
328
|
-
* delegated workers can. A
|
|
1036
|
+
* Coordinator boundary (FG-05, CA-20): while a plan's execution is ACTIVE and
|
|
1037
|
+
* subagent-driven, the coordinator session cannot mutate product state — only
|
|
1038
|
+
* authenticated delegated workers can. A historical subagent-driven menu choice
|
|
1039
|
+
* alone is not a boundary: a pending/paused/completed/inline execution leaves
|
|
1040
|
+
* the coordinator unblocked. A delegated worker without a task identity is
|
|
1041
|
+
* blocked.
|
|
329
1042
|
*/
|
|
330
1043
|
export const assertCoordinatorBoundary = (
|
|
331
1044
|
ctx: MutationContext | undefined,
|
|
332
|
-
|
|
1045
|
+
state: FlowState,
|
|
333
1046
|
): FlowGateResult => {
|
|
334
|
-
if (
|
|
1047
|
+
if (
|
|
1048
|
+
ctx?.role === "coordinator" &&
|
|
1049
|
+
state.execution.status === "active" &&
|
|
1050
|
+
state.execution.mode === "subagent-driven"
|
|
1051
|
+
) {
|
|
335
1052
|
return err("coordinator_blocked", COORDINATOR_RECOVERY_TEXT);
|
|
336
1053
|
}
|
|
337
1054
|
if (ctx?.role === "delegated" && !ctx.taskIdentity) {
|
|
@@ -347,6 +1064,11 @@ export const assertCoordinatorBoundary = (
|
|
|
347
1064
|
* The shared transition matrix (FG-09): draft -> approved in one receipt; a
|
|
348
1065
|
* legacy self_reviewed state still advances to approved. The self-review
|
|
349
1066
|
* validation runs automatically inside the draft transition.
|
|
1067
|
+
*
|
|
1068
|
+
* @deprecated public compat — production transitions (transitionSpec /
|
|
1069
|
+
* transitionPlan) hardcode "approved"; this matrix is retained only as the
|
|
1070
|
+
* documented single-source transition contract for tests and external
|
|
1071
|
+
* consumers of the exported API.
|
|
350
1072
|
*/
|
|
351
1073
|
export const nextFlowStatus = (current: FlowStatus): StatusTransition => {
|
|
352
1074
|
if (current === "draft") return { ok: true, next: "approved" };
|
|
@@ -679,6 +1401,9 @@ export const assertHostEvidence = (host: FlowHost, evidence: unknown): FlowGateR
|
|
|
679
1401
|
* Record flow activation and the canonical spec/plan paths when preparation
|
|
680
1402
|
* begins. The flow store lives under the canonical docs/<slug>/sdd/ layout
|
|
681
1403
|
* (Task 18 contract). Re-runs keep existing statuses while recording paths.
|
|
1404
|
+
* Activation is a locked critical section (CA-19): existing state is validated
|
|
1405
|
+
* and reconciled before being trusted, and malformed state fails closed without
|
|
1406
|
+
* overwriting the original file (CA-18).
|
|
682
1407
|
*/
|
|
683
1408
|
export const prepareFlowState = (
|
|
684
1409
|
root: string,
|
|
@@ -697,26 +1422,42 @@ export const prepareFlowState = (
|
|
|
697
1422
|
if (!resolved.ok) return err("flow_prepare_failed", resolved.error);
|
|
698
1423
|
const specPath = path.posix.join("docs", slug, "spec.md");
|
|
699
1424
|
const planPath = path.posix.join("docs", slug, "plan.md");
|
|
700
|
-
const
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
spec: { ...current.spec, path: specPath },
|
|
705
|
-
plan: { ...current.plan, path: planPath },
|
|
706
|
-
updated_at: Date.now(),
|
|
707
|
-
}
|
|
708
|
-
: {
|
|
1425
|
+
const file = flowPath(root, slug);
|
|
1426
|
+
const locked = withFlowLock<FlowGateResult>(file, () => {
|
|
1427
|
+
if (!existsSync(file)) {
|
|
1428
|
+
writeFlowFileAtomic(file, {
|
|
709
1429
|
slug,
|
|
710
1430
|
activated: true,
|
|
711
|
-
spec: { path: specPath, status: "draft", evidence: null },
|
|
712
|
-
plan: { path: planPath, status: "draft", evidence: null },
|
|
1431
|
+
spec: { path: specPath, status: "draft", evidence: null, approved_digest: null },
|
|
1432
|
+
plan: { path: planPath, status: "draft", evidence: null, approved_digest: null },
|
|
713
1433
|
menu: { presented: false, chosen: "", evidence: null },
|
|
1434
|
+
execution: { status: "pending", mode: null, evidence: null },
|
|
1435
|
+
handoff_destination: false,
|
|
714
1436
|
updated_at: Date.now(),
|
|
715
|
-
};
|
|
716
|
-
|
|
717
|
-
|
|
1437
|
+
});
|
|
1438
|
+
return { ok: true };
|
|
1439
|
+
}
|
|
1440
|
+
const strict = readFlowStrict(root, slug);
|
|
1441
|
+
if (!strict.ok) return strict;
|
|
1442
|
+
const reconciled = reconcileState(root, slug, strict.state);
|
|
1443
|
+
writeFlowFileAtomic(file, {
|
|
1444
|
+
...reconciled.state,
|
|
1445
|
+
spec: { ...reconciled.state.spec, path: specPath },
|
|
1446
|
+
plan: { ...reconciled.state.plan, path: planPath },
|
|
1447
|
+
updated_at: Date.now(),
|
|
1448
|
+
});
|
|
1449
|
+
return { ok: true };
|
|
1450
|
+
});
|
|
1451
|
+
if (!locked.locked) return locked.error;
|
|
1452
|
+
return locked.value;
|
|
718
1453
|
};
|
|
719
1454
|
|
|
1455
|
+
/**
|
|
1456
|
+
* Approve the canonical spec (CA-01): under the locked read/reconcile/mutate
|
|
1457
|
+
* critical section, reset any stale approval first, then read the exact bytes,
|
|
1458
|
+
* run the self-review on the decoded text, and atomically store the approval
|
|
1459
|
+
* evidence TOGETHER WITH the SHA-256 digest of those bytes.
|
|
1460
|
+
*/
|
|
720
1461
|
export const transitionSpec = (
|
|
721
1462
|
root: string,
|
|
722
1463
|
slug: string,
|
|
@@ -730,51 +1471,59 @@ export const transitionSpec = (
|
|
|
730
1471
|
if (!recorded.ok) return err("evidence_invalid", recorded.error);
|
|
731
1472
|
const doc = resolveDoc(root, slug, specPath, "spec");
|
|
732
1473
|
if (!doc.ok) return err("path_invalid", doc.error);
|
|
1474
|
+
const relPath = path.posix.join("docs", slug, "spec.md");
|
|
733
1475
|
return readModifyWrite(root, slug, (state) => {
|
|
734
1476
|
if (!existsSync(doc.path)) return err("spec_missing", `spec not found: ${specPath}`);
|
|
735
|
-
if (state.spec.status === "draft") {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
text = readFileSync(doc.path, "utf8");
|
|
739
|
-
} catch (error) {
|
|
1477
|
+
if (state.spec.status === "draft" || state.spec.status === "self_reviewed") {
|
|
1478
|
+
const digest = readCanonicalDigest(root, relPath);
|
|
1479
|
+
if (!digest.ok) {
|
|
740
1480
|
return err(
|
|
741
1481
|
"spec_self_review_failed",
|
|
742
|
-
`spec self-review failed: unreadable
|
|
1482
|
+
`spec self-review failed: unreadable or invalid UTF-8 canonical spec: ${specPath}`,
|
|
743
1483
|
);
|
|
744
1484
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1485
|
+
if (state.spec.status === "draft") {
|
|
1486
|
+
const hard = qualitySpec(digest.text).filter((f) => f.severity === "hard");
|
|
1487
|
+
const missing: string[] = [];
|
|
1488
|
+
if (!/^\s*\*+Branch:\*+/im.test(stripFences(digest.text)))
|
|
1489
|
+
missing.push("**Branch:** header missing");
|
|
1490
|
+
if (hard.length > 0 || missing.length > 0) {
|
|
1491
|
+
return err(
|
|
1492
|
+
"spec_self_review_failed",
|
|
1493
|
+
"spec self-review failed: " +
|
|
1494
|
+
hard
|
|
1495
|
+
.map((f) => `${f.code} — ${f.message}`)
|
|
1496
|
+
.concat(missing)
|
|
1497
|
+
.join("; ") +
|
|
1498
|
+
" — see templates/spec-template.md for the required structure",
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
759
1501
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1502
|
+
return {
|
|
1503
|
+
ok: true,
|
|
1504
|
+
next: {
|
|
1505
|
+
...state,
|
|
1506
|
+
spec: {
|
|
1507
|
+
path: relPath,
|
|
1508
|
+
status: "approved",
|
|
1509
|
+
evidence: recorded.evidence,
|
|
1510
|
+
approved_digest: digest.digest,
|
|
1511
|
+
},
|
|
1512
|
+
updated_at: Date.now(),
|
|
771
1513
|
},
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
return err("flow_already_approved", "already approved; no further transitions");
|
|
775
1517
|
});
|
|
776
1518
|
};
|
|
777
1519
|
|
|
1520
|
+
/**
|
|
1521
|
+
* Approve the canonical plan (CA-01): requires a currently valid spec approval;
|
|
1522
|
+
* under the locked read/reconcile/mutate critical section, reset any stale plan
|
|
1523
|
+
* approval first, then read the exact bytes, run the self-review on the decoded
|
|
1524
|
+
* text, and atomically store the approval evidence TOGETHER WITH the SHA-256
|
|
1525
|
+
* digest of those bytes.
|
|
1526
|
+
*/
|
|
778
1527
|
export const transitionPlan = (
|
|
779
1528
|
root: string,
|
|
780
1529
|
slug: string,
|
|
@@ -788,44 +1537,45 @@ export const transitionPlan = (
|
|
|
788
1537
|
if (!recorded.ok) return err("evidence_invalid", recorded.error);
|
|
789
1538
|
const doc = resolveDoc(root, slug, planPath, "plan");
|
|
790
1539
|
if (!doc.ok) return err("path_invalid", doc.error);
|
|
1540
|
+
const relPath = path.posix.join("docs", slug, "plan.md");
|
|
791
1541
|
return readModifyWrite(root, slug, (state) => {
|
|
792
1542
|
if (!existsSync(doc.path)) return err("plan_missing", `plan not found: ${planPath}`);
|
|
793
1543
|
if (state.spec.status !== "approved") {
|
|
794
1544
|
return err("spec_not_approved", "spec must be approved before the plan can be approved");
|
|
795
1545
|
}
|
|
796
|
-
if (state.plan.status === "draft") {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
text = readFileSync(doc.path, "utf8");
|
|
800
|
-
} catch (error) {
|
|
1546
|
+
if (state.plan.status === "draft" || state.plan.status === "self_reviewed") {
|
|
1547
|
+
const digest = readCanonicalDigest(root, relPath);
|
|
1548
|
+
if (!digest.ok) {
|
|
801
1549
|
return err(
|
|
802
1550
|
"plan_self_review_failed",
|
|
803
|
-
`plan self-review failed: unreadable
|
|
1551
|
+
`plan self-review failed: unreadable or invalid UTF-8 canonical plan: ${planPath}`,
|
|
804
1552
|
);
|
|
805
1553
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1554
|
+
if (state.plan.status === "draft") {
|
|
1555
|
+
const missing: string[] = [];
|
|
1556
|
+
const stripped = stripFences(digest.text);
|
|
1557
|
+
if (parseTasksFromPlan(digest.text).length === 0)
|
|
1558
|
+
missing.push("no ### Task N: sections outside fences");
|
|
1559
|
+
if (!/^\s*\*+Spec:\*+/im.test(stripped)) missing.push("**Spec:** header missing");
|
|
1560
|
+
if (!/^\s*\*+Branch:\*+/im.test(stripped)) missing.push("**Branch:** header missing");
|
|
1561
|
+
if (missing.length > 0)
|
|
1562
|
+
return err("plan_self_review_failed", "plan self-review failed: " + missing.join("; "));
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
ok: true,
|
|
1566
|
+
next: {
|
|
1567
|
+
...state,
|
|
1568
|
+
plan: {
|
|
1569
|
+
path: relPath,
|
|
1570
|
+
status: "approved",
|
|
1571
|
+
evidence: recorded.evidence,
|
|
1572
|
+
approved_digest: digest.digest,
|
|
1573
|
+
},
|
|
1574
|
+
updated_at: Date.now(),
|
|
825
1575
|
},
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
return err("flow_already_approved", "already approved; no further transitions");
|
|
829
1579
|
});
|
|
830
1580
|
};
|
|
831
1581
|
|
|
@@ -870,18 +1620,262 @@ export const recordMenuChoice = (
|
|
|
870
1620
|
return err("spec_not_approved", "spec must be approved before the execution menu");
|
|
871
1621
|
if (state.plan.status !== "approved")
|
|
872
1622
|
return err("plan_not_approved", "plan must be approved before the execution menu");
|
|
1623
|
+
// Recursive-handoff rejection (CA-09): a marked destination never re-offers
|
|
1624
|
+
// the originating handoff choice, even when an adapter or CLI caller
|
|
1625
|
+
// bypasses the destination prompt's four-choice wording.
|
|
1626
|
+
if (state.handoff_destination && choice === "handoff") {
|
|
1627
|
+
return err(
|
|
1628
|
+
"recursive_handoff",
|
|
1629
|
+
"this flow is already a handoff destination — a second handoff is rejected",
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
// Lifecycle is set ATOMICALLY with the menu evidence (CA-11/CA-13): an
|
|
1633
|
+
// executing choice starts the plan; a review/handoff choice leaves it
|
|
1634
|
+
// pending. The menu evidence IS the lifecycle evidence — the choice the
|
|
1635
|
+
// user selected on the native question.
|
|
1636
|
+
const executing = choice === "subagent-driven" || choice === "inline";
|
|
873
1637
|
return {
|
|
874
1638
|
ok: true,
|
|
875
1639
|
next: {
|
|
876
1640
|
...state,
|
|
877
|
-
|
|
1641
|
+
// Legacy fixup (CA-16): a hand-crafted legacy flow.json with an empty
|
|
1642
|
+
// plan.path keeps it empty through menu recording unless restored to
|
|
1643
|
+
// the canonical path here.
|
|
1644
|
+
plan: { ...state.plan, path: state.plan.path || `docs/${slug}/plan.md` },
|
|
878
1645
|
menu: { presented: true, chosen: choice, evidence: recorded.evidence },
|
|
1646
|
+
execution: executing
|
|
1647
|
+
? { status: "active", mode: choice as ExecutionMode, evidence: recorded.evidence }
|
|
1648
|
+
: { status: "pending", mode: null, evidence: recorded.evidence },
|
|
1649
|
+
updated_at: Date.now(),
|
|
1650
|
+
},
|
|
1651
|
+
};
|
|
1652
|
+
});
|
|
1653
|
+
};
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* Atomically mark a flow as a handoff destination (CA-07, CA-09): one effective
|
|
1657
|
+
* state mutation under the existing lock/CAS writer. Requires approved spec and
|
|
1658
|
+
* plan plus the source menu choice `handoff`; rejects an already marked
|
|
1659
|
+
* destination (recursive_handoff). Sets `handoff_destination: true`, resets the
|
|
1660
|
+
* menu presentation/evidence, and keeps execution pending. Host-neutral
|
|
1661
|
+
* (CA-10): OpenCode, Cursor, and the CLI all reach this single core mutation.
|
|
1662
|
+
*/
|
|
1663
|
+
export const markHandoffDestination = (
|
|
1664
|
+
root: string,
|
|
1665
|
+
slug: string,
|
|
1666
|
+
planPath: string,
|
|
1667
|
+
): FlowGateResult => {
|
|
1668
|
+
const doc = resolveDoc(root, slug, planPath, "plan");
|
|
1669
|
+
if (!doc.ok) return err("path_invalid", doc.error);
|
|
1670
|
+
return readModifyWrite(root, slug, (state) => {
|
|
1671
|
+
if (state.spec.status !== "approved")
|
|
1672
|
+
return err("spec_not_approved", "spec must be approved before marking a handoff destination");
|
|
1673
|
+
if (state.plan.status !== "approved")
|
|
1674
|
+
return err("plan_not_approved", "plan must be approved before marking a handoff destination");
|
|
1675
|
+
if (state.handoff_destination) {
|
|
1676
|
+
return err(
|
|
1677
|
+
"recursive_handoff",
|
|
1678
|
+
"this flow is already a handoff destination — a second handoff is rejected",
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
if (state.menu.chosen !== "handoff") {
|
|
1682
|
+
return err(
|
|
1683
|
+
"handoff_not_chosen",
|
|
1684
|
+
`source menu choice must be "handoff" to mark a handoff destination (chosen: ${JSON.stringify(state.menu.chosen)})`,
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
return {
|
|
1688
|
+
ok: true,
|
|
1689
|
+
next: {
|
|
1690
|
+
...state,
|
|
1691
|
+
handoff_destination: true,
|
|
1692
|
+
menu: { presented: false, chosen: "", evidence: null },
|
|
879
1693
|
updated_at: Date.now(),
|
|
880
1694
|
},
|
|
881
1695
|
};
|
|
882
1696
|
});
|
|
883
1697
|
};
|
|
884
1698
|
|
|
1699
|
+
const CLI_CONFIRMATION_KEYS = ["attested", "confirmation", "host"];
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* Strict shape validation for lifecycle evidence (CA-19, CA-21): OpenCode and
|
|
1703
|
+
* Cursor use the existing native-choice validation; CLI evidence accepts ONLY
|
|
1704
|
+
* the exact `{ host: "cli", attested: false, confirmation: "flag" | "tty" }`
|
|
1705
|
+
* constant — no caller data, no attestation.
|
|
1706
|
+
*/
|
|
1707
|
+
const validateLifecycleEvidence = (
|
|
1708
|
+
input: unknown,
|
|
1709
|
+
): { ok: true; evidence: LifecycleEvidence } | { ok: false; error: string } => {
|
|
1710
|
+
if (typeof input !== "object" || input === null) {
|
|
1711
|
+
return {
|
|
1712
|
+
ok: false,
|
|
1713
|
+
error: "lifecycle evidence required — native choice evidence or an exact CLI confirmation",
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
const record = input as Record<string, unknown>;
|
|
1717
|
+
if (record.host === "cli") {
|
|
1718
|
+
const validValue =
|
|
1719
|
+
record.attested === false &&
|
|
1720
|
+
(record.confirmation === "flag" || record.confirmation === "tty");
|
|
1721
|
+
const keys = Object.keys(record).sort();
|
|
1722
|
+
const exactShape =
|
|
1723
|
+
keys.length === CLI_CONFIRMATION_KEYS.length &&
|
|
1724
|
+
CLI_CONFIRMATION_KEYS.every((key) => keys.includes(key));
|
|
1725
|
+
if (validValue && exactShape) {
|
|
1726
|
+
return {
|
|
1727
|
+
ok: true,
|
|
1728
|
+
evidence: {
|
|
1729
|
+
host: "cli",
|
|
1730
|
+
attested: false,
|
|
1731
|
+
confirmation: record.confirmation as "flag" | "tty",
|
|
1732
|
+
},
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
return {
|
|
1736
|
+
ok: false,
|
|
1737
|
+
error:
|
|
1738
|
+
'cli confirmations accept only the exact { host: "cli", attested: false, confirmation: "flag" | "tty" } shape',
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
return assertEvidenceShape(input);
|
|
1742
|
+
};
|
|
1743
|
+
|
|
1744
|
+
const errPendingFlow = (action: string): FlowError =>
|
|
1745
|
+
err("flow_not_active", `cannot ${action} a pending flow — the execution menu has not started it`);
|
|
1746
|
+
|
|
1747
|
+
const errCompletedFlow = (action: string): FlowError =>
|
|
1748
|
+
err("flow_already_completed", `cannot ${action} a completed flow`);
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Completion (CA-23): acquire/read/reconcile/validate and capture the exact
|
|
1752
|
+
* effective state plus the ledger result; RELEASE the lock; run repository
|
|
1753
|
+
* verification outside the lock (no expensive command ever runs while a flow
|
|
1754
|
+
* lock is held); stop on nonzero verification; reacquire and compare-and-swap
|
|
1755
|
+
* the completed state against the captured state — a concurrent mutation during
|
|
1756
|
+
* verification returns flow_concurrent_conflict rather than rerunning
|
|
1757
|
+
* verification or overwriting the newer state.
|
|
1758
|
+
*/
|
|
1759
|
+
const completeExecution = (
|
|
1760
|
+
root: string,
|
|
1761
|
+
slug: string,
|
|
1762
|
+
deps?: { verifyProject?: typeof runVerifyProject },
|
|
1763
|
+
): FlowGateResult => {
|
|
1764
|
+
const file = flowPath(root, slug);
|
|
1765
|
+
const captured = readEffectiveFlowState(root, slug);
|
|
1766
|
+
if (!captured.ok) return captured;
|
|
1767
|
+
const exec = captured.state.execution;
|
|
1768
|
+
if (exec.status === "pending") return errPendingFlow("complete");
|
|
1769
|
+
if (exec.status === "completed") return errCompletedFlow("complete");
|
|
1770
|
+
const ledger = ledgerCompletion(root, slug);
|
|
1771
|
+
if (!ledger.complete) {
|
|
1772
|
+
return err(
|
|
1773
|
+
"execution_incomplete",
|
|
1774
|
+
`execution ledger incomplete for ${slug}: missing tasks ${ledger.missing.join(", ")}`,
|
|
1775
|
+
{ required: ledger.required, completed: ledger.completed, missing: ledger.missing },
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
const verifier = deps?.verifyProject ?? runVerifyProject;
|
|
1779
|
+
const verify = verifier(root, false);
|
|
1780
|
+
if (verify.exitCode !== 0) {
|
|
1781
|
+
return err(
|
|
1782
|
+
"verification_failed",
|
|
1783
|
+
`repository verification failed for ${slug} (exit ${verify.exitCode}) — see the verification output`,
|
|
1784
|
+
{ exitCode: verify.exitCode },
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
const locked = withFlowLock<FlowGateResult>(file, () => {
|
|
1788
|
+
const strict = readFlowStrict(root, slug);
|
|
1789
|
+
if (!strict.ok) return strict;
|
|
1790
|
+
const reconciled = reconcileState(root, slug, strict.state);
|
|
1791
|
+
const currentExec = reconciled.state.execution;
|
|
1792
|
+
if (currentExec.status !== exec.status || currentExec.mode !== exec.mode) {
|
|
1793
|
+
return err(
|
|
1794
|
+
"flow_concurrent_conflict",
|
|
1795
|
+
`concurrent execution state change detected for ${slug}: re-read the flow state and retry completion`,
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
const next: FlowState = {
|
|
1799
|
+
...reconciled.state,
|
|
1800
|
+
execution: { ...exec, status: "completed" },
|
|
1801
|
+
// A completed flow is never a destination: clear the context so the next
|
|
1802
|
+
// ordinary session gets the source five-choice reminder, not the stale
|
|
1803
|
+
// four-choice destination wording (CA-08). Both approval-drift resets
|
|
1804
|
+
// (resetForSpecDrift/resetForPlanDrift) and completion clear
|
|
1805
|
+
// handoff_destination; only a new-flow prepareFlowState initializes it.
|
|
1806
|
+
handoff_destination: false,
|
|
1807
|
+
updated_at: Date.now(),
|
|
1808
|
+
};
|
|
1809
|
+
const commit = writeFlowStateIfCurrent(root, captured.state, next);
|
|
1810
|
+
if (commit.ok) return { ok: true };
|
|
1811
|
+
if ("io_error" in commit) {
|
|
1812
|
+
return err("flow_io_error", `flow state write failed for ${slug}: ${commit.io_error}`);
|
|
1813
|
+
}
|
|
1814
|
+
return err(
|
|
1815
|
+
"flow_concurrent_conflict",
|
|
1816
|
+
`concurrent flow update detected for ${slug}: re-read the flow state and retry completion`,
|
|
1817
|
+
);
|
|
1818
|
+
});
|
|
1819
|
+
if (!locked.locked) return locked.error;
|
|
1820
|
+
return locked.value;
|
|
1821
|
+
};
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Execution lifecycle transitions (CA-11, CA-14, CA-23): pause, resume, and
|
|
1825
|
+
* complete move the plan between the only four states — pending, active,
|
|
1826
|
+
* paused, completed. Pause/resume run under the per-flow critical section and
|
|
1827
|
+
* preserve the retained mode and original lifecycle evidence; every SDD
|
|
1828
|
+
* artifact (briefs, reviews, ledger) is untouched. Completion is orchestrated
|
|
1829
|
+
* by completeExecution (ledger check -> verification outside the lock -> CAS).
|
|
1830
|
+
*/
|
|
1831
|
+
export const transitionExecution = (
|
|
1832
|
+
root: string,
|
|
1833
|
+
slug: string,
|
|
1834
|
+
planPath: string,
|
|
1835
|
+
action: "pause" | "resume" | "complete",
|
|
1836
|
+
evidence: LifecycleEvidence,
|
|
1837
|
+
ctx?: MutationContext,
|
|
1838
|
+
deps?: { verifyProject?: typeof runVerifyProject },
|
|
1839
|
+
): FlowGateResult => {
|
|
1840
|
+
const bound = assertMutationWorkspace(root, ctx);
|
|
1841
|
+
if (!bound.ok) return bound;
|
|
1842
|
+
const validated = validateLifecycleEvidence(evidence);
|
|
1843
|
+
if (!validated.ok) return err("evidence_invalid", validated.error);
|
|
1844
|
+
const doc = resolveDoc(root, slug, planPath, "plan");
|
|
1845
|
+
if (!doc.ok) return err("path_invalid", doc.error);
|
|
1846
|
+
|
|
1847
|
+
if (action === "complete") return completeExecution(root, slug, deps);
|
|
1848
|
+
|
|
1849
|
+
if (action === "pause") {
|
|
1850
|
+
return readModifyWrite(root, slug, (state) => {
|
|
1851
|
+
const exec = state.execution;
|
|
1852
|
+
if (exec.status === "pending") return errPendingFlow("pause");
|
|
1853
|
+
if (exec.status === "completed") return errCompletedFlow("pause");
|
|
1854
|
+
if (exec.status === "paused") return err("flow_already_paused", "flow is already paused");
|
|
1855
|
+
return {
|
|
1856
|
+
ok: true,
|
|
1857
|
+
next: { ...state, execution: { ...exec, status: "paused" }, updated_at: Date.now() },
|
|
1858
|
+
};
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
return readModifyWrite(root, slug, (state) => {
|
|
1862
|
+
const exec = state.execution;
|
|
1863
|
+
if (exec.status === "completed") return errCompletedFlow("resume");
|
|
1864
|
+
if (exec.status !== "paused") {
|
|
1865
|
+
return err(
|
|
1866
|
+
"flow_not_paused",
|
|
1867
|
+
exec.status === "active"
|
|
1868
|
+
? "flow is already active — cannot resume"
|
|
1869
|
+
: "cannot resume a pending flow — the execution menu has not started it",
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
return {
|
|
1873
|
+
ok: true,
|
|
1874
|
+
next: { ...state, execution: { ...exec, status: "active" }, updated_at: Date.now() },
|
|
1875
|
+
};
|
|
1876
|
+
});
|
|
1877
|
+
};
|
|
1878
|
+
|
|
885
1879
|
export const slugFromPath = (p: string) => {
|
|
886
1880
|
const dirName = path.basename(path.dirname(p));
|
|
887
1881
|
return dirName === "." || dirName === "/" || dirName === "" ? "" : dirName;
|
|
@@ -907,7 +1901,11 @@ export const assertFlowGates = (
|
|
|
907
1901
|
const doc = resolveDoc(root, "", planPath, "plan");
|
|
908
1902
|
if (!doc.ok) return err("path_invalid", doc.error);
|
|
909
1903
|
const slug = slugFromPath(planPath);
|
|
910
|
-
|
|
1904
|
+
// Effective read (CA-02): digest reconciliation runs before the gate trusts
|
|
1905
|
+
// persisted approvals; drift resets are persisted before gating.
|
|
1906
|
+
const effective = readEffectiveFlowState(root, slug);
|
|
1907
|
+
if (!effective.ok) return effective;
|
|
1908
|
+
const state = effective.state;
|
|
911
1909
|
if (state.spec.status !== "approved") {
|
|
912
1910
|
return err(
|
|
913
1911
|
"spec_not_approved",
|
|
@@ -934,6 +1932,8 @@ export const assertFlowGates = (
|
|
|
934
1932
|
* is blocked until the spec is approved, the plan is approved, the execution
|
|
935
1933
|
* menu has been recorded (when required), and the canonical docs validate.
|
|
936
1934
|
* The optional MutationContext adds the coordinator boundary (FG-05, CA-20).
|
|
1935
|
+
* The gate reconciles approval digests before trusting persisted approvals
|
|
1936
|
+
* (CA-02); drift resets are persisted before gating.
|
|
937
1937
|
*/
|
|
938
1938
|
export const assertProductGates = (
|
|
939
1939
|
root: string,
|
|
@@ -943,12 +1943,13 @@ export const assertProductGates = (
|
|
|
943
1943
|
): FlowGateResult => {
|
|
944
1944
|
const bound = assertMutationWorkspace(root, ctx);
|
|
945
1945
|
if (!bound.ok) return bound;
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
// fallback. Fail-closed is preserved — no gate ever
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1946
|
+
// Effective strict read (CA-18): missing state surfaces flow_not_activated,
|
|
1947
|
+
// malformed state flow_state_invalid — never a misleading spec_not_approved
|
|
1948
|
+
// from a silent draft fallback. Fail-closed is preserved — no gate ever
|
|
1949
|
+
// passes on absent state.
|
|
1950
|
+
const effective = readEffectiveFlowState(root, slug);
|
|
1951
|
+
if (!effective.ok) return effective;
|
|
1952
|
+
const state = effective.state;
|
|
952
1953
|
if (state.spec.status !== "approved") {
|
|
953
1954
|
return err(
|
|
954
1955
|
"spec_not_approved",
|
|
@@ -977,7 +1978,7 @@ export const assertProductGates = (
|
|
|
977
1978
|
});
|
|
978
1979
|
if (validated.ok === false) return err("docs_invalid", validated.error);
|
|
979
1980
|
}
|
|
980
|
-
return assertCoordinatorBoundary(ctx, state
|
|
1981
|
+
return assertCoordinatorBoundary(ctx, state);
|
|
981
1982
|
};
|
|
982
1983
|
|
|
983
1984
|
/**
|