cclaw-cli 6.11.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.
Files changed (41) 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 +29 -0
  5. package/dist/artifact-linter/tdd.js +398 -11
  6. package/dist/artifact-linter.js +10 -1
  7. package/dist/content/core-agents.d.ts +2 -2
  8. package/dist/content/core-agents.js +3 -3
  9. package/dist/content/examples.js +4 -4
  10. package/dist/content/hooks.js +48 -1
  11. package/dist/content/skills.d.ts +10 -0
  12. package/dist/content/skills.js +64 -2
  13. package/dist/content/stage-schema.js +13 -4
  14. package/dist/content/stages/plan.js +2 -1
  15. package/dist/content/stages/schema-types.d.ts +1 -1
  16. package/dist/content/stages/spec.js +2 -2
  17. package/dist/content/stages/tdd.js +8 -7
  18. package/dist/content/templates.js +10 -4
  19. package/dist/delegation.d.ts +73 -3
  20. package/dist/delegation.js +196 -6
  21. package/dist/flow-state.d.ts +35 -0
  22. package/dist/flow-state.js +7 -0
  23. package/dist/gate-evidence.d.ts +5 -0
  24. package/dist/gate-evidence.js +58 -1
  25. package/dist/install.js +173 -1
  26. package/dist/integration-fanin.d.ts +44 -0
  27. package/dist/integration-fanin.js +180 -0
  28. package/dist/internal/advance-stage/advance.js +16 -1
  29. package/dist/internal/advance-stage/start-flow.js +3 -1
  30. package/dist/internal/advance-stage.js +13 -4
  31. package/dist/internal/plan-split-waves.d.ts +39 -1
  32. package/dist/internal/plan-split-waves.js +190 -6
  33. package/dist/internal/set-worktree-mode.d.ts +10 -0
  34. package/dist/internal/set-worktree-mode.js +28 -0
  35. package/dist/managed-resources.js +2 -0
  36. package/dist/run-persistence.js +22 -0
  37. package/dist/worktree-manager.d.ts +50 -0
  38. package/dist/worktree-manager.js +136 -0
  39. package/dist/worktree-types.d.ts +36 -0
  40. package/dist/worktree-types.js +6 -0
  41. package/package.json +1 -1
@@ -0,0 +1,180 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { exists } from "./fs-utils.js";
6
+ import { readDelegationEvents, recordCclawFanInAudit } from "./delegation.js";
7
+ import { effectiveWorktreeExecutionMode } from "./flow-state.js";
8
+ const execFileAsync = promisify(execFile);
9
+ const WORKTREES_SEG = ".cclaw/worktrees";
10
+ /**
11
+ * Build a unified diff from `baseRef..HEAD` in the lane worktree and apply it
12
+ * to the integration branch in the main repo using three-way merge.
13
+ * On conflict, the integration branch working tree is reset and, when possible,
14
+ * git HEAD is restored to the branch that was checked out before fan-in.
15
+ */
16
+ export async function fanInLane(options) {
17
+ const { projectRoot, laneId, integrationBranch } = options;
18
+ const workdir = path.join(projectRoot, WORKTREES_SEG, laneId);
19
+ if (!(await exists(workdir))) {
20
+ return { ok: false, event: "abandoned", details: `missing lane workdir ${workdir}` };
21
+ }
22
+ let integrationRef;
23
+ try {
24
+ integrationRef = (await execFileAsync("git", ["rev-parse", "--verify", integrationBranch], {
25
+ cwd: projectRoot
26
+ })).stdout.trim();
27
+ }
28
+ catch {
29
+ return {
30
+ ok: false,
31
+ event: "abandoned",
32
+ details: `integration branch/ref not found: ${integrationBranch}`
33
+ };
34
+ }
35
+ let baseRef = options.baseRef?.trim() ?? "";
36
+ if (baseRef.length === 0) {
37
+ try {
38
+ baseRef = (await execFileAsync("git", ["merge-base", integrationRef, "HEAD"], { cwd: workdir })).stdout.trim();
39
+ }
40
+ catch (err) {
41
+ return {
42
+ ok: false,
43
+ event: "abandoned",
44
+ details: `cannot merge-base lane ${laneId} with ${integrationBranch}: ${err instanceof Error ? err.message : String(err)}`
45
+ };
46
+ }
47
+ }
48
+ const patchFile = path.join(projectRoot, WORKTREES_SEG, `.fanin-${laneId}.patch`);
49
+ let restoreBranch = null;
50
+ try {
51
+ let curBranch = "";
52
+ try {
53
+ curBranch = (await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: projectRoot })).stdout.trim();
54
+ }
55
+ catch {
56
+ curBranch = "";
57
+ }
58
+ if (curBranch.length > 0 && curBranch !== integrationBranch && curBranch !== "HEAD") {
59
+ restoreBranch = curBranch;
60
+ }
61
+ const { stdout: diffOut } = await execFileAsync("git", ["diff", `${baseRef}..HEAD`], { cwd: workdir, maxBuffer: 64 * 1024 * 1024 });
62
+ if (diffOut.trim().length === 0) {
63
+ return { ok: true, event: "applied", details: "empty diff; nothing to merge" };
64
+ }
65
+ await fs.writeFile(patchFile, diffOut, "utf8");
66
+ await execFileAsync("git", ["checkout", integrationBranch], { cwd: projectRoot });
67
+ try {
68
+ await execFileAsync("git", ["apply", "--3way", patchFile], { cwd: projectRoot });
69
+ return { ok: true, event: "applied", details: `applied lane ${laneId} onto ${integrationBranch}` };
70
+ }
71
+ catch (err) {
72
+ await execFileAsync("git", ["checkout", "--", "."], { cwd: projectRoot }).catch(() => undefined);
73
+ if (restoreBranch) {
74
+ await execFileAsync("git", ["checkout", restoreBranch], { cwd: projectRoot }).catch(() => undefined);
75
+ }
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ return {
78
+ ok: false,
79
+ event: "conflict",
80
+ details: `git apply --3way reported conflicts for lane ${laneId}: ${msg}`
81
+ };
82
+ }
83
+ }
84
+ finally {
85
+ await fs.rm(patchFile, { force: true });
86
+ }
87
+ }
88
+ /**
89
+ * Returns the canonical CLI hint for resolving fan-in conflicts for a slice.
90
+ */
91
+ export function buildResolveConflictDispatchHint(sliceId) {
92
+ return {
93
+ sliceId,
94
+ command: `slice-implementer --phase resolve-conflict --slice ${sliceId}`
95
+ };
96
+ }
97
+ /**
98
+ * Merge every lane that recorded a completed GREEN `ownerLaneId` for the
99
+ * active run, then emit `cclaw_fanin_*` audit rows. Does nothing in
100
+ * `single-tree` mode or when git is unavailable.
101
+ */
102
+ export async function runTddDeterministicFanInBeforeAdvance(projectRoot, flowState) {
103
+ if (effectiveWorktreeExecutionMode(flowState) !== "worktree-first") {
104
+ return { ok: true, issues: [] };
105
+ }
106
+ let integrationBranch;
107
+ try {
108
+ integrationBranch = (await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: projectRoot })).stdout.trim();
109
+ }
110
+ catch {
111
+ return {
112
+ ok: false,
113
+ issues: ["worktree fan-in: cannot read current git branch (not a repository or detached HEAD unsupported here)."]
114
+ };
115
+ }
116
+ const { events } = await readDelegationEvents(projectRoot);
117
+ const runId = flowState.activeRunId;
118
+ const laneToSlices = new Map();
119
+ for (const e of events) {
120
+ if (e.runId !== runId || e.stage !== "tdd")
121
+ continue;
122
+ if (e.agent !== "slice-implementer")
123
+ continue;
124
+ if (e.status !== "completed" || e.phase !== "green")
125
+ continue;
126
+ const lane = e.ownerLaneId?.trim();
127
+ const sid = e.sliceId?.trim();
128
+ if (!lane || !sid)
129
+ continue;
130
+ if (!laneToSlices.has(lane))
131
+ laneToSlices.set(lane, new Set());
132
+ laneToSlices.get(lane).add(sid);
133
+ }
134
+ if (laneToSlices.size === 0) {
135
+ return { ok: true, issues: [] };
136
+ }
137
+ const issues = [];
138
+ for (const [laneId, sliceSet] of laneToSlices) {
139
+ const result = await fanInLane({
140
+ projectRoot,
141
+ laneId: laneId,
142
+ integrationBranch,
143
+ baseRef: undefined
144
+ });
145
+ const sliceIds = [...sliceSet].sort();
146
+ if (!result.ok && result.event === "conflict") {
147
+ await recordCclawFanInAudit(projectRoot, {
148
+ kind: "cclaw_fanin_conflict",
149
+ runId,
150
+ laneId,
151
+ sliceIds,
152
+ integrationBranch,
153
+ details: result.details
154
+ });
155
+ issues.push(`${result.details} — ${buildResolveConflictDispatchHint(sliceIds[0] ?? "S-1").command}`);
156
+ continue;
157
+ }
158
+ if (!result.ok) {
159
+ await recordCclawFanInAudit(projectRoot, {
160
+ kind: "cclaw_fanin_abandoned",
161
+ runId,
162
+ laneId,
163
+ sliceIds,
164
+ integrationBranch,
165
+ details: result.details
166
+ });
167
+ issues.push(result.details);
168
+ continue;
169
+ }
170
+ await recordCclawFanInAudit(projectRoot, {
171
+ kind: "cclaw_fanin_applied",
172
+ runId,
173
+ laneId,
174
+ sliceIds,
175
+ integrationBranch,
176
+ details: result.details
177
+ });
178
+ }
179
+ return { ok: issues.length === 0, issues };
180
+ }
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { resolveArtifactPath } from "../../artifact-paths.js";
4
4
  import { appendDelegation, checkMandatoryDelegations, readDelegationEvents, readDelegationLedger } from "../../delegation.js";
5
- import { verifyCompletedStagesGateClosure, verifyCurrentStageGateEvidence } from "../../gate-evidence.js";
5
+ import { verifyCompletedStagesGateClosure, verifyCurrentStageGateEvidence, verifyTddWorktreeFanInClosure } from "../../gate-evidence.js";
6
6
  import { extractMarkdownSectionBody, learningsParseFailureHumanSummary, parseLearningsSection } from "../../artifact-linter.js";
7
7
  import { getAvailableTransitions, getTransitionGuards } from "../../flow-state.js";
8
8
  import { appendKnowledge } from "../../knowledge-store.js";
@@ -13,6 +13,7 @@ import { unique } from "./helpers.js";
13
13
  import { AUTO_REVIEW_LOOP_GATE_BY_STAGE, reviewLoopArtifactFixHint, reviewLoopEnvelopeExample, validateGateEvidenceShape } from "./review-loop.js";
14
14
  import { ensureProactiveDelegationTrace } from "./proactive-delegation-trace.js";
15
15
  import { consumeWaiverToken } from "../waiver-grant.js";
16
+ import { runTddDeterministicFanInBeforeAdvance } from "../../integration-fanin.js";
16
17
  function resolveSuccessorTransition(stage, track, transitionTargets, satisfiedGuards, selectedTransitionGuards) {
17
18
  const natural = transitionTargets[0] ?? null;
18
19
  const specialTargets = transitionTargets.filter((target) => target !== natural);
@@ -602,6 +603,20 @@ export async function runAdvanceStage(projectRoot, args, io) {
602
603
  }
603
604
  const satisfiedGuards = new Set([...nextPassed, ...selectedTransitionGuards]);
604
605
  const successor = resolveSuccessorTransition(args.stage, flowState.track, transitionTargets, satisfiedGuards, new Set(selectedTransitionGuards));
606
+ if (args.stage === "tdd" && successor !== null && successor !== "tdd") {
607
+ const fanIn = await runTddDeterministicFanInBeforeAdvance(projectRoot, flowState);
608
+ if (!fanIn.ok) {
609
+ io.stderr.write(`cclaw internal advance-stage: deterministic worktree fan-in failed:\n${fanIn.issues
610
+ .map((line) => ` - ${line}`)
611
+ .join("\n")}\n`);
612
+ return 1;
613
+ }
614
+ const closure = await verifyTddWorktreeFanInClosure(projectRoot, flowState);
615
+ if (closure.length > 0) {
616
+ io.stderr.write(`cclaw internal advance-stage: ${closure.join(" | ")}\n`);
617
+ return 1;
618
+ }
619
+ }
605
620
  const completedStages = blockedReviewRoute
606
621
  ? flowState.completedStages.filter((finished) => finished !== args.stage)
607
622
  : flowState.completedStages.includes(args.stage)
@@ -175,7 +175,8 @@ export async function runStartFlow(projectRoot, args, io) {
175
175
  guardEvidence,
176
176
  stageGateCatalog,
177
177
  rewinds: current.rewinds,
178
- staleStages: current.staleStages
178
+ staleStages: current.staleStages,
179
+ worktreeExecutionMode: current.worktreeExecutionMode ?? "worktree-first"
179
180
  };
180
181
  const validation = await buildValidationReport(projectRoot, nextState);
181
182
  const evidenceIssues = completedStageClosureEvidenceIssues(nextState);
@@ -193,6 +194,7 @@ export async function runStartFlow(projectRoot, args, io) {
193
194
  if (nextTaskClass !== undefined) {
194
195
  nextState = { ...nextState, taskClass: nextTaskClass };
195
196
  }
197
+ nextState = { ...nextState, worktreeExecutionMode: "worktree-first" };
196
198
  }
197
199
  if (args.fromIdeaArtifact) {
198
200
  const existingHints = nextState.interactionHints ?? {};
@@ -14,8 +14,9 @@ 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, DispatchClaimInvalidError, DispatchDuplicateError, DispatchOverlapError } from "../delegation.js";
18
18
  import { parsePlanSplitWavesArgs, runPlanSplitWaves } from "./plan-split-waves.js";
19
+ import { runSetWorktreeMode } from "./set-worktree-mode.js";
19
20
  /**
20
21
  * Subcommands that mutate or consult flow-state.json via the CLI runtime.
21
22
  * They all require the sha256 sidecar to match before continuing so a
@@ -28,12 +29,13 @@ const GUARD_ENFORCED_SUBCOMMANDS = new Set([
28
29
  "cancel-run",
29
30
  "rewind",
30
31
  "verify-flow-state-diff",
31
- "verify-current-state"
32
+ "verify-current-state",
33
+ "set-worktree-mode"
32
34
  ]);
33
35
  export async function runInternalCommand(projectRoot, argv, io) {
34
36
  const [subcommand, ...tokens] = argv;
35
37
  if (!subcommand) {
36
- io.stderr.write("cclaw internal requires a subcommand: advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant | plan-split-waves\n");
38
+ io.stderr.write("cclaw internal requires a subcommand: advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant | plan-split-waves | set-worktree-mode\n");
37
39
  return 1;
38
40
  }
39
41
  try {
@@ -88,7 +90,10 @@ export async function runInternalCommand(projectRoot, argv, io) {
88
90
  if (subcommand === "plan-split-waves") {
89
91
  return await runPlanSplitWaves(projectRoot, parsePlanSplitWavesArgs(tokens), io);
90
92
  }
91
- io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant | plan-split-waves\n`);
93
+ if (subcommand === "set-worktree-mode") {
94
+ return await runSetWorktreeMode(projectRoot, tokens, io);
95
+ }
96
+ io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant | plan-split-waves | set-worktree-mode\n`);
92
97
  return 1;
93
98
  }
94
99
  catch (err) {
@@ -112,6 +117,10 @@ export async function runInternalCommand(projectRoot, argv, io) {
112
117
  io.stderr.write(`error: dispatch_cap — ${err.message}\n`);
113
118
  return 2;
114
119
  }
120
+ if (err instanceof DispatchClaimInvalidError) {
121
+ io.stderr.write(`error: dispatch_claim_invalid — ${err.message}\n`);
122
+ return 2;
123
+ }
115
124
  io.stderr.write(`cclaw internal ${subcommand} failed: ${err instanceof Error ? err.message : String(err)}\n`);
116
125
  return 1;
117
126
  }
@@ -29,7 +29,7 @@ export interface PlanSplitWavesArgs {
29
29
  force: boolean;
30
30
  json: boolean;
31
31
  }
32
- export declare const PLAN_SPLIT_DEFAULT_WAVE_SIZE = 25;
32
+ export declare const PLAN_SPLIT_DEFAULT_WAVE_SIZE = 5;
33
33
  export declare const PLAN_SPLIT_SMALL_PLAN_THRESHOLD = 50;
34
34
  export interface ParsedImplementationUnit {
35
35
  id: string;
@@ -42,6 +42,44 @@ export interface ParsedImplementationUnit {
42
42
  /** Repo-relative path declarations from the optional `Files:` line. */
43
43
  paths: string[];
44
44
  }
45
+ export interface ImplementationUnitParallelFields {
46
+ unitId: string;
47
+ dependsOn: string[];
48
+ claimedPaths: string[];
49
+ parallelizable: boolean;
50
+ riskTier: "low" | "standard" | "high";
51
+ lane?: string;
52
+ }
53
+ export interface ParseImplementationUnitParallelOptions {
54
+ /**
55
+ * Legacy continuation (v6.13.0): when the plan predates explicit parallel
56
+ * bullets, units without a `parallelizable:` line default to serial eligibility
57
+ * in the scheduler (`parallelizable: false`).
58
+ */
59
+ legacyParallelDefaultSerial?: boolean;
60
+ }
61
+ /**
62
+ * Parse v6.13 parallel-metadata bullets from an implementation unit body.
63
+ * Missing keys use conservative defaults (`dependsOn: []`, `parallelizable: true`
64
+ * unless `legacyParallelDefaultSerial` is set).
65
+ */
66
+ export declare function parseImplementationUnitParallelFields(unit: ParsedImplementationUnit, options?: ParseImplementationUnitParallelOptions): ImplementationUnitParallelFields;
67
+ /**
68
+ * True when the plan has implementation units but any unit is missing v6.13.0
69
+ * `dependsOn` / `claimedPaths` / `parallelizable` / `riskTier` bullets.
70
+ */
71
+ export declare function planArtifactLacksV613ParallelMetadata(planMarkdown: string): boolean;
72
+ export declare function compareCanonicalUnitIds(a: string, b: string): number;
73
+ /**
74
+ * Group implementation units into waves: topological order, then greedy
75
+ * placement with disjoint `claimedPaths` and `cap` members per wave.
76
+ */
77
+ export declare function buildConflictAwareWavesFromUnits(units: ParsedImplementationUnit[], cap: number): ParsedImplementationUnit[][];
78
+ export declare function buildParallelExecutionPlanSection(waves: ParsedImplementationUnit[][], cap: number): string;
79
+ /**
80
+ * Replace or append the managed Parallel Execution Plan block.
81
+ */
82
+ export declare function upsertParallelExecutionPlanSection(planMarkdown: string, managedBlock: string): string;
45
83
  /**
46
84
  * Parse `## Implementation Units` section into individual unit blocks.
47
85
  * Recognizes the canonical heading shape in the TDD-velocity plan template
@@ -3,11 +3,196 @@ import path from "node:path";
3
3
  import { resolveArtifactPath } from "../artifact-paths.js";
4
4
  import { exists, writeFileSafe } from "../fs-utils.js";
5
5
  import { readFlowState } from "../runs.js";
6
- export const PLAN_SPLIT_DEFAULT_WAVE_SIZE = 25;
6
+ export const PLAN_SPLIT_DEFAULT_WAVE_SIZE = 5;
7
7
  export const PLAN_SPLIT_SMALL_PLAN_THRESHOLD = 50;
8
8
  const WAVE_PLANS_DIR = "wave-plans";
9
9
  const WAVE_MANAGED_START = "<!-- wave-split-managed-start -->";
10
10
  const WAVE_MANAGED_END = "<!-- wave-split-managed-end -->";
11
+ const PARALLEL_EXEC_MANAGED_START = "<!-- parallel-exec-managed-start -->";
12
+ const PARALLEL_EXEC_MANAGED_END = "<!-- parallel-exec-managed-end -->";
13
+ /**
14
+ * Parse v6.13 parallel-metadata bullets from an implementation unit body.
15
+ * Missing keys use conservative defaults (`dependsOn: []`, `parallelizable: true`
16
+ * unless `legacyParallelDefaultSerial` is set).
17
+ */
18
+ export function parseImplementationUnitParallelFields(unit, options) {
19
+ const text = unit.body;
20
+ const pick = (label) => {
21
+ const re = new RegExp(`^[-*]\\s*\\*{0,2}${label}\\*{0,2}\\s*:\\s*(.*)$`, "imu");
22
+ for (const rawLine of text.split(/\r?\n/u)) {
23
+ const line = rawLine.trim();
24
+ const m = re.exec(line);
25
+ if (m)
26
+ return m[1]?.trim();
27
+ }
28
+ return undefined;
29
+ };
30
+ const id = pick("id") ?? unit.id;
31
+ const depRaw = pick("dependsOn") ?? pick("depends on") ?? "";
32
+ const dependsOn = depRaw
33
+ .split(/,/u)
34
+ .map((s) => s.trim())
35
+ .filter((s) => s.length > 0 && !/^none$/iu.test(s));
36
+ const pathsRaw = pick("claimedPaths") ?? pick("claimed paths") ?? "";
37
+ const claimedPaths = pathsRaw.length > 0
38
+ ? pathsRaw
39
+ .split(",")
40
+ .map((s) => s.replace(/[`\s]/gu, "").trim())
41
+ .filter((s) => s.length > 0)
42
+ : [...unit.paths];
43
+ const explicitParallel = pick("parallelizable");
44
+ const parallelRaw = (explicitParallel ?? "true").toLowerCase();
45
+ let parallelizable = parallelRaw === "true" || parallelRaw === "yes" || parallelRaw === "y";
46
+ if (options?.legacyParallelDefaultSerial && explicitParallel === undefined) {
47
+ parallelizable = false;
48
+ }
49
+ const riskRaw = (pick("riskTier") ?? pick("risk tier") ?? "standard").toLowerCase();
50
+ const riskTier = riskRaw === "low" ? "low" : riskRaw === "high" ? "high" : "standard";
51
+ const laneRaw = pick("lane");
52
+ const lane = laneRaw && laneRaw.length > 0 ? laneRaw : undefined;
53
+ return { unitId: id, dependsOn, claimedPaths, parallelizable, riskTier, lane };
54
+ }
55
+ function unitBodyHasV613ParallelBullet(body, label) {
56
+ const re = new RegExp(`^[-*]\\s*\\*{0,2}${label}\\*{0,2}\\s*:`, "imu");
57
+ return body.split(/\r?\n/u).some((raw) => re.test(raw.trim()));
58
+ }
59
+ /**
60
+ * True when the plan has implementation units but any unit is missing v6.13.0
61
+ * `dependsOn` / `claimedPaths` / `parallelizable` / `riskTier` bullets.
62
+ */
63
+ export function planArtifactLacksV613ParallelMetadata(planMarkdown) {
64
+ const units = parseImplementationUnits(planMarkdown);
65
+ if (units.length === 0)
66
+ return false;
67
+ const labels = ["dependsOn", "claimedPaths", "parallelizable", "riskTier"];
68
+ return units.some((u) => !labels.every((lab) => unitBodyHasV613ParallelBullet(u.body, lab)));
69
+ }
70
+ export function compareCanonicalUnitIds(a, b) {
71
+ const ma = /^U-(\d+)$/u.exec(a);
72
+ const mb = /^U-(\d+)$/u.exec(b);
73
+ if (ma && mb)
74
+ return Number(ma[1]) - Number(mb[1]);
75
+ return a.localeCompare(b);
76
+ }
77
+ function topoSortPlanUnits(meta) {
78
+ const idSet = new Set(meta.map((m) => m.unitId));
79
+ const incoming = new Map();
80
+ for (const m of meta)
81
+ incoming.set(m.unitId, 0);
82
+ for (const m of meta) {
83
+ for (const d of m.dependsOn) {
84
+ if (!idSet.has(d))
85
+ continue;
86
+ incoming.set(m.unitId, (incoming.get(m.unitId) ?? 0) + 1);
87
+ }
88
+ }
89
+ const queue = meta
90
+ .filter((m) => (incoming.get(m.unitId) ?? 0) === 0)
91
+ .sort((a, b) => compareCanonicalUnitIds(a.unitId, b.unitId));
92
+ const out = [];
93
+ while (queue.length > 0) {
94
+ const m = queue.shift();
95
+ out.push(m);
96
+ for (const other of meta) {
97
+ if (!other.dependsOn.includes(m.unitId))
98
+ continue;
99
+ const v = (incoming.get(other.unitId) ?? 0) - 1;
100
+ incoming.set(other.unitId, v);
101
+ if (v === 0) {
102
+ queue.push(other);
103
+ queue.sort((a, b) => compareCanonicalUnitIds(a.unitId, b.unitId));
104
+ }
105
+ }
106
+ }
107
+ if (out.length !== meta.length) {
108
+ return [...meta].sort((a, b) => compareCanonicalUnitIds(a.unitId, b.unitId));
109
+ }
110
+ return out;
111
+ }
112
+ /**
113
+ * Group implementation units into waves: topological order, then greedy
114
+ * placement with disjoint `claimedPaths` and `cap` members per wave.
115
+ */
116
+ export function buildConflictAwareWavesFromUnits(units, cap) {
117
+ const metaList = units.map((u) => parseImplementationUnitParallelFields(u));
118
+ const ordered = topoSortPlanUnits(metaList);
119
+ const unitById = new Map(units.map((u) => [parseImplementationUnitParallelFields(u).unitId, u]));
120
+ const waves = [];
121
+ const allMetaIds = new Set(metaList.map((m) => m.unitId));
122
+ for (const m of ordered) {
123
+ const u = unitById.get(m.unitId);
124
+ if (!u)
125
+ continue;
126
+ let placed = false;
127
+ for (let wi = 0; wi < waves.length; wi++) {
128
+ const wave = waves[wi];
129
+ if (wave.length >= cap)
130
+ continue;
131
+ const priorIds = new Set(waves
132
+ .slice(0, wi)
133
+ .flat()
134
+ .map((wu) => parseImplementationUnitParallelFields(wu).unitId));
135
+ const depsOk = m.dependsOn.every((d) => priorIds.has(d) || !allMetaIds.has(d));
136
+ if (!depsOk)
137
+ continue;
138
+ const pathsInWave = new Set();
139
+ for (const wu of wave) {
140
+ for (const p of parseImplementationUnitParallelFields(wu).claimedPaths) {
141
+ pathsInWave.add(p);
142
+ }
143
+ }
144
+ const clash = m.claimedPaths.some((p) => pathsInWave.has(p));
145
+ if (clash)
146
+ continue;
147
+ wave.push(u);
148
+ placed = true;
149
+ break;
150
+ }
151
+ if (!placed) {
152
+ waves.push([u]);
153
+ }
154
+ }
155
+ return waves;
156
+ }
157
+ export function buildParallelExecutionPlanSection(waves, cap) {
158
+ const lines = [];
159
+ lines.push(PARALLEL_EXEC_MANAGED_START);
160
+ lines.push("## Parallel Execution Plan");
161
+ lines.push("");
162
+ lines.push(`- **Cap:** ${cap} parallel units per wave (conflict-aware via \`claimedPaths\`).`);
163
+ lines.push("");
164
+ for (let i = 0; i < waves.length; i += 1) {
165
+ const w = waves[i];
166
+ const ids = w.map((unit) => parseImplementationUnitParallelFields(unit).unitId);
167
+ const union = new Set();
168
+ for (const unit of w) {
169
+ for (const p of parseImplementationUnitParallelFields(unit).claimedPaths) {
170
+ union.add(p);
171
+ }
172
+ }
173
+ lines.push(`### Wave ${padWaveIndex(i + 1)}`);
174
+ lines.push(`- **Members:** ${ids.join(", ")}`);
175
+ lines.push(`- **Claimed paths union:** ${[...union].sort().join(", ") || "(none)"}`);
176
+ lines.push("");
177
+ }
178
+ lines.push(PARALLEL_EXEC_MANAGED_END);
179
+ return lines.join("\n");
180
+ }
181
+ /**
182
+ * Replace or append the managed Parallel Execution Plan block.
183
+ */
184
+ export function upsertParallelExecutionPlanSection(planMarkdown, managedBlock) {
185
+ const startIdx = planMarkdown.indexOf(PARALLEL_EXEC_MANAGED_START);
186
+ const endIdx = planMarkdown.indexOf(PARALLEL_EXEC_MANAGED_END);
187
+ if (startIdx >= 0 && endIdx > startIdx) {
188
+ const before = planMarkdown.slice(0, startIdx);
189
+ const after = planMarkdown.slice(endIdx + PARALLEL_EXEC_MANAGED_END.length);
190
+ const joined = `${before}${managedBlock}${after}`;
191
+ return joined.endsWith("\n") ? joined : `${joined}\n`;
192
+ }
193
+ const trimmed = planMarkdown.replace(/\s+$/u, "");
194
+ return `${trimmed}\n\n${managedBlock}\n`;
195
+ }
11
196
  /**
12
197
  * Parse `## Implementation Units` section into individual unit blocks.
13
198
  * Recognizes the canonical heading shape in the TDD-velocity plan template
@@ -193,10 +378,7 @@ export async function runPlanSplitWaves(projectRoot, args, io) {
193
378
  }
194
379
  return 0;
195
380
  }
196
- const waves = [];
197
- for (let i = 0; i < units.length; i += args.waveSize) {
198
- waves.push(units.slice(i, i + args.waveSize));
199
- }
381
+ const waves = buildConflictAwareWavesFromUnits(units, args.waveSize);
200
382
  const artifactsDir = path.dirname(planResolved.absPath);
201
383
  const wavePlansAbsDir = path.join(artifactsDir, WAVE_PLANS_DIR);
202
384
  const waveFileNames = waves.map((_, idx) => `${WAVE_PLANS_DIR}/wave-${padWaveIndex(idx + 1)}.md`);
@@ -217,7 +399,9 @@ export async function runPlanSplitWaves(projectRoot, args, io) {
217
399
  await writeFileSafe(path.join(artifactsDir, fileName), body);
218
400
  }
219
401
  const managed = buildWavePlansSection(waveFileNames);
220
- const updatedPlan = upsertWavePlansSection(raw, managed);
402
+ let updatedPlan = upsertWavePlansSection(raw, managed);
403
+ const parallelBlock = buildParallelExecutionPlanSection(waves, args.waveSize);
404
+ updatedPlan = upsertParallelExecutionPlanSection(updatedPlan, parallelBlock);
221
405
  if (updatedPlan !== raw) {
222
406
  await writeFileSafe(planResolved.absPath, updatedPlan);
223
407
  }
@@ -0,0 +1,10 @@
1
+ import type { Writable } from "node:stream";
2
+ export declare function parseSetWorktreeModeArgs(tokens: string[]): {
3
+ mode: "single-tree" | "worktree-first";
4
+ } | null;
5
+ /**
6
+ * Set `flow-state.json::worktreeExecutionMode` without advancing the stage DAG.
7
+ */
8
+ export declare function runSetWorktreeMode(projectRoot: string, tokens: string[], io: {
9
+ stderr: Writable;
10
+ }): Promise<number>;
@@ -0,0 +1,28 @@
1
+ import { readFlowState, writeFlowState } from "../runs.js";
2
+ export function parseSetWorktreeModeArgs(tokens) {
3
+ let mode = null;
4
+ for (const token of tokens) {
5
+ if (token.startsWith("--mode=")) {
6
+ const raw = token.slice("--mode=".length).trim();
7
+ if (raw === "single-tree" || raw === "worktree-first") {
8
+ mode = raw;
9
+ }
10
+ }
11
+ }
12
+ if (!mode)
13
+ return null;
14
+ return { mode };
15
+ }
16
+ /**
17
+ * Set `flow-state.json::worktreeExecutionMode` without advancing the stage DAG.
18
+ */
19
+ export async function runSetWorktreeMode(projectRoot, tokens, io) {
20
+ const parsed = parseSetWorktreeModeArgs(tokens);
21
+ if (!parsed) {
22
+ io.stderr.write("cclaw internal set-worktree-mode: usage: --mode=single-tree|worktree-first\n");
23
+ return 1;
24
+ }
25
+ const state = await readFlowState(projectRoot);
26
+ await writeFlowState(projectRoot, { ...state, worktreeExecutionMode: parsed.mode }, { writerSubsystem: "set-worktree-mode" });
27
+ return 0;
28
+ }
@@ -60,6 +60,8 @@ export function isManagedGeneratedPath(relPath) {
60
60
  return false;
61
61
  if (relPath.startsWith(`${RUNTIME_ROOT}/artifacts/`))
62
62
  return false;
63
+ if (relPath.startsWith(`${RUNTIME_ROOT}/worktrees/`))
64
+ return false;
63
65
  if (relPath.startsWith(`${RUNTIME_ROOT}/archive/`))
64
66
  return false;
65
67
  if (relPath === `${RUNTIME_ROOT}/state/flow-state.json`)
@@ -471,6 +471,9 @@ function coerceFlowState(parsed) {
471
471
  const taskClass = coerceTaskClass(parsed.taskClass);
472
472
  const repoSignals = coerceRepoSignals(parsed.repoSignals);
473
473
  const completedStageMeta = sanitizeCompletedStageMeta(parsed.completedStageMeta);
474
+ const tddCutoverSliceId = coerceTddCutoverSliceId(parsed.tddCutoverSliceId);
475
+ const worktreeExecutionMode = coerceWorktreeExecutionMode(parsed.worktreeExecutionMode);
476
+ const legacyContinuation = typeof parsed.legacyContinuation === "boolean" ? parsed.legacyContinuation : undefined;
474
477
  const state = {
475
478
  schemaVersion: FLOW_STATE_SCHEMA_VERSION,
476
479
  activeRunId,
@@ -483,6 +486,9 @@ function coerceFlowState(parsed) {
483
486
  ...(taskClass !== undefined ? { taskClass } : {}),
484
487
  ...(repoSignals ? { repoSignals } : {}),
485
488
  ...(completedStageMeta ? { completedStageMeta } : {}),
489
+ ...(tddCutoverSliceId ? { tddCutoverSliceId } : {}),
490
+ ...(worktreeExecutionMode !== undefined ? { worktreeExecutionMode } : {}),
491
+ ...(legacyContinuation !== undefined ? { legacyContinuation } : {}),
486
492
  skippedStages: sanitizeSkippedStages(parsed.skippedStages, track),
487
493
  staleStages: sanitizeStaleStages(parsed.staleStages),
488
494
  rewinds: sanitizeRewinds(parsed.rewinds),
@@ -492,6 +498,22 @@ function coerceFlowState(parsed) {
492
498
  };
493
499
  return { state };
494
500
  }
501
+ /**
502
+ * v6.12.0 — best-effort coercion for `tddCutoverSliceId`. Returns the value
503
+ * only when it matches the canonical slice id shape `S-<digits>`; otherwise
504
+ * returns null so the field is omitted from the rehydrated state.
505
+ */
506
+ function coerceTddCutoverSliceId(value) {
507
+ if (typeof value !== "string")
508
+ return null;
509
+ const trimmed = value.trim();
510
+ return /^S-\d+$/u.test(trimmed) ? trimmed : null;
511
+ }
512
+ function coerceWorktreeExecutionMode(value) {
513
+ if (value === "single-tree" || value === "worktree-first")
514
+ return value;
515
+ return undefined;
516
+ }
495
517
  export class CorruptFlowStateError extends Error {
496
518
  statePath;
497
519
  quarantinedPath;