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.
- package/dist/artifact-linter/plan.js +213 -5
- package/dist/artifact-linter/shared.d.ts +1 -0
- package/dist/artifact-linter/shared.js +5 -0
- package/dist/artifact-linter/ship.js +169 -1
- package/dist/artifact-linter/spec.js +33 -1
- package/dist/artifact-linter/tdd.d.ts +5 -0
- package/dist/artifact-linter/tdd.js +202 -2
- package/dist/config.d.ts +4 -1
- package/dist/config.js +24 -3
- package/dist/content/core-agents.js +15 -0
- package/dist/content/hooks.js +37 -0
- package/dist/content/stage-schema.js +6 -1
- package/dist/content/stages/plan.js +1 -0
- package/dist/content/stages/ship.js +4 -0
- package/dist/content/stages/spec.js +1 -0
- package/dist/content/stages/tdd.js +2 -0
- package/dist/content/templates.js +5 -0
- package/dist/delegation.d.ts +39 -0
- package/dist/delegation.js +66 -1
- package/dist/gate-evidence.js +10 -12
- package/dist/internal/advance-stage/start-flow.js +13 -4
- package/dist/internal/cohesion-contract-stub.js +2 -14
- package/dist/internal/plan-split-waves.js +19 -14
- package/dist/internal/slice-commit.js +161 -7
- package/dist/internal/wave-status.js +6 -4
- package/dist/stack-detection.d.ts +94 -0
- package/dist/stack-detection.js +431 -0
- package/dist/tdd-cycle.js +7 -5
- package/dist/types.d.ts +22 -0
- package/dist/util/slice-id.d.ts +58 -0
- package/dist/util/slice-id.js +89 -0
- package/package.json +1 -1
package/dist/delegation.js
CHANGED
|
@@ -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(
|
|
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 &&
|
package/dist/gate-evidence.js
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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+)
|
|
38
|
+
const u = /^U-(\d+)([a-z][a-z0-9]*)?$/iu.exec(t);
|
|
38
39
|
if (u) {
|
|
39
|
-
const
|
|
40
|
-
|
|
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
|
|
43
|
-
if (
|
|
44
|
-
const
|
|
45
|
-
return { unitId: `U-${
|
|
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
|
|
94
|
-
if (!
|
|
96
|
+
const parsedSlice = parseSliceId(col1);
|
|
97
|
+
if (!parsedSlice)
|
|
95
98
|
return null;
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
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/
|
|
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
|
|
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
|
|
358
|
-
|
|
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
|
|
467
|
+
driftPaths,
|
|
468
|
+
lockfileTwinPolicy,
|
|
469
|
+
stackAdapterId: stackAdapter.id
|
|
367
470
|
},
|
|
368
|
-
message: `slice_commit_path_drift: ${
|
|
471
|
+
message: `slice_commit_path_drift: ${driftPaths.join(", ")} (lockfileTwinPolicy=strict-fence)`
|
|
369
472
|
}, "stderr");
|
|
370
473
|
return 2;
|
|
371
474
|
}
|
|
372
|
-
|
|
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", "--", ...
|
|
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
|
|
98
|
-
if (
|
|
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 {};
|