cclaw-cli 6.12.0 → 6.13.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.
@@ -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 } 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,70 @@ 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
+ }
614
733
  function readMaxParallelOverrideFromEnv() {
615
734
  const raw = process.env.CCLAW_MAX_PARALLEL_SLICE_IMPLEMENTERS;
616
735
  if (typeof raw !== "string" || raw.trim().length === 0)
@@ -740,8 +859,45 @@ async function writeSubagentTracker(projectRoot, entries) {
740
859
  }));
741
860
  await writeFileSafe(subagentsStatePath(projectRoot), `${JSON.stringify({ active, updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
742
861
  }
862
+ function latestClaimTokenForSpan(entries, spanId) {
863
+ if (!spanId)
864
+ return null;
865
+ let latest = null;
866
+ for (const e of entries) {
867
+ if (e.spanId !== spanId)
868
+ continue;
869
+ if (typeof e.claimToken === "string" && e.claimToken.trim().length > 0) {
870
+ latest = e.claimToken.trim();
871
+ }
872
+ }
873
+ return latest;
874
+ }
875
+ function assertSliceClaimInvariant(flow, stamped, prior) {
876
+ if (stamped.stage !== "tdd" || stamped.agent !== "slice-implementer")
877
+ return;
878
+ if (effectiveWorktreeExecutionMode(flow) !== "worktree-first")
879
+ return;
880
+ if (stamped.status === "scheduled" && typeof stamped.sliceId === "string" && stamped.sliceId.length > 0) {
881
+ const tok = stamped.claimToken?.trim() ?? "";
882
+ if (tok.length === 0) {
883
+ throw new DispatchClaimInvalidError("dispatch_claim_invalid — worktree-first requires --claim-token when scheduling slice-implementer with --slice");
884
+ }
885
+ }
886
+ if (!TERMINAL_DELEGATION_STATUSES.has(stamped.status))
887
+ return;
888
+ if (stamped.status === "waived" || stamped.status === "stale")
889
+ return;
890
+ const expected = latestClaimTokenForSpan(prior, stamped.spanId);
891
+ if (!expected)
892
+ return;
893
+ const got = stamped.claimToken?.trim() ?? "";
894
+ if (got !== expected) {
895
+ throw new DispatchClaimInvalidError("dispatch_claim_invalid — claimToken must match the scheduled claim for this span");
896
+ }
897
+ }
743
898
  export async function appendDelegation(projectRoot, entry) {
744
- const { activeRunId } = await readFlowState(projectRoot);
899
+ const flowState = await readFlowState(projectRoot);
900
+ const { activeRunId } = flowState;
745
901
  await withDirectoryLock(delegationLockPath(projectRoot), async () => {
746
902
  const filePath = delegationLogPath(projectRoot);
747
903
  const prior = await readDelegationLedger(projectRoot);
@@ -802,6 +958,7 @@ export async function appendDelegation(projectRoot, entry) {
802
958
  if (prior.entries.some((existing) => existing.spanId === stamped.spanId && existing.status === stamped.status)) {
803
959
  return;
804
960
  }
961
+ assertSliceClaimInvariant(flowState, stamped, prior.entries);
805
962
  validateMonotonicTimestamps(stamped, prior.entries);
806
963
  if (stamped.status === "scheduled") {
807
964
  // v6.10.0 (P1+P2): for slice-implementer rows with declared
@@ -839,6 +996,39 @@ export async function appendDelegation(projectRoot, entry) {
839
996
  await writeSubagentTracker(projectRoot, ledger.entries);
840
997
  });
841
998
  }
999
+ /**
1000
+ * Scan delegation events for expired `leasedUntil` timestamps and append
1001
+ * best-effort `cclaw_slice_lease_expired` audit rows (one per span/slice key).
1002
+ */
1003
+ export async function reclaimExpiredDelegationClaims(projectRoot, now = new Date()) {
1004
+ const { events } = await readDelegationEvents(projectRoot);
1005
+ const seen = new Set();
1006
+ let count = 0;
1007
+ const ts = now.toISOString();
1008
+ const filePath = delegationEventsPath(projectRoot);
1009
+ const cutoff = Date.parse(ts);
1010
+ for (const e of events) {
1011
+ if (e.leaseState !== "claimed")
1012
+ continue;
1013
+ if (typeof e.leasedUntil !== "string" || e.leasedUntil.length === 0)
1014
+ continue;
1015
+ if (Date.parse(e.leasedUntil) > cutoff)
1016
+ continue;
1017
+ const key = `${e.spanId ?? ""}|${e.sliceId ?? ""}`;
1018
+ if (seen.has(key))
1019
+ continue;
1020
+ seen.add(key);
1021
+ await fs.appendFile(filePath, `${JSON.stringify({
1022
+ event: "cclaw_slice_lease_expired",
1023
+ spanId: e.spanId,
1024
+ sliceId: e.sliceId,
1025
+ leasedUntil: e.leasedUntil,
1026
+ eventTs: ts
1027
+ })}\n`, { encoding: "utf8", mode: 0o600 });
1028
+ count += 1;
1029
+ }
1030
+ return count;
1031
+ }
842
1032
  /**
843
1033
  * Aggregate the fulfillment mode cclaw expects for the active harness set.
844
1034
  * 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
@@ -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, 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);
@@ -1093,6 +1155,7 @@ async function materializeRuntime(projectRoot, config, forceStateReset, operatio
1093
1155
  await writeState(projectRoot, config, forceStateReset);
1094
1156
  if (operation === "sync" || operation === "upgrade") {
1095
1157
  await applyTddCutoverIfNeeded(projectRoot);
1158
+ await applyPlanLegacyContinuationIfNeeded(projectRoot);
1096
1159
  }
1097
1160
  try {
1098
1161
  await ensureRunSystem(projectRoot, { createIfMissing: false });
@@ -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
+ }>;