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.
- package/dist/artifact-linter/tdd.js +142 -100
- package/dist/delegation.d.ts +55 -0
- package/dist/delegation.js +161 -0
- package/dist/internal/advance-stage.js +9 -1
- package/package.json +1 -1
|
@@ -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
|
|
529
|
-
const
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
}
|
package/dist/delegation.d.ts
CHANGED
|
@@ -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`).
|
package/dist/delegation.js
CHANGED
|
@@ -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;
|