cclaw-cli 7.0.5 → 7.0.6

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.
@@ -481,6 +481,134 @@ function groupBySlice(entries) {
481
481
  }
482
482
  return grouped;
483
483
  }
484
+ /** Group completed phase rows for a slice by `spanId` (falls back to a single legacy bucket). */
485
+ function groupSliceRowsBySpanId(rows) {
486
+ const grouped = new Map();
487
+ for (const entry of rows) {
488
+ const spanKey = typeof entry.spanId === "string" && entry.spanId.length > 0 ? entry.spanId : "__missing-span__";
489
+ const list = grouped.get(spanKey) ?? [];
490
+ list.push(entry);
491
+ grouped.set(spanKey, list);
492
+ }
493
+ return grouped;
494
+ }
495
+ function maxPhaseTimestampForSpan(rows) {
496
+ let max = "";
497
+ for (const entry of rows) {
498
+ const ts = entry.completedTs ?? entry.endTs ?? entry.ts ?? "";
499
+ if (typeof ts === "string" && ts.length > 0 && ts > max)
500
+ max = ts;
501
+ }
502
+ return max;
503
+ }
504
+ /**
505
+ * Validate RED→GREEN→REFACTOR (incl. green `refactorOutcome`) monotonicity for one slice-builder span.
506
+ * `rows` must contain only entries for that span.
507
+ */
508
+ function evaluateSingleSpanSliceCycle(sliceId, spanId, rows) {
509
+ const errors = [];
510
+ const findings = [];
511
+ const sec = (slug) => `${slug}:${sliceId}@${spanId}`;
512
+ const reds = rows.filter((entry) => entry.phase === "red");
513
+ const greens = rows.filter((entry) => entry.phase === "green");
514
+ const refactors = rows.filter((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
515
+ const redTs = pickEventTs(reds);
516
+ const greenTs = pickEventTs(greens);
517
+ if (reds.length === 0) {
518
+ errors.push(`${sliceId}: phase=red event missing.`);
519
+ findings.push({
520
+ section: sec("tdd_slice_red_missing"),
521
+ required: true,
522
+ rule: "Each TDD slice with phase events must include a `phase=red` row.",
523
+ found: false,
524
+ details: `${sliceId} (span ${spanId}): no phase=red event recorded for the active run.`
525
+ });
526
+ return { ok: false, errors, findings };
527
+ }
528
+ if (greens.length === 0) {
529
+ errors.push(`${sliceId}: phase=green event missing.`);
530
+ findings.push({
531
+ section: sec("tdd_slice_green_missing"),
532
+ required: true,
533
+ rule: "Each TDD slice with a phase=red event must reach a `phase=green` row before stage-complete.",
534
+ found: false,
535
+ details: `${sliceId} (span ${spanId}): no phase=green event recorded; RED has no matching GREEN.`
536
+ });
537
+ return { ok: false, errors, findings };
538
+ }
539
+ if (greenTs && redTs && greenTs < redTs) {
540
+ errors.push(`${sliceId}: phase=green completedTs (${greenTs}) precedes phase=red (${redTs}).`);
541
+ findings.push({
542
+ section: sec("tdd_slice_phase_order_invalid"),
543
+ required: true,
544
+ rule: "Phase events must be monotonic: phase=green completedTs >= phase=red completedTs.",
545
+ found: false,
546
+ details: `${sliceId} (span ${spanId}): green at ${greenTs} precedes red at ${redTs}.`
547
+ });
548
+ return { ok: false, errors, findings };
549
+ }
550
+ const greenEvidenceRef = greens
551
+ .flatMap((entry) => (Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs : []))
552
+ .find((ref) => typeof ref === "string" && ref.trim().length > 0);
553
+ if (!greenEvidenceRef) {
554
+ errors.push(`${sliceId}: phase=green row has empty evidenceRefs.`);
555
+ findings.push({
556
+ section: sec("tdd_slice_evidence_missing"),
557
+ required: true,
558
+ rule: "Each `phase=green` event must record at least one evidenceRef (path to test artifact, span id, or pasted-output pointer).",
559
+ found: false,
560
+ details: `${sliceId} (span ${spanId}): phase=green event missing evidenceRefs.`
561
+ });
562
+ return { ok: false, errors, findings };
563
+ }
564
+ const greenWithOutcome = greens.find((entry) => entry.refactorOutcome &&
565
+ (entry.refactorOutcome.mode === "inline" || entry.refactorOutcome.mode === "deferred"));
566
+ if (refactors.length === 0 && !greenWithOutcome) {
567
+ errors.push(`${sliceId}: phase=refactor or phase=refactor-deferred event missing.`);
568
+ findings.push({
569
+ section: sec("tdd_slice_refactor_missing"),
570
+ required: true,
571
+ rule: "Each TDD slice must close with a `phase=refactor` event, a `phase=refactor-deferred` event whose evidenceRefs / refactorRationale captures why refactor was deferred, OR a `phase=green` event carrying `refactorOutcome`.",
572
+ found: false,
573
+ details: `${sliceId} (span ${spanId}): no phase=refactor / phase=refactor-deferred event and no refactorOutcome on phase=green.`
574
+ });
575
+ return { ok: false, errors, findings };
576
+ }
577
+ if (greenWithOutcome &&
578
+ greenWithOutcome.refactorOutcome?.mode === "deferred" &&
579
+ !greenWithOutcome.refactorOutcome.rationale &&
580
+ !(Array.isArray(greenWithOutcome.evidenceRefs) &&
581
+ greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0))) {
582
+ errors.push(`${sliceId}: phase=green refactorOutcome=deferred missing rationale.`);
583
+ findings.push({
584
+ section: sec("tdd_slice_refactor_missing"),
585
+ required: true,
586
+ rule: "phase=green refactorOutcome=deferred requires a rationale (via --refactor-rationale or --evidence-ref).",
587
+ found: false,
588
+ details: `${sliceId} (span ${spanId}): phase=green refactorOutcome.mode=deferred recorded without rationale.`
589
+ });
590
+ return { ok: false, errors, findings };
591
+ }
592
+ const deferred = refactors.find((entry) => entry.phase === "refactor-deferred");
593
+ if (refactors.length > 0 &&
594
+ deferred &&
595
+ refactors.every((entry) => entry.phase === "refactor-deferred")) {
596
+ const refs = Array.isArray(deferred.evidenceRefs) ? deferred.evidenceRefs : [];
597
+ const hasRationale = refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
598
+ if (!hasRationale) {
599
+ errors.push(`${sliceId}: phase=refactor-deferred row needs evidenceRefs containing a rationale.`);
600
+ findings.push({
601
+ section: sec("tdd_slice_refactor_missing"),
602
+ required: true,
603
+ rule: "phase=refactor-deferred must record a rationale via --refactor-rationale or via --evidence-ref pointing at the rationale text.",
604
+ found: false,
605
+ details: `${sliceId} (span ${spanId}): phase=refactor-deferred recorded without rationale evidenceRefs.`
606
+ });
607
+ return { ok: false, errors, findings };
608
+ }
609
+ }
610
+ return { ok: true, errors: [], findings: [] };
611
+ }
484
612
  export function evaluateEventsWatchedRed(slices) {
485
613
  const errors = [];
486
614
  let redCount = 0;
@@ -525,109 +653,23 @@ export function evaluateEventsSliceCycle(slices) {
525
653
  const errors = [];
526
654
  const findings = [];
527
655
  for (const [sliceId, rows] of slices.entries()) {
528
- const reds = rows.filter((entry) => entry.phase === "red");
529
- const greens = rows.filter((entry) => entry.phase === "green");
530
- const refactors = rows.filter((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
531
- const redTs = pickEventTs(reds);
532
- const greenTs = pickEventTs(greens);
533
- if (reds.length === 0) {
534
- errors.push(`${sliceId}: phase=red event missing.`);
535
- findings.push({
536
- section: `tdd_slice_red_missing:${sliceId}`,
537
- required: true,
538
- rule: "Each TDD slice with phase events must include a `phase=red` row.",
539
- found: false,
540
- details: `${sliceId}: no phase=red event recorded for the active run.`
541
- });
542
- continue;
543
- }
544
- if (greens.length === 0) {
545
- errors.push(`${sliceId}: phase=green event missing.`);
546
- findings.push({
547
- section: `tdd_slice_green_missing:${sliceId}`,
548
- required: true,
549
- rule: "Each TDD slice with a phase=red event must reach a `phase=green` row before stage-complete.",
550
- found: false,
551
- details: `${sliceId}: no phase=green event recorded; RED has no matching GREEN.`
552
- });
553
- continue;
554
- }
555
- if (greenTs && redTs && greenTs < redTs) {
556
- errors.push(`${sliceId}: phase=green completedTs (${greenTs}) precedes phase=red (${redTs}).`);
557
- findings.push({
558
- section: `tdd_slice_phase_order_invalid:${sliceId}`,
559
- required: true,
560
- rule: "Phase events must be monotonic: phase=green completedTs >= phase=red completedTs.",
561
- found: false,
562
- details: `${sliceId}: green at ${greenTs} precedes red at ${redTs}.`
656
+ const bySpan = groupSliceRowsBySpanId(rows);
657
+ const spanOutcomes = [];
658
+ for (const [spanId, spanRows] of bySpan.entries()) {
659
+ const result = evaluateSingleSpanSliceCycle(sliceId, spanId, spanRows);
660
+ spanOutcomes.push({
661
+ spanId,
662
+ maxTs: maxPhaseTimestampForSpan(spanRows),
663
+ result
563
664
  });
564
- continue;
565
665
  }
566
- const greenEvidenceRef = greens
567
- .flatMap((entry) => Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs : [])
568
- .find((ref) => typeof ref === "string" && ref.trim().length > 0);
569
- if (!greenEvidenceRef) {
570
- errors.push(`${sliceId}: phase=green row has empty evidenceRefs.`);
571
- findings.push({
572
- section: `tdd_slice_evidence_missing:${sliceId}`,
573
- required: true,
574
- rule: "Each `phase=green` event must record at least one evidenceRef (path to test artifact, span id, or pasted-output pointer).",
575
- found: false,
576
- details: `${sliceId}: phase=green event missing evidenceRefs.`
577
- });
666
+ if (spanOutcomes.some((s) => s.result.ok)) {
578
667
  continue;
579
668
  }
580
- // refactorOutcome on phase=green satisfies REFACTOR coverage
581
- // without a separate phase=refactor / phase=refactor-deferred row.
582
- // - mode: "inline" → REFACTOR ran inline as part of GREEN.
583
- // - mode: "deferred" → rationale required (carried in evidenceRefs[0]
584
- // by the hook helper so legacy linters keep working).
585
- const greenWithOutcome = greens.find((entry) => entry.refactorOutcome &&
586
- (entry.refactorOutcome.mode === "inline" || entry.refactorOutcome.mode === "deferred"));
587
- if (refactors.length === 0 && !greenWithOutcome) {
588
- errors.push(`${sliceId}: phase=refactor or phase=refactor-deferred event missing.`);
589
- findings.push({
590
- section: `tdd_slice_refactor_missing:${sliceId}`,
591
- required: true,
592
- rule: "Each TDD slice must close with a `phase=refactor` event, a `phase=refactor-deferred` event whose evidenceRefs / refactorRationale captures why refactor was deferred, OR a `phase=green` event carrying `refactorOutcome`.",
593
- found: false,
594
- details: `${sliceId}: no phase=refactor / phase=refactor-deferred event and no refactorOutcome on phase=green.`
595
- });
596
- continue;
597
- }
598
- if (greenWithOutcome &&
599
- greenWithOutcome.refactorOutcome?.mode === "deferred" &&
600
- !greenWithOutcome.refactorOutcome.rationale &&
601
- !(Array.isArray(greenWithOutcome.evidenceRefs) &&
602
- greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0))) {
603
- errors.push(`${sliceId}: phase=green refactorOutcome=deferred missing rationale.`);
604
- findings.push({
605
- section: `tdd_slice_refactor_missing:${sliceId}`,
606
- required: true,
607
- rule: "phase=green refactorOutcome=deferred requires a rationale (via --refactor-rationale or --evidence-ref).",
608
- found: false,
609
- details: `${sliceId}: phase=green refactorOutcome.mode=deferred recorded without rationale.`
610
- });
611
- continue;
612
- }
613
- const deferred = refactors.find((entry) => entry.phase === "refactor-deferred");
614
- if (refactors.length > 0 &&
615
- deferred &&
616
- refactors.every((entry) => entry.phase === "refactor-deferred")) {
617
- const refs = Array.isArray(deferred.evidenceRefs) ? deferred.evidenceRefs : [];
618
- const hasRationale = refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
619
- if (!hasRationale) {
620
- errors.push(`${sliceId}: phase=refactor-deferred row needs evidenceRefs containing a rationale.`);
621
- findings.push({
622
- section: `tdd_slice_refactor_missing:${sliceId}`,
623
- required: true,
624
- rule: "phase=refactor-deferred must record a rationale via --refactor-rationale or via --evidence-ref pointing at the rationale text.",
625
- found: false,
626
- details: `${sliceId}: phase=refactor-deferred recorded without rationale evidenceRefs.`
627
- });
628
- continue;
629
- }
630
- }
669
+ spanOutcomes.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : 0));
670
+ const chosen = spanOutcomes[0];
671
+ errors.push(...chosen.result.errors);
672
+ findings.push(...chosen.result.findings);
631
673
  }
632
674
  if (errors.length > 0) {
633
675
  return {
@@ -638,7 +680,7 @@ export function evaluateEventsSliceCycle(slices) {
638
680
  }
639
681
  return {
640
682
  ok: true,
641
- details: `${slices.size} slice(s) show monotonic phase=red -> phase=green -> phase=refactor (deferred-with-rationale accepted).`,
683
+ details: `${slices.size} slice(s) show monotonic phase=red -> phase=green -> phase=refactor (deferred-with-rationale accepted); at least one span per slice satisfies the cycle.`,
642
684
  findings: []
643
685
  };
644
686
  }
@@ -344,6 +344,61 @@ export declare class DispatchCapError extends Error {
344
344
  };
345
345
  });
346
346
  }
347
+ /**
348
+ * Return `true` when `path` is a repo-relative path owned by the cclaw
349
+ * managed runtime under `.cclaw/`. Used by `validateClaimedPathsNotProtected`
350
+ * during `appendDelegation` to reject `slice-builder` (or any worker)
351
+ * spans that try to claim ownership of cclaw-managed files. Does not
352
+ * normalise the input — callers pass the path exactly as the worker wrote
353
+ * it into `claimedPaths` so the error message points at the real string.
354
+ */
355
+ export declare function isManagedRuntimePath(path: string): boolean;
356
+ /**
357
+ * Thrown by `appendDelegation` when a scheduled span declares a
358
+ * `claimedPaths` entry that lives under the cclaw managed runtime
359
+ * (see `isManagedRuntimePath`). Workers must never edit those paths
360
+ * directly — they are owned by the managed sync surface. The error
361
+ * lists the offending paths so the operator can drop or rewrite them.
362
+ */
363
+ export declare class DispatchClaimedPathProtectedError extends Error {
364
+ readonly protectedPaths: string[];
365
+ readonly spanId: string;
366
+ constructor(params: {
367
+ protectedPaths: string[];
368
+ spanId: string;
369
+ });
370
+ }
371
+ /**
372
+ * Reject any worker span that declares `claimedPaths` entries owned by
373
+ * the cclaw managed runtime. Called from `appendDelegation` for
374
+ * `status === "scheduled"` rows alongside the overlap and fan-out
375
+ * checks. Throws `DispatchClaimedPathProtectedError` listing every
376
+ * offending path so the operator can fix the dispatch in one pass.
377
+ */
378
+ export declare function validateClaimedPathsNotProtected(stamped: DelegationEntry): void;
379
+ /**
380
+ * Thrown by `appendDelegation` when a new `scheduled` span would open a
381
+ * second TDD cycle for a slice that already has at least one closed span
382
+ * (a span with completed phase rows for `red`, `green`, at least one of
383
+ * `refactor`/`refactor-deferred`, and `doc`) in the same run. Re-running
384
+ * a slice under a fresh span is almost always controller drift —
385
+ * legitimate replay reuses the original spanId and is absorbed by the
386
+ * existing dedup. Motivated by the hox-session 7.0.5 finding where
387
+ * `S-36` had two scheduled spans (`span-w07-S-36-final` and `span-w07-S-36`)
388
+ * that the linter then misread as out-of-order phases.
389
+ */
390
+ export declare class SliceAlreadyClosedError extends Error {
391
+ readonly sliceId: string;
392
+ readonly runId: string;
393
+ readonly closedSpanId: string;
394
+ readonly newSpanId: string;
395
+ constructor(params: {
396
+ sliceId: string;
397
+ runId: string;
398
+ closedSpanId: string;
399
+ newSpanId: string;
400
+ });
401
+ }
347
402
  /**
348
403
  * Default cap on active `slice-builder` spans in a single TDD run. Override
349
404
  * via `CCLAW_MAX_PARALLEL_SLICE_BUILDERS=<int>` (validated `>=1`).
@@ -630,6 +630,151 @@ export class DispatchCapError extends Error {
630
630
  this.pair = params.pair;
631
631
  }
632
632
  }
633
+ /**
634
+ * Patterns describing repo-relative paths owned by the cclaw managed
635
+ * runtime under `.cclaw/`. Workers MUST NOT claim these as
636
+ * `claimedPaths` because they are regenerated/rebound by `cclaw-cli sync`
637
+ * (and similar managed flows), and worker writes silently bypass the
638
+ * managed-resources manifest. Note: `.cclaw/artifacts/` is intentionally
639
+ * NOT protected — slice-builders legitimately write slice cards there.
640
+ *
641
+ * Motivated by the hox-session 7.0.5 finding: subagent S-36 hand-edited
642
+ * `.cclaw/hooks/delegation-record.mjs`, which had to be reverted because
643
+ * the next `cclaw-cli sync` would have stomped the change.
644
+ */
645
+ const MANAGED_RUNTIME_PATH_PATTERNS = [
646
+ /^\.cclaw\/(hooks|agents|skills|commands|templates|seeds|rules|state)\//u,
647
+ /^\.cclaw\/config\.yaml$/u,
648
+ /^\.cclaw\/managed-resources\.json$/u,
649
+ /^\.cclaw\/\.flow-state\.guard\.json$/u
650
+ ];
651
+ /**
652
+ * Return `true` when `path` is a repo-relative path owned by the cclaw
653
+ * managed runtime under `.cclaw/`. Used by `validateClaimedPathsNotProtected`
654
+ * during `appendDelegation` to reject `slice-builder` (or any worker)
655
+ * spans that try to claim ownership of cclaw-managed files. Does not
656
+ * normalise the input — callers pass the path exactly as the worker wrote
657
+ * it into `claimedPaths` so the error message points at the real string.
658
+ */
659
+ export function isManagedRuntimePath(path) {
660
+ if (typeof path !== "string" || path.length === 0)
661
+ return false;
662
+ return MANAGED_RUNTIME_PATH_PATTERNS.some((pattern) => pattern.test(path));
663
+ }
664
+ /**
665
+ * Thrown by `appendDelegation` when a scheduled span declares a
666
+ * `claimedPaths` entry that lives under the cclaw managed runtime
667
+ * (see `isManagedRuntimePath`). Workers must never edit those paths
668
+ * directly — they are owned by the managed sync surface. The error
669
+ * lists the offending paths so the operator can drop or rewrite them.
670
+ */
671
+ export class DispatchClaimedPathProtectedError extends Error {
672
+ protectedPaths;
673
+ spanId;
674
+ constructor(params) {
675
+ super(`dispatch_claimed_path_protected — span ${params.spanId} claims managed-runtime path(s) ${params.protectedPaths.join(", ")}; ` +
676
+ `paths under .cclaw/{hooks,agents,skills,commands,templates,seeds,rules,state}/, .cclaw/config.yaml, .cclaw/managed-resources.json, and .cclaw/.flow-state.guard.json are owned by cclaw-cli sync and must not appear in claimedPaths. ` +
677
+ `Drop them from claimedPaths or, if a managed-runtime change is genuinely required, ship it through a cclaw release rather than a worker span.`);
678
+ this.name = "DispatchClaimedPathProtectedError";
679
+ this.protectedPaths = params.protectedPaths;
680
+ this.spanId = params.spanId;
681
+ }
682
+ }
683
+ /**
684
+ * Reject any worker span that declares `claimedPaths` entries owned by
685
+ * the cclaw managed runtime. Called from `appendDelegation` for
686
+ * `status === "scheduled"` rows alongside the overlap and fan-out
687
+ * checks. Throws `DispatchClaimedPathProtectedError` listing every
688
+ * offending path so the operator can fix the dispatch in one pass.
689
+ */
690
+ export function validateClaimedPathsNotProtected(stamped) {
691
+ const claimed = Array.isArray(stamped.claimedPaths) ? stamped.claimedPaths : [];
692
+ if (claimed.length === 0)
693
+ return;
694
+ const offending = claimed.filter((p) => isManagedRuntimePath(p));
695
+ if (offending.length === 0)
696
+ return;
697
+ throw new DispatchClaimedPathProtectedError({
698
+ protectedPaths: offending,
699
+ spanId: stamped.spanId ?? "unknown"
700
+ });
701
+ }
702
+ /**
703
+ * Thrown by `appendDelegation` when a new `scheduled` span would open a
704
+ * second TDD cycle for a slice that already has at least one closed span
705
+ * (a span with completed phase rows for `red`, `green`, at least one of
706
+ * `refactor`/`refactor-deferred`, and `doc`) in the same run. Re-running
707
+ * a slice under a fresh span is almost always controller drift —
708
+ * legitimate replay reuses the original spanId and is absorbed by the
709
+ * existing dedup. Motivated by the hox-session 7.0.5 finding where
710
+ * `S-36` had two scheduled spans (`span-w07-S-36-final` and `span-w07-S-36`)
711
+ * that the linter then misread as out-of-order phases.
712
+ */
713
+ export class SliceAlreadyClosedError extends Error {
714
+ sliceId;
715
+ runId;
716
+ closedSpanId;
717
+ newSpanId;
718
+ constructor(params) {
719
+ super(`slice ${params.sliceId} already has a closed span (${params.closedSpanId}); refusing to schedule new span ${params.newSpanId} in run ${params.runId}`);
720
+ this.name = "SliceAlreadyClosedError";
721
+ this.sliceId = params.sliceId;
722
+ this.runId = params.runId;
723
+ this.closedSpanId = params.closedSpanId;
724
+ this.newSpanId = params.newSpanId;
725
+ }
726
+ }
727
+ /**
728
+ * Detect closed spans for `(sliceId, runId)`. A span is considered
729
+ * closed when it has completed phase rows for `red`, `green`, REFACTOR
730
+ * coverage (either `phase=refactor`, `phase=refactor-deferred`, or
731
+ * `phase=green` carrying `refactorOutcome`), AND `doc`. Returns the set of
732
+ * closed spanIds; callers use this to reject new scheduled spans on
733
+ * already-closed slices.
734
+ */
735
+ function closedSliceSpans(prior, sliceId, runId) {
736
+ const closed = new Set();
737
+ if (typeof sliceId !== "string" || sliceId.length === 0)
738
+ return closed;
739
+ const matches = prior.filter((entry) => entry.sliceId === sliceId &&
740
+ entry.runId === runId &&
741
+ typeof entry.spanId === "string" &&
742
+ entry.spanId.length > 0);
743
+ const bySpan = new Map();
744
+ for (const entry of matches) {
745
+ const spanId = entry.spanId;
746
+ const existing = bySpan.get(spanId) ?? [];
747
+ existing.push(entry);
748
+ bySpan.set(spanId, existing);
749
+ }
750
+ for (const [spanId, entries] of bySpan.entries()) {
751
+ const phases = new Set(entries
752
+ .filter((e) => e.status === "completed" && typeof e.phase === "string")
753
+ .map((e) => e.phase));
754
+ const hasRed = phases.has("red");
755
+ const hasGreen = phases.has("green");
756
+ const hasRefactorPhase = phases.has("refactor") || phases.has("refactor-deferred");
757
+ const greens = entries.filter((e) => e.status === "completed" && e.phase === "green");
758
+ const greenWithOutcome = greens.find((e) => e.refactorOutcome &&
759
+ (e.refactorOutcome.mode === "inline" || e.refactorOutcome.mode === "deferred"));
760
+ let hasRefactorFromGreen = false;
761
+ if (greenWithOutcome?.refactorOutcome?.mode === "deferred") {
762
+ hasRefactorFromGreen = !!((greenWithOutcome.refactorOutcome.rationale &&
763
+ greenWithOutcome.refactorOutcome.rationale.trim().length > 0) ||
764
+ (Array.isArray(greenWithOutcome.evidenceRefs) &&
765
+ greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0)));
766
+ }
767
+ else if (greenWithOutcome?.refactorOutcome?.mode === "inline") {
768
+ hasRefactorFromGreen = true;
769
+ }
770
+ const hasRefactor = hasRefactorPhase || hasRefactorFromGreen;
771
+ const hasDoc = phases.has("doc");
772
+ if (hasRed && hasGreen && hasRefactor && hasDoc) {
773
+ closed.add(spanId);
774
+ }
775
+ }
776
+ return closed;
777
+ }
633
778
  /**
634
779
  * Default cap on active `slice-builder` spans in a single TDD run. Override
635
780
  * via `CCLAW_MAX_PARALLEL_SLICE_BUILDERS=<int>` (validated `>=1`).
@@ -1037,7 +1182,23 @@ export async function appendDelegation(projectRoot, entry) {
1037
1182
  return;
1038
1183
  }
1039
1184
  validateMonotonicTimestamps(stamped, prior.entries);
1185
+ if (stamped.status === "scheduled" &&
1186
+ typeof stamped.sliceId === "string" &&
1187
+ stamped.sliceId.length > 0 &&
1188
+ stamped.phase === undefined) {
1189
+ const closed = closedSliceSpans(prior.entries, stamped.sliceId, activeRunId);
1190
+ if (closed.size > 0 && !(stamped.spanId && closed.has(stamped.spanId))) {
1191
+ const closedSpanId = closed.values().next().value;
1192
+ throw new SliceAlreadyClosedError({
1193
+ sliceId: stamped.sliceId,
1194
+ runId: activeRunId,
1195
+ closedSpanId,
1196
+ newSpanId: stamped.spanId ?? "unknown"
1197
+ });
1198
+ }
1199
+ }
1040
1200
  if (stamped.status === "scheduled") {
1201
+ validateClaimedPathsNotProtected(stamped);
1041
1202
  const sameRunPrior = prior.entries.filter((entry) => entry.runId === activeRunId);
1042
1203
  const activeForRun = computeActiveSubagents(sameRunPrior);
1043
1204
  const overlap = validateFileOverlap(stamped, activeForRun);
@@ -14,7 +14,7 @@ import { parseAdvanceStageArgs, parseCancelRunArgs, parseHookArgs, parseRewindAr
14
14
  import { parseFlowStateRepairArgs, runFlowStateRepair } from "./flow-state-repair.js";
15
15
  import { parseWaiverGrantArgs, runWaiverGrant } from "./waiver-grant.js";
16
16
  import { FlowStateGuardMismatchError, verifyFlowStateGuard } from "../run-persistence.js";
17
- import { DelegationTimestampError, DispatchCapError, DispatchDuplicateError, DispatchOverlapError } from "../delegation.js";
17
+ import { DelegationTimestampError, DispatchCapError, DispatchClaimedPathProtectedError, DispatchDuplicateError, DispatchOverlapError, SliceAlreadyClosedError } from "../delegation.js";
18
18
  import { parsePlanSplitWavesArgs, runPlanSplitWaves } from "./plan-split-waves.js";
19
19
  import { runWaveStatusCommand } from "./wave-status.js";
20
20
  import { runCohesionContractCommand } from "./cohesion-contract-stub.js";
@@ -116,6 +116,14 @@ export async function runInternalCommand(projectRoot, argv, io) {
116
116
  io.stderr.write(`error: dispatch_overlap — ${err.message}\n`);
117
117
  return 2;
118
118
  }
119
+ if (err instanceof DispatchClaimedPathProtectedError) {
120
+ io.stderr.write(`error: dispatch_claimed_path_protected — ${err.message}\n`);
121
+ return 2;
122
+ }
123
+ if (err instanceof SliceAlreadyClosedError) {
124
+ io.stderr.write(`error: slice_already_closed — ${err.message}\n`);
125
+ return 2;
126
+ }
119
127
  if (err instanceof DispatchCapError) {
120
128
  io.stderr.write(`error: dispatch_cap — ${err.message}\n`);
121
129
  return 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "7.0.5",
3
+ "version": "7.0.6",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {