cclaw-cli 7.0.5 → 7.1.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/tdd.js +142 -100
- package/dist/config.d.ts +4 -1
- package/dist/config.js +44 -5
- package/dist/content/core-agents.js +1 -0
- package/dist/content/hooks.d.ts +1 -0
- package/dist/content/hooks.js +116 -0
- package/dist/content/stages/tdd.js +4 -4
- package/dist/delegation.d.ts +55 -0
- package/dist/delegation.js +161 -0
- package/dist/install.js +3 -1
- package/dist/internal/advance-stage.js +15 -3
- package/dist/internal/slice-commit.d.ts +7 -0
- package/dist/internal/slice-commit.js +296 -0
- package/dist/tdd-verification-evidence.js +101 -10
- package/dist/types.d.ts +12 -0
- package/package.json +1 -1
package/dist/delegation.js
CHANGED
|
@@ -630,6 +630,151 @@ export class DispatchCapError extends Error {
|
|
|
630
630
|
this.pair = params.pair;
|
|
631
631
|
}
|
|
632
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* Patterns describing repo-relative paths owned by the cclaw managed
|
|
635
|
+
* runtime under `.cclaw/`. Workers MUST NOT claim these as
|
|
636
|
+
* `claimedPaths` because they are regenerated/rebound by `cclaw-cli sync`
|
|
637
|
+
* (and similar managed flows), and worker writes silently bypass the
|
|
638
|
+
* managed-resources manifest. Note: `.cclaw/artifacts/` is intentionally
|
|
639
|
+
* NOT protected — slice-builders legitimately write slice cards there.
|
|
640
|
+
*
|
|
641
|
+
* Motivated by the hox-session 7.0.5 finding: subagent S-36 hand-edited
|
|
642
|
+
* `.cclaw/hooks/delegation-record.mjs`, which had to be reverted because
|
|
643
|
+
* the next `cclaw-cli sync` would have stomped the change.
|
|
644
|
+
*/
|
|
645
|
+
const MANAGED_RUNTIME_PATH_PATTERNS = [
|
|
646
|
+
/^\.cclaw\/(hooks|agents|skills|commands|templates|seeds|rules|state)\//u,
|
|
647
|
+
/^\.cclaw\/config\.yaml$/u,
|
|
648
|
+
/^\.cclaw\/managed-resources\.json$/u,
|
|
649
|
+
/^\.cclaw\/\.flow-state\.guard\.json$/u
|
|
650
|
+
];
|
|
651
|
+
/**
|
|
652
|
+
* Return `true` when `path` is a repo-relative path owned by the cclaw
|
|
653
|
+
* managed runtime under `.cclaw/`. Used by `validateClaimedPathsNotProtected`
|
|
654
|
+
* during `appendDelegation` to reject `slice-builder` (or any worker)
|
|
655
|
+
* spans that try to claim ownership of cclaw-managed files. Does not
|
|
656
|
+
* normalise the input — callers pass the path exactly as the worker wrote
|
|
657
|
+
* it into `claimedPaths` so the error message points at the real string.
|
|
658
|
+
*/
|
|
659
|
+
export function isManagedRuntimePath(path) {
|
|
660
|
+
if (typeof path !== "string" || path.length === 0)
|
|
661
|
+
return false;
|
|
662
|
+
return MANAGED_RUNTIME_PATH_PATTERNS.some((pattern) => pattern.test(path));
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Thrown by `appendDelegation` when a scheduled span declares a
|
|
666
|
+
* `claimedPaths` entry that lives under the cclaw managed runtime
|
|
667
|
+
* (see `isManagedRuntimePath`). Workers must never edit those paths
|
|
668
|
+
* directly — they are owned by the managed sync surface. The error
|
|
669
|
+
* lists the offending paths so the operator can drop or rewrite them.
|
|
670
|
+
*/
|
|
671
|
+
export class DispatchClaimedPathProtectedError extends Error {
|
|
672
|
+
protectedPaths;
|
|
673
|
+
spanId;
|
|
674
|
+
constructor(params) {
|
|
675
|
+
super(`dispatch_claimed_path_protected — span ${params.spanId} claims managed-runtime path(s) ${params.protectedPaths.join(", ")}; ` +
|
|
676
|
+
`paths under .cclaw/{hooks,agents,skills,commands,templates,seeds,rules,state}/, .cclaw/config.yaml, .cclaw/managed-resources.json, and .cclaw/.flow-state.guard.json are owned by cclaw-cli sync and must not appear in claimedPaths. ` +
|
|
677
|
+
`Drop them from claimedPaths or, if a managed-runtime change is genuinely required, ship it through a cclaw release rather than a worker span.`);
|
|
678
|
+
this.name = "DispatchClaimedPathProtectedError";
|
|
679
|
+
this.protectedPaths = params.protectedPaths;
|
|
680
|
+
this.spanId = params.spanId;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Reject any worker span that declares `claimedPaths` entries owned by
|
|
685
|
+
* the cclaw managed runtime. Called from `appendDelegation` for
|
|
686
|
+
* `status === "scheduled"` rows alongside the overlap and fan-out
|
|
687
|
+
* checks. Throws `DispatchClaimedPathProtectedError` listing every
|
|
688
|
+
* offending path so the operator can fix the dispatch in one pass.
|
|
689
|
+
*/
|
|
690
|
+
export function validateClaimedPathsNotProtected(stamped) {
|
|
691
|
+
const claimed = Array.isArray(stamped.claimedPaths) ? stamped.claimedPaths : [];
|
|
692
|
+
if (claimed.length === 0)
|
|
693
|
+
return;
|
|
694
|
+
const offending = claimed.filter((p) => isManagedRuntimePath(p));
|
|
695
|
+
if (offending.length === 0)
|
|
696
|
+
return;
|
|
697
|
+
throw new DispatchClaimedPathProtectedError({
|
|
698
|
+
protectedPaths: offending,
|
|
699
|
+
spanId: stamped.spanId ?? "unknown"
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Thrown by `appendDelegation` when a new `scheduled` span would open a
|
|
704
|
+
* second TDD cycle for a slice that already has at least one closed span
|
|
705
|
+
* (a span with completed phase rows for `red`, `green`, at least one of
|
|
706
|
+
* `refactor`/`refactor-deferred`, and `doc`) in the same run. Re-running
|
|
707
|
+
* a slice under a fresh span is almost always controller drift —
|
|
708
|
+
* legitimate replay reuses the original spanId and is absorbed by the
|
|
709
|
+
* existing dedup. Motivated by the hox-session 7.0.5 finding where
|
|
710
|
+
* `S-36` had two scheduled spans (`span-w07-S-36-final` and `span-w07-S-36`)
|
|
711
|
+
* that the linter then misread as out-of-order phases.
|
|
712
|
+
*/
|
|
713
|
+
export class SliceAlreadyClosedError extends Error {
|
|
714
|
+
sliceId;
|
|
715
|
+
runId;
|
|
716
|
+
closedSpanId;
|
|
717
|
+
newSpanId;
|
|
718
|
+
constructor(params) {
|
|
719
|
+
super(`slice ${params.sliceId} already has a closed span (${params.closedSpanId}); refusing to schedule new span ${params.newSpanId} in run ${params.runId}`);
|
|
720
|
+
this.name = "SliceAlreadyClosedError";
|
|
721
|
+
this.sliceId = params.sliceId;
|
|
722
|
+
this.runId = params.runId;
|
|
723
|
+
this.closedSpanId = params.closedSpanId;
|
|
724
|
+
this.newSpanId = params.newSpanId;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Detect closed spans for `(sliceId, runId)`. A span is considered
|
|
729
|
+
* closed when it has completed phase rows for `red`, `green`, REFACTOR
|
|
730
|
+
* coverage (either `phase=refactor`, `phase=refactor-deferred`, or
|
|
731
|
+
* `phase=green` carrying `refactorOutcome`), AND `doc`. Returns the set of
|
|
732
|
+
* closed spanIds; callers use this to reject new scheduled spans on
|
|
733
|
+
* already-closed slices.
|
|
734
|
+
*/
|
|
735
|
+
function closedSliceSpans(prior, sliceId, runId) {
|
|
736
|
+
const closed = new Set();
|
|
737
|
+
if (typeof sliceId !== "string" || sliceId.length === 0)
|
|
738
|
+
return closed;
|
|
739
|
+
const matches = prior.filter((entry) => entry.sliceId === sliceId &&
|
|
740
|
+
entry.runId === runId &&
|
|
741
|
+
typeof entry.spanId === "string" &&
|
|
742
|
+
entry.spanId.length > 0);
|
|
743
|
+
const bySpan = new Map();
|
|
744
|
+
for (const entry of matches) {
|
|
745
|
+
const spanId = entry.spanId;
|
|
746
|
+
const existing = bySpan.get(spanId) ?? [];
|
|
747
|
+
existing.push(entry);
|
|
748
|
+
bySpan.set(spanId, existing);
|
|
749
|
+
}
|
|
750
|
+
for (const [spanId, entries] of bySpan.entries()) {
|
|
751
|
+
const phases = new Set(entries
|
|
752
|
+
.filter((e) => e.status === "completed" && typeof e.phase === "string")
|
|
753
|
+
.map((e) => e.phase));
|
|
754
|
+
const hasRed = phases.has("red");
|
|
755
|
+
const hasGreen = phases.has("green");
|
|
756
|
+
const hasRefactorPhase = phases.has("refactor") || phases.has("refactor-deferred");
|
|
757
|
+
const greens = entries.filter((e) => e.status === "completed" && e.phase === "green");
|
|
758
|
+
const greenWithOutcome = greens.find((e) => e.refactorOutcome &&
|
|
759
|
+
(e.refactorOutcome.mode === "inline" || e.refactorOutcome.mode === "deferred"));
|
|
760
|
+
let hasRefactorFromGreen = false;
|
|
761
|
+
if (greenWithOutcome?.refactorOutcome?.mode === "deferred") {
|
|
762
|
+
hasRefactorFromGreen = !!((greenWithOutcome.refactorOutcome.rationale &&
|
|
763
|
+
greenWithOutcome.refactorOutcome.rationale.trim().length > 0) ||
|
|
764
|
+
(Array.isArray(greenWithOutcome.evidenceRefs) &&
|
|
765
|
+
greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0)));
|
|
766
|
+
}
|
|
767
|
+
else if (greenWithOutcome?.refactorOutcome?.mode === "inline") {
|
|
768
|
+
hasRefactorFromGreen = true;
|
|
769
|
+
}
|
|
770
|
+
const hasRefactor = hasRefactorPhase || hasRefactorFromGreen;
|
|
771
|
+
const hasDoc = phases.has("doc");
|
|
772
|
+
if (hasRed && hasGreen && hasRefactor && hasDoc) {
|
|
773
|
+
closed.add(spanId);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return closed;
|
|
777
|
+
}
|
|
633
778
|
/**
|
|
634
779
|
* Default cap on active `slice-builder` spans in a single TDD run. Override
|
|
635
780
|
* via `CCLAW_MAX_PARALLEL_SLICE_BUILDERS=<int>` (validated `>=1`).
|
|
@@ -1037,7 +1182,23 @@ export async function appendDelegation(projectRoot, entry) {
|
|
|
1037
1182
|
return;
|
|
1038
1183
|
}
|
|
1039
1184
|
validateMonotonicTimestamps(stamped, prior.entries);
|
|
1185
|
+
if (stamped.status === "scheduled" &&
|
|
1186
|
+
typeof stamped.sliceId === "string" &&
|
|
1187
|
+
stamped.sliceId.length > 0 &&
|
|
1188
|
+
stamped.phase === undefined) {
|
|
1189
|
+
const closed = closedSliceSpans(prior.entries, stamped.sliceId, activeRunId);
|
|
1190
|
+
if (closed.size > 0 && !(stamped.spanId && closed.has(stamped.spanId))) {
|
|
1191
|
+
const closedSpanId = closed.values().next().value;
|
|
1192
|
+
throw new SliceAlreadyClosedError({
|
|
1193
|
+
sliceId: stamped.sliceId,
|
|
1194
|
+
runId: activeRunId,
|
|
1195
|
+
closedSpanId,
|
|
1196
|
+
newSpanId: stamped.spanId ?? "unknown"
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1040
1200
|
if (stamped.status === "scheduled") {
|
|
1201
|
+
validateClaimedPathsNotProtected(stamped);
|
|
1041
1202
|
const sameRunPrior = prior.entries.filter((entry) => entry.runId === activeRunId);
|
|
1042
1203
|
const activeForRun = computeActiveSubagents(sameRunPrior);
|
|
1043
1204
|
const overlap = validateFileOverlap(stamped, activeForRun);
|
package/dist/install.js
CHANGED
|
@@ -13,7 +13,7 @@ import { cancelCommandContract, cancelCommandSkillMarkdown } from "./content/can
|
|
|
13
13
|
import { subagentDrivenDevSkill, parallelAgentsSkill } from "./content/subagents.js";
|
|
14
14
|
import { sessionHooksSkillMarkdown } from "./content/session-hooks.js";
|
|
15
15
|
import { ironLawsSkillMarkdown } from "./content/iron-laws.js";
|
|
16
|
-
import { stageCompleteScript, startFlowScript, cancelRunScript, runHookCmdScript, delegationRecordScript, opencodePluginJs, claudeHooksJson, codexHooksJson, cursorHooksJson } from "./content/hooks.js";
|
|
16
|
+
import { stageCompleteScript, startFlowScript, cancelRunScript, runHookCmdScript, delegationRecordScript, sliceCommitScript, opencodePluginJs, claudeHooksJson, codexHooksJson, cursorHooksJson } from "./content/hooks.js";
|
|
17
17
|
import { nodeHookRuntimeScript } from "./content/node-hooks.js";
|
|
18
18
|
import { META_SKILL_NAME, usingCclawSkillMarkdown } from "./content/meta-skill.js";
|
|
19
19
|
import { ARTIFACT_TEMPLATES, CURSOR_GUIDELINES_RULE_MDC, CURSOR_WORKFLOW_RULE_MDC, RULEBOOK_MARKDOWN, buildRulesJson } from "./content/templates.js";
|
|
@@ -692,6 +692,7 @@ async function writeHooks(projectRoot, config) {
|
|
|
692
692
|
await writeFileSafe(path.join(hooksDir, "run-hook.mjs"), bundledHookRuntime ?? nodeHookRuntimeScript(hookRuntimeOptions));
|
|
693
693
|
await writeFileSafe(path.join(hooksDir, "run-hook.cmd"), runHookCmdScript());
|
|
694
694
|
await writeFileSafe(path.join(hooksDir, "delegation-record.mjs"), delegationRecordScript());
|
|
695
|
+
await writeFileSafe(path.join(hooksDir, "slice-commit.mjs"), sliceCommitScript());
|
|
695
696
|
const opencodePluginSource = opencodePluginJs();
|
|
696
697
|
await writeFileSafe(path.join(hooksDir, "opencode-plugin.mjs"), opencodePluginSource);
|
|
697
698
|
try {
|
|
@@ -701,6 +702,7 @@ async function writeHooks(projectRoot, config) {
|
|
|
701
702
|
"run-hook.mjs",
|
|
702
703
|
"run-hook.cmd",
|
|
703
704
|
"delegation-record.mjs",
|
|
705
|
+
"slice-commit.mjs",
|
|
704
706
|
"opencode-plugin.mjs",
|
|
705
707
|
"cancel-run.mjs"
|
|
706
708
|
]) {
|
|
@@ -14,10 +14,11 @@ 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, DispatchClaimedPathProtectedError, DispatchDuplicateError, DispatchOverlapError, SliceAlreadyClosedError } from "../delegation.js";
|
|
18
18
|
import { parsePlanSplitWavesArgs, runPlanSplitWaves } from "./plan-split-waves.js";
|
|
19
19
|
import { runWaveStatusCommand } from "./wave-status.js";
|
|
20
20
|
import { runCohesionContractCommand } from "./cohesion-contract-stub.js";
|
|
21
|
+
import { runSliceCommitCommand } from "./slice-commit.js";
|
|
21
22
|
/**
|
|
22
23
|
* Subcommands that mutate or consult flow-state.json via the CLI runtime.
|
|
23
24
|
* They all require the sha256 sidecar to match before continuing so a
|
|
@@ -35,7 +36,7 @@ const GUARD_ENFORCED_SUBCOMMANDS = new Set([
|
|
|
35
36
|
export async function runInternalCommand(projectRoot, argv, io) {
|
|
36
37
|
const [subcommand, ...tokens] = argv;
|
|
37
38
|
if (!subcommand) {
|
|
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 | wave-status | cohesion-contract\n");
|
|
39
|
+
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 | slice-commit | flow-state-repair | waiver-grant | plan-split-waves | wave-status | cohesion-contract\n");
|
|
39
40
|
return 1;
|
|
40
41
|
}
|
|
41
42
|
try {
|
|
@@ -81,6 +82,9 @@ export async function runInternalCommand(projectRoot, argv, io) {
|
|
|
81
82
|
if (subcommand === "hook") {
|
|
82
83
|
return await runHookCommand(projectRoot, parseHookArgs(tokens), io);
|
|
83
84
|
}
|
|
85
|
+
if (subcommand === "slice-commit") {
|
|
86
|
+
return await runSliceCommitCommand(projectRoot, tokens, io);
|
|
87
|
+
}
|
|
84
88
|
if (subcommand === "flow-state-repair") {
|
|
85
89
|
return await runFlowStateRepair(projectRoot, parseFlowStateRepairArgs(tokens), io);
|
|
86
90
|
}
|
|
@@ -96,7 +100,7 @@ export async function runInternalCommand(projectRoot, argv, io) {
|
|
|
96
100
|
if (subcommand === "cohesion-contract") {
|
|
97
101
|
return await runCohesionContractCommand(projectRoot, tokens, io);
|
|
98
102
|
}
|
|
99
|
-
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 | wave-status | cohesion-contract\n`);
|
|
103
|
+
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 | slice-commit | flow-state-repair | waiver-grant | plan-split-waves | wave-status | cohesion-contract\n`);
|
|
100
104
|
return 1;
|
|
101
105
|
}
|
|
102
106
|
catch (err) {
|
|
@@ -116,6 +120,14 @@ export async function runInternalCommand(projectRoot, argv, io) {
|
|
|
116
120
|
io.stderr.write(`error: dispatch_overlap — ${err.message}\n`);
|
|
117
121
|
return 2;
|
|
118
122
|
}
|
|
123
|
+
if (err instanceof DispatchClaimedPathProtectedError) {
|
|
124
|
+
io.stderr.write(`error: dispatch_claimed_path_protected — ${err.message}\n`);
|
|
125
|
+
return 2;
|
|
126
|
+
}
|
|
127
|
+
if (err instanceof SliceAlreadyClosedError) {
|
|
128
|
+
io.stderr.write(`error: slice_already_closed — ${err.message}\n`);
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
119
131
|
if (err instanceof DispatchCapError) {
|
|
120
132
|
io.stderr.write(`error: dispatch_cap — ${err.message}\n`);
|
|
121
133
|
return 2;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { readConfig, resolveTddCommitMode } from "../config.js";
|
|
5
|
+
import { readDelegationLedger } from "../delegation.js";
|
|
6
|
+
import { exists } from "../fs-utils.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
function parseCsv(raw) {
|
|
9
|
+
return raw
|
|
10
|
+
.split(",")
|
|
11
|
+
.map((value) => value.trim())
|
|
12
|
+
.filter((value) => value.length > 0);
|
|
13
|
+
}
|
|
14
|
+
function normalizePathLike(value) {
|
|
15
|
+
const slashes = value.replace(/\\/gu, "/");
|
|
16
|
+
const withoutDot = slashes.replace(/^\.\//u, "");
|
|
17
|
+
return withoutDot.replace(/\/+$/u, "");
|
|
18
|
+
}
|
|
19
|
+
function parseSliceCommitArgs(tokens) {
|
|
20
|
+
let sliceId = "";
|
|
21
|
+
let spanId = "";
|
|
22
|
+
let taskId;
|
|
23
|
+
let title;
|
|
24
|
+
let runId;
|
|
25
|
+
const claimedPaths = [];
|
|
26
|
+
let json = false;
|
|
27
|
+
let quiet = false;
|
|
28
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
29
|
+
const token = tokens[i];
|
|
30
|
+
const next = tokens[i + 1];
|
|
31
|
+
const valueFrom = (flag) => {
|
|
32
|
+
if (token.startsWith(`${flag}=`))
|
|
33
|
+
return token.slice(flag.length + 1);
|
|
34
|
+
if (token === flag && next && !next.startsWith("--")) {
|
|
35
|
+
i += 1;
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`${flag} requires a value.`);
|
|
39
|
+
};
|
|
40
|
+
if (token === "--json") {
|
|
41
|
+
json = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (token === "--quiet") {
|
|
45
|
+
quiet = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (token.startsWith("--slice=") || token === "--slice") {
|
|
49
|
+
sliceId = valueFrom("--slice").trim();
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (token.startsWith("--span-id=") || token === "--span-id") {
|
|
53
|
+
spanId = valueFrom("--span-id").trim();
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (token.startsWith("--task-id=") || token === "--task-id") {
|
|
57
|
+
taskId = valueFrom("--task-id").trim();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (token.startsWith("--title=") || token === "--title") {
|
|
61
|
+
title = valueFrom("--title").trim();
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (token.startsWith("--run-id=") || token === "--run-id") {
|
|
65
|
+
runId = valueFrom("--run-id").trim();
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (token.startsWith("--claimed-paths=") || token === "--claimed-paths") {
|
|
69
|
+
claimedPaths.push(...parseCsv(valueFrom("--claimed-paths")));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (token.startsWith("--claimed-path=") || token === "--claimed-path") {
|
|
73
|
+
const one = valueFrom("--claimed-path").trim();
|
|
74
|
+
if (one.length > 0)
|
|
75
|
+
claimedPaths.push(one);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`Unknown flag for internal slice-commit: ${token}`);
|
|
79
|
+
}
|
|
80
|
+
if (sliceId.length === 0) {
|
|
81
|
+
throw new Error("internal slice-commit requires --slice=<S-N>.");
|
|
82
|
+
}
|
|
83
|
+
if (spanId.length === 0) {
|
|
84
|
+
throw new Error("internal slice-commit requires --span-id=<span-id>.");
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
sliceId,
|
|
88
|
+
spanId,
|
|
89
|
+
taskId,
|
|
90
|
+
title,
|
|
91
|
+
runId,
|
|
92
|
+
claimedPaths,
|
|
93
|
+
json,
|
|
94
|
+
quiet
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function output(io, args, payload, channel = "stdout") {
|
|
98
|
+
if (args.quiet && channel === "stdout")
|
|
99
|
+
return;
|
|
100
|
+
const writer = channel === "stdout" ? io.stdout : io.stderr;
|
|
101
|
+
if (args.json) {
|
|
102
|
+
writer.write(`${JSON.stringify(payload)}\n`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const message = typeof payload.message === "string"
|
|
106
|
+
? payload.message
|
|
107
|
+
: JSON.stringify(payload);
|
|
108
|
+
writer.write(`${message}\n`);
|
|
109
|
+
}
|
|
110
|
+
function parsePorcelainPaths(raw) {
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const line of raw.split(/\r?\n/gu)) {
|
|
113
|
+
const trimmed = line.trimEnd();
|
|
114
|
+
if (trimmed.length < 4)
|
|
115
|
+
continue;
|
|
116
|
+
// porcelain line shape: XY<space><path>
|
|
117
|
+
const status = trimmed.slice(0, 2);
|
|
118
|
+
if (status === "??") {
|
|
119
|
+
const p = normalizePathLike(trimmed.slice(3).trim());
|
|
120
|
+
if (p.length > 0)
|
|
121
|
+
out.push(p);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let p = trimmed.slice(3).trim();
|
|
125
|
+
const renameIdx = p.indexOf(" -> ");
|
|
126
|
+
if (renameIdx >= 0) {
|
|
127
|
+
p = p.slice(renameIdx + 4);
|
|
128
|
+
}
|
|
129
|
+
p = normalizePathLike(p.replace(/^"/u, "").replace(/"$/u, ""));
|
|
130
|
+
if (p.length > 0)
|
|
131
|
+
out.push(p);
|
|
132
|
+
}
|
|
133
|
+
return [...new Set(out)];
|
|
134
|
+
}
|
|
135
|
+
function matchesClaimedPath(changedPath, claimedPaths) {
|
|
136
|
+
const changed = normalizePathLike(changedPath);
|
|
137
|
+
return claimedPaths.some((rawClaimed) => {
|
|
138
|
+
const claimed = normalizePathLike(rawClaimed);
|
|
139
|
+
if (claimed.length === 0)
|
|
140
|
+
return false;
|
|
141
|
+
if (changed === claimed)
|
|
142
|
+
return true;
|
|
143
|
+
return changed.startsWith(`${claimed}/`);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function resolveClaimedPathsFromLedger(projectRoot, args) {
|
|
147
|
+
const ledger = await readDelegationLedger(projectRoot);
|
|
148
|
+
const matches = ledger.entries.filter((entry) => entry.stage === "tdd" &&
|
|
149
|
+
entry.agent === "slice-builder" &&
|
|
150
|
+
entry.sliceId === args.sliceId &&
|
|
151
|
+
entry.spanId === args.spanId &&
|
|
152
|
+
(!args.runId || entry.runId === args.runId) &&
|
|
153
|
+
Array.isArray(entry.claimedPaths) &&
|
|
154
|
+
entry.claimedPaths.length > 0);
|
|
155
|
+
matches.sort((a, b) => {
|
|
156
|
+
const aTs = a.ts ?? a.startTs ?? "";
|
|
157
|
+
const bTs = b.ts ?? b.startTs ?? "";
|
|
158
|
+
return aTs < bTs ? 1 : aTs > bTs ? -1 : 0;
|
|
159
|
+
});
|
|
160
|
+
const fromLedger = matches[0]?.claimedPaths ?? [];
|
|
161
|
+
return [...new Set(fromLedger.map((p) => normalizePathLike(p)).filter((p) => p.length > 0))];
|
|
162
|
+
}
|
|
163
|
+
export async function runSliceCommitCommand(projectRoot, tokens, io) {
|
|
164
|
+
let args;
|
|
165
|
+
try {
|
|
166
|
+
args = parseSliceCommitArgs(tokens);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
io.stderr.write(`cclaw internal slice-commit: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
const config = await readConfig(projectRoot).catch(() => null);
|
|
173
|
+
const commitMode = resolveTddCommitMode(config);
|
|
174
|
+
if (commitMode !== "managed-per-slice") {
|
|
175
|
+
output(io, args, {
|
|
176
|
+
ok: true,
|
|
177
|
+
skipped: true,
|
|
178
|
+
reason: "commit-mode-not-managed",
|
|
179
|
+
commitMode,
|
|
180
|
+
message: `slice-commit skipped: commitMode=${commitMode}`
|
|
181
|
+
});
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
const gitPresent = await exists(path.join(projectRoot, ".git"));
|
|
185
|
+
if (!gitPresent) {
|
|
186
|
+
output(io, args, {
|
|
187
|
+
ok: true,
|
|
188
|
+
skipped: true,
|
|
189
|
+
reason: "no-git",
|
|
190
|
+
message: "slice-commit skipped: .git is missing"
|
|
191
|
+
});
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const claimedPaths = args.claimedPaths.length > 0
|
|
195
|
+
? [...new Set(args.claimedPaths.map((p) => normalizePathLike(p)).filter((p) => p.length > 0))]
|
|
196
|
+
: await resolveClaimedPathsFromLedger(projectRoot, args);
|
|
197
|
+
if (claimedPaths.length === 0) {
|
|
198
|
+
output(io, args, {
|
|
199
|
+
ok: false,
|
|
200
|
+
errorCode: "slice_commit_claimed_paths_missing",
|
|
201
|
+
details: {
|
|
202
|
+
sliceId: args.sliceId,
|
|
203
|
+
spanId: args.spanId
|
|
204
|
+
},
|
|
205
|
+
message: `slice_commit_claimed_paths_missing: no claimed paths for ${args.sliceId}/${args.spanId}`
|
|
206
|
+
}, "stderr");
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
const { stdout: statusRaw } = await execFileAsync("git", ["status", "--porcelain", "-uall"], {
|
|
210
|
+
cwd: projectRoot
|
|
211
|
+
});
|
|
212
|
+
const changedPaths = parsePorcelainPaths(statusRaw);
|
|
213
|
+
if (changedPaths.length === 0) {
|
|
214
|
+
output(io, args, {
|
|
215
|
+
ok: true,
|
|
216
|
+
skipped: true,
|
|
217
|
+
reason: "no-changes",
|
|
218
|
+
message: `slice-commit skipped: no working-tree changes for ${args.sliceId}`
|
|
219
|
+
});
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
const pathDrift = changedPaths.filter((p) => !matchesClaimedPath(p, claimedPaths));
|
|
223
|
+
if (pathDrift.length > 0) {
|
|
224
|
+
output(io, args, {
|
|
225
|
+
ok: false,
|
|
226
|
+
errorCode: "slice_commit_path_drift",
|
|
227
|
+
details: {
|
|
228
|
+
sliceId: args.sliceId,
|
|
229
|
+
spanId: args.spanId,
|
|
230
|
+
claimedPaths,
|
|
231
|
+
driftPaths: pathDrift
|
|
232
|
+
},
|
|
233
|
+
message: `slice_commit_path_drift: ${pathDrift.join(", ")}`
|
|
234
|
+
}, "stderr");
|
|
235
|
+
return 2;
|
|
236
|
+
}
|
|
237
|
+
const changedInClaim = changedPaths.filter((p) => matchesClaimedPath(p, claimedPaths));
|
|
238
|
+
if (changedInClaim.length === 0) {
|
|
239
|
+
output(io, args, {
|
|
240
|
+
ok: true,
|
|
241
|
+
skipped: true,
|
|
242
|
+
reason: "claimed-paths-unchanged",
|
|
243
|
+
message: `slice-commit skipped: no changes within claimed paths for ${args.sliceId}`
|
|
244
|
+
});
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await execFileAsync("git", ["add", "--", ...claimedPaths], {
|
|
249
|
+
cwd: projectRoot
|
|
250
|
+
});
|
|
251
|
+
const taskPart = args.taskId && args.taskId.length > 0 ? args.taskId : "task";
|
|
252
|
+
const titlePart = args.title && args.title.length > 0 ? args.title : "slice update";
|
|
253
|
+
const header = `${args.sliceId}/${taskPart}: ${titlePart}`;
|
|
254
|
+
const body = [
|
|
255
|
+
`span-id: ${args.spanId}`,
|
|
256
|
+
`run-id: ${args.runId ?? "unknown"}`,
|
|
257
|
+
"phase-cycle: red->green->refactor->doc"
|
|
258
|
+
].join("\n");
|
|
259
|
+
await execFileAsync("git", ["commit", "-m", header, "-m", body], {
|
|
260
|
+
cwd: projectRoot
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
265
|
+
if (/nothing to commit/iu.test(message)) {
|
|
266
|
+
output(io, args, {
|
|
267
|
+
ok: true,
|
|
268
|
+
skipped: true,
|
|
269
|
+
reason: "nothing-to-commit",
|
|
270
|
+
message: `slice-commit skipped: nothing to commit for ${args.sliceId}`
|
|
271
|
+
});
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
output(io, args, {
|
|
275
|
+
ok: false,
|
|
276
|
+
errorCode: "slice_commit_failed",
|
|
277
|
+
details: { message },
|
|
278
|
+
message: `slice_commit_failed: ${message}`
|
|
279
|
+
}, "stderr");
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
const { stdout: shaStdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
|
|
283
|
+
cwd: projectRoot
|
|
284
|
+
});
|
|
285
|
+
const commitSha = shaStdout.trim();
|
|
286
|
+
output(io, args, {
|
|
287
|
+
ok: true,
|
|
288
|
+
commitSha,
|
|
289
|
+
sliceId: args.sliceId,
|
|
290
|
+
spanId: args.spanId,
|
|
291
|
+
claimedPaths,
|
|
292
|
+
changedPaths: changedInClaim,
|
|
293
|
+
message: `slice commit created for ${args.sliceId}: ${commitSha}`
|
|
294
|
+
});
|
|
295
|
+
return 0;
|
|
296
|
+
}
|