cclaw-cli 6.6.0 → 6.8.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 +241 -7
  9. package/dist/content/node-hooks.js +43 -0
  10. package/dist/content/skills-elicitation.js +3 -6
  11. package/dist/content/skills.js +3 -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 +107 -0
  16. package/dist/delegation.js +223 -6
  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 +41 -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
@@ -60,6 +60,15 @@ export type DelegationEntry = {
60
60
  taskId?: string;
61
61
  waiverReason?: string;
62
62
  acceptedBy?: DelegationWaiverAcceptedBy;
63
+ /**
64
+ * Waiver approval token captured from `cclaw-cli internal waiver-grant`.
65
+ * Present on waiver rows written after v6.7.0. Legacy waiver rows omit
66
+ * these fields and are surfaced as the advisory linter finding
67
+ * `waiver_legacy_provenance`.
68
+ */
69
+ approvalToken?: string;
70
+ approvalReason?: string;
71
+ approvalIssuedAt?: string;
63
72
  ts?: string;
64
73
  /**
65
74
  * Run id the entry belongs to. Older ledgers written before 0.5.17 may omit this;
@@ -107,6 +116,19 @@ export type DelegationEntry = {
107
116
  * `dispatchSurface`, `agentDefinitionPath`, and ACK timestamp
108
117
  */
109
118
  schemaVersion?: 1 | 2 | 3;
119
+ /**
120
+ * v6.8.0 — when set, the operator explicitly opted into running this
121
+ * scheduled span concurrently with another active span on the same
122
+ * `(stage, agent)` pair. Bypasses the dispatch-dedup check.
123
+ */
124
+ allowParallel?: boolean;
125
+ /**
126
+ * v6.8.0 — set on synthetic terminal `stale` rows written via
127
+ * `--supersede=<prevSpanId>`. References the new spanId that
128
+ * superseded this span. Helps `/cc tree` and the linter report a
129
+ * coherent successor chain.
130
+ */
131
+ supersededBy?: string;
110
132
  };
111
133
  export declare const DELEGATION_LEDGER_SCHEMA_VERSION: 3;
112
134
  export type DelegationLedger = {
@@ -135,6 +157,91 @@ export declare function readDelegationEvents(projectRoot: string): Promise<{
135
157
  events: DelegationEvent[];
136
158
  corruptLines: number[];
137
159
  }>;
160
+ /**
161
+ * Fold ledger entries to the latest row per `spanId` and keep only spans
162
+ * whose latest status is still active (`scheduled | launched |
163
+ * acknowledged`). Used by the `state/subagents.json` writer so the
164
+ * tracker never reports a span that already has a terminal row.
165
+ *
166
+ * Output is ordered by ascending `startTs ?? ts` so existing UI
167
+ * consumers see a stable presentation order.
168
+ *
169
+ * Rows without a `spanId` are skipped — they are not addressable by
170
+ * the tracker contract and would collide on the empty key.
171
+ *
172
+ * Callers are expected to pass entries already filtered to the active
173
+ * `runId`; cross-run rows are therefore not re-filtered here.
174
+ *
175
+ * keep in sync with the inline copy in
176
+ * `src/content/hooks.ts::delegationRecordScript`.
177
+ */
178
+ export declare function computeActiveSubagents(entries: DelegationEntry[]): DelegationEntry[];
179
+ /**
180
+ * v6.8.0 — thrown by `validateMonotonicTimestamps` when an incoming row
181
+ * would push a span's timeline backwards. Carries enough context that
182
+ * the CLI / hook surface can format a `delegation_timestamp_non_monotonic`
183
+ * JSON payload without re-deriving the offending field.
184
+ *
185
+ * keep in sync with the inline copy in
186
+ * `src/content/hooks.ts::delegationRecordScript`.
187
+ */
188
+ export declare class DelegationTimestampError extends Error {
189
+ readonly field: string;
190
+ readonly actual: string;
191
+ readonly priorBound: string;
192
+ constructor(field: string, actual: string, priorBound: string);
193
+ }
194
+ /**
195
+ * v6.8.0 — enforce that lifecycle timestamps on a delegation span move
196
+ * forward (or stay equal). Validates both per-row invariants
197
+ * (`startTs ≤ launchedTs ≤ ackTs ≤ completedTs`) and a cross-row
198
+ * invariant: the union of prior rows for this `spanId` plus the
199
+ * incoming row must have non-decreasing `ts`.
200
+ *
201
+ * Equality is allowed because fast-completing dispatches legitimately
202
+ * collapse multiple lifecycle markers onto the same instant.
203
+ *
204
+ * keep in sync with the inline copy in
205
+ * `src/content/hooks.ts::delegationRecordScript`.
206
+ */
207
+ export declare function validateMonotonicTimestamps(stamped: DelegationEntry, prior: DelegationEntry[]): void;
208
+ /**
209
+ * v6.8.0 — thrown by `appendDelegation` when the operator opens a
210
+ * second `scheduled` span on the same `(stage, agent)` pair while an
211
+ * earlier span on the same pair is still active. Callers can catch and
212
+ * either pass the existing span id via `--supersede=<id>` (which
213
+ * pre-writes a synthetic `stale` row) or `--allow-parallel` to record
214
+ * concurrent spans intentionally.
215
+ */
216
+ export declare class DispatchDuplicateError extends Error {
217
+ readonly existingSpanId: string;
218
+ readonly existingStatus: DelegationStatus;
219
+ readonly newSpanId: string;
220
+ readonly pair: {
221
+ stage: string;
222
+ agent: string;
223
+ };
224
+ constructor(params: {
225
+ existingSpanId: string;
226
+ existingStatus: DelegationStatus;
227
+ newSpanId: string;
228
+ pair: {
229
+ stage: string;
230
+ agent: string;
231
+ };
232
+ });
233
+ }
234
+ /**
235
+ * v6.8.0 — find the latest active span for a given `(stage, agent)`
236
+ * pair in the supplied ledger entries. Returns the row whose latest
237
+ * status (after the latest-by-spanId fold) is still in the active set
238
+ * (`scheduled | launched | acknowledged`). Caller is responsible for
239
+ * filtering to the current run.
240
+ *
241
+ * keep in sync with the inline copy in
242
+ * `src/content/hooks.ts::delegationRecordScript`.
243
+ */
244
+ export declare function findActiveSpanForPair(stage: string, agent: string, runId: string, ledger: DelegationLedger): DelegationEntry | null;
138
245
  export declare function appendDelegation(projectRoot: string, entry: DelegationEntry): Promise<void>;
139
246
  /**
140
247
  * Aggregate the fulfillment mode cclaw expects for the active harness set.
@@ -199,6 +199,9 @@ function isDelegationEntry(value) {
199
199
  (o.taskId === undefined || typeof o.taskId === "string") &&
200
200
  (o.waiverReason === undefined || typeof o.waiverReason === "string") &&
201
201
  (o.acceptedBy === undefined || o.acceptedBy === "user-flag") &&
202
+ (o.approvalToken === undefined || typeof o.approvalToken === "string") &&
203
+ (o.approvalReason === undefined || typeof o.approvalReason === "string") &&
204
+ (o.approvalIssuedAt === undefined || typeof o.approvalIssuedAt === "string") &&
202
205
  waiverOk &&
203
206
  (o.runId === undefined || typeof o.runId === "string") &&
204
207
  (o.fulfillmentMode === undefined ||
@@ -219,7 +222,9 @@ function isDelegationEntry(value) {
219
222
  retryOk &&
220
223
  (o.evidenceRefs === undefined || (Array.isArray(o.evidenceRefs) && o.evidenceRefs.every((item) => typeof item === "string"))) &&
221
224
  (o.skill === undefined || typeof o.skill === "string") &&
222
- (o.schemaVersion === undefined || o.schemaVersion === 1 || o.schemaVersion === 2 || o.schemaVersion === 3));
225
+ (o.schemaVersion === undefined || o.schemaVersion === 1 || o.schemaVersion === 2 || o.schemaVersion === 3) &&
226
+ (o.allowParallel === undefined || typeof o.allowParallel === "boolean") &&
227
+ (o.supersededBy === undefined || typeof o.supersededBy === "string"));
223
228
  }
224
229
  function isDelegationDispatchSurface(value) {
225
230
  return typeof value === "string" && DELEGATION_DISPATCH_SURFACES.includes(value);
@@ -375,10 +380,196 @@ async function appendDelegationEvent(projectRoot, event) {
375
380
  await fs.mkdir(path.dirname(filePath), { recursive: true });
376
381
  await fs.appendFile(filePath, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
377
382
  }
383
+ /**
384
+ * Effective timestamp used to order rows that share a `spanId`. Newest
385
+ * lifecycle column wins. Returns the empty string when nothing is set
386
+ * so the caller still has a stable lexicographic compare key.
387
+ *
388
+ * keep in sync with the inline copy in
389
+ * `src/content/hooks.ts::delegationRecordScript`.
390
+ */
391
+ function effectiveSpanTs(entry) {
392
+ return entry.completedTs ?? entry.ackTs ?? entry.launchedTs ?? entry.endTs ?? entry.startTs ?? entry.ts ?? "";
393
+ }
394
+ const ACTIVE_DELEGATION_STATUSES = new Set([
395
+ "scheduled",
396
+ "launched",
397
+ "acknowledged"
398
+ ]);
399
+ /**
400
+ * Fold ledger entries to the latest row per `spanId` and keep only spans
401
+ * whose latest status is still active (`scheduled | launched |
402
+ * acknowledged`). Used by the `state/subagents.json` writer so the
403
+ * tracker never reports a span that already has a terminal row.
404
+ *
405
+ * Output is ordered by ascending `startTs ?? ts` so existing UI
406
+ * consumers see a stable presentation order.
407
+ *
408
+ * Rows without a `spanId` are skipped — they are not addressable by
409
+ * the tracker contract and would collide on the empty key.
410
+ *
411
+ * Callers are expected to pass entries already filtered to the active
412
+ * `runId`; cross-run rows are therefore not re-filtered here.
413
+ *
414
+ * keep in sync with the inline copy in
415
+ * `src/content/hooks.ts::delegationRecordScript`.
416
+ */
417
+ export function computeActiveSubagents(entries) {
418
+ const latestBySpan = new Map();
419
+ for (const entry of entries) {
420
+ if (!entry.spanId)
421
+ continue;
422
+ const existing = latestBySpan.get(entry.spanId);
423
+ if (!existing) {
424
+ latestBySpan.set(entry.spanId, entry);
425
+ continue;
426
+ }
427
+ const existingTs = effectiveSpanTs(existing);
428
+ const incomingTs = effectiveSpanTs(entry);
429
+ if (incomingTs >= existingTs) {
430
+ latestBySpan.set(entry.spanId, entry);
431
+ }
432
+ }
433
+ const folded = [];
434
+ for (const entry of latestBySpan.values()) {
435
+ if (ACTIVE_DELEGATION_STATUSES.has(entry.status)) {
436
+ folded.push(entry);
437
+ }
438
+ }
439
+ folded.sort((a, b) => {
440
+ const aKey = a.startTs ?? a.ts ?? "";
441
+ const bKey = b.startTs ?? b.ts ?? "";
442
+ if (aKey === bKey)
443
+ return 0;
444
+ return aKey < bKey ? -1 : 1;
445
+ });
446
+ return folded;
447
+ }
448
+ /**
449
+ * v6.8.0 — thrown by `validateMonotonicTimestamps` when an incoming row
450
+ * would push a span's timeline backwards. Carries enough context that
451
+ * the CLI / hook surface can format a `delegation_timestamp_non_monotonic`
452
+ * JSON payload without re-deriving the offending field.
453
+ *
454
+ * keep in sync with the inline copy in
455
+ * `src/content/hooks.ts::delegationRecordScript`.
456
+ */
457
+ export class DelegationTimestampError extends Error {
458
+ field;
459
+ actual;
460
+ priorBound;
461
+ constructor(field, actual, priorBound) {
462
+ super(`delegation_timestamp_non_monotonic — ${field}: ${actual} < ${priorBound}`);
463
+ this.name = "DelegationTimestampError";
464
+ this.field = field;
465
+ this.actual = actual;
466
+ this.priorBound = priorBound;
467
+ }
468
+ }
469
+ /**
470
+ * v6.8.0 — enforce that lifecycle timestamps on a delegation span move
471
+ * forward (or stay equal). Validates both per-row invariants
472
+ * (`startTs ≤ launchedTs ≤ ackTs ≤ completedTs`) and a cross-row
473
+ * invariant: the union of prior rows for this `spanId` plus the
474
+ * incoming row must have non-decreasing `ts`.
475
+ *
476
+ * Equality is allowed because fast-completing dispatches legitimately
477
+ * collapse multiple lifecycle markers onto the same instant.
478
+ *
479
+ * keep in sync with the inline copy in
480
+ * `src/content/hooks.ts::delegationRecordScript`.
481
+ */
482
+ export function validateMonotonicTimestamps(stamped, prior) {
483
+ const startTs = stamped.startTs;
484
+ if (stamped.launchedTs && startTs && stamped.launchedTs < startTs) {
485
+ throw new DelegationTimestampError("launchedTs", stamped.launchedTs, startTs);
486
+ }
487
+ if (stamped.ackTs) {
488
+ const ackBound = stamped.launchedTs ?? startTs;
489
+ if (ackBound && stamped.ackTs < ackBound) {
490
+ throw new DelegationTimestampError("ackTs", stamped.ackTs, ackBound);
491
+ }
492
+ }
493
+ if (stamped.completedTs) {
494
+ const completedBound = stamped.ackTs ?? stamped.launchedTs ?? startTs;
495
+ if (completedBound && stamped.completedTs < completedBound) {
496
+ throw new DelegationTimestampError("completedTs", stamped.completedTs, completedBound);
497
+ }
498
+ }
499
+ if (!stamped.spanId)
500
+ return;
501
+ const priorForSpan = prior.filter((entry) => entry.spanId === stamped.spanId);
502
+ if (priorForSpan.length === 0)
503
+ return;
504
+ const timeline = [...priorForSpan, stamped]
505
+ .map((entry) => ({ entry, ts: entry.ts ?? entry.startTs ?? "" }))
506
+ .filter((row) => row.ts.length > 0)
507
+ .sort((a, b) => (a.ts === b.ts ? 0 : a.ts < b.ts ? -1 : 1));
508
+ for (let i = 1; i < timeline.length; i += 1) {
509
+ const previous = timeline[i - 1];
510
+ const current = timeline[i];
511
+ if (current.ts < previous.ts) {
512
+ throw new DelegationTimestampError("ts", current.ts, previous.ts);
513
+ }
514
+ }
515
+ // Find the latest existing row by `ts` for the same spanId; if the
516
+ // new row's `ts` is older than that latest, the timeline regressed.
517
+ const latestPrior = priorForSpan
518
+ .map((entry) => entry.ts ?? entry.startTs ?? "")
519
+ .filter((ts) => ts.length > 0)
520
+ .sort()
521
+ .at(-1);
522
+ const stampedTs = stamped.ts ?? stamped.startTs ?? "";
523
+ if (latestPrior && stampedTs && stampedTs < latestPrior) {
524
+ throw new DelegationTimestampError("ts", stampedTs, latestPrior);
525
+ }
526
+ }
527
+ /**
528
+ * v6.8.0 — thrown by `appendDelegation` when the operator opens a
529
+ * second `scheduled` span on the same `(stage, agent)` pair while an
530
+ * earlier span on the same pair is still active. Callers can catch and
531
+ * either pass the existing span id via `--supersede=<id>` (which
532
+ * pre-writes a synthetic `stale` row) or `--allow-parallel` to record
533
+ * concurrent spans intentionally.
534
+ */
535
+ export class DispatchDuplicateError extends Error {
536
+ existingSpanId;
537
+ existingStatus;
538
+ newSpanId;
539
+ pair;
540
+ constructor(params) {
541
+ super(`dispatch_duplicate — already-active spanId=${params.existingSpanId} (status=${params.existingStatus}) on stage=${params.pair.stage}, agent=${params.pair.agent}. ` +
542
+ `pass --supersede=${params.existingSpanId} to close the previous span as stale, or --allow-parallel to record both as concurrent.`);
543
+ this.name = "DispatchDuplicateError";
544
+ this.existingSpanId = params.existingSpanId;
545
+ this.existingStatus = params.existingStatus;
546
+ this.newSpanId = params.newSpanId;
547
+ this.pair = params.pair;
548
+ }
549
+ }
550
+ /**
551
+ * v6.8.0 — find the latest active span for a given `(stage, agent)`
552
+ * pair in the supplied ledger entries. Returns the row whose latest
553
+ * status (after the latest-by-spanId fold) is still in the active set
554
+ * (`scheduled | launched | acknowledged`). Caller is responsible for
555
+ * filtering to the current run.
556
+ *
557
+ * keep in sync with the inline copy in
558
+ * `src/content/hooks.ts::delegationRecordScript`.
559
+ */
560
+ export function findActiveSpanForPair(stage, agent, runId, ledger) {
561
+ const sameRun = ledger.entries.filter((entry) => {
562
+ if (entry.runId && entry.runId !== runId)
563
+ return false;
564
+ return entry.stage === stage && entry.agent === agent;
565
+ });
566
+ for (const entry of computeActiveSubagents(sameRun)) {
567
+ return entry;
568
+ }
569
+ return null;
570
+ }
378
571
  async function writeSubagentTracker(projectRoot, entries) {
379
- const active = entries
380
- .filter((entry) => entry.status === "scheduled" || entry.status === "launched" || entry.status === "acknowledged")
381
- .map((entry) => ({
572
+ const active = computeActiveSubagents(entries).map((entry) => ({
382
573
  spanId: entry.spanId,
383
574
  dispatchId: entry.dispatchId,
384
575
  workerRunId: entry.workerRunId,
@@ -389,7 +580,8 @@ async function writeSubagentTracker(projectRoot, entries) {
389
580
  agentDefinitionPath: entry.agentDefinitionPath,
390
581
  startedAt: entry.startTs,
391
582
  launchedAt: entry.launchedTs,
392
- acknowledgedAt: entry.ackTs
583
+ acknowledgedAt: entry.ackTs,
584
+ allowParallel: entry.allowParallel
393
585
  }));
394
586
  await writeFileSafe(subagentsStatePath(projectRoot), `${JSON.stringify({ active, updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
395
587
  }
@@ -398,7 +590,20 @@ export async function appendDelegation(projectRoot, entry) {
398
590
  await withDirectoryLock(delegationLockPath(projectRoot), async () => {
399
591
  const filePath = delegationLogPath(projectRoot);
400
592
  const prior = await readDelegationLedger(projectRoot);
401
- const startTs = entry.startTs ?? entry.ts ?? new Date().toISOString();
593
+ // Span start anchor: prefer explicit `startTs`; otherwise fall back to
594
+ // the earliest provided lifecycle marker so the monotonic validator
595
+ // never sees a synthetic `now` overshoot a real event timestamp.
596
+ const lifecycleCandidates = [
597
+ entry.startTs,
598
+ entry.launchedTs,
599
+ entry.ackTs,
600
+ entry.completedTs,
601
+ entry.ts
602
+ ].filter((value) => typeof value === "string" && value.length > 0);
603
+ const earliestLifecycle = lifecycleCandidates.length > 0
604
+ ? lifecycleCandidates.reduce((min, candidate) => (candidate < min ? candidate : min))
605
+ : undefined;
606
+ const startTs = entry.startTs ?? earliestLifecycle ?? new Date().toISOString();
402
607
  if (entry.status === "waived" && !hasValidWaiverReason(entry.waiverReason)) {
403
608
  throw new Error("waived delegation entries require a non-empty waiverReason");
404
609
  }
@@ -442,6 +647,18 @@ export async function appendDelegation(projectRoot, entry) {
442
647
  if (prior.entries.some((existing) => existing.spanId === stamped.spanId && existing.status === stamped.status)) {
443
648
  return;
444
649
  }
650
+ validateMonotonicTimestamps(stamped, prior.entries);
651
+ if (stamped.status === "scheduled" && stamped.allowParallel !== true) {
652
+ const existing = findActiveSpanForPair(stamped.stage, stamped.agent, activeRunId, prior);
653
+ if (existing && existing.spanId && existing.spanId !== stamped.spanId) {
654
+ throw new DispatchDuplicateError({
655
+ existingSpanId: existing.spanId,
656
+ existingStatus: existing.status,
657
+ newSpanId: stamped.spanId,
658
+ pair: { stage: stamped.stage, agent: stamped.agent }
659
+ });
660
+ }
661
+ }
445
662
  await appendDelegationEvent(projectRoot, eventFromEntry(stamped));
446
663
  const ledger = {
447
664
  runId: activeRunId,
@@ -12,6 +12,7 @@ import { extractReviewLoopEnvelopeFromArtifact } from "../../content/review-loop
12
12
  import { unique } from "./helpers.js";
13
13
  import { AUTO_REVIEW_LOOP_GATE_BY_STAGE, reviewLoopArtifactFixHint, reviewLoopEnvelopeExample, validateGateEvidenceShape } from "./review-loop.js";
14
14
  import { ensureProactiveDelegationTrace } from "./proactive-delegation-trace.js";
15
+ import { consumeWaiverToken } from "../waiver-grant.js";
15
16
  function resolveSuccessorTransition(stage, track, transitionTargets, satisfiedGuards, selectedTransitionGuards) {
16
17
  const natural = transitionTargets[0] ?? null;
17
18
  const specialTargets = transitionTargets.filter((target) => target !== natural);
@@ -542,9 +543,30 @@ export async function runAdvanceStage(projectRoot, args, io) {
542
543
  }
543
544
  return 1;
544
545
  }
546
+ let approvalRecord = null;
547
+ if (args.acceptProactiveWaiver) {
548
+ const tokenRaw = args.acceptProactiveWaiverToken?.trim() ?? "";
549
+ if (tokenRaw.length === 0) {
550
+ io.stderr.write(`cclaw internal advance-stage: --accept-proactive-waiver now requires =<token>. Run \`cclaw-cli internal waiver-grant --stage ${args.stage} --reason "<why safe>"\` to issue one, then rerun with --accept-proactive-waiver=<token>.\n`);
551
+ return 2;
552
+ }
553
+ const consumed = await consumeWaiverToken(projectRoot, {
554
+ stage: args.stage,
555
+ token: tokenRaw,
556
+ consumedBy: "advance-stage"
557
+ });
558
+ if (!consumed.ok) {
559
+ io.stderr.write(`cclaw internal advance-stage: waiver token rejected (${consumed.reason}): ${consumed.detail}. Issue a fresh token via \`cclaw-cli internal waiver-grant --stage ${args.stage} --reason "<why safe>"\`.\n`);
560
+ return 2;
561
+ }
562
+ approvalRecord = consumed.record;
563
+ }
545
564
  const proactiveTrace = await ensureProactiveDelegationTrace(projectRoot, args.stage, {
546
565
  acceptWaiver: args.acceptProactiveWaiver,
547
566
  waiverReason: args.acceptProactiveWaiverReason,
567
+ approvalToken: approvalRecord?.token,
568
+ approvalReason: approvalRecord?.reason,
569
+ approvalIssuedAt: approvalRecord?.issuedAt,
548
570
  discoveryMode: flowState.discoveryMode,
549
571
  repoSignals: flowState.repoSignals
550
572
  });
@@ -600,7 +622,7 @@ export async function runAdvanceStage(projectRoot, args, io) {
600
622
  currentStage: successor ?? args.stage,
601
623
  interactionHints
602
624
  };
603
- await writeFlowState(projectRoot, finalState);
625
+ await writeFlowState(projectRoot, finalState, { writerSubsystem: "advance-stage" });
604
626
  if (args.quiet) {
605
627
  io.stdout.write(`${JSON.stringify({
606
628
  ok: true,
@@ -8,6 +8,14 @@ export interface AdvanceStageArgs {
8
8
  waiverReason?: string;
9
9
  acceptProactiveWaiver: boolean;
10
10
  acceptProactiveWaiverReason?: string;
11
+ /**
12
+ * Approval token issued by `cclaw-cli internal waiver-grant`. Required
13
+ * (via `--accept-proactive-waiver=<token>`) whenever the caller asserts
14
+ * `acceptProactiveWaiver`. Legacy `--accept-proactive-waiver` without a
15
+ * token is still parsed but rejected downstream by the advance-stage
16
+ * handler so operators see the error at runtime.
17
+ */
18
+ acceptProactiveWaiverToken?: string;
11
19
  skipQuestions: boolean;
12
20
  quiet: boolean;
13
21
  json: boolean;
@@ -12,6 +12,7 @@ export function parseAdvanceStageArgs(tokens) {
12
12
  let waiverReason;
13
13
  let acceptProactiveWaiver = false;
14
14
  let acceptProactiveWaiverReason;
15
+ let acceptProactiveWaiverToken;
15
16
  let skipQuestions = false;
16
17
  let quiet = false;
17
18
  let json = false;
@@ -81,6 +82,11 @@ export function parseAdvanceStageArgs(tokens) {
81
82
  acceptProactiveWaiver = true;
82
83
  continue;
83
84
  }
85
+ if (token.startsWith("--accept-proactive-waiver=")) {
86
+ acceptProactiveWaiver = true;
87
+ acceptProactiveWaiverToken = token.slice("--accept-proactive-waiver=".length).trim();
88
+ continue;
89
+ }
84
90
  if (token === "--skip-questions") {
85
91
  skipQuestions = true;
86
92
  continue;
@@ -107,6 +113,7 @@ export function parseAdvanceStageArgs(tokens) {
107
113
  waiverReason,
108
114
  acceptProactiveWaiver,
109
115
  acceptProactiveWaiverReason,
116
+ acceptProactiveWaiverToken,
110
117
  skipQuestions,
111
118
  quiet,
112
119
  json
@@ -16,6 +16,9 @@ export interface ProactiveDelegationTraceResult {
16
16
  export declare function ensureProactiveDelegationTrace(projectRoot: string, stage: FlowStage, options: {
17
17
  acceptWaiver: boolean;
18
18
  waiverReason?: string;
19
+ approvalToken?: string;
20
+ approvalReason?: string;
21
+ approvalIssuedAt?: string;
19
22
  discoveryMode: DiscoveryMode;
20
23
  repoSignals?: RepoSignals;
21
24
  }): Promise<ProactiveDelegationTraceResult>;
@@ -31,7 +31,11 @@ export async function ensureProactiveDelegationTrace(projectRoot, stage, options
31
31
  return { missingRules: [] };
32
32
  if (!options.acceptWaiver)
33
33
  return { missingRules };
34
- const waiverReason = options.waiverReason?.trim() || "accepted via --accept-proactive-waiver";
34
+ const approvalToken = options.approvalToken?.trim();
35
+ const approvalReason = options.approvalReason?.trim();
36
+ const waiverReason = options.waiverReason?.trim() ||
37
+ approvalReason ||
38
+ "accepted via --accept-proactive-waiver";
35
39
  for (const rule of missingRules) {
36
40
  await appendDelegation(projectRoot, {
37
41
  stage,
@@ -42,6 +46,9 @@ export async function ensureProactiveDelegationTrace(projectRoot, stage, options
42
46
  acceptedBy: "user-flag",
43
47
  conditionTrigger: rule.when,
44
48
  skill: rule.skill,
49
+ ...(approvalToken ? { approvalToken } : {}),
50
+ ...(approvalReason ? { approvalReason } : {}),
51
+ ...(options.approvalIssuedAt ? { approvalIssuedAt: options.approvalIssuedAt } : {}),
45
52
  ts: new Date().toISOString()
46
53
  });
47
54
  }
@@ -40,7 +40,7 @@ export async function runRewind(projectRoot, args, io) {
40
40
  const staleStages = { ...current.staleStages };
41
41
  delete staleStages[args.targetStage];
42
42
  const nextState = { ...current, staleStages };
43
- await writeFlowState(projectRoot, nextState);
43
+ await writeFlowState(projectRoot, nextState, { writerSubsystem: "rewind-ack" });
44
44
  const payload = {
45
45
  ok: true,
46
46
  command: "rewind",
@@ -85,7 +85,7 @@ export async function runRewind(projectRoot, args, io) {
85
85
  staleStages,
86
86
  rewinds: [...current.rewinds, record]
87
87
  };
88
- await writeFlowState(projectRoot, nextState);
88
+ await writeFlowState(projectRoot, nextState, { writerSubsystem: "rewind" });
89
89
  const payload = {
90
90
  ok: true,
91
91
  command: "rewind",
@@ -209,7 +209,10 @@ export async function runStartFlow(projectRoot, args, io) {
209
209
  }
210
210
  const repoSignals = await collectRepoSignals(projectRoot);
211
211
  nextState = { ...nextState, repoSignals };
212
- await writeFlowState(projectRoot, nextState, { allowReset: true });
212
+ await writeFlowState(projectRoot, nextState, {
213
+ allowReset: true,
214
+ writerSubsystem: "start-flow"
215
+ });
213
216
  await appendIdeaArtifact(projectRoot, args, current);
214
217
  const successPayload = {
215
218
  ok: true,
@@ -11,13 +11,34 @@ import { runRewind } from "./advance-stage/rewind.js";
11
11
  import { runVerifyFlowStateDiff, runVerifyCurrentState } from "./advance-stage/verify.js";
12
12
  import { runHookCommand } from "./advance-stage/hook.js";
13
13
  import { parseAdvanceStageArgs, parseCancelRunArgs, parseHookArgs, parseRewindArgs, parseStartFlowArgs, parseVerifyCurrentStateArgs, parseVerifyFlowStateDiffArgs } from "./advance-stage/parsers.js";
14
+ import { parseFlowStateRepairArgs, runFlowStateRepair } from "./flow-state-repair.js";
15
+ import { parseWaiverGrantArgs, runWaiverGrant } from "./waiver-grant.js";
16
+ import { FlowStateGuardMismatchError, verifyFlowStateGuard } from "../run-persistence.js";
17
+ import { DelegationTimestampError, DispatchDuplicateError } from "../delegation.js";
18
+ /**
19
+ * Subcommands that mutate or consult flow-state.json via the CLI runtime.
20
+ * They all require the sha256 sidecar to match before continuing so a
21
+ * manual edit hard-blocks with exit code 2 (same contract as the inline
22
+ * hook checks).
23
+ */
24
+ const GUARD_ENFORCED_SUBCOMMANDS = new Set([
25
+ "advance-stage",
26
+ "start-flow",
27
+ "cancel-run",
28
+ "rewind",
29
+ "verify-flow-state-diff",
30
+ "verify-current-state"
31
+ ]);
14
32
  export async function runInternalCommand(projectRoot, argv, io) {
15
33
  const [subcommand, ...tokens] = argv;
16
34
  if (!subcommand) {
17
- io.stderr.write("cclaw internal requires a subcommand: advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook\n");
35
+ io.stderr.write("cclaw internal requires a subcommand: advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant\n");
18
36
  return 1;
19
37
  }
20
38
  try {
39
+ if (GUARD_ENFORCED_SUBCOMMANDS.has(subcommand)) {
40
+ await verifyFlowStateGuard(projectRoot);
41
+ }
21
42
  if (subcommand === "advance-stage") {
22
43
  return await runAdvanceStage(projectRoot, parseAdvanceStageArgs(tokens), io);
23
44
  }
@@ -57,10 +78,28 @@ export async function runInternalCommand(projectRoot, argv, io) {
57
78
  if (subcommand === "hook") {
58
79
  return await runHookCommand(projectRoot, parseHookArgs(tokens), io);
59
80
  }
60
- io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook\n`);
81
+ if (subcommand === "flow-state-repair") {
82
+ return await runFlowStateRepair(projectRoot, parseFlowStateRepairArgs(tokens), io);
83
+ }
84
+ if (subcommand === "waiver-grant") {
85
+ return await runWaiverGrant(projectRoot, parseWaiverGrantArgs(tokens), io);
86
+ }
87
+ io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant\n`);
61
88
  return 1;
62
89
  }
63
90
  catch (err) {
91
+ if (err instanceof FlowStateGuardMismatchError) {
92
+ io.stderr.write(`cclaw internal ${subcommand}: ${err.message}\n`);
93
+ return 2;
94
+ }
95
+ if (err instanceof DelegationTimestampError) {
96
+ io.stderr.write(`error: delegation_timestamp_non_monotonic — ${err.field}: ${err.actual} < ${err.priorBound}\n`);
97
+ return 2;
98
+ }
99
+ if (err instanceof DispatchDuplicateError) {
100
+ io.stderr.write(`error: dispatch_duplicate — ${err.message}\n`);
101
+ return 2;
102
+ }
64
103
  io.stderr.write(`cclaw internal ${subcommand} failed: ${err instanceof Error ? err.message : String(err)}\n`);
65
104
  return 1;
66
105
  }
@@ -0,0 +1,13 @@
1
+ import type { Writable } from "node:stream";
2
+ interface InternalIo {
3
+ stdout: Writable;
4
+ stderr: Writable;
5
+ }
6
+ export interface FlowStateRepairArgs {
7
+ reason: string;
8
+ json: boolean;
9
+ quiet: boolean;
10
+ }
11
+ export declare function parseFlowStateRepairArgs(tokens: string[]): FlowStateRepairArgs;
12
+ export declare function runFlowStateRepair(projectRoot: string, args: FlowStateRepairArgs, io: InternalIo): Promise<number>;
13
+ export {};