cclaw-cli 6.12.0 → 6.13.1

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 (36) hide show
  1. package/dist/artifact-linter/plan.js +60 -2
  2. package/dist/artifact-linter/shared.d.ts +9 -0
  3. package/dist/artifact-linter/spec.js +14 -0
  4. package/dist/artifact-linter/tdd.d.ts +19 -6
  5. package/dist/artifact-linter/tdd.js +225 -47
  6. package/dist/artifact-linter.js +10 -1
  7. package/dist/content/hooks.js +88 -1
  8. package/dist/content/skills.js +17 -10
  9. package/dist/content/stages/plan.js +2 -1
  10. package/dist/content/stages/spec.js +2 -2
  11. package/dist/content/stages/tdd.js +7 -6
  12. package/dist/content/start-command.js +6 -3
  13. package/dist/content/templates.js +10 -4
  14. package/dist/delegation.d.ts +82 -3
  15. package/dist/delegation.js +244 -6
  16. package/dist/flow-state.d.ts +20 -0
  17. package/dist/flow-state.js +7 -0
  18. package/dist/gate-evidence.d.ts +5 -0
  19. package/dist/gate-evidence.js +58 -1
  20. package/dist/install.js +90 -2
  21. package/dist/integration-fanin.d.ts +44 -0
  22. package/dist/integration-fanin.js +180 -0
  23. package/dist/internal/advance-stage/advance.js +16 -1
  24. package/dist/internal/advance-stage/start-flow.js +3 -1
  25. package/dist/internal/advance-stage.js +13 -4
  26. package/dist/internal/plan-split-waves.d.ts +85 -1
  27. package/dist/internal/plan-split-waves.js +409 -6
  28. package/dist/internal/set-worktree-mode.d.ts +10 -0
  29. package/dist/internal/set-worktree-mode.js +28 -0
  30. package/dist/managed-resources.js +2 -0
  31. package/dist/run-persistence.js +9 -0
  32. package/dist/worktree-manager.d.ts +50 -0
  33. package/dist/worktree-manager.js +136 -0
  34. package/dist/worktree-types.d.ts +36 -0
  35. package/dist/worktree-types.js +6 -0
  36. package/package.json +1 -1
@@ -8,6 +8,8 @@ import { exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
8
8
  import { HARNESS_ADAPTERS } from "./harness-adapters.js";
9
9
  import { readFlowState } from "./runs.js";
10
10
  import { mandatoryAgentsFor, stageSchema } from "./content/stage-schema.js";
11
+ import { effectiveWorktreeExecutionMode } from "./flow-state.js";
12
+ import { compareCanonicalUnitIds, mergeParallelWaveDefinitions, parseImplementationUnitParallelFields, parseImplementationUnits, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "./internal/plan-split-waves.js";
11
13
  const execFileAsync = promisify(execFile);
12
14
  const TERMINAL_DELEGATION_STATUSES = new Set(["completed", "failed", "waived", "stale"]);
13
15
  export const DELEGATION_DISPATCH_SURFACES = [
@@ -43,7 +45,8 @@ export const DELEGATION_PHASES = [
43
45
  "green",
44
46
  "refactor",
45
47
  "refactor-deferred",
46
- "doc"
48
+ "doc",
49
+ "resolve-conflict"
47
50
  ];
48
51
  export const DELEGATION_LEDGER_SCHEMA_VERSION = 3;
49
52
  function delegationLogPath(projectRoot) {
@@ -238,7 +241,23 @@ function isDelegationEntry(value) {
238
241
  (typeof o.sliceId === "string" && o.sliceId.length > 0)) &&
239
242
  (o.phase === undefined ||
240
243
  (typeof o.phase === "string" &&
241
- DELEGATION_PHASES.includes(o.phase))));
244
+ DELEGATION_PHASES.includes(o.phase))) &&
245
+ (o.claimToken === undefined || typeof o.claimToken === "string") &&
246
+ (o.ownerLaneId === undefined || typeof o.ownerLaneId === "string") &&
247
+ (o.leasedUntil === undefined || typeof o.leasedUntil === "string") &&
248
+ (o.leaseState === undefined ||
249
+ o.leaseState === "claimed" ||
250
+ o.leaseState === "expired" ||
251
+ o.leaseState === "released" ||
252
+ o.leaseState === "reclaimed") &&
253
+ (o.dependsOn === undefined ||
254
+ (Array.isArray(o.dependsOn) && o.dependsOn.every((item) => typeof item === "string"))) &&
255
+ (o.integrationState === undefined ||
256
+ o.integrationState === "pending" ||
257
+ o.integrationState === "applied" ||
258
+ o.integrationState === "conflict" ||
259
+ o.integrationState === "resolved" ||
260
+ o.integrationState === "abandoned"));
242
261
  }
243
262
  function isDelegationDispatchSurface(value) {
244
263
  return typeof value === "string" && DELEGATION_DISPATCH_SURFACES.includes(value);
@@ -348,7 +367,12 @@ export async function readDelegationLedger(projectRoot) {
348
367
  const NON_DELEGATION_AUDIT_EVENTS = new Set([
349
368
  "mandatory_delegations_skipped_by_track",
350
369
  "artifact_validation_demoted_by_track",
351
- "expansion_strategist_skipped_by_track"
370
+ "expansion_strategist_skipped_by_track",
371
+ "cclaw_slice_lease_expired",
372
+ "cclaw_fanin_applied",
373
+ "cclaw_fanin_conflict",
374
+ "cclaw_fanin_resolved",
375
+ "cclaw_fanin_abandoned"
352
376
  ]);
353
377
  function isAuditEventLine(parsed) {
354
378
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
@@ -356,13 +380,47 @@ function isAuditEventLine(parsed) {
356
380
  const evt = parsed.event;
357
381
  return typeof evt === "string" && NON_DELEGATION_AUDIT_EVENTS.has(evt);
358
382
  }
383
+ const FAN_IN_AUDIT_EVENT_KINDS = new Set([
384
+ "cclaw_fanin_applied",
385
+ "cclaw_fanin_conflict",
386
+ "cclaw_fanin_resolved",
387
+ "cclaw_fanin_abandoned"
388
+ ]);
389
+ function isFanInAuditRecord(value) {
390
+ if (!value || typeof value !== "object" || Array.isArray(value))
391
+ return false;
392
+ const o = value;
393
+ const evt = o.event;
394
+ if (typeof evt !== "string" || !FAN_IN_AUDIT_EVENT_KINDS.has(evt)) {
395
+ return false;
396
+ }
397
+ return typeof o.ts === "string" && o.ts.length > 0;
398
+ }
399
+ /**
400
+ * Append a deterministic fan-in audit row (not a delegation lifecycle event).
401
+ */
402
+ export async function recordCclawFanInAudit(projectRoot, params) {
403
+ const filePath = delegationEventsPath(projectRoot);
404
+ const payload = {
405
+ event: params.kind,
406
+ runId: params.runId,
407
+ laneId: params.laneId,
408
+ sliceIds: params.sliceIds,
409
+ integrationBranch: params.integrationBranch,
410
+ details: params.details,
411
+ ts: new Date().toISOString()
412
+ };
413
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
414
+ await fs.appendFile(filePath, `${JSON.stringify(payload)}\n`, { encoding: "utf8", mode: 0o600 });
415
+ }
359
416
  export async function readDelegationEvents(projectRoot) {
360
417
  const filePath = delegationEventsPath(projectRoot);
361
418
  if (!(await exists(filePath))) {
362
- return { events: [], corruptLines: [] };
419
+ return { events: [], corruptLines: [], fanInAudits: [] };
363
420
  }
364
421
  const events = [];
365
422
  const corruptLines = [];
423
+ const fanInAudits = [];
366
424
  const text = await fs.readFile(filePath, "utf8").catch(() => "");
367
425
  const lines = text.split(/\r?\n/gu);
368
426
  for (let index = 0; index < lines.length; index += 1) {
@@ -374,6 +432,9 @@ export async function readDelegationEvents(projectRoot) {
374
432
  if (isDelegationEvent(parsed)) {
375
433
  events.push(parsed);
376
434
  }
435
+ else if (isFanInAuditRecord(parsed)) {
436
+ fanInAudits.push(parsed);
437
+ }
377
438
  else if (isAuditEventLine(parsed)) {
378
439
  // Wave 24 audit-only row (e.g. mandatory_delegations_skipped_by_track).
379
440
  // Not a delegation lifecycle event but valid audit content.
@@ -387,7 +448,7 @@ export async function readDelegationEvents(projectRoot) {
387
448
  corruptLines.push(index + 1);
388
449
  }
389
450
  }
390
- return { events, corruptLines };
451
+ return { events, corruptLines, fanInAudits };
391
452
  }
392
453
  async function appendDelegationEvent(projectRoot, event) {
393
454
  const filePath = delegationEventsPath(projectRoot);
@@ -605,12 +666,118 @@ export class DispatchCapError extends Error {
605
666
  this.pair = params.pair;
606
667
  }
607
668
  }
669
+ /**
670
+ * v6.13.0 — claim / lease contract violation for worktree-first TDD rows.
671
+ */
672
+ export class DispatchClaimInvalidError extends Error {
673
+ constructor(message) {
674
+ super(message);
675
+ this.name = "DispatchClaimInvalidError";
676
+ }
677
+ }
608
678
  /**
609
679
  * v6.10.0 (P2) — default cap on active `slice-implementer` spans in a
610
680
  * single TDD run. Aligned with evanflow's parallel cap. Override via
611
681
  * `CCLAW_MAX_PARALLEL_SLICE_IMPLEMENTERS=<int>` (validated `>=1`).
612
682
  */
613
683
  export const MAX_PARALLEL_SLICE_IMPLEMENTERS = 5;
684
+ /**
685
+ * Return up to `cap` slice units whose dependsOn are satisfied, avoiding
686
+ * `claimedPaths` intersections with already-selected units and active holders.
687
+ */
688
+ export function selectReadySlices(units, opts) {
689
+ const pool = opts.legacyContinuation ? units.filter((u) => u.parallelizable) : units;
690
+ const ordered = [...pool].sort((a, b) => compareCanonicalUnitIds(a.unitId, b.unitId));
691
+ const selected = [];
692
+ const blockedPaths = new Set();
693
+ for (const holder of opts.activePathHolders) {
694
+ for (const p of holder.paths) {
695
+ blockedPaths.add(p);
696
+ }
697
+ }
698
+ for (const u of ordered) {
699
+ if (opts.completedUnitIds.has(u.unitId))
700
+ continue;
701
+ if (!u.dependsOn.every((d) => opts.completedUnitIds.has(d)))
702
+ continue;
703
+ let clash = false;
704
+ for (const p of u.claimedPaths) {
705
+ if (blockedPaths.has(p)) {
706
+ clash = true;
707
+ break;
708
+ }
709
+ }
710
+ if (clash)
711
+ continue;
712
+ for (const v of selected) {
713
+ for (const pu of u.claimedPaths) {
714
+ if (v.claimedPaths.includes(pu)) {
715
+ clash = true;
716
+ break;
717
+ }
718
+ }
719
+ if (clash)
720
+ break;
721
+ }
722
+ if (clash)
723
+ continue;
724
+ selected.push(u);
725
+ for (const p of u.claimedPaths) {
726
+ blockedPaths.add(p);
727
+ }
728
+ if (selected.length >= opts.cap)
729
+ break;
730
+ }
731
+ return selected;
732
+ }
733
+ /**
734
+ * v6.13.1 — build scheduler rows from merged parallel wave definitions + plan units.
735
+ */
736
+ export function readySliceUnitsFromMergedWaves(mergedWaves, planMarkdown, options) {
737
+ const units = parseImplementationUnits(planMarkdown);
738
+ const metaByUnit = new Map(units.map((u) => {
739
+ const m = parseImplementationUnitParallelFields(u, options);
740
+ return [m.unitId, m];
741
+ }));
742
+ const sliceSet = new Set();
743
+ for (const w of mergedWaves) {
744
+ for (const m of w.members) {
745
+ sliceSet.add(m.sliceId);
746
+ }
747
+ }
748
+ const out = [];
749
+ for (const sliceId of [...sliceSet].sort((a, b) => a.localeCompare(b))) {
750
+ const member = mergedWaves.flatMap((w) => w.members).find((x) => x.sliceId === sliceId);
751
+ if (!member)
752
+ continue;
753
+ const meta = metaByUnit.get(member.unitId);
754
+ if (!meta) {
755
+ out.push({
756
+ unitId: member.unitId,
757
+ sliceId,
758
+ dependsOn: [],
759
+ claimedPaths: [],
760
+ parallelizable: true
761
+ });
762
+ continue;
763
+ }
764
+ out.push({
765
+ unitId: meta.unitId,
766
+ sliceId,
767
+ dependsOn: meta.dependsOn,
768
+ claimedPaths: meta.claimedPaths,
769
+ parallelizable: meta.parallelizable
770
+ });
771
+ }
772
+ return out;
773
+ }
774
+ /**
775
+ * v6.13.1 — load merged wave plan (Parallel Execution Plan block + wave-plans/) and map to `ReadySliceUnit[]`.
776
+ */
777
+ export async function loadTddReadySlicePool(planMarkdown, artifactsDir, options) {
778
+ const merged = mergeParallelWaveDefinitions(parseParallelExecutionPlanWaves(planMarkdown), await parseWavePlanDirectory(artifactsDir));
779
+ return readySliceUnitsFromMergedWaves(merged, planMarkdown, options);
780
+ }
614
781
  function readMaxParallelOverrideFromEnv() {
615
782
  const raw = process.env.CCLAW_MAX_PARALLEL_SLICE_IMPLEMENTERS;
616
783
  if (typeof raw !== "string" || raw.trim().length === 0)
@@ -740,8 +907,45 @@ async function writeSubagentTracker(projectRoot, entries) {
740
907
  }));
741
908
  await writeFileSafe(subagentsStatePath(projectRoot), `${JSON.stringify({ active, updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
742
909
  }
910
+ function latestClaimTokenForSpan(entries, spanId) {
911
+ if (!spanId)
912
+ return null;
913
+ let latest = null;
914
+ for (const e of entries) {
915
+ if (e.spanId !== spanId)
916
+ continue;
917
+ if (typeof e.claimToken === "string" && e.claimToken.trim().length > 0) {
918
+ latest = e.claimToken.trim();
919
+ }
920
+ }
921
+ return latest;
922
+ }
923
+ function assertSliceClaimInvariant(flow, stamped, prior) {
924
+ if (stamped.stage !== "tdd" || stamped.agent !== "slice-implementer")
925
+ return;
926
+ if (effectiveWorktreeExecutionMode(flow) !== "worktree-first")
927
+ return;
928
+ if (stamped.status === "scheduled" && typeof stamped.sliceId === "string" && stamped.sliceId.length > 0) {
929
+ const tok = stamped.claimToken?.trim() ?? "";
930
+ if (tok.length === 0) {
931
+ throw new DispatchClaimInvalidError("dispatch_claim_invalid — worktree-first requires --claim-token when scheduling slice-implementer with --slice");
932
+ }
933
+ }
934
+ if (!TERMINAL_DELEGATION_STATUSES.has(stamped.status))
935
+ return;
936
+ if (stamped.status === "waived" || stamped.status === "stale")
937
+ return;
938
+ const expected = latestClaimTokenForSpan(prior, stamped.spanId);
939
+ if (!expected)
940
+ return;
941
+ const got = stamped.claimToken?.trim() ?? "";
942
+ if (got !== expected) {
943
+ throw new DispatchClaimInvalidError("dispatch_claim_invalid — claimToken must match the scheduled claim for this span");
944
+ }
945
+ }
743
946
  export async function appendDelegation(projectRoot, entry) {
744
- const { activeRunId } = await readFlowState(projectRoot);
947
+ const flowState = await readFlowState(projectRoot);
948
+ const { activeRunId } = flowState;
745
949
  await withDirectoryLock(delegationLockPath(projectRoot), async () => {
746
950
  const filePath = delegationLogPath(projectRoot);
747
951
  const prior = await readDelegationLedger(projectRoot);
@@ -802,6 +1006,7 @@ export async function appendDelegation(projectRoot, entry) {
802
1006
  if (prior.entries.some((existing) => existing.spanId === stamped.spanId && existing.status === stamped.status)) {
803
1007
  return;
804
1008
  }
1009
+ assertSliceClaimInvariant(flowState, stamped, prior.entries);
805
1010
  validateMonotonicTimestamps(stamped, prior.entries);
806
1011
  if (stamped.status === "scheduled") {
807
1012
  // v6.10.0 (P1+P2): for slice-implementer rows with declared
@@ -839,6 +1044,39 @@ export async function appendDelegation(projectRoot, entry) {
839
1044
  await writeSubagentTracker(projectRoot, ledger.entries);
840
1045
  });
841
1046
  }
1047
+ /**
1048
+ * Scan delegation events for expired `leasedUntil` timestamps and append
1049
+ * best-effort `cclaw_slice_lease_expired` audit rows (one per span/slice key).
1050
+ */
1051
+ export async function reclaimExpiredDelegationClaims(projectRoot, now = new Date()) {
1052
+ const { events } = await readDelegationEvents(projectRoot);
1053
+ const seen = new Set();
1054
+ let count = 0;
1055
+ const ts = now.toISOString();
1056
+ const filePath = delegationEventsPath(projectRoot);
1057
+ const cutoff = Date.parse(ts);
1058
+ for (const e of events) {
1059
+ if (e.leaseState !== "claimed")
1060
+ continue;
1061
+ if (typeof e.leasedUntil !== "string" || e.leasedUntil.length === 0)
1062
+ continue;
1063
+ if (Date.parse(e.leasedUntil) > cutoff)
1064
+ continue;
1065
+ const key = `${e.spanId ?? ""}|${e.sliceId ?? ""}`;
1066
+ if (seen.has(key))
1067
+ continue;
1068
+ seen.add(key);
1069
+ await fs.appendFile(filePath, `${JSON.stringify({
1070
+ event: "cclaw_slice_lease_expired",
1071
+ spanId: e.spanId,
1072
+ sliceId: e.sliceId,
1073
+ leasedUntil: e.leasedUntil,
1074
+ eventTs: ts
1075
+ })}\n`, { encoding: "utf8", mode: 0o600 });
1076
+ count += 1;
1077
+ }
1078
+ return count;
1079
+ }
842
1080
  /**
843
1081
  * Aggregate the fulfillment mode cclaw expects for the active harness set.
844
1082
  * Priority native > generic-dispatch > role-switch > waiver — the best
@@ -136,7 +136,27 @@ export interface FlowState {
136
136
  * sync hasn't visited yet.
137
137
  */
138
138
  tddCutoverSliceId?: string;
139
+ /**
140
+ * v6.13.0 — when `worktree-first` (default for newly initialized runs),
141
+ * slice-implementer work happens in isolated git worktrees with explicit
142
+ * claims/leases and deterministic fan-in integration.
143
+ *
144
+ * Omitted on legacy `flow-state.json` files: treated as `single-tree` via
145
+ * `effectiveWorktreeExecutionMode`.
146
+ */
147
+ worktreeExecutionMode?: "single-tree" | "worktree-first";
148
+ /**
149
+ * v6.13.0 — set by `cclaw-cli sync` when the plan predates parallel-metadata
150
+ * fields. Relaxes some plan linters for existing implementation units and
151
+ * defaults scheduler parallelism to opt-in only for those units.
152
+ */
153
+ legacyContinuation?: boolean;
139
154
  }
155
+ /**
156
+ * Effective worktree mode: legacy state files without the field keep
157
+ * single-tree scheduling to avoid breaking existing runs on upgrade.
158
+ */
159
+ export declare function effectiveWorktreeExecutionMode(state: FlowState): "single-tree" | "worktree-first";
140
160
  export interface StageInteractionHint {
141
161
  skipQuestions?: boolean;
142
162
  sourceStage?: FlowStage;
@@ -44,6 +44,13 @@ export function createInitialCloseoutState() {
44
44
  compoundPromoted: 0
45
45
  };
46
46
  }
47
+ /**
48
+ * Effective worktree mode: legacy state files without the field keep
49
+ * single-tree scheduling to avoid breaking existing runs on upgrade.
50
+ */
51
+ export function effectiveWorktreeExecutionMode(state) {
52
+ return state.worktreeExecutionMode ?? "single-tree";
53
+ }
47
54
  export function isFlowTrack(value) {
48
55
  return typeof value === "string" && FLOW_TRACKS.includes(value);
49
56
  }
@@ -66,6 +66,11 @@ export interface VerifyCurrentStageGateEvidenceOptions {
66
66
  extraStageFlags?: string[];
67
67
  }
68
68
  export declare function verifyCurrentStageGateEvidence(projectRoot: string, flowState: FlowState, options?: VerifyCurrentStageGateEvidenceOptions): Promise<GateEvidenceCheckResult>;
69
+ /**
70
+ * Validates that every lane-backed slice which reached REFACTOR has a matching
71
+ * `cclaw_fanin_applied` audit row for the active run (v6.13.0 worktree-first).
72
+ */
73
+ export declare function verifyTddWorktreeFanInClosure(projectRoot: string, flowState: FlowState): Promise<string[]>;
69
74
  export declare function verifyCompletedStagesGateClosure(flowState: FlowState): CompletedStagesClosureResult;
70
75
  export interface GateReconciliationResult {
71
76
  stage: FlowStage;
@@ -5,7 +5,8 @@ import { ELICITATION_STAGES, evaluateQaLogFloor } from "./artifact-linter/shared
5
5
  import { resolveArtifactPath } from "./artifact-paths.js";
6
6
  import { RUNTIME_ROOT } from "./constants.js";
7
7
  import { stageSchema } from "./content/stage-schema.js";
8
- import { readDelegationLedger } from "./delegation.js";
8
+ import { readDelegationLedger, readDelegationEvents } from "./delegation.js";
9
+ import { effectiveWorktreeExecutionMode } from "./flow-state.js";
9
10
  import { exists } from "./fs-utils.js";
10
11
  import { computeEarlyLoopStatus, isEarlyLoopStage, normalizeEarlyLoopMaxIterations } from "./early-loop.js";
11
12
  import { detectPublicApiChanges } from "./internal/detect-public-api-changes.js";
@@ -487,6 +488,62 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState, opt
487
488
  qaLogFloor
488
489
  };
489
490
  }
491
+ function sliceHasTerminalRefactor(events, runId, sliceId) {
492
+ for (const e of events) {
493
+ if (e.runId !== runId || e.stage !== "tdd" || e.sliceId !== sliceId)
494
+ continue;
495
+ if (e.agent !== "slice-implementer" || e.status !== "completed")
496
+ continue;
497
+ if (e.phase === "refactor" || e.phase === "refactor-deferred")
498
+ return true;
499
+ }
500
+ return false;
501
+ }
502
+ function closedSlicesWithLane(events, runId) {
503
+ const withLane = new Set();
504
+ for (const e of events) {
505
+ if (e.runId !== runId || e.stage !== "tdd")
506
+ continue;
507
+ if (e.agent !== "slice-implementer" || e.status !== "completed" || e.phase !== "green")
508
+ continue;
509
+ if (!e.ownerLaneId?.trim() || !e.sliceId)
510
+ continue;
511
+ withLane.add(e.sliceId);
512
+ }
513
+ const out = new Set();
514
+ for (const sid of withLane) {
515
+ if (sliceHasTerminalRefactor(events, runId, sid))
516
+ out.add(sid);
517
+ }
518
+ return out;
519
+ }
520
+ /**
521
+ * Validates that every lane-backed slice which reached REFACTOR has a matching
522
+ * `cclaw_fanin_applied` audit row for the active run (v6.13.0 worktree-first).
523
+ */
524
+ export async function verifyTddWorktreeFanInClosure(projectRoot, flowState) {
525
+ if (effectiveWorktreeExecutionMode(flowState) !== "worktree-first")
526
+ return [];
527
+ const runId = flowState.activeRunId;
528
+ const { events, fanInAudits } = await readDelegationEvents(projectRoot);
529
+ const needing = closedSlicesWithLane(events, runId);
530
+ if (needing.size === 0)
531
+ return [];
532
+ const applied = new Set();
533
+ for (const a of fanInAudits) {
534
+ if (a.runId !== runId || a.event !== "cclaw_fanin_applied")
535
+ continue;
536
+ for (const s of a.sliceIds ?? [])
537
+ applied.add(s);
538
+ }
539
+ const issues = [];
540
+ for (const sid of needing) {
541
+ if (!applied.has(sid)) {
542
+ issues.push(`tdd worktree fan-in closure: slice ${sid} completed on a lane but cclaw_fanin_applied is missing for run ${runId}.`);
543
+ }
544
+ }
545
+ return issues;
546
+ }
490
547
  export function verifyCompletedStagesGateClosure(flowState) {
491
548
  const issues = [];
492
549
  const openStages = [];
package/dist/install.js CHANGED
@@ -25,7 +25,7 @@ import { LANGUAGE_RULE_PACK_DIR, LEGACY_LANGUAGE_RULE_PACK_FOLDERS } from "./con
25
25
  import { RESEARCH_PLAYBOOKS } from "./content/research-playbooks.js";
26
26
  import { SUBAGENT_CONTEXT_SKILLS } from "./content/subagent-context-skills.js";
27
27
  import { CCLAW_AGENTS } from "./content/core-agents.js";
28
- import { createInitialFlowState } from "./flow-state.js";
28
+ import { createInitialFlowState, effectiveWorktreeExecutionMode } from "./flow-state.js";
29
29
  import { ensureDir, exists, writeFileSafe } from "./fs-utils.js";
30
30
  import { ManagedResourceSession, setActiveManagedResourceSession } from "./managed-resources.js";
31
31
  import { ensureGitignore, removeGitignorePatterns } from "./gitignore.js";
@@ -33,7 +33,8 @@ import { HARNESS_ADAPTERS, harnessShimFileNames, harnessShimSkillNames, syncHarn
33
33
  import { validateHookDocument } from "./hook-schema.js";
34
34
  import { detectHarnesses } from "./init-detect.js";
35
35
  import { classifyCodexHooksFlag, codexConfigPath, readCodexConfig } from "./codex-feature-flag.js";
36
- import { CorruptFlowStateError, ensureRunSystem } from "./runs.js";
36
+ import { CorruptFlowStateError, ensureRunSystem, readFlowState, writeFlowState } from "./runs.js";
37
+ import { PLAN_SPLIT_DEFAULT_WAVE_SIZE, buildParallelExecutionPlanSection, formatNextParallelWaveSyncHint, mergeParallelWaveDefinitions, parseParallelExecutionPlanWaves, parseWavePlanDirectory, planArtifactLacksV613ParallelMetadata, upsertParallelExecutionPlanSection } from "./internal/plan-split-waves.js";
37
38
  import { FLOW_STAGES } from "./types.js";
38
39
  const OPENCODE_PLUGIN_REL_PATH = ".opencode/plugins/cclaw-plugin.mjs";
39
40
  const CURSOR_RULE_REL_PATH = ".cursor/rules/cclaw-workflow.mdc";
@@ -941,6 +942,67 @@ async function applyTddCutoverIfNeeded(projectRoot) {
941
942
  obj.tddCutoverSliceId = cutoverSliceId;
942
943
  await writeFileSafe(flowStatePath, `${JSON.stringify(obj, null, 2)}\n`, { mode: 0o600 });
943
944
  }
945
+ const V613_LEGACY_PLAN_BANNER = "<!-- legacy-continuation: predates v6.13 parallel metadata. New units MAY add dependsOn/claimedPaths/parallelizable; existing units treated as best-effort serial. -->";
946
+ /**
947
+ * v6.13.0 — when `05-plan.md` lacks parallel-metadata bullets on any
948
+ * implementation unit, stamp `flow-state.json::legacyContinuation`, insert
949
+ * a banner + managed Parallel Execution Plan stub, and keep behavior idempotent.
950
+ */
951
+ async function applyPlanLegacyContinuationIfNeeded(projectRoot) {
952
+ const planArtifactPath = runtimePath(projectRoot, "artifacts", "05-plan.md");
953
+ let existingPlan;
954
+ try {
955
+ existingPlan = await fs.readFile(planArtifactPath, "utf8");
956
+ }
957
+ catch {
958
+ return;
959
+ }
960
+ if (!planArtifactLacksV613ParallelMetadata(existingPlan)) {
961
+ return;
962
+ }
963
+ let nextPlan = existingPlan;
964
+ if (!nextPlan.includes("legacy-continuation: predates v6.13")) {
965
+ if (nextPlan.startsWith("---\n")) {
966
+ const fmEnd = nextPlan.indexOf("\n---", 4);
967
+ if (fmEnd >= 0) {
968
+ const fmClose = fmEnd + 4;
969
+ const head = nextPlan.slice(0, fmClose);
970
+ const tail = nextPlan.slice(fmClose);
971
+ nextPlan = `${head}\n\n${V613_LEGACY_PLAN_BANNER}\n${tail}`;
972
+ }
973
+ else {
974
+ nextPlan = `${V613_LEGACY_PLAN_BANNER}\n\n${nextPlan}`;
975
+ }
976
+ }
977
+ else {
978
+ nextPlan = `${V613_LEGACY_PLAN_BANNER}\n\n${nextPlan}`;
979
+ }
980
+ }
981
+ const parallelStub = buildParallelExecutionPlanSection([], PLAN_SPLIT_DEFAULT_WAVE_SIZE);
982
+ if (!nextPlan.includes("<!-- parallel-exec-managed-start -->")) {
983
+ nextPlan = upsertParallelExecutionPlanSection(nextPlan, parallelStub);
984
+ }
985
+ if (nextPlan !== existingPlan) {
986
+ await writeFileSafe(planArtifactPath, nextPlan);
987
+ }
988
+ const flowStatePath = runtimePath(projectRoot, "state", "flow-state.json");
989
+ if (!(await exists(flowStatePath))) {
990
+ return;
991
+ }
992
+ try {
993
+ const state = await readFlowState(projectRoot);
994
+ if (state.legacyContinuation === true) {
995
+ return;
996
+ }
997
+ await writeFlowState(projectRoot, { ...state, legacyContinuation: true }, {
998
+ allowReset: true,
999
+ writerSubsystem: "plan-legacy-continuation-sync"
1000
+ });
1001
+ }
1002
+ catch {
1003
+ // Best-effort: corrupt/missing state is handled elsewhere on sync.
1004
+ }
1005
+ }
944
1006
  async function cleanLegacyArtifacts(projectRoot) {
945
1007
  for (const legacyFolder of DEPRECATED_UTILITY_SKILL_FOLDERS) {
946
1008
  await removeBestEffort(runtimePath(projectRoot, "skills", legacyFolder), true);
@@ -1073,6 +1135,28 @@ async function assertExpectedHarnessShims(projectRoot, harnesses) {
1073
1135
  }
1074
1136
  }
1075
1137
  }
1138
+ async function maybeLogParallelWaveDispatchHint(projectRoot) {
1139
+ const flowPath = runtimePath(projectRoot, "state", "flow-state.json");
1140
+ if (!(await exists(flowPath)))
1141
+ return;
1142
+ try {
1143
+ const state = await readFlowState(projectRoot);
1144
+ if (effectiveWorktreeExecutionMode(state) !== "worktree-first")
1145
+ return;
1146
+ const planPath = runtimePath(projectRoot, "artifacts", "05-plan.md");
1147
+ if (!(await exists(planPath)))
1148
+ return;
1149
+ const planRaw = await fs.readFile(planPath, "utf8");
1150
+ const merged = mergeParallelWaveDefinitions(parseParallelExecutionPlanWaves(planRaw), await parseWavePlanDirectory(runtimePath(projectRoot, "artifacts")));
1151
+ const hint = formatNextParallelWaveSyncHint(merged);
1152
+ if (hint) {
1153
+ process.stdout.write(`cclaw: ${hint}\n`);
1154
+ }
1155
+ }
1156
+ catch {
1157
+ // best-effort note only
1158
+ }
1159
+ }
1076
1160
  async function materializeRuntime(projectRoot, config, forceStateReset, operation = "sync") {
1077
1161
  await warnStaleInitSentinel(projectRoot, operation);
1078
1162
  const sentinelPath = await writeInitSentinel(projectRoot, operation);
@@ -1093,6 +1177,7 @@ async function materializeRuntime(projectRoot, config, forceStateReset, operatio
1093
1177
  await writeState(projectRoot, config, forceStateReset);
1094
1178
  if (operation === "sync" || operation === "upgrade") {
1095
1179
  await applyTddCutoverIfNeeded(projectRoot);
1180
+ await applyPlanLegacyContinuationIfNeeded(projectRoot);
1096
1181
  }
1097
1182
  try {
1098
1183
  await ensureRunSystem(projectRoot, { createIfMissing: false });
@@ -1112,6 +1197,9 @@ async function materializeRuntime(projectRoot, config, forceStateReset, operatio
1112
1197
  await assertExpectedHarnessShims(projectRoot, harnesses);
1113
1198
  await writeCursorWorkflowRule(projectRoot, harnesses);
1114
1199
  await ensureGitignore(projectRoot);
1200
+ if (operation === "sync" || operation === "upgrade") {
1201
+ await maybeLogParallelWaveDispatchHint(projectRoot);
1202
+ }
1115
1203
  await managedSession.commit();
1116
1204
  await fs.unlink(sentinelPath).catch(() => undefined);
1117
1205
  }
@@ -0,0 +1,44 @@
1
+ import { type FlowState } from "./flow-state.js";
2
+ import type { WorktreeLaneId } from "./worktree-types.js";
3
+ export type FanInEventKind = "applied" | "conflict" | "resolved" | "abandoned";
4
+ export interface FanInLaneOptions {
5
+ projectRoot: string;
6
+ /** Lane directory under `.cclaw/worktrees/<laneId>`. */
7
+ laneId: WorktreeLaneId;
8
+ /** Integration branch to receive the patch (must already exist locally). */
9
+ integrationBranch: string;
10
+ /**
11
+ * Baseline ref for `git diff` in the lane (fork point vs integration).
12
+ * When omitted, computed as `git merge-base <integration> HEAD` in the lane.
13
+ */
14
+ baseRef?: string;
15
+ }
16
+ export interface FanInLaneResult {
17
+ ok: boolean;
18
+ event: FanInEventKind;
19
+ details: string;
20
+ }
21
+ /**
22
+ * Build a unified diff from `baseRef..HEAD` in the lane worktree and apply it
23
+ * to the integration branch in the main repo using three-way merge.
24
+ * On conflict, the integration branch working tree is reset and, when possible,
25
+ * git HEAD is restored to the branch that was checked out before fan-in.
26
+ */
27
+ export declare function fanInLane(options: FanInLaneOptions): Promise<FanInLaneResult>;
28
+ export interface ResolverDispatchHint {
29
+ sliceId: string;
30
+ command: string;
31
+ }
32
+ /**
33
+ * Returns the canonical CLI hint for resolving fan-in conflicts for a slice.
34
+ */
35
+ export declare function buildResolveConflictDispatchHint(sliceId: string): ResolverDispatchHint;
36
+ /**
37
+ * Merge every lane that recorded a completed GREEN `ownerLaneId` for the
38
+ * active run, then emit `cclaw_fanin_*` audit rows. Does nothing in
39
+ * `single-tree` mode or when git is unavailable.
40
+ */
41
+ export declare function runTddDeterministicFanInBeforeAdvance(projectRoot: string, flowState: FlowState): Promise<{
42
+ ok: boolean;
43
+ issues: string[];
44
+ }>;