cclaw-cli 6.8.0 → 6.10.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/design.js +1 -1
- package/dist/artifact-linter/plan.js +37 -0
- package/dist/artifact-linter/shared.d.ts +48 -2
- package/dist/artifact-linter/shared.js +54 -5
- package/dist/artifact-linter/tdd.d.ts +31 -0
- package/dist/artifact-linter/tdd.js +357 -17
- package/dist/artifact-linter.js +87 -2
- package/dist/content/examples.js +9 -9
- package/dist/content/harness-doc.js +1 -1
- package/dist/content/hooks.js +140 -3
- package/dist/content/iron-laws.js +6 -2
- package/dist/content/node-hooks.js +15 -1308
- package/dist/content/reference-patterns.js +2 -2
- package/dist/content/skills-elicitation.js +2 -2
- package/dist/content/skills.js +1 -1
- package/dist/content/stages/brainstorm.js +2 -2
- package/dist/content/stages/design.js +2 -2
- package/dist/content/stages/scope.js +2 -2
- package/dist/content/stages/tdd.js +7 -8
- package/dist/content/subagents.js +20 -2
- package/dist/content/templates.js +5 -15
- package/dist/delegation.d.ts +102 -3
- package/dist/delegation.js +172 -14
- package/dist/early-loop.js +15 -1
- package/dist/gate-evidence.js +15 -23
- package/dist/harness-adapters.js +4 -2
- package/dist/install.js +37 -221
- package/dist/internal/advance-stage.js +19 -3
- package/dist/internal/detect-supply-chain-changes.d.ts +6 -0
- package/dist/internal/detect-supply-chain-changes.js +138 -0
- package/dist/internal/flow-state-repair.d.ts +7 -0
- package/dist/internal/flow-state-repair.js +57 -18
- package/dist/internal/plan-split-waves.d.ts +66 -0
- package/dist/internal/plan-split-waves.js +249 -0
- package/dist/run-persistence.d.ts +2 -0
- package/dist/run-persistence.js +62 -3
- package/dist/runtime/run-hook.mjs +44 -8729
- package/dist/tdd-slices.d.ts +90 -0
- package/dist/tdd-slices.js +375 -0
- package/package.json +1 -1
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { RUNTIME_ROOT } from "../constants.js";
|
|
4
|
+
import { clampEarlyLoopStatusForWrite, computeEarlyLoopStatus, isEarlyLoopStage } from "../early-loop.js";
|
|
5
|
+
import { writeFileSafe } from "../fs-utils.js";
|
|
3
6
|
import { repairFlowStateGuard } from "../run-persistence.js";
|
|
7
|
+
import { readFlowState } from "../runs.js";
|
|
4
8
|
export function parseFlowStateRepairArgs(tokens) {
|
|
5
9
|
let reason;
|
|
6
10
|
let json = false;
|
|
7
11
|
let quiet = false;
|
|
12
|
+
let earlyLoop = false;
|
|
8
13
|
for (let i = 0; i < tokens.length; i += 1) {
|
|
9
14
|
const token = tokens[i];
|
|
10
15
|
const nextToken = tokens[i + 1];
|
|
@@ -16,6 +21,10 @@ export function parseFlowStateRepairArgs(tokens) {
|
|
|
16
21
|
quiet = true;
|
|
17
22
|
continue;
|
|
18
23
|
}
|
|
24
|
+
if (token === "--early-loop") {
|
|
25
|
+
earlyLoop = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
19
28
|
if (token === "--reason") {
|
|
20
29
|
if (!nextToken || nextToken.startsWith("--")) {
|
|
21
30
|
throw new Error("--reason requires a short slug value.");
|
|
@@ -33,33 +42,63 @@ export function parseFlowStateRepairArgs(tokens) {
|
|
|
33
42
|
if (!reason || reason.length === 0) {
|
|
34
43
|
throw new Error("internal flow-state-repair requires --reason=<slug> (e.g. --reason=manual_edit_recovery).");
|
|
35
44
|
}
|
|
36
|
-
return { reason, json, quiet };
|
|
45
|
+
return { reason, json, quiet, earlyLoop };
|
|
46
|
+
}
|
|
47
|
+
async function repairEarlyLoopFile(projectRoot, io) {
|
|
48
|
+
const flow = await readFlowState(projectRoot).catch(() => null);
|
|
49
|
+
if (!flow) {
|
|
50
|
+
return { performed: false, skipped: "flow-state-unreadable" };
|
|
51
|
+
}
|
|
52
|
+
const stage = flow.currentStage;
|
|
53
|
+
if (!isEarlyLoopStage(stage)) {
|
|
54
|
+
return { performed: false, skipped: `current-stage-${stage}-not-early-loop` };
|
|
55
|
+
}
|
|
56
|
+
const runId = flow.activeRunId.trim();
|
|
57
|
+
if (runId.length === 0) {
|
|
58
|
+
io.stderr.write("cclaw internal flow-state-repair --early-loop: active run has no runId; cannot derive canonical early-loop.json.\n");
|
|
59
|
+
return { performed: false, skipped: "missing-active-runId" };
|
|
60
|
+
}
|
|
61
|
+
const stateDir = path.join(projectRoot, RUNTIME_ROOT, "state");
|
|
62
|
+
const logPath = path.join(stateDir, "early-loop-log.jsonl");
|
|
63
|
+
const status = await computeEarlyLoopStatus(stage, runId, logPath);
|
|
64
|
+
const persisted = clampEarlyLoopStatusForWrite(status);
|
|
65
|
+
const finalStatus = persisted.status;
|
|
66
|
+
const target = path.join(stateDir, "early-loop.json");
|
|
67
|
+
await writeFileSafe(target, `${JSON.stringify(finalStatus, null, 2)}\n`);
|
|
68
|
+
return {
|
|
69
|
+
performed: true,
|
|
70
|
+
stage,
|
|
71
|
+
runId,
|
|
72
|
+
iteration: finalStatus.iteration,
|
|
73
|
+
openConcernCount: finalStatus.openConcerns.length
|
|
74
|
+
};
|
|
37
75
|
}
|
|
38
76
|
export async function runFlowStateRepair(projectRoot, args, io) {
|
|
39
77
|
const result = await repairFlowStateGuard(projectRoot, args.reason);
|
|
40
78
|
const logRel = path.relative(projectRoot, result.repairLogPath).replace(/\\/gu, "/");
|
|
41
79
|
const guardRel = path.relative(projectRoot, result.guardPath).replace(/\\/gu, "/");
|
|
80
|
+
let earlyLoopOutcome = null;
|
|
81
|
+
if (args.earlyLoop) {
|
|
82
|
+
earlyLoopOutcome = await repairEarlyLoopFile(projectRoot, io);
|
|
83
|
+
}
|
|
84
|
+
void fs;
|
|
85
|
+
const payload = {
|
|
86
|
+
ok: true,
|
|
87
|
+
command: "flow-state-repair",
|
|
88
|
+
reason: args.reason,
|
|
89
|
+
sidecar: result.sidecar,
|
|
90
|
+
guardPath: guardRel,
|
|
91
|
+
repairLogPath: logRel,
|
|
92
|
+
completedStageMetaBackfilled: result.completedStageMetaBackfilled,
|
|
93
|
+
earlyLoop: earlyLoopOutcome,
|
|
94
|
+
runtimeRoot: RUNTIME_ROOT
|
|
95
|
+
};
|
|
42
96
|
if (args.json) {
|
|
43
|
-
io.stdout.write(`${JSON.stringify(
|
|
44
|
-
ok: true,
|
|
45
|
-
command: "flow-state-repair",
|
|
46
|
-
reason: args.reason,
|
|
47
|
-
sidecar: result.sidecar,
|
|
48
|
-
guardPath: guardRel,
|
|
49
|
-
repairLogPath: logRel,
|
|
50
|
-
runtimeRoot: RUNTIME_ROOT
|
|
51
|
-
})}\n`);
|
|
97
|
+
io.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
52
98
|
return 0;
|
|
53
99
|
}
|
|
54
100
|
if (!args.quiet) {
|
|
55
|
-
io.stdout.write(`${JSON.stringify(
|
|
56
|
-
ok: true,
|
|
57
|
-
command: "flow-state-repair",
|
|
58
|
-
reason: args.reason,
|
|
59
|
-
sidecar: result.sidecar,
|
|
60
|
-
guardPath: guardRel,
|
|
61
|
-
repairLogPath: logRel
|
|
62
|
-
}, null, 2)}\n`);
|
|
101
|
+
io.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
63
102
|
}
|
|
64
103
|
return 0;
|
|
65
104
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Writable } from "node:stream";
|
|
2
|
+
interface InternalIo {
|
|
3
|
+
stdout: Writable;
|
|
4
|
+
stderr: Writable;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* v6.10.0 (P3) — split a large `05-plan.md` Implementation Units section
|
|
8
|
+
* into wave-NN.md sub-files so an executor can carry one wave at a time
|
|
9
|
+
* without re-reading the whole plan.
|
|
10
|
+
*
|
|
11
|
+
* Threshold contract:
|
|
12
|
+
* - total units < SMALL_PLAN_THRESHOLD → no-op, exit 0.
|
|
13
|
+
* - total units >= SMALL_PLAN_THRESHOLD → split into waves of `--wave-size`
|
|
14
|
+
* (default 25).
|
|
15
|
+
*
|
|
16
|
+
* Files written:
|
|
17
|
+
* - `<artifacts-dir>/wave-plans/wave-NN.md` per wave (1-indexed).
|
|
18
|
+
* - In-place update to `05-plan.md` adding (or refreshing) a
|
|
19
|
+
* `## Wave Plans` section between
|
|
20
|
+
* `<!-- wave-split-managed-start -->` and `<!-- wave-split-managed-end -->`
|
|
21
|
+
* markers. Outside-marker content is preserved verbatim.
|
|
22
|
+
*
|
|
23
|
+
* `--dry-run` prints the plan but does not write. `--force` overwrites
|
|
24
|
+
* existing wave files; without it, the command refuses to clobber.
|
|
25
|
+
*/
|
|
26
|
+
export interface PlanSplitWavesArgs {
|
|
27
|
+
waveSize: number;
|
|
28
|
+
dryRun: boolean;
|
|
29
|
+
force: boolean;
|
|
30
|
+
json: boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare const PLAN_SPLIT_DEFAULT_WAVE_SIZE = 25;
|
|
33
|
+
export declare const PLAN_SPLIT_SMALL_PLAN_THRESHOLD = 50;
|
|
34
|
+
export interface ParsedImplementationUnit {
|
|
35
|
+
id: string;
|
|
36
|
+
/**
|
|
37
|
+
* The full markdown body of this unit, starting at the
|
|
38
|
+
* `### Implementation Unit U-N` heading and ending right before the
|
|
39
|
+
* next unit heading (or the next `## ` H2, or end of file).
|
|
40
|
+
*/
|
|
41
|
+
body: string;
|
|
42
|
+
/** Repo-relative path declarations from the optional `Files:` line. */
|
|
43
|
+
paths: string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parse `## Implementation Units` section into individual unit blocks.
|
|
47
|
+
* Recognizes the canonical heading shape in the TDD-velocity plan template
|
|
48
|
+
* (`### Implementation Unit U-<n>`). Tolerant of `Files:` listed either
|
|
49
|
+
* inline or as a `- **Files (...):**` bullet block.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseImplementationUnits(planMarkdown: string): ParsedImplementationUnit[];
|
|
52
|
+
/**
|
|
53
|
+
* Pull repo-relative paths from a `Files:` line or the `Files (...)` bullet
|
|
54
|
+
* block. Both shapes appear in the wild; the parser extracts after the colon
|
|
55
|
+
* and splits on commas. Empty/whitespace items are dropped.
|
|
56
|
+
*/
|
|
57
|
+
export declare function extractPathsLine(unitBody: string): string[];
|
|
58
|
+
export declare function parsePlanSplitWavesArgs(tokens: string[]): PlanSplitWavesArgs;
|
|
59
|
+
/**
|
|
60
|
+
* Replace any existing managed Wave Plans block with the new one, or append
|
|
61
|
+
* it at the end of the file when no markers are present yet. The helper
|
|
62
|
+
* never touches text outside the markers.
|
|
63
|
+
*/
|
|
64
|
+
export declare function upsertWavePlansSection(planMarkdown: string, managedBlock: string): string;
|
|
65
|
+
export declare function runPlanSplitWaves(projectRoot: string, args: PlanSplitWavesArgs, io: InternalIo): Promise<number>;
|
|
66
|
+
export {};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveArtifactPath } from "../artifact-paths.js";
|
|
4
|
+
import { exists, writeFileSafe } from "../fs-utils.js";
|
|
5
|
+
import { readFlowState } from "../runs.js";
|
|
6
|
+
export const PLAN_SPLIT_DEFAULT_WAVE_SIZE = 25;
|
|
7
|
+
export const PLAN_SPLIT_SMALL_PLAN_THRESHOLD = 50;
|
|
8
|
+
const WAVE_PLANS_DIR = "wave-plans";
|
|
9
|
+
const WAVE_MANAGED_START = "<!-- wave-split-managed-start -->";
|
|
10
|
+
const WAVE_MANAGED_END = "<!-- wave-split-managed-end -->";
|
|
11
|
+
/**
|
|
12
|
+
* Parse `## Implementation Units` section into individual unit blocks.
|
|
13
|
+
* Recognizes the canonical heading shape in the TDD-velocity plan template
|
|
14
|
+
* (`### Implementation Unit U-<n>`). Tolerant of `Files:` listed either
|
|
15
|
+
* inline or as a `- **Files (...):**` bullet block.
|
|
16
|
+
*/
|
|
17
|
+
export function parseImplementationUnits(planMarkdown) {
|
|
18
|
+
const units = [];
|
|
19
|
+
const headingRegex = /(^|\n)###\s+Implementation Unit\s+(U-\d+)\b/gu;
|
|
20
|
+
const matches = [];
|
|
21
|
+
let match;
|
|
22
|
+
while ((match = headingRegex.exec(planMarkdown)) !== null) {
|
|
23
|
+
const offset = match[1] === "" ? 0 : 1; // strip the leading newline if present
|
|
24
|
+
matches.push({
|
|
25
|
+
id: match[2],
|
|
26
|
+
start: match.index + offset,
|
|
27
|
+
headingEnd: match.index + match[0].length
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
for (let i = 0; i < matches.length; i += 1) {
|
|
31
|
+
const current = matches[i];
|
|
32
|
+
const next = matches[i + 1];
|
|
33
|
+
let endIndex = next ? next.start : planMarkdown.length;
|
|
34
|
+
// If a higher-level H2 (`## ...`) appears before the next unit, end at the H2.
|
|
35
|
+
const tail = planMarkdown.slice(current.headingEnd, endIndex);
|
|
36
|
+
const sectionBreak = /\n##\s+\S/u.exec(tail);
|
|
37
|
+
if (sectionBreak) {
|
|
38
|
+
endIndex = current.headingEnd + sectionBreak.index + 1; // include the trailing newline
|
|
39
|
+
}
|
|
40
|
+
const body = planMarkdown.slice(current.start, endIndex).replace(/\s+$/u, "");
|
|
41
|
+
units.push({
|
|
42
|
+
id: current.id,
|
|
43
|
+
body,
|
|
44
|
+
paths: extractPathsLine(body)
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return units;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Pull repo-relative paths from a `Files:` line or the `Files (...)` bullet
|
|
51
|
+
* block. Both shapes appear in the wild; the parser extracts after the colon
|
|
52
|
+
* and splits on commas. Empty/whitespace items are dropped.
|
|
53
|
+
*/
|
|
54
|
+
export function extractPathsLine(unitBody) {
|
|
55
|
+
const lines = unitBody.split(/\r?\n/u);
|
|
56
|
+
for (const rawLine of lines) {
|
|
57
|
+
const line = rawLine.trim();
|
|
58
|
+
const filesMatch = /^[-*]?\s*\*?\*?Files\s*(?:\([^)]*\))?\s*:\*?\*?\s*(.*)$/iu.exec(line);
|
|
59
|
+
if (!filesMatch)
|
|
60
|
+
continue;
|
|
61
|
+
const remainder = filesMatch[1].trim();
|
|
62
|
+
if (remainder.length === 0)
|
|
63
|
+
continue;
|
|
64
|
+
return remainder
|
|
65
|
+
.split(",")
|
|
66
|
+
.map((item) => item.replace(/[`*]/gu, "").trim())
|
|
67
|
+
.filter((item) => item.length > 0);
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
export function parsePlanSplitWavesArgs(tokens) {
|
|
72
|
+
let waveSize = PLAN_SPLIT_DEFAULT_WAVE_SIZE;
|
|
73
|
+
let dryRun = false;
|
|
74
|
+
let force = false;
|
|
75
|
+
let json = false;
|
|
76
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
77
|
+
const token = tokens[i];
|
|
78
|
+
const next = tokens[i + 1];
|
|
79
|
+
if (token === "--dry-run") {
|
|
80
|
+
dryRun = true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (token === "--force") {
|
|
84
|
+
force = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (token === "--json") {
|
|
88
|
+
json = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (token === "--wave-size" || token.startsWith("--wave-size=")) {
|
|
92
|
+
let raw = "";
|
|
93
|
+
if (token.startsWith("--wave-size=")) {
|
|
94
|
+
raw = token.slice("--wave-size=".length);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
if (next === undefined || next.startsWith("--")) {
|
|
98
|
+
throw new Error("--wave-size requires an integer value.");
|
|
99
|
+
}
|
|
100
|
+
raw = next;
|
|
101
|
+
i += 1;
|
|
102
|
+
}
|
|
103
|
+
const trimmed = raw.trim();
|
|
104
|
+
if (!/^[0-9]+$/u.test(trimmed)) {
|
|
105
|
+
throw new Error("--wave-size must be a positive integer.");
|
|
106
|
+
}
|
|
107
|
+
waveSize = Number(trimmed);
|
|
108
|
+
if (waveSize < 1) {
|
|
109
|
+
throw new Error("--wave-size must be >= 1.");
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`Unknown flag for internal plan-split-waves: ${token}`);
|
|
114
|
+
}
|
|
115
|
+
return { waveSize, dryRun, force, json };
|
|
116
|
+
}
|
|
117
|
+
function padWaveIndex(index) {
|
|
118
|
+
return index.toString().padStart(2, "0");
|
|
119
|
+
}
|
|
120
|
+
function buildWaveFileBody(waveIndex, units, sourceLabel) {
|
|
121
|
+
const idsRange = `${units[0].id}..${units[units.length - 1].id}`;
|
|
122
|
+
return [
|
|
123
|
+
`# Wave ${padWaveIndex(waveIndex)}`,
|
|
124
|
+
"",
|
|
125
|
+
`Source: ${sourceLabel} units ${idsRange}`,
|
|
126
|
+
"",
|
|
127
|
+
"## Implementation Units",
|
|
128
|
+
"",
|
|
129
|
+
units.map((unit) => unit.body.trim()).join("\n\n"),
|
|
130
|
+
""
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
function buildWavePlansSection(waveFiles) {
|
|
134
|
+
const lines = [];
|
|
135
|
+
lines.push(WAVE_MANAGED_START);
|
|
136
|
+
lines.push("## Wave Plans");
|
|
137
|
+
lines.push("");
|
|
138
|
+
for (let i = 0; i < waveFiles.length; i += 1) {
|
|
139
|
+
lines.push(`- Wave ${padWaveIndex(i + 1)}: \`${waveFiles[i]}\``);
|
|
140
|
+
}
|
|
141
|
+
lines.push("");
|
|
142
|
+
lines.push(WAVE_MANAGED_END);
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Replace any existing managed Wave Plans block with the new one, or append
|
|
147
|
+
* it at the end of the file when no markers are present yet. The helper
|
|
148
|
+
* never touches text outside the markers.
|
|
149
|
+
*/
|
|
150
|
+
export function upsertWavePlansSection(planMarkdown, managedBlock) {
|
|
151
|
+
const startIdx = planMarkdown.indexOf(WAVE_MANAGED_START);
|
|
152
|
+
const endIdx = planMarkdown.indexOf(WAVE_MANAGED_END);
|
|
153
|
+
if (startIdx >= 0 && endIdx > startIdx) {
|
|
154
|
+
const before = planMarkdown.slice(0, startIdx);
|
|
155
|
+
const after = planMarkdown.slice(endIdx + WAVE_MANAGED_END.length);
|
|
156
|
+
const joined = `${before}${managedBlock}${after}`;
|
|
157
|
+
return joined.endsWith("\n") ? joined : `${joined}\n`;
|
|
158
|
+
}
|
|
159
|
+
const trimmed = planMarkdown.replace(/\s+$/u, "");
|
|
160
|
+
return `${trimmed}\n\n${managedBlock}\n`;
|
|
161
|
+
}
|
|
162
|
+
export async function runPlanSplitWaves(projectRoot, args, io) {
|
|
163
|
+
const flow = await readFlowState(projectRoot).catch(() => null);
|
|
164
|
+
const track = flow?.track;
|
|
165
|
+
const planResolved = await resolveArtifactPath("plan", {
|
|
166
|
+
projectRoot,
|
|
167
|
+
track,
|
|
168
|
+
intent: "read"
|
|
169
|
+
});
|
|
170
|
+
if (!(await exists(planResolved.absPath))) {
|
|
171
|
+
io.stderr.write(`cclaw internal plan-split-waves: plan artifact not found at ${planResolved.relPath}.\n`);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
const raw = await fs.readFile(planResolved.absPath, "utf8");
|
|
175
|
+
const units = parseImplementationUnits(raw);
|
|
176
|
+
if (units.length < PLAN_SPLIT_SMALL_PLAN_THRESHOLD) {
|
|
177
|
+
const outcome = {
|
|
178
|
+
ok: true,
|
|
179
|
+
command: "plan-split-waves",
|
|
180
|
+
totalUnits: units.length,
|
|
181
|
+
waveCount: 0,
|
|
182
|
+
waveSize: args.waveSize,
|
|
183
|
+
smallPlanNoOp: true,
|
|
184
|
+
dryRun: args.dryRun,
|
|
185
|
+
waveFiles: [],
|
|
186
|
+
planUpdated: false
|
|
187
|
+
};
|
|
188
|
+
if (args.json) {
|
|
189
|
+
io.stdout.write(`${JSON.stringify(outcome)}\n`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
io.stdout.write(`plan is small (${units.length} unit(s), threshold ${PLAN_SPLIT_SMALL_PLAN_THRESHOLD}); no wave split needed.\n`);
|
|
193
|
+
}
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
const waves = [];
|
|
197
|
+
for (let i = 0; i < units.length; i += args.waveSize) {
|
|
198
|
+
waves.push(units.slice(i, i + args.waveSize));
|
|
199
|
+
}
|
|
200
|
+
const artifactsDir = path.dirname(planResolved.absPath);
|
|
201
|
+
const wavePlansAbsDir = path.join(artifactsDir, WAVE_PLANS_DIR);
|
|
202
|
+
const waveFileNames = waves.map((_, idx) => `${WAVE_PLANS_DIR}/wave-${padWaveIndex(idx + 1)}.md`);
|
|
203
|
+
if (!args.dryRun && !args.force) {
|
|
204
|
+
for (const fileName of waveFileNames) {
|
|
205
|
+
const abs = path.join(artifactsDir, fileName);
|
|
206
|
+
if (await exists(abs)) {
|
|
207
|
+
io.stderr.write(`cclaw internal plan-split-waves: wave file already exists: ${path.relative(projectRoot, abs)}. Pass --force to overwrite.\n`);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!args.dryRun) {
|
|
213
|
+
await fs.mkdir(wavePlansAbsDir, { recursive: true });
|
|
214
|
+
for (let i = 0; i < waves.length; i += 1) {
|
|
215
|
+
const fileName = waveFileNames[i];
|
|
216
|
+
const body = buildWaveFileBody(i + 1, waves[i], planResolved.fileName);
|
|
217
|
+
await writeFileSafe(path.join(artifactsDir, fileName), body);
|
|
218
|
+
}
|
|
219
|
+
const managed = buildWavePlansSection(waveFileNames);
|
|
220
|
+
const updatedPlan = upsertWavePlansSection(raw, managed);
|
|
221
|
+
if (updatedPlan !== raw) {
|
|
222
|
+
await writeFileSafe(planResolved.absPath, updatedPlan);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const outcome = {
|
|
226
|
+
ok: true,
|
|
227
|
+
command: "plan-split-waves",
|
|
228
|
+
totalUnits: units.length,
|
|
229
|
+
waveCount: waves.length,
|
|
230
|
+
waveSize: args.waveSize,
|
|
231
|
+
smallPlanNoOp: false,
|
|
232
|
+
dryRun: args.dryRun,
|
|
233
|
+
waveFiles: waveFileNames,
|
|
234
|
+
planUpdated: !args.dryRun
|
|
235
|
+
};
|
|
236
|
+
if (args.json) {
|
|
237
|
+
io.stdout.write(`${JSON.stringify(outcome)}\n`);
|
|
238
|
+
}
|
|
239
|
+
else if (args.dryRun) {
|
|
240
|
+
io.stdout.write(`dry run: would split ${units.length} unit(s) into ${waves.length} wave file(s) of size ${args.waveSize}:\n`);
|
|
241
|
+
for (const fileName of waveFileNames) {
|
|
242
|
+
io.stdout.write(` - ${fileName}\n`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
io.stdout.write(`wrote ${waves.length} wave file(s) under ${path.relative(projectRoot, wavePlansAbsDir)} and refreshed Wave Plans section in ${planResolved.relPath}.\n`);
|
|
247
|
+
}
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
@@ -92,6 +92,8 @@ export interface FlowStateRepairResult {
|
|
|
92
92
|
sidecar: FlowStateGuardSidecar;
|
|
93
93
|
repairLogPath: string;
|
|
94
94
|
guardPath: string;
|
|
95
|
+
/** Stages that were retro-backfilled into completedStageMeta during repair. */
|
|
96
|
+
completedStageMetaBackfilled: FlowStage[];
|
|
95
97
|
}
|
|
96
98
|
/**
|
|
97
99
|
* Recompute the write-guard sidecar from the current on-disk flow-state
|
package/dist/run-persistence.js
CHANGED
|
@@ -700,6 +700,43 @@ export async function writeFlowState(projectRoot, state, options = {}) {
|
|
|
700
700
|
export async function writeFlowStateGuarded(projectRoot, state, options = {}) {
|
|
701
701
|
await writeFlowState(projectRoot, state, options);
|
|
702
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* v6.9.0 — backfill missing `completedStageMeta` rows for any stage that
|
|
705
|
+
* already lives in `completedStages` but has no audit timestamp. Uses the
|
|
706
|
+
* stage's artifact mtime when available, otherwise the current time. This
|
|
707
|
+
* runs as part of `flow-state-repair` so legacy v6.8 flow-state.json files
|
|
708
|
+
* get their meta carried forward without a destructive rewrite.
|
|
709
|
+
*/
|
|
710
|
+
async function backfillCompletedStageMeta(projectRoot, state) {
|
|
711
|
+
const meta = { ...(state.completedStageMeta ?? {}) };
|
|
712
|
+
const backfilled = [];
|
|
713
|
+
for (const stage of state.completedStages) {
|
|
714
|
+
if (meta[stage] && typeof meta[stage].completedAt === "string" && meta[stage].completedAt.length > 0) {
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
let completedAt = new Date().toISOString();
|
|
718
|
+
try {
|
|
719
|
+
const { resolveArtifactPath } = await import("./artifact-paths.js");
|
|
720
|
+
const resolved = await resolveArtifactPath(stage, {
|
|
721
|
+
projectRoot,
|
|
722
|
+
track: state.track,
|
|
723
|
+
intent: "read"
|
|
724
|
+
});
|
|
725
|
+
const stat = await fs.stat(resolved.absPath);
|
|
726
|
+
completedAt = new Date(stat.mtimeMs).toISOString();
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
// artifact missing or unreadable — fall back to "now" so the meta row
|
|
730
|
+
// is at least consistently populated; operators can re-edit if needed.
|
|
731
|
+
}
|
|
732
|
+
meta[stage] = { completedAt };
|
|
733
|
+
backfilled.push(stage);
|
|
734
|
+
}
|
|
735
|
+
if (backfilled.length === 0) {
|
|
736
|
+
return { state, backfilled };
|
|
737
|
+
}
|
|
738
|
+
return { state: { ...state, completedStageMeta: meta }, backfilled };
|
|
739
|
+
}
|
|
703
740
|
/**
|
|
704
741
|
* Recompute the write-guard sidecar from the current on-disk flow-state
|
|
705
742
|
* contents and append an audit entry to `.cclaw/.flow-state-repair.log`.
|
|
@@ -720,12 +757,26 @@ export async function repairFlowStateGuard(projectRoot, reason) {
|
|
|
720
757
|
throw new Error(`flow-state-repair: ${FLOW_STATE_REL_PATH} does not exist; nothing to repair.`);
|
|
721
758
|
}
|
|
722
759
|
return withDirectoryLock(flowStateLockPath(projectRoot), async () => {
|
|
723
|
-
|
|
760
|
+
let raw = await fs.readFile(statePath, "utf8");
|
|
724
761
|
let runId = "unknown-run";
|
|
762
|
+
let backfilledStages = [];
|
|
725
763
|
try {
|
|
726
764
|
const parsed = JSON.parse(raw);
|
|
727
765
|
const coerced = coerceFlowState(parsed).state;
|
|
728
766
|
runId = coerced.activeRunId;
|
|
767
|
+
const { state: nextState, backfilled } = await backfillCompletedStageMeta(projectRoot, coerced);
|
|
768
|
+
backfilledStages = backfilled;
|
|
769
|
+
if (backfilled.length > 0) {
|
|
770
|
+
// Persist the migrated state inside the same lock window so the
|
|
771
|
+
// sha sidecar below covers the post-migration bytes, not the
|
|
772
|
+
// pre-migration ones.
|
|
773
|
+
await writeFlowState(projectRoot, nextState, {
|
|
774
|
+
allowReset: true,
|
|
775
|
+
skipLock: true,
|
|
776
|
+
writerSubsystem: "flow-state-repair-backfill"
|
|
777
|
+
});
|
|
778
|
+
raw = await fs.readFile(statePath, "utf8");
|
|
779
|
+
}
|
|
729
780
|
}
|
|
730
781
|
catch {
|
|
731
782
|
// parsing failure falls back to "unknown-run"; repair intentionally
|
|
@@ -743,9 +794,17 @@ export async function repairFlowStateGuard(projectRoot, reason) {
|
|
|
743
794
|
await writeFileSafe(guardPath, `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
|
|
744
795
|
const logPath = repairLogPath(projectRoot);
|
|
745
796
|
await ensureDir(path.dirname(logPath));
|
|
746
|
-
const
|
|
797
|
+
const backfillNote = backfilledStages.length > 0
|
|
798
|
+
? ` backfilledCompletedStageMeta=${backfilledStages.join(",")}`
|
|
799
|
+
: "";
|
|
800
|
+
const logLine = `${sidecar.writtenAt} reason=${trimmed} runId=${sidecar.runId} sha256=${sidecar.sha256}${backfillNote}\n`;
|
|
747
801
|
await fs.appendFile(logPath, logLine, "utf8");
|
|
748
|
-
return {
|
|
802
|
+
return {
|
|
803
|
+
sidecar,
|
|
804
|
+
repairLogPath: logPath,
|
|
805
|
+
guardPath,
|
|
806
|
+
completedStageMetaBackfilled: backfilledStages
|
|
807
|
+
};
|
|
749
808
|
});
|
|
750
809
|
}
|
|
751
810
|
export function flowStateGuardSidecarPathFor(projectRoot) {
|