cclaw-cli 6.6.0 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/artifact-linter/findings-dedup.d.ts +56 -0
  2. package/dist/artifact-linter/findings-dedup.js +232 -0
  3. package/dist/artifact-linter/plan.js +3 -2
  4. package/dist/artifact-linter/shared.d.ts +49 -0
  5. package/dist/artifact-linter/shared.js +35 -0
  6. package/dist/artifact-linter.d.ts +1 -1
  7. package/dist/artifact-linter.js +45 -3
  8. package/dist/content/hooks.js +36 -1
  9. package/dist/content/node-hooks.js +43 -0
  10. package/dist/content/skills-elicitation.js +3 -6
  11. package/dist/content/skills.js +1 -1
  12. package/dist/content/stages/brainstorm.js +4 -4
  13. package/dist/content/stages/scope.js +2 -2
  14. package/dist/content/templates.js +3 -2
  15. package/dist/delegation.d.ts +9 -0
  16. package/dist/delegation.js +3 -0
  17. package/dist/internal/advance-stage/advance.js +23 -1
  18. package/dist/internal/advance-stage/parsers.d.ts +8 -0
  19. package/dist/internal/advance-stage/parsers.js +7 -0
  20. package/dist/internal/advance-stage/proactive-delegation-trace.d.ts +3 -0
  21. package/dist/internal/advance-stage/proactive-delegation-trace.js +8 -1
  22. package/dist/internal/advance-stage/rewind.js +2 -2
  23. package/dist/internal/advance-stage/start-flow.js +4 -1
  24. package/dist/internal/advance-stage.js +32 -2
  25. package/dist/internal/flow-state-repair.d.ts +13 -0
  26. package/dist/internal/flow-state-repair.js +65 -0
  27. package/dist/internal/waiver-grant.d.ts +62 -0
  28. package/dist/internal/waiver-grant.js +294 -0
  29. package/dist/run-persistence.d.ts +70 -0
  30. package/dist/run-persistence.js +215 -3
  31. package/dist/runs.d.ts +1 -1
  32. package/dist/runs.js +1 -1
  33. package/dist/runtime/run-hook.mjs +43 -0
  34. package/package.json +1 -1
@@ -0,0 +1,294 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { RUNTIME_ROOT } from "../constants.js";
5
+ import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "../fs-utils.js";
6
+ import { FLOW_STAGES } from "../types.js";
7
+ /**
8
+ * Tokens issued by `cclaw internal waiver-grant` live under the runtime
9
+ * root. The ledger also tracks `consumed[]` entries so consumption is
10
+ * traceable and one-shot.
11
+ */
12
+ const WAIVER_LEDGER_REL_PATH = `${RUNTIME_ROOT}/.waivers.json`;
13
+ const WAIVER_LEDGER_LOCK_REL_PATH = `${RUNTIME_ROOT}/.waivers.json.lock`;
14
+ export const WAIVER_TOKEN_DEFAULT_TTL_MINUTES = 30;
15
+ export const WAIVER_TOKEN_MAX_TTL_MINUTES = 120;
16
+ export const WAIVER_REASON_PATTERN = /^[a-z][a-z0-9_-]{2,}$/u;
17
+ const WAIVER_TOKEN_PREFIX = "WV";
18
+ const WAIVER_LEDGER_SCHEMA_VERSION = 1;
19
+ function waiverLedgerPath(projectRoot) {
20
+ return path.join(projectRoot, WAIVER_LEDGER_REL_PATH);
21
+ }
22
+ function waiverLedgerLockPath(projectRoot) {
23
+ return path.join(projectRoot, WAIVER_LEDGER_LOCK_REL_PATH);
24
+ }
25
+ function sanitizeWaiverRecord(value) {
26
+ if (!value || typeof value !== "object" || Array.isArray(value))
27
+ return null;
28
+ const row = value;
29
+ const token = typeof row.token === "string" ? row.token : "";
30
+ const stage = row.stage;
31
+ const reason = typeof row.reason === "string" ? row.reason : "";
32
+ const issuedAt = typeof row.issuedAt === "string" ? row.issuedAt : "";
33
+ const expiresAt = typeof row.expiresAt === "string" ? row.expiresAt : "";
34
+ const consumedAt = typeof row.consumedAt === "string" ? row.consumedAt : null;
35
+ const issuerSubsystem = typeof row.issuerSubsystem === "string" ? row.issuerSubsystem : "";
36
+ const consumedBy = typeof row.consumedBy === "string" ? row.consumedBy : undefined;
37
+ if (token.length === 0 ||
38
+ typeof stage !== "string" ||
39
+ !FLOW_STAGES.includes(stage) ||
40
+ reason.length === 0 ||
41
+ issuedAt.length === 0 ||
42
+ expiresAt.length === 0 ||
43
+ issuerSubsystem.length === 0) {
44
+ return null;
45
+ }
46
+ return {
47
+ token,
48
+ stage: stage,
49
+ reason,
50
+ issuedAt,
51
+ expiresAt,
52
+ consumedAt,
53
+ issuerSubsystem,
54
+ ...(consumedBy ? { consumedBy } : {})
55
+ };
56
+ }
57
+ async function readWaiverLedger(projectRoot) {
58
+ const statePath = waiverLedgerPath(projectRoot);
59
+ if (!(await exists(statePath))) {
60
+ return { schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION, pending: [], consumed: [] };
61
+ }
62
+ let raw;
63
+ try {
64
+ raw = await fs.readFile(statePath, "utf8");
65
+ }
66
+ catch {
67
+ return { schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION, pending: [], consumed: [] };
68
+ }
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ }
73
+ catch {
74
+ return { schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION, pending: [], consumed: [] };
75
+ }
76
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
77
+ return { schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION, pending: [], consumed: [] };
78
+ }
79
+ const typed = parsed;
80
+ const pending = Array.isArray(typed.pending)
81
+ ? typed.pending
82
+ .map((item) => sanitizeWaiverRecord(item))
83
+ .filter((item) => item !== null)
84
+ : [];
85
+ const consumed = Array.isArray(typed.consumed)
86
+ ? typed.consumed
87
+ .map((item) => sanitizeWaiverRecord(item))
88
+ .filter((item) => item !== null)
89
+ : [];
90
+ return { schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION, pending, consumed };
91
+ }
92
+ async function writeWaiverLedger(projectRoot, ledger) {
93
+ const next = {
94
+ schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION,
95
+ pending: ledger.pending,
96
+ consumed: ledger.consumed
97
+ };
98
+ await writeFileSafe(waiverLedgerPath(projectRoot), `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
99
+ }
100
+ function formatExpiresSlug(expiresAt) {
101
+ // Minute-precision slug for the token: e.g. `20260502T220500Z`
102
+ return expiresAt.toISOString().replace(/[-:]/gu, "").replace(/\..+$/u, "").concat("Z");
103
+ }
104
+ function minuteFingerprint(stage, reason, issuedAt) {
105
+ const payload = `${stage}|${reason}|${issuedAt.toISOString()}|${Math.random().toString(16).slice(2, 12)}`;
106
+ return createHash("sha256").update(payload, "utf8").digest("hex").slice(0, 8);
107
+ }
108
+ export function formatWaiverToken(stage, fingerprint, expiresAt) {
109
+ return `${WAIVER_TOKEN_PREFIX}-${stage}-${fingerprint}-${formatExpiresSlug(expiresAt)}`;
110
+ }
111
+ export async function issueWaiverToken(projectRoot, options) {
112
+ if (!FLOW_STAGES.includes(options.stage)) {
113
+ throw new Error(`waiver-grant: --stage must be one of ${FLOW_STAGES.join(", ")}.`);
114
+ }
115
+ const reason = options.reason.trim();
116
+ if (reason.length === 0) {
117
+ throw new Error("waiver-grant: --reason is required.");
118
+ }
119
+ if (!WAIVER_REASON_PATTERN.test(reason)) {
120
+ throw new Error("waiver-grant: --reason must match /^[a-z][a-z0-9_-]{2,}$/ (short lowercase slug, e.g. architect_unavailable).");
121
+ }
122
+ const ttlRaw = typeof options.expiresInMinutes === "number" && Number.isFinite(options.expiresInMinutes)
123
+ ? Math.floor(options.expiresInMinutes)
124
+ : WAIVER_TOKEN_DEFAULT_TTL_MINUTES;
125
+ if (ttlRaw < 1) {
126
+ throw new Error("waiver-grant: --ttl must be >= 1 minute.");
127
+ }
128
+ if (ttlRaw > WAIVER_TOKEN_MAX_TTL_MINUTES) {
129
+ throw new Error(`waiver-grant: --ttl must be <= ${WAIVER_TOKEN_MAX_TTL_MINUTES} minutes.`);
130
+ }
131
+ const issuedAtDate = options.now ?? new Date();
132
+ const expiresAtDate = new Date(issuedAtDate.getTime() + ttlRaw * 60 * 1000);
133
+ const fingerprint = minuteFingerprint(options.stage, reason, issuedAtDate);
134
+ const token = formatWaiverToken(options.stage, fingerprint, expiresAtDate);
135
+ const record = {
136
+ token,
137
+ stage: options.stage,
138
+ reason,
139
+ issuedAt: issuedAtDate.toISOString(),
140
+ expiresAt: expiresAtDate.toISOString(),
141
+ consumedAt: null,
142
+ issuerSubsystem: options.issuerSubsystem?.trim() || "cli"
143
+ };
144
+ await ensureDir(path.dirname(waiverLedgerPath(projectRoot)));
145
+ await withDirectoryLock(waiverLedgerLockPath(projectRoot), async () => {
146
+ const ledger = await readWaiverLedger(projectRoot);
147
+ ledger.pending.push(record);
148
+ await writeWaiverLedger(projectRoot, ledger);
149
+ });
150
+ return record;
151
+ }
152
+ export async function consumeWaiverToken(projectRoot, options) {
153
+ const token = options.token.trim();
154
+ if (token.length === 0) {
155
+ return {
156
+ ok: false,
157
+ reason: "not-found",
158
+ detail: "waiver token is required"
159
+ };
160
+ }
161
+ const now = (options.now ?? new Date()).getTime();
162
+ return withDirectoryLock(waiverLedgerLockPath(projectRoot), async () => {
163
+ const ledger = await readWaiverLedger(projectRoot);
164
+ const pendingIdx = ledger.pending.findIndex((entry) => entry.token === token);
165
+ const consumedMatch = ledger.consumed.find((entry) => entry.token === token);
166
+ if (pendingIdx < 0) {
167
+ if (consumedMatch) {
168
+ return {
169
+ ok: false,
170
+ reason: "already-consumed",
171
+ record: consumedMatch,
172
+ detail: `waiver token ${token} was already consumed at ${consumedMatch.consumedAt ?? "unknown time"}`
173
+ };
174
+ }
175
+ return {
176
+ ok: false,
177
+ reason: "not-found",
178
+ detail: `no pending waiver token "${token}" found in ${WAIVER_LEDGER_REL_PATH}`
179
+ };
180
+ }
181
+ const record = ledger.pending[pendingIdx];
182
+ if (record.stage !== options.stage) {
183
+ return {
184
+ ok: false,
185
+ reason: "wrong-stage",
186
+ record,
187
+ detail: `waiver token ${token} was issued for stage "${record.stage}", not "${options.stage}"`
188
+ };
189
+ }
190
+ const expiresAt = Date.parse(record.expiresAt);
191
+ if (Number.isFinite(expiresAt) && expiresAt < now) {
192
+ return {
193
+ ok: false,
194
+ reason: "expired",
195
+ record,
196
+ detail: `waiver token ${token} expired at ${record.expiresAt}`
197
+ };
198
+ }
199
+ const consumedAtIso = (options.now ?? new Date()).toISOString();
200
+ const consumedRecord = {
201
+ ...record,
202
+ consumedAt: consumedAtIso,
203
+ ...(options.consumedBy ? { consumedBy: options.consumedBy } : {})
204
+ };
205
+ ledger.pending.splice(pendingIdx, 1);
206
+ ledger.consumed.push(consumedRecord);
207
+ await writeWaiverLedger(projectRoot, ledger);
208
+ return { ok: true, record: consumedRecord };
209
+ });
210
+ }
211
+ export function parseWaiverGrantArgs(tokens) {
212
+ let stage;
213
+ let reason;
214
+ let ttlMinutes = WAIVER_TOKEN_DEFAULT_TTL_MINUTES;
215
+ let json = false;
216
+ let quiet = false;
217
+ for (let i = 0; i < tokens.length; i += 1) {
218
+ const token = tokens[i];
219
+ const nextToken = tokens[i + 1];
220
+ const readValue = (flag) => {
221
+ if (token.startsWith(`${flag}=`))
222
+ return token.slice(flag.length + 1);
223
+ if (token === flag && nextToken && !nextToken.startsWith("--")) {
224
+ i += 1;
225
+ return nextToken;
226
+ }
227
+ throw new Error(`${flag} requires a value.`);
228
+ };
229
+ if (token === "--json") {
230
+ json = true;
231
+ continue;
232
+ }
233
+ if (token === "--quiet") {
234
+ quiet = true;
235
+ continue;
236
+ }
237
+ if (token === "--stage" || token.startsWith("--stage=")) {
238
+ const raw = readValue("--stage").trim();
239
+ if (!FLOW_STAGES.includes(raw)) {
240
+ throw new Error(`waiver-grant: --stage must be one of ${FLOW_STAGES.join(", ")}.`);
241
+ }
242
+ stage = raw;
243
+ continue;
244
+ }
245
+ if (token === "--reason" || token.startsWith("--reason=")) {
246
+ reason = readValue("--reason").trim();
247
+ continue;
248
+ }
249
+ if (token === "--ttl" || token.startsWith("--ttl=")) {
250
+ const raw = readValue("--ttl").trim();
251
+ if (!/^[0-9]+$/u.test(raw)) {
252
+ throw new Error("waiver-grant: --ttl must be an integer number of minutes.");
253
+ }
254
+ ttlMinutes = Number(raw);
255
+ continue;
256
+ }
257
+ throw new Error(`Unknown flag for internal waiver-grant: ${token}`);
258
+ }
259
+ if (!stage) {
260
+ throw new Error(`internal waiver-grant requires --stage=<${FLOW_STAGES.join("|")}>.`);
261
+ }
262
+ if (!reason) {
263
+ throw new Error(`internal waiver-grant requires --reason=<short-slug> (e.g. architect_unavailable).`);
264
+ }
265
+ return { stage, reason, ttlMinutes, json, quiet };
266
+ }
267
+ export async function runWaiverGrant(projectRoot, args, io) {
268
+ const record = await issueWaiverToken(projectRoot, {
269
+ stage: args.stage,
270
+ reason: args.reason,
271
+ expiresInMinutes: args.ttlMinutes,
272
+ issuerSubsystem: "cli"
273
+ });
274
+ if (args.json) {
275
+ io.stdout.write(`${JSON.stringify({
276
+ ok: true,
277
+ command: "waiver-grant",
278
+ token: record.token,
279
+ stage: record.stage,
280
+ reason: record.reason,
281
+ issuedAt: record.issuedAt,
282
+ expiresAt: record.expiresAt,
283
+ ttlMinutes: args.ttlMinutes,
284
+ consumption: `cclaw-cli internal advance-stage ${record.stage} --accept-proactive-waiver=${record.token} --accept-proactive-waiver-reason="${record.reason}"`
285
+ })}\n`);
286
+ return 0;
287
+ }
288
+ io.stdout.write(`${record.token}\n`);
289
+ if (!args.quiet) {
290
+ io.stdout.write(`Waiver token issued for stage="${record.stage}" reason="${record.reason}" expires=${record.expiresAt}.\n`);
291
+ io.stdout.write(`Consume with: node ${RUNTIME_ROOT}/hooks/stage-complete.mjs ${record.stage} --accept-proactive-waiver=${record.token} --accept-proactive-waiver-reason="${record.reason}"\n`);
292
+ }
293
+ return 0;
294
+ }
@@ -18,6 +18,13 @@ export interface WriteFlowStateOptions {
18
18
  * flow-state reset inside one atomic lock window.
19
19
  */
20
20
  skipLock?: boolean;
21
+ /**
22
+ * Free-form writer identifier persisted in the `.flow-state.guard.json`
23
+ * sidecar. Helps operators trace which subsystem wrote a given state
24
+ * (e.g. `advance-stage`, `start-flow`, `run-archive`). Defaults to
25
+ * `cclaw-cli` when omitted.
26
+ */
27
+ writerSubsystem?: string;
21
28
  }
22
29
  export interface ReadFlowStateOptions {
23
30
  /**
@@ -26,13 +33,76 @@ export interface ReadFlowStateOptions {
26
33
  */
27
34
  repairFeatureSystem?: boolean;
28
35
  }
36
+ export interface FlowStateGuardSidecar {
37
+ sha256: string;
38
+ writtenAt: string;
39
+ writerSubsystem: string;
40
+ runId: string;
41
+ }
42
+ export interface FlowStateGuardMismatchDetails {
43
+ expectedSha: string;
44
+ actualSha: string;
45
+ lastWriter: string;
46
+ writtenAt: string;
47
+ runId: string;
48
+ statePath: string;
49
+ guardPath: string;
50
+ repairCommand: string;
51
+ }
52
+ export declare class FlowStateGuardMismatchError extends Error {
53
+ readonly expectedSha: string;
54
+ readonly actualSha: string;
55
+ readonly lastWriter: string;
56
+ readonly writtenAt: string;
57
+ readonly runId: string;
58
+ readonly statePath: string;
59
+ readonly guardPath: string;
60
+ readonly repairCommand: string;
61
+ constructor(details: FlowStateGuardMismatchDetails);
62
+ }
29
63
  export declare class CorruptFlowStateError extends Error {
30
64
  readonly statePath: string;
31
65
  readonly quarantinedPath: string;
32
66
  constructor(statePath: string, quarantinedPath: string, cause: unknown);
33
67
  }
68
+ /**
69
+ * Verify the on-disk flow-state against the sha256 sidecar. Throws
70
+ * `FlowStateGuardMismatchError` when manual editing is detected. Safe to
71
+ * call on projects that have never written a sidecar: a missing sidecar is
72
+ * treated as "legacy runtime" and the check silently succeeds.
73
+ */
74
+ export declare function verifyFlowStateGuard(projectRoot: string): Promise<void>;
34
75
  export declare function readFlowState(projectRoot: string, options?: ReadFlowStateOptions): Promise<FlowState>;
76
+ /**
77
+ * Guarded read wrapper used by runtime hook scripts and the repair CLI.
78
+ * Unlike `readFlowState`, it enforces the sha256 sidecar before returning:
79
+ * a manual edit to flow-state.json fails fast with
80
+ * `FlowStateGuardMismatchError`.
81
+ */
82
+ export declare function readFlowStateGuarded(projectRoot: string, options?: ReadFlowStateOptions): Promise<FlowState>;
35
83
  export declare function writeFlowState(projectRoot: string, state: FlowState, options?: WriteFlowStateOptions): Promise<void>;
84
+ /**
85
+ * Named entry point for the write-guard workstream. Equivalent to
86
+ * `writeFlowState`: the write always produces the sha256 sidecar via
87
+ * the internal implementation so every existing writer inherits the
88
+ * guard without rewriting callsites.
89
+ */
90
+ export declare function writeFlowStateGuarded(projectRoot: string, state: FlowState, options?: WriteFlowStateOptions): Promise<void>;
91
+ export interface FlowStateRepairResult {
92
+ sidecar: FlowStateGuardSidecar;
93
+ repairLogPath: string;
94
+ guardPath: string;
95
+ }
96
+ /**
97
+ * Recompute the write-guard sidecar from the current on-disk flow-state
98
+ * contents and append an audit entry to `.cclaw/.flow-state-repair.log`.
99
+ * The reason is required so no repair happens without an operator-visible
100
+ * rationale. Intended to be called only from the explicit
101
+ * `cclaw-cli internal flow-state-repair` subcommand.
102
+ */
103
+ export declare function repairFlowStateGuard(projectRoot: string, reason: string): Promise<FlowStateRepairResult>;
104
+ export declare function flowStateGuardSidecarPathFor(projectRoot: string): string;
105
+ export declare function flowStateRepairLogPathFor(projectRoot: string): string;
36
106
  /**
37
107
  * Exposed path helper so callers that need to serialize a multi-step
38
108
  * state operation with flow-state writes (e.g. run archival) can
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { RUNTIME_ROOT } from "./constants.js";
@@ -15,9 +16,49 @@ export class InvalidStageTransitionError extends Error {
15
16
  }
16
17
  }
17
18
  const FLOW_STATE_REL_PATH = `${RUNTIME_ROOT}/state/flow-state.json`;
19
+ const FLOW_STATE_GUARD_REL_PATH = `${RUNTIME_ROOT}/.flow-state.guard.json`;
20
+ const FLOW_STATE_REPAIR_LOG_REL_PATH = `${RUNTIME_ROOT}/.flow-state-repair.log`;
18
21
  const ARCHIVE_DIR_REL_PATH = `${RUNTIME_ROOT}/archive`;
19
22
  const ACTIVE_ARTIFACTS_REL_PATH = `${RUNTIME_ROOT}/artifacts`;
20
23
  const FLOW_STAGE_SET = new Set(FLOW_STAGES);
24
+ const DEFAULT_WRITER_SUBSYSTEM = "cclaw-cli";
25
+ const DEFAULT_REPAIR_REASON_PATTERN = /^[a-z][a-z0-9_-]{2,}$/u;
26
+ export class FlowStateGuardMismatchError extends Error {
27
+ expectedSha;
28
+ actualSha;
29
+ lastWriter;
30
+ writtenAt;
31
+ runId;
32
+ statePath;
33
+ guardPath;
34
+ repairCommand;
35
+ constructor(details) {
36
+ super(`flow-state guard mismatch: ${details.runId}\n` +
37
+ `expected sha: ${details.expectedSha}\n` +
38
+ `actual sha: ${details.actualSha}\n` +
39
+ `last writer: ${details.lastWriter}@${details.writtenAt}\n` +
40
+ `do not edit flow-state.json by hand. To recover, run:\n` +
41
+ ` ${details.repairCommand}`);
42
+ this.name = "FlowStateGuardMismatchError";
43
+ this.expectedSha = details.expectedSha;
44
+ this.actualSha = details.actualSha;
45
+ this.lastWriter = details.lastWriter;
46
+ this.writtenAt = details.writtenAt;
47
+ this.runId = details.runId;
48
+ this.statePath = details.statePath;
49
+ this.guardPath = details.guardPath;
50
+ this.repairCommand = details.repairCommand;
51
+ }
52
+ }
53
+ function canonicalFlowStateShaFromRaw(raw) {
54
+ return createHash("sha256").update(raw, "utf8").digest("hex");
55
+ }
56
+ function guardSidecarPath(projectRoot) {
57
+ return path.join(projectRoot, FLOW_STATE_GUARD_REL_PATH);
58
+ }
59
+ function repairLogPath(projectRoot) {
60
+ return path.join(projectRoot, FLOW_STATE_REPAIR_LOG_REL_PATH);
61
+ }
21
62
  function validateFlowTransition(prev, next) {
22
63
  if (prev.activeRunId !== next.activeRunId) {
23
64
  // New run — only reset paths may change the runId, but those set allowReset.
@@ -488,6 +529,72 @@ async function quarantineCorruptState(statePath, cause) {
488
529
  }
489
530
  throw new CorruptFlowStateError(statePath, quarantinedPath, cause);
490
531
  }
532
+ function buildRepairCommand(reason = "<manual_edit_recovery>") {
533
+ return `cclaw-cli internal flow-state-repair --reason "${reason}"`;
534
+ }
535
+ async function readGuardSidecar(projectRoot) {
536
+ const guardPath = guardSidecarPath(projectRoot);
537
+ try {
538
+ const raw = await fs.readFile(guardPath, "utf8");
539
+ const parsed = JSON.parse(raw);
540
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
541
+ return null;
542
+ }
543
+ const sha256 = typeof parsed.sha256 === "string" ? parsed.sha256 : "";
544
+ const writtenAt = typeof parsed.writtenAt === "string" ? parsed.writtenAt : "";
545
+ const writerSubsystem = typeof parsed.writerSubsystem === "string" ? parsed.writerSubsystem : "";
546
+ const runId = typeof parsed.runId === "string" ? parsed.runId : "";
547
+ if (!sha256 || !writtenAt || !writerSubsystem || !runId) {
548
+ return null;
549
+ }
550
+ return { sha256, writtenAt, writerSubsystem, runId };
551
+ }
552
+ catch {
553
+ return null;
554
+ }
555
+ }
556
+ async function verifyFlowStateGuardFromRaw(projectRoot, statePath, rawContents) {
557
+ const sidecar = await readGuardSidecar(projectRoot);
558
+ if (!sidecar) {
559
+ // Legacy: flow-state.json was written by a pre-guard runtime, or sidecar
560
+ // was intentionally reset. Permit the read so existing projects keep
561
+ // working; the next legitimate stage-complete writes a fresh sidecar.
562
+ return;
563
+ }
564
+ const actualSha = canonicalFlowStateShaFromRaw(rawContents);
565
+ if (actualSha === sidecar.sha256) {
566
+ return;
567
+ }
568
+ throw new FlowStateGuardMismatchError({
569
+ expectedSha: sidecar.sha256,
570
+ actualSha,
571
+ lastWriter: sidecar.writerSubsystem,
572
+ writtenAt: sidecar.writtenAt,
573
+ runId: sidecar.runId,
574
+ statePath,
575
+ guardPath: guardSidecarPath(projectRoot),
576
+ repairCommand: buildRepairCommand("manual_edit_recovery")
577
+ });
578
+ }
579
+ /**
580
+ * Verify the on-disk flow-state against the sha256 sidecar. Throws
581
+ * `FlowStateGuardMismatchError` when manual editing is detected. Safe to
582
+ * call on projects that have never written a sidecar: a missing sidecar is
583
+ * treated as "legacy runtime" and the check silently succeeds.
584
+ */
585
+ export async function verifyFlowStateGuard(projectRoot) {
586
+ const statePath = flowStatePath(projectRoot);
587
+ if (!(await exists(statePath)))
588
+ return;
589
+ let raw;
590
+ try {
591
+ raw = await fs.readFile(statePath, "utf8");
592
+ }
593
+ catch {
594
+ return;
595
+ }
596
+ await verifyFlowStateGuardFromRaw(projectRoot, statePath, raw);
597
+ }
491
598
  export async function readFlowState(projectRoot, options = {}) {
492
599
  void options;
493
600
  const statePath = flowStatePath(projectRoot);
@@ -513,13 +620,46 @@ export async function readFlowState(projectRoot, options = {}) {
513
620
  }
514
621
  return coerceFlowState(parsed).state;
515
622
  }
623
+ /**
624
+ * Guarded read wrapper used by runtime hook scripts and the repair CLI.
625
+ * Unlike `readFlowState`, it enforces the sha256 sidecar before returning:
626
+ * a manual edit to flow-state.json fails fast with
627
+ * `FlowStateGuardMismatchError`.
628
+ */
629
+ export async function readFlowStateGuarded(projectRoot, options = {}) {
630
+ void options;
631
+ const statePath = flowStatePath(projectRoot);
632
+ if (!(await exists(statePath))) {
633
+ return createInitialFlowState();
634
+ }
635
+ let raw;
636
+ try {
637
+ raw = await fs.readFile(statePath, "utf8");
638
+ }
639
+ catch (readErr) {
640
+ throw new CorruptFlowStateError(statePath, statePath, readErr);
641
+ }
642
+ await verifyFlowStateGuardFromRaw(projectRoot, statePath, raw);
643
+ let parsed;
644
+ try {
645
+ parsed = JSON.parse(raw);
646
+ }
647
+ catch (parseErr) {
648
+ await quarantineCorruptState(statePath, parseErr);
649
+ }
650
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
651
+ await quarantineCorruptState(statePath, new Error("flow-state.json did not deserialize to a JSON object"));
652
+ }
653
+ return coerceFlowState(parsed).state;
654
+ }
516
655
  export async function writeFlowState(projectRoot, state, options = {}) {
656
+ const writerSubsystem = options.writerSubsystem?.trim() || DEFAULT_WRITER_SUBSYSTEM;
517
657
  const doWrite = async () => {
518
658
  const statePath = flowStatePath(projectRoot);
519
659
  if (!options.allowReset && (await exists(statePath))) {
520
660
  try {
521
- const raw = await fs.readFile(statePath, "utf8");
522
- const parsed = JSON.parse(raw);
661
+ const rawExisting = await fs.readFile(statePath, "utf8");
662
+ const parsed = JSON.parse(rawExisting);
523
663
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
524
664
  const prev = coerceFlowState(parsed).state;
525
665
  validateFlowTransition(prev, state);
@@ -533,7 +673,16 @@ export async function writeFlowState(projectRoot, state, options = {}) {
533
673
  }
534
674
  }
535
675
  const safe = coerceFlowState({ ...state }).state;
536
- await writeFileSafe(statePath, `${JSON.stringify(safe, null, 2)}\n`, { mode: 0o600 });
676
+ const canonicalPayload = `${JSON.stringify(safe, null, 2)}\n`;
677
+ const sha256 = canonicalFlowStateShaFromRaw(canonicalPayload);
678
+ await writeFileSafe(statePath, canonicalPayload, { mode: 0o600 });
679
+ const sidecar = {
680
+ sha256,
681
+ writtenAt: new Date().toISOString(),
682
+ writerSubsystem,
683
+ runId: safe.activeRunId
684
+ };
685
+ await writeFileSafe(guardSidecarPath(projectRoot), `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
537
686
  };
538
687
  if (options.skipLock) {
539
688
  await doWrite();
@@ -542,6 +691,69 @@ export async function writeFlowState(projectRoot, state, options = {}) {
542
691
  await withDirectoryLock(flowStateLockPath(projectRoot), doWrite);
543
692
  }
544
693
  }
694
+ /**
695
+ * Named entry point for the write-guard workstream. Equivalent to
696
+ * `writeFlowState`: the write always produces the sha256 sidecar via
697
+ * the internal implementation so every existing writer inherits the
698
+ * guard without rewriting callsites.
699
+ */
700
+ export async function writeFlowStateGuarded(projectRoot, state, options = {}) {
701
+ await writeFlowState(projectRoot, state, options);
702
+ }
703
+ /**
704
+ * Recompute the write-guard sidecar from the current on-disk flow-state
705
+ * contents and append an audit entry to `.cclaw/.flow-state-repair.log`.
706
+ * The reason is required so no repair happens without an operator-visible
707
+ * rationale. Intended to be called only from the explicit
708
+ * `cclaw-cli internal flow-state-repair` subcommand.
709
+ */
710
+ export async function repairFlowStateGuard(projectRoot, reason) {
711
+ const trimmed = reason.trim();
712
+ if (trimmed.length === 0) {
713
+ throw new Error("flow-state-repair requires --reason=<slug> (e.g. --reason=\"manual_edit_recovery\").");
714
+ }
715
+ if (!DEFAULT_REPAIR_REASON_PATTERN.test(trimmed)) {
716
+ throw new Error("flow-state-repair --reason must match /^[a-z][a-z0-9_-]{2,}$/ (short lowercase slug).");
717
+ }
718
+ const statePath = flowStatePath(projectRoot);
719
+ if (!(await exists(statePath))) {
720
+ throw new Error(`flow-state-repair: ${FLOW_STATE_REL_PATH} does not exist; nothing to repair.`);
721
+ }
722
+ return withDirectoryLock(flowStateLockPath(projectRoot), async () => {
723
+ const raw = await fs.readFile(statePath, "utf8");
724
+ let runId = "unknown-run";
725
+ try {
726
+ const parsed = JSON.parse(raw);
727
+ const coerced = coerceFlowState(parsed).state;
728
+ runId = coerced.activeRunId;
729
+ }
730
+ catch {
731
+ // parsing failure falls back to "unknown-run"; repair intentionally
732
+ // accepts the contents as-is so operators can recover even from
733
+ // borderline JSON after manual edits.
734
+ }
735
+ const sha256 = canonicalFlowStateShaFromRaw(raw);
736
+ const sidecar = {
737
+ sha256,
738
+ writtenAt: new Date().toISOString(),
739
+ writerSubsystem: "flow-state-repair",
740
+ runId
741
+ };
742
+ const guardPath = guardSidecarPath(projectRoot);
743
+ await writeFileSafe(guardPath, `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
744
+ const logPath = repairLogPath(projectRoot);
745
+ await ensureDir(path.dirname(logPath));
746
+ const logLine = `${sidecar.writtenAt} reason=${trimmed} runId=${sidecar.runId} sha256=${sidecar.sha256}\n`;
747
+ await fs.appendFile(logPath, logLine, "utf8");
748
+ return { sidecar, repairLogPath: logPath, guardPath };
749
+ });
750
+ }
751
+ export function flowStateGuardSidecarPathFor(projectRoot) {
752
+ return guardSidecarPath(projectRoot);
753
+ }
754
+ export function flowStateRepairLogPathFor(projectRoot) {
755
+ return repairLogPath(projectRoot);
756
+ }
545
757
  /**
546
758
  * Exposed path helper so callers that need to serialize a multi-step
547
759
  * state operation with flow-state writes (e.g. run archival) can
package/dist/runs.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { CorruptFlowStateError, InvalidStageTransitionError, type WriteFlowStateOptions, ensureRunSystem, readFlowState, writeFlowState } from "./run-persistence.js";
1
+ export { CorruptFlowStateError, FlowStateGuardMismatchError, InvalidStageTransitionError, type FlowStateGuardSidecar, type FlowStateRepairResult, type WriteFlowStateOptions, ensureRunSystem, flowStateGuardSidecarPathFor, flowStateRepairLogPathFor, readFlowState, readFlowStateGuarded, repairFlowStateGuard, verifyFlowStateGuard, writeFlowState, writeFlowStateGuarded } from "./run-persistence.js";
2
2
  export { ARCHIVE_DISPOSITIONS, archiveRun, countActiveKnowledgeEntries, listRuns, type ArchiveDisposition, type ArchiveManifest, type ArchiveRunOptions, type ArchiveRunResult, type CclawRunMeta } from "./run-archive.js";
package/dist/runs.js CHANGED
@@ -1,2 +1,2 @@
1
- export { CorruptFlowStateError, InvalidStageTransitionError, ensureRunSystem, readFlowState, writeFlowState } from "./run-persistence.js";
1
+ export { CorruptFlowStateError, FlowStateGuardMismatchError, InvalidStageTransitionError, ensureRunSystem, flowStateGuardSidecarPathFor, flowStateRepairLogPathFor, readFlowState, readFlowStateGuarded, repairFlowStateGuard, verifyFlowStateGuard, writeFlowState, writeFlowStateGuarded } from "./run-persistence.js";
2
2
  export { ARCHIVE_DISPOSITIONS, archiveRun, countActiveKnowledgeEntries, listRuns } from "./run-archive.js";