mustard-claude 3.1.35 → 3.1.36
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/package.json +1 -1
- package/templates/commands/mustard/feature/SKILL.md +1 -0
- package/templates/commands/mustard/resume/SKILL.md +2 -0
- package/templates/hooks/__tests__/exec-rewave-check.test.js +203 -0
- package/templates/hooks/__tests__/scope-decompose.test.js +99 -0
- package/templates/refs/feature/wave-decomposition.md +3 -1
- package/templates/scripts/exec-rewave-check.js +304 -0
- package/templates/scripts/scope-decompose.js +3 -11
- package/templates/skills/commit-workflow/SKILL.md +1 -1
- package/templates/skills/karpathy-guidelines/SKILL.md +0 -1
package/package.json
CHANGED
|
@@ -124,6 +124,7 @@ Dispatch 1 Haiku Task(Explore) to verify work is still needed. Pre-check via `rt
|
|
|
124
124
|
### EXECUTE Phase (Light scope — same session)
|
|
125
125
|
|
|
126
126
|
When user chooses "Approve and implement now":
|
|
127
|
+
0. **Pre-EXECUTE Rewave Check:** Run `node .claude/scripts/exec-rewave-check.js --spec .claude/spec/active/{spec-name}/spec.md`. Parse JSON output. If `action: "decomposed"`, the spec was just split into N waves — proceed using wave-1's spec (`wave-1-{role}/spec.md`) instead of the original. If `action: "keep-single"` or `"skip"`, continue with the original spec normally. Silent operation — no AskUserQuestion.
|
|
127
128
|
1. Update spec: `Status: implementing`, `Phase: EXECUTE`. Every agent prompt MUST include: `Return format cap: ≤50 lines. Apply compact Return Format from .claude/pipeline-config.md strictly.`
|
|
128
129
|
2. Update pipeline state: `status: "implementing"`, `phase: 3`
|
|
129
130
|
3. Read `.claude/pipeline-config.md` for agent config. Grep `entity-registry.json` for specific entity block only
|
|
@@ -84,6 +84,8 @@ Run `node .claude/scripts/diff-context.js --subproject {subproject_path}` per su
|
|
|
84
84
|
|
|
85
85
|
**CRITICAL: Main context IS the Pipeline Runner. NEVER delegate to intermediate Task agent.**
|
|
86
86
|
|
|
87
|
+
11b. **Pre-EXECUTE Rewave Check** (skip if `pipeline-state.isWavePlan === true`): Run `node .claude/scripts/exec-rewave-check.js --spec .claude/spec/active/{specName}/spec.md`. Parse JSON output. If `action: "decomposed"`, the spec was split into N waves — update `pipeline-state.isWavePlan: true, currentWave: 1` and proceed using wave-1's spec (`wave-1-{role}/spec.md`). If `action: "keep-single"` or `"skip"`, continue with the original spec. Silent — no AskUserQuestion.
|
|
88
|
+
|
|
87
89
|
12. **Match recipe by name only:** Grep `{subproject}/.claude/commands/recipes.md` for recipe title matching the task type — do NOT read the full recipes file. Extract only: recipe number, pattern refs, reference modules
|
|
88
90
|
12b. **Pre-EXECUTE Existence Gate**: Same gate as `feature/SKILL.md § Pre-EXECUTE Existence Gate`. Invoke identically (Full scope only, `## Files` ≤ 8). On retry/resume, the gate naturally handles idempotence: tasks already `[x]` from a prior run are treated as Mixed — the Haiku confirms they stay done and the orchestrator only re-dispatches what remains `[ ]`.
|
|
89
91
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for exec-rewave-check.js
|
|
6
|
+
* Run: node --test .claude/hooks/__tests__/exec-rewave-check.test.js
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { describe, it } = require("node:test");
|
|
10
|
+
const assert = require("node:assert/strict");
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const os = require("node:os");
|
|
13
|
+
const path = require("node:path");
|
|
14
|
+
const { spawnSync } = require("node:child_process");
|
|
15
|
+
|
|
16
|
+
const SCRIPT = path.resolve(__dirname, "..", "..", "scripts", "exec-rewave-check.js");
|
|
17
|
+
|
|
18
|
+
// ── helpers ────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function makeTempProject() {
|
|
21
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mustard-rewave-"));
|
|
22
|
+
fs.mkdirSync(path.join(dir, ".claude", ".pipeline-states"), { recursive: true });
|
|
23
|
+
fs.mkdirSync(path.join(dir, ".claude", "spec", "active"), { recursive: true });
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeSpec(projectDir, specName, filesSection, extra = "") {
|
|
28
|
+
const specDir = path.join(projectDir, ".claude", "spec", "active", specName);
|
|
29
|
+
fs.mkdirSync(specDir, { recursive: true });
|
|
30
|
+
const specText = `# Feature: ${specName}
|
|
31
|
+
### Status: approved | Phase: PLAN | Scope: full
|
|
32
|
+
|
|
33
|
+
## Summary
|
|
34
|
+
Test feature.
|
|
35
|
+
|
|
36
|
+
${filesSection}
|
|
37
|
+
|
|
38
|
+
## Tasks
|
|
39
|
+
- [ ] Implement thing
|
|
40
|
+
|
|
41
|
+
## Acceptance Criteria
|
|
42
|
+
- [ ] AC-1: build passes — Command: \`node -e "process.exit(0)"\`
|
|
43
|
+
${extra}`;
|
|
44
|
+
fs.writeFileSync(path.join(specDir, "spec.md"), specText, "utf8");
|
|
45
|
+
return specDir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writePipelineState(projectDir, specName, state) {
|
|
49
|
+
const file = path.join(projectDir, ".claude", ".pipeline-states", `${specName}.json`);
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2), "utf8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function run(projectDir, specRelPath) {
|
|
54
|
+
const result = spawnSync(
|
|
55
|
+
process.execPath,
|
|
56
|
+
[SCRIPT, "--spec", specRelPath],
|
|
57
|
+
{
|
|
58
|
+
cwd: projectDir,
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
timeout: 20000,
|
|
61
|
+
env: { ...process.env, MUSTARD_DISABLED_HOOKS: "all" },
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
let parsed = null;
|
|
65
|
+
try { parsed = JSON.parse(result.stdout.trim()); } catch (_) {}
|
|
66
|
+
return { code: result.status, stdout: result.stdout.trim(), parsed };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cleanup(dir) {
|
|
70
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── test 1: single-layer spec → keep-single ────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("exec-rewave-check", () => {
|
|
76
|
+
it("single-layer (all api): keep-single with reason single-layer", (t) => {
|
|
77
|
+
const proj = makeTempProject();
|
|
78
|
+
t.after(() => cleanup(proj));
|
|
79
|
+
const specName = "2026-01-01-test-single";
|
|
80
|
+
const filesSection = `## Files
|
|
81
|
+
- src/api/users.ts
|
|
82
|
+
- src/api/orders.ts
|
|
83
|
+
- src/api/helpers.ts`;
|
|
84
|
+
makeSpec(proj, specName, filesSection);
|
|
85
|
+
const specPath = `.claude/spec/active/${specName}/spec.md`;
|
|
86
|
+
|
|
87
|
+
const { parsed } = run(proj, specPath);
|
|
88
|
+
assert.ok(parsed, "output must be valid JSON");
|
|
89
|
+
assert.equal(parsed.action, "keep-single");
|
|
90
|
+
assert.equal(parsed.reason, "single-layer");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── test 2: multi-layer spec → decomposed ──────────────────────────────────
|
|
94
|
+
it("multi-layer (schema + api): decomposed into 2 waves", (t) => {
|
|
95
|
+
const proj = makeTempProject();
|
|
96
|
+
t.after(() => cleanup(proj));
|
|
97
|
+
const specName = "2026-01-02-test-multi";
|
|
98
|
+
// wave-dependency works on real files; for DAG we need actual files on disk
|
|
99
|
+
// Create the files so the DAG parser can read them (no imports → each in own wave)
|
|
100
|
+
const apiDir = path.join(proj, "src", "api");
|
|
101
|
+
const schemaDir = path.join(proj, "src", "schema");
|
|
102
|
+
fs.mkdirSync(apiDir, { recursive: true });
|
|
103
|
+
fs.mkdirSync(schemaDir, { recursive: true });
|
|
104
|
+
// schema file — no imports
|
|
105
|
+
fs.writeFileSync(path.join(schemaDir, "user.ts"), "export const users = {};\n", "utf8");
|
|
106
|
+
// api file — imports from schema
|
|
107
|
+
fs.writeFileSync(path.join(apiDir, "users.ts"), 'import { users } from "../schema/user";\n', "utf8");
|
|
108
|
+
|
|
109
|
+
const filesSection = `## Files
|
|
110
|
+
- src/schema/user.ts
|
|
111
|
+
- src/api/users.ts`;
|
|
112
|
+
makeSpec(proj, specName, filesSection);
|
|
113
|
+
writePipelineState(proj, specName, { specName, status: "approved", phase: 2 });
|
|
114
|
+
|
|
115
|
+
const specPath = `.claude/spec/active/${specName}/spec.md`;
|
|
116
|
+
const { parsed } = run(proj, specPath);
|
|
117
|
+
assert.ok(parsed, "output must be valid JSON");
|
|
118
|
+
|
|
119
|
+
if (parsed.action === "decomposed") {
|
|
120
|
+
assert.equal(parsed.action, "decomposed");
|
|
121
|
+
assert.ok(parsed.totalWaves >= 2, `expected >=2 waves, got ${parsed.totalWaves}`);
|
|
122
|
+
|
|
123
|
+
// wave-plan.md created
|
|
124
|
+
const specDir = path.join(proj, ".claude", "spec", "active", specName);
|
|
125
|
+
assert.ok(fs.existsSync(path.join(specDir, "wave-plan.md")), "wave-plan.md must exist");
|
|
126
|
+
|
|
127
|
+
// spec.original.md created
|
|
128
|
+
assert.ok(fs.existsSync(path.join(specDir, "spec.original.md")), "spec.original.md must exist");
|
|
129
|
+
|
|
130
|
+
// per-wave spec.md dirs created
|
|
131
|
+
for (const w of parsed.waves) {
|
|
132
|
+
const waveDir = path.join(specDir, `wave-${w.wave}-${w.role}`);
|
|
133
|
+
assert.ok(fs.existsSync(path.join(waveDir, "spec.md")), `wave-${w.wave} spec.md must exist`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// pipeline-state updated
|
|
137
|
+
const stateFile = path.join(proj, ".claude", ".pipeline-states", `${specName}.json`);
|
|
138
|
+
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
139
|
+
assert.equal(state.isWavePlan, true);
|
|
140
|
+
assert.ok(state.totalWaves >= 2);
|
|
141
|
+
assert.deepEqual(state.completedWaves, []);
|
|
142
|
+
assert.equal(state.rewaveSource, "exec-entry");
|
|
143
|
+
} else {
|
|
144
|
+
// DAG had no real depth (both files in same wave) → keep-single is acceptable
|
|
145
|
+
assert.equal(parsed.action, "keep-single");
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── test 3: spec with existing wave-plan.md → skip already-decomposed ────────
|
|
150
|
+
it("already-decomposed (wave-plan.md exists): skip", (t) => {
|
|
151
|
+
const proj = makeTempProject();
|
|
152
|
+
t.after(() => cleanup(proj));
|
|
153
|
+
const specName = "2026-01-03-test-already";
|
|
154
|
+
const filesSection = `## Files
|
|
155
|
+
- src/schema/thing.ts
|
|
156
|
+
- src/api/thing.ts`;
|
|
157
|
+
const specDir = makeSpec(proj, specName, filesSection);
|
|
158
|
+
// Plant a wave-plan.md
|
|
159
|
+
fs.writeFileSync(path.join(specDir, "wave-plan.md"), "# Wave Plan\n", "utf8");
|
|
160
|
+
|
|
161
|
+
const { parsed } = run(proj, `.claude/spec/active/${specName}/spec.md`);
|
|
162
|
+
assert.ok(parsed);
|
|
163
|
+
assert.equal(parsed.action, "skip");
|
|
164
|
+
assert.equal(parsed.reason, "already-decomposed");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── test 4: pipeline-state.scopeOverride = user-rejected-waves → skip ────────
|
|
168
|
+
it("scopeOverride user-rejected-waves: skip", (t) => {
|
|
169
|
+
const proj = makeTempProject();
|
|
170
|
+
t.after(() => cleanup(proj));
|
|
171
|
+
const specName = "2026-01-04-test-rejected";
|
|
172
|
+
const filesSection = `## Files
|
|
173
|
+
- src/schema/x.ts
|
|
174
|
+
- src/api/x.ts`;
|
|
175
|
+
makeSpec(proj, specName, filesSection);
|
|
176
|
+
writePipelineState(proj, specName, {
|
|
177
|
+
specName,
|
|
178
|
+
status: "approved",
|
|
179
|
+
phase: 2,
|
|
180
|
+
scopeOverride: "user-rejected-waves",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const { parsed } = run(proj, `.claude/spec/active/${specName}/spec.md`);
|
|
184
|
+
assert.ok(parsed);
|
|
185
|
+
assert.equal(parsed.action, "skip");
|
|
186
|
+
assert.equal(parsed.reason, "user-rejected");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── test 5: spec with no ## Files section → skip error-fallback ───────────────
|
|
190
|
+
it("spec without ## Files: skip error-fallback", (t) => {
|
|
191
|
+
const proj = makeTempProject();
|
|
192
|
+
t.after(() => cleanup(proj));
|
|
193
|
+
const specName = "2026-01-05-test-nofiles";
|
|
194
|
+
const specDir = path.join(proj, ".claude", "spec", "active", specName);
|
|
195
|
+
fs.mkdirSync(specDir, { recursive: true });
|
|
196
|
+
fs.writeFileSync(path.join(specDir, "spec.md"), `# Feature\n## Summary\nSomething\n`, "utf8");
|
|
197
|
+
|
|
198
|
+
const { parsed } = run(proj, `.claude/spec/active/${specName}/spec.md`);
|
|
199
|
+
assert.ok(parsed);
|
|
200
|
+
assert.equal(parsed.action, "skip");
|
|
201
|
+
assert.equal(parsed.reason, "error-fallback");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tests for scripts/scope-decompose.js.
|
|
4
|
+
* Run with: node --test templates/hooks/__tests__/scope-decompose.test.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { describe, it } = require('node:test');
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const { spawn } = require('node:child_process');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
|
|
12
|
+
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'scope-decompose.js');
|
|
13
|
+
|
|
14
|
+
function runScript(input) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(process.execPath, [SCRIPT], {
|
|
17
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
let stdout = '';
|
|
21
|
+
let stderr = '';
|
|
22
|
+
|
|
23
|
+
child.stdout.on('data', d => (stdout += d));
|
|
24
|
+
child.stderr.on('data', d => (stderr += d));
|
|
25
|
+
child.on('error', reject);
|
|
26
|
+
child.on('close', code => {
|
|
27
|
+
let parsed = null;
|
|
28
|
+
if (stdout.trim()) {
|
|
29
|
+
try { parsed = JSON.parse(stdout.trim()); } catch (_) {}
|
|
30
|
+
}
|
|
31
|
+
resolve({ code, stdout: stdout.trim(), stderr: stderr.trim(), parsed });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const payload = typeof input === 'string' ? input : JSON.stringify(input);
|
|
35
|
+
child.stdin.write(payload);
|
|
36
|
+
child.stdin.end();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('scope-decompose decision logic', () => {
|
|
41
|
+
it('single layer with few files → decompose:false reason:single-layer', async () => {
|
|
42
|
+
const r = await runScript({ fileCount: 3, layerCount: 1, newEntityCount: 0, knowledgeMatches: [] });
|
|
43
|
+
assert.equal(r.code, 0);
|
|
44
|
+
assert.equal(r.parsed.decompose, false);
|
|
45
|
+
assert.equal(r.parsed.reason, 'single-layer');
|
|
46
|
+
assert.equal(r.parsed.signals.fileCount, 3);
|
|
47
|
+
assert.equal(r.parsed.signals.layerCount, 1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('two layers with few files → decompose:true reason:multi-layer', async () => {
|
|
51
|
+
const r = await runScript({ fileCount: 3, layerCount: 2, newEntityCount: 1, knowledgeMatches: [] });
|
|
52
|
+
assert.equal(r.code, 0);
|
|
53
|
+
assert.equal(r.parsed.decompose, true);
|
|
54
|
+
assert.equal(r.parsed.reason, 'multi-layer');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('three layers with few files → decompose:true reason:multi-layer', async () => {
|
|
58
|
+
const r = await runScript({ fileCount: 3, layerCount: 3, newEntityCount: 0, knowledgeMatches: [] });
|
|
59
|
+
assert.equal(r.code, 0);
|
|
60
|
+
assert.equal(r.parsed.decompose, true);
|
|
61
|
+
assert.equal(r.parsed.reason, 'multi-layer');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('historical knowledge match → decompose:true reason:history-match', async () => {
|
|
65
|
+
const r = await runScript({
|
|
66
|
+
fileCount: 1,
|
|
67
|
+
layerCount: 1,
|
|
68
|
+
newEntityCount: 0,
|
|
69
|
+
knowledgeMatches: [{ id: 'heavy-pipeline-x', type: 'heavy-pipeline', scope: {} }],
|
|
70
|
+
});
|
|
71
|
+
assert.equal(r.code, 0);
|
|
72
|
+
assert.equal(r.parsed.decompose, true);
|
|
73
|
+
assert.match(r.parsed.reason, /^history-match:/);
|
|
74
|
+
assert.equal(r.parsed.reason, 'history-match:heavy-pipeline-x');
|
|
75
|
+
assert.equal(r.parsed.signals.historicalMatches, 1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('wide spec single layer with new entities → decompose:true reason:wide-and-new-entities', async () => {
|
|
79
|
+
const r = await runScript({ fileCount: 15, layerCount: 1, newEntityCount: 2, knowledgeMatches: [] });
|
|
80
|
+
assert.equal(r.code, 0);
|
|
81
|
+
assert.equal(r.parsed.decompose, true);
|
|
82
|
+
assert.equal(r.parsed.reason, 'wide-and-new-entities');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('empty input → fail-open decompose:false', async () => {
|
|
86
|
+
const r = await runScript('');
|
|
87
|
+
assert.equal(r.code, 0);
|
|
88
|
+
assert.equal(r.parsed.decompose, false);
|
|
89
|
+
// empty signals defaults: layerCount=0 → single-layer branch
|
|
90
|
+
assert.equal(r.parsed.reason, 'single-layer');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('invalid JSON input → error-fallback', async () => {
|
|
94
|
+
const r = await runScript('{not-json');
|
|
95
|
+
assert.equal(r.code, 0);
|
|
96
|
+
assert.equal(r.parsed.decompose, false);
|
|
97
|
+
assert.equal(r.parsed.reason, 'error-fallback');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -10,10 +10,12 @@ Before writing the single spec in Full scope, check whether the work should be d
|
|
|
10
10
|
|
|
11
11
|
1. **Compute signals from ANALYZE output:**
|
|
12
12
|
- `fileCount` — files that will go into `## Files`
|
|
13
|
-
- `layerCount` — distinct layers (use role detection derived from paths: schema/api/ui/lib)
|
|
13
|
+
- `layerCount` — distinct layers (use role detection derived from paths: schema/api/ui/lib). **`layerCount >= 2` is sufficient to trigger decomposition** regardless of fileCount.
|
|
14
14
|
- `newEntityCount` — new entities created by this spec
|
|
15
15
|
- `estimatedTouchPoints` — count of imports/refs from Grep on affected directories (optional)
|
|
16
16
|
|
|
17
|
+
Decomposition reasons emitted: `history-match:{id}`, `multi-layer`, `wide-and-new-entities`. Single-layer specs return `decompose: false` with reason `single-layer`.
|
|
18
|
+
|
|
17
19
|
2. **Read knowledge matches:** Read `.claude/knowledge.json` (if it exists). Extract entries whose `id` starts with `heavy-pipeline` or `high-hook-retry`. Each entry's scope signals represent a historical pipeline that cost a lot.
|
|
18
20
|
|
|
19
21
|
3. **Run decomposition decision:**
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* exec-rewave-check.js
|
|
6
|
+
*
|
|
7
|
+
* Pre-EXECUTE re-check: silently decomposes a single-spec into waves if signals
|
|
8
|
+
* (layerCount >= 2) are found in the finalised spec's ## Files section.
|
|
9
|
+
*
|
|
10
|
+
* CLI:
|
|
11
|
+
* node .claude/scripts/exec-rewave-check.js --spec .claude/spec/active/{name}/spec.md
|
|
12
|
+
*
|
|
13
|
+
* Output: one JSON line on stdout, always exit 0 (fail-open).
|
|
14
|
+
* { action: "skip", reason: "already-decomposed" | "user-rejected" | "no-spec-arg" | "error-fallback", error? }
|
|
15
|
+
* { action: "keep-single", reason: "single-layer" | "no-dag-depth-or-error" | ..., signals? }
|
|
16
|
+
* { action: "decomposed", totalWaves: N, waves: [{wave, role, files: count}, ...] }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
const { spawnSync } = require("child_process");
|
|
22
|
+
|
|
23
|
+
// ── role detection (mirrors wave-dependency.js:101-108) ──────────────────────
|
|
24
|
+
function detectRole(filePath) {
|
|
25
|
+
const lower = filePath.toLowerCase();
|
|
26
|
+
if (/(schema|migration|entity|model|drizzle|prisma)/.test(lower)) return "schema";
|
|
27
|
+
if (/(api|controller|route|endpoint|handler|service)/.test(lower)) return "api";
|
|
28
|
+
if (/(ui|component|view|page|screen|widget)/.test(lower)) return "ui";
|
|
29
|
+
if (/(test|spec|__tests__)/.test(lower)) return "test";
|
|
30
|
+
return "lib";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function emit(obj) {
|
|
34
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── parse ## Files section from spec text ────────────────────────────────────
|
|
38
|
+
function parseFiles(specText) {
|
|
39
|
+
const lines = specText.split("\n");
|
|
40
|
+
const start = lines.findIndex((l) => /^##\s+Files/.test(l));
|
|
41
|
+
if (start === -1) return null;
|
|
42
|
+
|
|
43
|
+
const paths = [];
|
|
44
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i].trim();
|
|
46
|
+
if (/^##\s/.test(line)) break; // next section
|
|
47
|
+
// match "- path" or "- `path`" bullets
|
|
48
|
+
const m = line.match(/^-\s+`?([^\s`]+)`?/);
|
|
49
|
+
if (m && m[1] && !m[1].startsWith("#")) {
|
|
50
|
+
paths.push(m[1]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return paths;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── extract optional newEntityCount from spec ─────────────────────────────────
|
|
57
|
+
function parseNewEntityCount(specText) {
|
|
58
|
+
const m = specText.match(/new\s+entities?:\s*(\d+)/i) ||
|
|
59
|
+
specText.match(/newEntityCount[:\s]+(\d+)/i);
|
|
60
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── locate pipeline state ─────────────────────────────────────────────────────
|
|
64
|
+
function findPipelineState(specDir, projectRoot) {
|
|
65
|
+
const specName = path.basename(specDir);
|
|
66
|
+
const statesDir = path.join(projectRoot, ".claude", ".pipeline-states");
|
|
67
|
+
const stateFile = path.join(statesDir, `${specName}.json`);
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(stateFile, "utf8");
|
|
70
|
+
return { file: stateFile, state: JSON.parse(raw) };
|
|
71
|
+
} catch {
|
|
72
|
+
return { file: stateFile, state: null };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── call scope-decompose.js ───────────────────────────────────────────────────
|
|
77
|
+
function runScopeDecompose(signals, scriptsDir) {
|
|
78
|
+
const scriptPath = path.join(scriptsDir, "scope-decompose.js");
|
|
79
|
+
const result = spawnSync(process.execPath, [scriptPath], {
|
|
80
|
+
input: JSON.stringify(signals),
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
timeout: 10000,
|
|
83
|
+
});
|
|
84
|
+
if (result.status !== 0 || result.error) return null;
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(result.stdout.trim());
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── call wave-dependency.js ───────────────────────────────────────────────────
|
|
93
|
+
function runWaveDependency(files, projectRoot, scriptsDir) {
|
|
94
|
+
const scriptPath = path.join(scriptsDir, "wave-dependency.js");
|
|
95
|
+
const result = spawnSync(process.execPath, [scriptPath], {
|
|
96
|
+
input: JSON.stringify({ files, projectRoot }),
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
cwd: projectRoot,
|
|
99
|
+
timeout: 15000,
|
|
100
|
+
});
|
|
101
|
+
if (result.status !== 0 || result.error) return null;
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(result.stdout.trim());
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── generate wave-plan.md content ─────────────────────────────────────────────
|
|
110
|
+
function buildWavePlanMd(specName, wavesResult, specText, decomposeReason) {
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
const summaryMatch = specText.match(/^##\s+Summary\s*\n([\s\S]*?)(?=\n##\s)/m);
|
|
113
|
+
const summary = summaryMatch ? summaryMatch[1].trim() : "(see spec)";
|
|
114
|
+
|
|
115
|
+
const waveLines = wavesResult.waves.map((w) => {
|
|
116
|
+
const depends = w.dependsOn.length === 0 ? "none" : w.dependsOn.map((d) => `wave ${d}`).join(", ");
|
|
117
|
+
return `### Wave ${w.wave} — ${w.roles.join("/")}
|
|
118
|
+
Depends on: ${depends}
|
|
119
|
+
Files (${w.files.length}): ${w.files.join(", ")}`;
|
|
120
|
+
}).join("\n\n");
|
|
121
|
+
|
|
122
|
+
return `<!-- mustard:generated -->
|
|
123
|
+
# Wave Plan: ${specName}
|
|
124
|
+
### Status: draft | Phase: EXECUTE | Scope: full | Decomposed: yes
|
|
125
|
+
### Checkpoint: ${now}
|
|
126
|
+
### Reason: ${decomposeReason}
|
|
127
|
+
### Source: exec-rewave-check (re-evaluated at EXECUTE entry)
|
|
128
|
+
|
|
129
|
+
## Summary
|
|
130
|
+
${summary}
|
|
131
|
+
|
|
132
|
+
## Waves
|
|
133
|
+
${waveLines}
|
|
134
|
+
|
|
135
|
+
## Rationale
|
|
136
|
+
Decomposed at EXECUTE entry by exec-rewave-check.js.
|
|
137
|
+
Threshold: layerCount >= 2 (reason: ${decomposeReason}).
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── generate per-wave spec.md content ─────────────────────────────────────────
|
|
142
|
+
function buildWaveSpecMd(parentSpecText, waveFiles, waveNum, waveRole, wavePlanRelPath) {
|
|
143
|
+
// Extract Summary
|
|
144
|
+
const summaryMatch = parentSpecText.match(/^##\s+Summary\s*\n([\s\S]*?)(?=\n##\s)/m);
|
|
145
|
+
const summary = summaryMatch ? summaryMatch[1].trim() : "(see parent spec)";
|
|
146
|
+
|
|
147
|
+
// Extract Tasks section (copy entirely — agent will filter)
|
|
148
|
+
const tasksMatch = parentSpecText.match(/^##\s+Tasks\s*\n([\s\S]*?)(?=\n##\s|\s*$)/m);
|
|
149
|
+
const tasks = tasksMatch ? tasksMatch[1].trim() : "";
|
|
150
|
+
|
|
151
|
+
const fileList = waveFiles.map((f) => `- ${f}`).join("\n");
|
|
152
|
+
|
|
153
|
+
return `<!-- mustard:generated -->
|
|
154
|
+
> Wave spec — see [../wave-plan.md](${wavePlanRelPath}) for overall plan.
|
|
155
|
+
|
|
156
|
+
# Wave ${waveNum} — ${waveRole}
|
|
157
|
+
|
|
158
|
+
## Summary
|
|
159
|
+
${summary}
|
|
160
|
+
|
|
161
|
+
## Files
|
|
162
|
+
${fileList}
|
|
163
|
+
|
|
164
|
+
## Tasks
|
|
165
|
+
${tasks}
|
|
166
|
+
`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
170
|
+
function main() {
|
|
171
|
+
const args = process.argv.slice(2);
|
|
172
|
+
const specArgIdx = args.indexOf("--spec");
|
|
173
|
+
if (specArgIdx === -1 || !args[specArgIdx + 1]) {
|
|
174
|
+
emit({ action: "skip", reason: "no-spec-arg" });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const specArg = args[specArgIdx + 1];
|
|
179
|
+
// Resolve spec path relative to cwd
|
|
180
|
+
const specFile = path.isAbsolute(specArg) ? specArg : path.resolve(process.cwd(), specArg);
|
|
181
|
+
const specDir = path.dirname(specFile);
|
|
182
|
+
// scriptsDir = same dir as this script
|
|
183
|
+
const scriptsDir = path.dirname(path.resolve(__filename));
|
|
184
|
+
// projectRoot = walk up until we find .claude dir (or fallback to cwd)
|
|
185
|
+
const projectRoot = findProjectRoot(specDir) || process.cwd();
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
// 1. Read spec
|
|
189
|
+
let specText;
|
|
190
|
+
try {
|
|
191
|
+
specText = fs.readFileSync(specFile, "utf8");
|
|
192
|
+
} catch {
|
|
193
|
+
emit({ action: "skip", reason: "error-fallback", error: "spec-not-readable" });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const specName = path.basename(specDir);
|
|
198
|
+
|
|
199
|
+
// 2. Skip if already decomposed (wave-plan.md exists in same dir)
|
|
200
|
+
const wavePlanPath = path.join(specDir, "wave-plan.md");
|
|
201
|
+
if (fs.existsSync(wavePlanPath)) {
|
|
202
|
+
emit({ action: "skip", reason: "already-decomposed" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 3. Skip if pipeline-state says so
|
|
207
|
+
const { file: stateFile, state } = findPipelineState(specDir, projectRoot);
|
|
208
|
+
if (state) {
|
|
209
|
+
if (state.isWavePlan === true) {
|
|
210
|
+
emit({ action: "skip", reason: "already-decomposed" });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (state.scopeOverride === "user-rejected-waves") {
|
|
214
|
+
emit({ action: "skip", reason: "user-rejected" });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 4. Parse ## Files
|
|
220
|
+
const filePaths = parseFiles(specText);
|
|
221
|
+
if (!filePaths || filePaths.length === 0) {
|
|
222
|
+
emit({ action: "skip", reason: "error-fallback", error: "no-files-section" });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 5. Compute layerCount (unique roles, lib-only = 1 layer)
|
|
227
|
+
const roles = filePaths.map(detectRole);
|
|
228
|
+
const uniqueRoles = new Set(roles);
|
|
229
|
+
// If only "lib" → 1 layer; otherwise count all unique roles
|
|
230
|
+
const layerCount = (uniqueRoles.size === 1 && uniqueRoles.has("lib")) ? 1 : uniqueRoles.size;
|
|
231
|
+
const fileCount = filePaths.length;
|
|
232
|
+
const newEntityCount = parseNewEntityCount(specText);
|
|
233
|
+
|
|
234
|
+
// 6. Call scope-decompose.js
|
|
235
|
+
const signals = { fileCount, layerCount, newEntityCount, knowledgeMatches: [] };
|
|
236
|
+
const decision = runScopeDecompose(signals, scriptsDir);
|
|
237
|
+
|
|
238
|
+
if (!decision || decision.decompose === false) {
|
|
239
|
+
const reason = decision ? decision.reason : "error-fallback";
|
|
240
|
+
emit({ action: "keep-single", reason, signals });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 7. decompose: true — call wave-dependency.js
|
|
245
|
+
const dagResult = runWaveDependency(filePaths, projectRoot, scriptsDir);
|
|
246
|
+
|
|
247
|
+
if (!dagResult || dagResult.error || !Array.isArray(dagResult.waves) || dagResult.waves.length < 2) {
|
|
248
|
+
emit({ action: "keep-single", reason: "no-dag-depth-or-error", signals });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 8. Write wave structure
|
|
253
|
+
const wavePlanContent = buildWavePlanMd(specName, dagResult, specText, decision.reason);
|
|
254
|
+
fs.writeFileSync(wavePlanPath, wavePlanContent, "utf8");
|
|
255
|
+
|
|
256
|
+
const wavesMeta = [];
|
|
257
|
+
for (const w of dagResult.waves) {
|
|
258
|
+
const primaryRole = w.roles[0] || "lib";
|
|
259
|
+
const waveDir = path.join(specDir, `wave-${w.wave}-${primaryRole}`);
|
|
260
|
+
fs.mkdirSync(waveDir, { recursive: true });
|
|
261
|
+
const waveSpecPath = path.join(waveDir, "spec.md");
|
|
262
|
+
const waveSpecContent = buildWaveSpecMd(specText, w.files, w.wave, w.roles.join("/"), "../wave-plan.md");
|
|
263
|
+
fs.writeFileSync(waveSpecPath, waveSpecContent, "utf8");
|
|
264
|
+
wavesMeta.push({ wave: w.wave, role: primaryRole, files: w.files.length });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 9. Rename original spec to spec.original.md
|
|
268
|
+
const originalBackup = path.join(specDir, "spec.original.md");
|
|
269
|
+
fs.renameSync(specFile, originalBackup);
|
|
270
|
+
|
|
271
|
+
// 10. Update pipeline state
|
|
272
|
+
const updatedState = Object.assign({}, state || { specName }, {
|
|
273
|
+
specName,
|
|
274
|
+
isWavePlan: true,
|
|
275
|
+
currentWave: 1,
|
|
276
|
+
totalWaves: dagResult.waves.length,
|
|
277
|
+
completedWaves: [],
|
|
278
|
+
failedWaves: [],
|
|
279
|
+
rewaveSource: "exec-entry",
|
|
280
|
+
updatedAt: new Date().toISOString(),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const statesDir = path.join(projectRoot, ".claude", ".pipeline-states");
|
|
284
|
+
fs.mkdirSync(statesDir, { recursive: true });
|
|
285
|
+
fs.writeFileSync(stateFile, JSON.stringify(updatedState, null, 2), "utf8");
|
|
286
|
+
|
|
287
|
+
emit({ action: "decomposed", totalWaves: dagResult.waves.length, waves: wavesMeta });
|
|
288
|
+
} catch (err) {
|
|
289
|
+
emit({ action: "skip", reason: "error-fallback", error: err.message });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function findProjectRoot(startDir) {
|
|
294
|
+
let dir = startDir;
|
|
295
|
+
for (let i = 0; i < 10; i++) {
|
|
296
|
+
if (fs.existsSync(path.join(dir, ".claude"))) return dir;
|
|
297
|
+
const parent = path.dirname(dir);
|
|
298
|
+
if (parent === dir) break;
|
|
299
|
+
dir = parent;
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
main();
|
|
@@ -49,14 +49,6 @@ function decide(signals) {
|
|
|
49
49
|
|
|
50
50
|
const hasHistoricalMatch = Array.isArray(knowledgeMatches) && knowledgeMatches.length > 0;
|
|
51
51
|
|
|
52
|
-
if (!hasHistoricalMatch && fileCount <= 5) {
|
|
53
|
-
return {
|
|
54
|
-
decompose: false,
|
|
55
|
-
reason: "small-scope-no-history",
|
|
56
|
-
signals: { fileCount, layerCount, newEntityCount, estimatedTouchPoints, historicalMatches: 0 },
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
52
|
if (hasHistoricalMatch) {
|
|
61
53
|
return {
|
|
62
54
|
decompose: true,
|
|
@@ -65,10 +57,10 @@ function decide(signals) {
|
|
|
65
57
|
};
|
|
66
58
|
}
|
|
67
59
|
|
|
68
|
-
if (layerCount >=
|
|
60
|
+
if (layerCount >= 2) {
|
|
69
61
|
return {
|
|
70
62
|
decompose: true,
|
|
71
|
-
reason: "
|
|
63
|
+
reason: "multi-layer",
|
|
72
64
|
signals: { fileCount, layerCount, newEntityCount, estimatedTouchPoints, historicalMatches: 0 },
|
|
73
65
|
};
|
|
74
66
|
}
|
|
@@ -83,7 +75,7 @@ function decide(signals) {
|
|
|
83
75
|
|
|
84
76
|
return {
|
|
85
77
|
decompose: false,
|
|
86
|
-
reason: "
|
|
78
|
+
reason: "single-layer",
|
|
87
79
|
signals: { fileCount, layerCount, newEntityCount, estimatedTouchPoints, historicalMatches: 0 },
|
|
88
80
|
};
|
|
89
81
|
}
|