cclaw-cli 7.4.0 → 7.6.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.
@@ -9,6 +9,7 @@ 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
11
  import { compareCanonicalUnitIds, mergeParallelWaveDefinitions, parseImplementationUnitParallelFields, parseImplementationUnits, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "./internal/plan-split-waves.js";
12
+ import { compareSliceIds } from "./util/slice-id.js";
12
13
  const execFileAsync = promisify(execFile);
13
14
  const TERMINAL_DELEGATION_STATUSES = new Set(["completed", "failed", "waived", "stale"]);
14
15
  export const DELEGATION_DISPATCH_SURFACES = [
@@ -700,6 +701,69 @@ export function validateClaimedPathsNotProtected(stamped) {
700
701
  spanId: stamped.spanId ?? "unknown"
701
702
  });
702
703
  }
704
+ /**
705
+ * Thrown by `appendDelegation` (and the inline `delegation-record.mjs`
706
+ * helper) when an event with a non-null `phase` is recorded with
707
+ * `status="acknowledged"`. Phase-level granularity only makes sense on
708
+ * terminal outcomes (`completed` or `failed`); the dispatch-level ACK
709
+ * (no phase) is the controller saying "I see the dispatch surface back".
710
+ *
711
+ * Motivated by hox W-08/S-41: the slice-builder agent recorded all four
712
+ * phase events with `--status=acknowledged`, which the helper silently
713
+ * accepted but `slice-commit.mjs` only fires on `phase=doc status=completed`.
714
+ * `wave-status` then saw the slice as phantom-open even though the
715
+ * worker had finished. Recovery required raw backfill commands.
716
+ *
717
+ * 7.6.0 makes the constraint explicit: pair `--phase=<phase>` with
718
+ * `--status=completed` (or `--status=failed`) and use
719
+ * `--status=acknowledged` only for the dispatch-level ack (no phase).
720
+ */
721
+ export class PhaseEventRequiresTerminalStatusError extends Error {
722
+ phase;
723
+ status;
724
+ spanId;
725
+ correctedCommandHint;
726
+ constructor(params) {
727
+ super(`phase_event_requires_completed_or_failed_status — span ${params.spanId} recorded --phase=${params.phase} with --status=${params.status}; ` +
728
+ `phase-level events are only valid on terminal outcomes (--status=completed or --status=failed). ` +
729
+ `The dispatch-level ack (no --phase) can still use --status=acknowledged. ` +
730
+ `Corrected command: ${params.correctedCommandHint}`);
731
+ this.name = "PhaseEventRequiresTerminalStatusError";
732
+ this.phase = params.phase;
733
+ this.status = params.status;
734
+ this.spanId = params.spanId;
735
+ this.correctedCommandHint = params.correctedCommandHint;
736
+ }
737
+ }
738
+ /**
739
+ * Reject delegation rows where `phase` is set but `status` is not
740
+ * `completed` or `failed`. Acknowledged/launched/scheduled/waived/stale
741
+ * rows must NOT carry a phase — the phase-level lifecycle exists only
742
+ * to record terminal outcomes per phase (RED/GREEN/REFACTOR/DOC).
743
+ *
744
+ * Throws `PhaseEventRequiresTerminalStatusError`; the message includes
745
+ * an actionable corrected-command hint that the controller can paste.
746
+ */
747
+ export function validatePhaseEventStatus(stamped) {
748
+ if (typeof stamped.phase !== "string" || stamped.phase.length === 0)
749
+ return;
750
+ if (stamped.status === "completed" || stamped.status === "failed")
751
+ return;
752
+ const phase = stamped.phase;
753
+ const sliceFlag = typeof stamped.sliceId === "string" && stamped.sliceId.length > 0
754
+ ? `--slice=${stamped.sliceId} `
755
+ : "";
756
+ const spanFlag = typeof stamped.spanId === "string" && stamped.spanId.length > 0
757
+ ? `--span-id=${stamped.spanId} `
758
+ : "";
759
+ const correctedCommandHint = `node .cclaw/hooks/delegation-record.mjs --stage=${stamped.stage} --agent=${stamped.agent} --mode=${stamped.mode} --status=completed --phase=${phase} ${sliceFlag}${spanFlag}--evidence-ref="<phase outcome>"`;
760
+ throw new PhaseEventRequiresTerminalStatusError({
761
+ phase,
762
+ status: stamped.status,
763
+ spanId: stamped.spanId ?? "unknown",
764
+ correctedCommandHint
765
+ });
766
+ }
703
767
  /**
704
768
  * Thrown by `appendDelegation` when a new `scheduled` span would open a
705
769
  * second TDD cycle for a slice that already has at least one closed span
@@ -845,7 +909,7 @@ export function readySliceUnitsFromMergedWaves(mergedWaves, planMarkdown, option
845
909
  }
846
910
  }
847
911
  const out = [];
848
- for (const sliceId of [...sliceSet].sort((a, b) => a.localeCompare(b))) {
912
+ for (const sliceId of [...sliceSet].sort(compareSliceIds)) {
849
913
  const member = mergedWaves.flatMap((w) => w.members).find((x) => x.sliceId === sliceId);
850
914
  if (!member)
851
915
  continue;
@@ -1183,6 +1247,7 @@ export async function appendDelegation(projectRoot, entry) {
1183
1247
  return;
1184
1248
  }
1185
1249
  validateMonotonicTimestamps(stamped, prior.entries);
1250
+ validatePhaseEventStatus(stamped);
1186
1251
  if (stamped.status === "scheduled" &&
1187
1252
  typeof stamped.sliceId === "string" &&
1188
1253
  stamped.sliceId.length > 0 &&
@@ -11,6 +11,7 @@ import { computeEarlyLoopStatus, isEarlyLoopStage, normalizeEarlyLoopMaxIteratio
11
11
  import { detectPublicApiChanges } from "./internal/detect-public-api-changes.js";
12
12
  import { detectSupplyChainChanges } from "./internal/detect-supply-chain-changes.js";
13
13
  import { readFlowState, writeFlowState } from "./runs.js";
14
+ import { loadStackAdapter } from "./stack-detection.js";
14
15
  import { validateTddVerificationEvidence } from "./tdd-verification-evidence.js";
15
16
  async function currentStageArtifactExists(projectRoot, stage, track) {
16
17
  const resolved = await resolveArtifactPath(stage, {
@@ -88,20 +89,17 @@ async function discoverRealTestCommands(projectRoot) {
88
89
  commands.push(name === "test" ? "bun test" : `bun run ${name}`);
89
90
  }
90
91
  }
91
- if (await exists(path.join(projectRoot, "pyproject.toml")))
92
- commands.push("pytest");
92
+ // 7.6.0 — pull additional commands from the stack-adapter's
93
+ // testCommandHints rather than hardcoding pytest/go test/cargo
94
+ // test/mvn/gradle here. Adapters that don't apply to the project
95
+ // contribute no commands; pytest.ini support is kept as an
96
+ // explicit fallback because pyproject.toml-less projects exist.
97
+ const stackAdapter = await loadStackAdapter(projectRoot);
98
+ for (const hint of stackAdapter.testCommandHints) {
99
+ commands.push(hint);
100
+ }
93
101
  if (await exists(path.join(projectRoot, "pytest.ini")))
94
102
  commands.push("pytest");
95
- if (await exists(path.join(projectRoot, "go.mod")))
96
- commands.push("go test ./...");
97
- if (await exists(path.join(projectRoot, "Cargo.toml")))
98
- commands.push("cargo test");
99
- if (await exists(path.join(projectRoot, "pom.xml")))
100
- commands.push("mvn test");
101
- if (await exists(path.join(projectRoot, "build.gradle")) ||
102
- await exists(path.join(projectRoot, "build.gradle.kts"))) {
103
- commands.push("gradle test", "./gradlew test");
104
- }
105
103
  return unique(commands);
106
104
  }
107
105
  async function verifyDiscoveredCommandEvidence(projectRoot, stage, gateId, flowState) {
@@ -4,7 +4,7 @@ import { RUNTIME_ROOT } from "../../constants.js";
4
4
  import { createInitialFlowState } from "../../flow-state.js";
5
5
  import { readFlowState, writeFlowState } from "../../runs.js";
6
6
  import { listExistingFiles, listFilesUnder, pathExists } from "./helpers.js";
7
- import { STACK_DISCOVERY_DIR_MARKERS, STACK_DISCOVERY_MARKERS } from "../../stack-detection.js";
7
+ import { STACK_DISCOVERY_DIR_MARKERS, STACK_DISCOVERY_MARKERS, loadStackAdapter } from "../../stack-detection.js";
8
8
  import { TRACK_STAGES } from "../../types.js";
9
9
  import { buildValidationReport } from "./advance.js";
10
10
  import { carriedCompletedStageCatalog, completedStageClosureEvidenceIssues, firstIncompleteStageForTrack } from "./verify.js";
@@ -58,11 +58,20 @@ export async function collectRepoSignals(projectRoot) {
58
58
  // ignore
59
59
  }
60
60
  }
61
- for (const manifest of ["package.json", "pyproject.toml", "Cargo.toml"]) {
61
+ // 7.6.0 manifest detection now routes through the stack-adapter
62
+ // contract instead of hardcoding `package.json` / `pyproject.toml` /
63
+ // `Cargo.toml`. Adapters that declare manifestGlobs probe their
64
+ // declared paths; the unknown adapter is a no-op.
65
+ const stackAdapter = await loadStackAdapter(projectRoot);
66
+ for (const manifestGlob of stackAdapter.manifestGlobs) {
67
+ if (manifestGlob.includes("*"))
68
+ continue;
62
69
  try {
63
- const st = await fs.stat(path.join(projectRoot, manifest));
64
- if (st.isFile())
70
+ const st = await fs.stat(path.join(projectRoot, manifestGlob));
71
+ if (st.isFile()) {
65
72
  hasPackageManifest = true;
73
+ break;
74
+ }
66
75
  }
67
76
  catch {
68
77
  // ignore
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { RUNTIME_ROOT } from "../constants.js";
4
4
  import { writeFileSafe } from "../fs-utils.js";
5
5
  import { readDelegationLedger, isParallelTddSliceWorker } from "../delegation.js";
6
+ import { compareSliceIds } from "../util/slice-id.js";
6
7
  export function parseCohesionContractArgs(tokens) {
7
8
  const args = { stub: false, force: false, reason: null };
8
9
  for (const token of tokens) {
@@ -143,18 +144,5 @@ function collectSliceIds(entries) {
143
144
  continue;
144
145
  set.add(entry.sliceId);
145
146
  }
146
- return [...set].sort((a, b) => {
147
- const an = parseSliceNum(a);
148
- const bn = parseSliceNum(b);
149
- if (an !== null && bn !== null)
150
- return an - bn;
151
- return a.localeCompare(b);
152
- });
153
- }
154
- function parseSliceNum(sliceId) {
155
- const m = /^S-(\d+)$/u.exec(sliceId);
156
- if (!m)
157
- return null;
158
- const n = Number.parseInt(m[1], 10);
159
- return Number.isFinite(n) ? n : null;
147
+ return [...set].sort(compareSliceIds);
160
148
  }
@@ -3,6 +3,7 @@ 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
+ import { compareSliceIds, parseSliceId } from "../util/slice-id.js";
6
7
  export const PLAN_SPLIT_DEFAULT_WAVE_SIZE = 5;
7
8
  export const PLAN_SPLIT_SMALL_PLAN_THRESHOLD = 50;
8
9
  const WAVE_PLANS_DIR = "wave-plans";
@@ -34,15 +35,17 @@ export function extractParallelExecutionManagedBody(planMarkdown) {
34
35
  }
35
36
  function tokenToSliceAndUnit(token) {
36
37
  const t = token.trim().replace(/^[`"'[\]()]+|[`"'[\]()]+$/gu, "");
37
- const u = /^U-(\d+)$/u.exec(t);
38
+ const u = /^U-(\d+)([a-z][a-z0-9]*)?$/iu.exec(t);
38
39
  if (u) {
39
- const n = u[1];
40
- return { unitId: `U-${n}`, sliceId: `S-${n}` };
40
+ const num = u[1];
41
+ const suffix = (u[2] ?? "").toLowerCase();
42
+ const tail = suffix.length > 0 ? `${num}${suffix}` : num;
43
+ return { unitId: `U-${tail}`, sliceId: `S-${tail}` };
41
44
  }
42
- const s = /^S-(\d+)$/u.exec(t);
43
- if (s) {
44
- const n = s[1];
45
- return { unitId: `U-${n}`, sliceId: `S-${n}` };
45
+ const parsed = parseSliceId(t);
46
+ if (parsed) {
47
+ const tail = parsed.suffix.length > 0 ? `${parsed.numeric}${parsed.suffix}` : `${parsed.numeric}`;
48
+ return { unitId: `U-${tail}`, sliceId: parsed.id };
46
49
  }
47
50
  return null;
48
51
  }
@@ -90,12 +93,14 @@ export function parseTableRowMember(trimmedLine) {
90
93
  return null;
91
94
  const stripDecorations = (raw) => raw.replace(/^[`"'[\]()]+|[`"'[\]()]+$/gu, "").trim();
92
95
  const col1 = stripDecorations(cells[0]);
93
- const sliceMatch = /^S-(\d+)$/u.exec(col1);
94
- if (!sliceMatch)
96
+ const parsedSlice = parseSliceId(col1);
97
+ if (!parsedSlice)
95
98
  return null;
96
- const sliceNum = sliceMatch[1];
97
- const sliceId = `S-${sliceNum}`;
98
- let unitId = `U-${sliceNum}`;
99
+ const sliceTail = parsedSlice.suffix.length > 0
100
+ ? `${parsedSlice.numeric}${parsedSlice.suffix}`
101
+ : `${parsedSlice.numeric}`;
102
+ const sliceId = parsedSlice.id;
103
+ let unitId = `U-${sliceTail}`;
99
104
  if (cells.length >= 2) {
100
105
  const col2 = stripDecorations(cells[1]);
101
106
  if (col2.length > 0) {
@@ -235,7 +240,7 @@ export function parseWavePlanFileBody(body, waveId) {
235
240
  }
236
241
  }
237
242
  if (members.length === 0) {
238
- const regex = /\b(S-\d+)\b/gu;
243
+ const regex = /\b(S-\d+(?:[a-z][a-z0-9]*)?)\b/giu;
239
244
  let match;
240
245
  while ((match = regex.exec(body)) !== null) {
241
246
  const ids = tokenToSliceAndUnit(match[1]);
@@ -307,7 +312,7 @@ export function mergeParallelWaveDefinitions(primary, secondary) {
307
312
  .sort(([a], [b]) => a.localeCompare(b))
308
313
  .map(([wid, memMap]) => ({
309
314
  waveId: wid,
310
- members: [...memMap.values()].sort((p, q) => p.sliceId.localeCompare(q.sliceId))
315
+ members: [...memMap.values()].sort((p, q) => compareSliceIds(p.sliceId, q.sliceId))
311
316
  }));
312
317
  }
313
318
  /**
@@ -1,9 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import path from "node:path";
3
3
  import { promisify } from "node:util";
4
- import { readConfig, resolveTddCommitMode, resolveTddIsolationMode, resolveTddWorktreeRoot } from "../config.js";
4
+ import { readConfig, resolveLockfileTwinPolicy, resolveTddCommitMode, resolveTddIsolationMode, resolveTddWorktreeRoot } from "../config.js";
5
5
  import { readDelegationLedger } from "../delegation.js";
6
6
  import { exists } from "../fs-utils.js";
7
+ import { loadStackAdapter } from "../stack-detection.js";
7
8
  import { cleanupWorktree, commitAndMergeBack, createSliceWorktree, WorktreeMergeConflictError, WorktreeUnsupportedError } from "../worktree-manager.js";
8
9
  const execFileAsync = promisify(execFile);
9
10
  function parseCsv(raw) {
@@ -165,6 +166,71 @@ function matchesClaimedPath(changedPath, claimedPaths) {
165
166
  return changed.startsWith(`${claimed}/`);
166
167
  });
167
168
  }
169
+ /**
170
+ * 7.6.0 — match a candidate path against a stack-adapter glob pattern.
171
+ *
172
+ * Adapter globs are intentionally simple: literal paths (`Cargo.toml`),
173
+ * recursive prefix (`**\/Cargo.toml`), or single-level wildcard
174
+ * (`*.csproj`). We translate those shapes here without pulling in a
175
+ * full glob library so the slice-commit hook stays dependency-light.
176
+ */
177
+ function matchesAdapterGlob(candidate, glob) {
178
+ const normalizedCandidate = normalizePathLike(candidate);
179
+ const normalizedGlob = normalizePathLike(glob);
180
+ if (normalizedGlob.length === 0)
181
+ return false;
182
+ if (normalizedGlob.includes("**")) {
183
+ // `**/foo` → match either `foo` at root or any nested `foo`.
184
+ if (normalizedGlob.startsWith("**/")) {
185
+ const tail = normalizedGlob.slice(3);
186
+ if (tail === normalizedCandidate)
187
+ return true;
188
+ return normalizedCandidate.endsWith(`/${tail}`);
189
+ }
190
+ // Generic ** in the middle: collapse to suffix match for simplicity.
191
+ const tail = normalizedGlob.split("**/").pop() ?? "";
192
+ return tail.length > 0 && normalizedCandidate.endsWith(tail);
193
+ }
194
+ if (normalizedGlob.includes("*")) {
195
+ // Single-segment wildcard like `*.csproj`. Convert to a basic regex.
196
+ const regexSrc = normalizedGlob
197
+ .split("/")
198
+ .map((segment) => segment
199
+ .replace(/[.+?^${}()|[\]\\]/gu, "\\$&")
200
+ .replace(/\*/gu, "[^/]*"))
201
+ .join("/");
202
+ return new RegExp(`^${regexSrc}$`, "u").test(normalizedCandidate);
203
+ }
204
+ return normalizedGlob === normalizedCandidate;
205
+ }
206
+ /**
207
+ * Find lockfile twins whose manifestGlob matches at least one claimed
208
+ * path. The returned twins are the candidates whose lockfileGlob we
209
+ * should auto-include / auto-revert when they drift.
210
+ */
211
+ function activeLockfileTwins(adapter, claimedPaths) {
212
+ if (adapter.lockfileTwins.length === 0)
213
+ return [];
214
+ const active = [];
215
+ for (const twin of adapter.lockfileTwins) {
216
+ const claimedManifest = claimedPaths.some((claimed) => matchesAdapterGlob(claimed, twin.manifestGlob));
217
+ if (claimedManifest)
218
+ active.push(twin);
219
+ }
220
+ return active;
221
+ }
222
+ /**
223
+ * Partition a candidate path: `is it a lockfile twin we should
224
+ * auto-handle?`. Returns the twin entry that matches, or null.
225
+ */
226
+ function findMatchingLockfileTwin(changedPath, twins) {
227
+ for (const twin of twins) {
228
+ if (matchesAdapterGlob(changedPath, twin.lockfileGlob)) {
229
+ return twin;
230
+ }
231
+ }
232
+ return null;
233
+ }
168
234
  async function resolveClaimedPathsFromLedger(projectRoot, args) {
169
235
  const ledger = await readDelegationLedger(projectRoot);
170
236
  const matches = ledger.entries.filter((entry) => entry.stage === "tdd" &&
@@ -195,6 +261,8 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
195
261
  const commitMode = resolveTddCommitMode(config);
196
262
  const isolationMode = resolveTddIsolationMode(config);
197
263
  const worktreeRoot = resolveTddWorktreeRoot(config);
264
+ const lockfileTwinPolicy = resolveLockfileTwinPolicy(config);
265
+ const stackAdapter = await loadStackAdapter(projectRoot);
198
266
  const gitPresent = await exists(path.join(projectRoot, ".git"));
199
267
  if (args.prepareWorktree) {
200
268
  if (!gitPresent) {
@@ -354,8 +422,41 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
354
422
  });
355
423
  return 0;
356
424
  }
357
- const pathDrift = changedPaths.filter((p) => !matchesClaimedPath(p, claimedPaths));
358
- if (pathDrift.length > 0) {
425
+ const initialDrift = changedPaths.filter((p) => !matchesClaimedPath(p, claimedPaths));
426
+ const twinsForCommit = activeLockfileTwins(stackAdapter, claimedPaths);
427
+ // 7.6.0 — split drift into "lockfile twin drift" (handle per policy)
428
+ // vs "true drift" (always rejected).
429
+ const lockfileTwinDrift = [];
430
+ const trueDrift = [];
431
+ for (const driftPath of initialDrift) {
432
+ const twin = findMatchingLockfileTwin(driftPath, twinsForCommit);
433
+ if (twin) {
434
+ lockfileTwinDrift.push({ path: driftPath, twin });
435
+ }
436
+ else {
437
+ trueDrift.push(driftPath);
438
+ }
439
+ }
440
+ // Report a separate true-drift error when there is actual non-twin
441
+ // drift, regardless of policy: the operator's claim should still
442
+ // cover everything they changed.
443
+ if (trueDrift.length > 0) {
444
+ output(io, args, {
445
+ ok: false,
446
+ errorCode: "slice_commit_path_drift",
447
+ details: {
448
+ sliceId: args.sliceId,
449
+ spanId: args.spanId,
450
+ claimedPaths,
451
+ driftPaths: trueDrift
452
+ },
453
+ message: `slice_commit_path_drift: ${trueDrift.join(", ")}`
454
+ }, "stderr");
455
+ return 2;
456
+ }
457
+ // strict-fence: lockfile twins still count as drift.
458
+ if (lockfileTwinDrift.length > 0 && lockfileTwinPolicy === "strict-fence") {
459
+ const driftPaths = lockfileTwinDrift.map((entry) => entry.path);
359
460
  output(io, args, {
360
461
  ok: false,
361
462
  errorCode: "slice_commit_path_drift",
@@ -363,13 +464,62 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
363
464
  sliceId: args.sliceId,
364
465
  spanId: args.spanId,
365
466
  claimedPaths,
366
- driftPaths: pathDrift
467
+ driftPaths,
468
+ lockfileTwinPolicy,
469
+ stackAdapterId: stackAdapter.id
367
470
  },
368
- message: `slice_commit_path_drift: ${pathDrift.join(", ")}`
471
+ message: `slice_commit_path_drift: ${driftPaths.join(", ")} (lockfileTwinPolicy=strict-fence)`
369
472
  }, "stderr");
370
473
  return 2;
371
474
  }
372
- const changedInClaim = changedPaths.filter((p) => matchesClaimedPath(p, claimedPaths));
475
+ // auto-revert: restore the lockfile, then exclude from changed set.
476
+ const revertedTwinPaths = [];
477
+ if (lockfileTwinDrift.length > 0 && lockfileTwinPolicy === "auto-revert") {
478
+ for (const entry of lockfileTwinDrift) {
479
+ try {
480
+ await execFileAsync("git", ["restore", "--", entry.path], { cwd: activeCwd });
481
+ revertedTwinPaths.push(entry.path);
482
+ }
483
+ catch {
484
+ // Fall through; if restore fails the drift will reappear in the
485
+ // recomputed status and we'll reject as drift.
486
+ }
487
+ }
488
+ changedPaths = await gitChangedPaths(activeCwd);
489
+ const remainingDrift = changedPaths.filter((p) => !matchesClaimedPath(p, claimedPaths));
490
+ if (remainingDrift.length > 0) {
491
+ output(io, args, {
492
+ ok: false,
493
+ errorCode: "slice_commit_path_drift",
494
+ details: {
495
+ sliceId: args.sliceId,
496
+ spanId: args.spanId,
497
+ claimedPaths,
498
+ driftPaths: remainingDrift,
499
+ lockfileTwinPolicy,
500
+ stackAdapterId: stackAdapter.id
501
+ },
502
+ message: `slice_commit_path_drift: ${remainingDrift.join(", ")}`
503
+ }, "stderr");
504
+ return 2;
505
+ }
506
+ }
507
+ // auto-include: add the twin path(s) to the effective claim so the
508
+ // commit picks them up. We don't mutate the persisted claim — only
509
+ // the in-memory list used for the upcoming `git add`.
510
+ const effectiveCommitPaths = [...claimedPaths];
511
+ const includedTwinPaths = [];
512
+ if (lockfileTwinDrift.length > 0 && lockfileTwinPolicy === "auto-include") {
513
+ for (const entry of lockfileTwinDrift) {
514
+ if (!effectiveCommitPaths.includes(entry.path)) {
515
+ effectiveCommitPaths.push(entry.path);
516
+ }
517
+ includedTwinPaths.push(entry.path);
518
+ }
519
+ }
520
+ const changedInClaim = changedPaths.filter((p) => matchesClaimedPath(p, claimedPaths) ||
521
+ (lockfileTwinPolicy === "auto-include" &&
522
+ findMatchingLockfileTwin(p, twinsForCommit) !== null));
373
523
  if (changedInClaim.length === 0) {
374
524
  await cleanupManagedWorktree();
375
525
  output(io, args, {
@@ -381,7 +531,7 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
381
531
  return 0;
382
532
  }
383
533
  try {
384
- await execFileAsync("git", ["add", "--", ...claimedPaths], {
534
+ await execFileAsync("git", ["add", "--", ...effectiveCommitPaths], {
385
535
  cwd: activeCwd
386
536
  });
387
537
  const taskPart = args.taskId && args.taskId.length > 0 ? args.taskId : "task";
@@ -459,6 +609,10 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
459
609
  changedPaths: changedInClaim,
460
610
  worktreePath: managedWorktreePath ?? undefined,
461
611
  degradedToInPlace: degradedToInPlace || undefined,
612
+ lockfileTwinPolicy,
613
+ lockfileTwinsIncluded: includedTwinPaths.length > 0 ? includedTwinPaths : undefined,
614
+ lockfileTwinsReverted: revertedTwinPaths.length > 0 ? revertedTwinPaths : undefined,
615
+ stackAdapterId: stackAdapter.id,
462
616
  message: `slice commit created for ${args.sliceId}: ${commitSha}`
463
617
  });
464
618
  return 0;
@@ -5,6 +5,7 @@ import { readDelegationEvents, readDelegationLedger } from "../delegation.js";
5
5
  import { readFlowState } from "../runs.js";
6
6
  import { DEFAULT_SLICE_STREAM_REL_PATH, readEventStreamFile } from "../streaming/event-stream.js";
7
7
  import { mergeParallelWaveDefinitions, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "./plan-split-waves.js";
8
+ import { compareSliceIds, parseSliceId } from "../util/slice-id.js";
8
9
  const PARALLEL_EXEC_MANAGED_START = "<!-- parallel-exec-managed-start -->";
9
10
  const PARALLEL_EXEC_MANAGED_END = "<!-- parallel-exec-managed-end -->";
10
11
  function parseArgs(tokens) {
@@ -94,9 +95,10 @@ function parseManagedWaveClaimedPaths(planMarkdown) {
94
95
  if (cells.every((cell) => /^:?-{3,}:?$/u.test(cell))) {
95
96
  continue;
96
97
  }
97
- const sliceId = cells[0].trim().toUpperCase();
98
- if (!/^S-\d+$/u.test(sliceId))
98
+ const parsedSlice = parseSliceId(cells[0]);
99
+ if (!parsedSlice)
99
100
  continue;
101
+ const sliceId = parsedSlice.id;
100
102
  const pathsIdx = headerIdx.get("claimedpaths");
101
103
  const rawPaths = pathsIdx !== undefined ? (cells[pathsIdx] ?? "") : "";
102
104
  const claimedPaths = rawPaths.length === 0
@@ -111,7 +113,7 @@ function parseManagedWaveClaimedPaths(planMarkdown) {
111
113
  }
112
114
  function detectPathConflicts(readySlices, bySlice) {
113
115
  const conflicts = new Set();
114
- const ordered = [...readySlices].sort();
116
+ const ordered = [...readySlices].sort(compareSliceIds);
115
117
  for (let i = 0; i < ordered.length; i += 1) {
116
118
  const leftSlice = ordered[i];
117
119
  const leftPaths = bySlice.get(leftSlice) ?? [];
@@ -338,7 +340,7 @@ export async function runWaveStatus(projectRoot, options = {}) {
338
340
  };
339
341
  }
340
342
  else {
341
- const readyToDispatch = [...firstOpenWave.readyMembers].sort();
343
+ const readyToDispatch = [...firstOpenWave.readyMembers].sort(compareSliceIds);
342
344
  const claimedPathsByWave = parseManagedWaveClaimedPaths(planRaw);
343
345
  const conflicts = detectPathConflicts(readyToDispatch, claimedPathsByWave.get(firstOpenWave.waveId) ?? new Map());
344
346
  const mode = conflicts.length > 0
@@ -20,3 +20,97 @@ export declare const STACK_DISCOVERY_MARKERS: readonly string[];
20
20
  * Directory markers (checked with pathExists) for stack discovery.
21
21
  */
22
22
  export declare const STACK_DISCOVERY_DIR_MARKERS: readonly string[];
23
+ export type StackAdapterId = "rust" | "node" | "python" | "go" | "ruby" | "php" | "swift" | "dotnet" | "elixir" | "java" | "unknown";
24
+ /** Twin describing manifest → lockfile coupling for a stack. */
25
+ export interface ManifestLockfileTwin {
26
+ /** Manifest glob (path relative to repo root). */
27
+ manifestGlob: string;
28
+ /** Lockfile glob that the manifest's package manager regenerates. */
29
+ lockfileGlob: string;
30
+ }
31
+ /**
32
+ * Wiring-aggregator contract — describes whether a new file in a stack
33
+ * needs an explicit aggregator/parent module update for the new module to
34
+ * be reachable from the rest of the project.
35
+ *
36
+ * - `aggregatorPattern` is a human-facing description; consumers should
37
+ * call `resolveAggregatorFor(filePath, repoState?)` to compute the
38
+ * concrete aggregator path.
39
+ * - `resolveAggregatorFor` returns the concrete repo-relative path of
40
+ * the aggregator file required to wire `filePath`, or `null` when no
41
+ * aggregator is required (e.g. file IS the aggregator, or the stack
42
+ * layout makes wiring implicit).
43
+ * - `repoState.headFiles` lets the resolver check whether sibling
44
+ * aggregators already exist (so e.g. node-ts only requires
45
+ * `index.ts` updates when an `index.ts` already exists in that
46
+ * directory).
47
+ */
48
+ export interface WiringAggregatorContract {
49
+ aggregatorPattern: string;
50
+ /**
51
+ * Resolve the aggregator path required to wire `filePath` into its
52
+ * parent module, given a snapshot of repo state. Return `null` when
53
+ * no aggregator update is required.
54
+ */
55
+ resolveAggregatorFor(filePath: string, repoState?: {
56
+ headFiles?: ReadonlySet<string>;
57
+ }): string | null;
58
+ }
59
+ /**
60
+ * Universal stack-adapter contract used by hooks (slice-commit lockfile
61
+ * twins), linters (`plan_module_introducing_slice_wires_root`), and
62
+ * future stack-specific evidence validators.
63
+ *
64
+ * Each stack returns:
65
+ * - `id` — short stable identifier; routes used elsewhere should match.
66
+ * - `displayName` — used in user-facing prose so error messages stay
67
+ * stack-agnostic at the surface ("Rust workspace" vs "Node project"
68
+ * are forbidden in generic code; use `adapter.displayName` instead).
69
+ * - `manifestGlobs` — repo-relative manifest paths the stack uses.
70
+ * - `lockfileTwins` — manifest→lockfile twin entries; auto-detected
71
+ * from disk at adapter init so node projects with yarn.lock get
72
+ * `yarn.lock`, pnpm gets `pnpm-lock.yaml`, etc.
73
+ * - `testCommandHints` — example test command lines for prompts and
74
+ * evidence validators (advisory; not authoritative).
75
+ * - `wiringAggregator` — see contract above. `undefined` when the
76
+ * stack has no aggregator pattern (Go, Java, Ruby, Swift, .NET,
77
+ * Elixir use implicit/automatic wiring).
78
+ */
79
+ export interface StackAdapter {
80
+ id: StackAdapterId;
81
+ displayName: string;
82
+ manifestGlobs: string[];
83
+ lockfileTwins: ManifestLockfileTwin[];
84
+ testCommandHints: string[];
85
+ wiringAggregator?: WiringAggregatorContract;
86
+ }
87
+ interface LoadStackAdapterOptions {
88
+ /**
89
+ * Override the project root for tests. Defaults to the supplied
90
+ * argument; primarily here so callers can inject a synthesized
91
+ * directory in fixtures.
92
+ */
93
+ projectRoot?: string;
94
+ }
95
+ /**
96
+ * Load the stack-adapter for a project. Walks the registered factories
97
+ * in order; the first detector that returns true wins. Returns the
98
+ * `unknown` adapter (no-op) when no detector matches.
99
+ *
100
+ * Adapter init reads the filesystem to auto-detect lockfile twins
101
+ * (e.g. yarn.lock vs package-lock.json). Callers should cache the
102
+ * adapter for the lifetime of the operation rather than calling this
103
+ * per-row.
104
+ */
105
+ export declare function loadStackAdapter(projectRoot: string, options?: LoadStackAdapterOptions): Promise<StackAdapter>;
106
+ /**
107
+ * Synthesize a stack adapter from explicit lockfile-twin overrides.
108
+ * Useful in tests that want to pin twins without a real filesystem
109
+ * scan, and for the linter test suite.
110
+ */
111
+ export declare function buildStackAdapterForTests(partial: Partial<StackAdapter> & {
112
+ id: StackAdapterId;
113
+ displayName: string;
114
+ }): StackAdapter;
115
+ export declare const UNKNOWN_STACK: StackAdapter;
116
+ export {};