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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mustard-claude",
3
- "version": "3.1.35",
3
+ "version": "3.1.36",
4
4
  "description": "Framework-agnostic CLI for Claude Code project setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 >= 3) {
60
+ if (layerCount >= 2) {
69
61
  return {
70
62
  decompose: true,
71
- reason: "deep-layers",
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: "below-thresholds",
78
+ reason: "single-layer",
87
79
  signals: { fileCount, layerCount, newEntityCount, estimatedTouchPoints, historicalMatches: 0 },
88
80
  };
89
81
  }
@@ -2,8 +2,8 @@
2
2
  name: commit-workflow
3
3
  description: Git commit strategy, submodule-aware, budget ≤15 API calls.
4
4
  disable-model-invocation: true
5
+ source: manual
5
6
  ---
6
- <!-- mustard:generated -->
7
7
 
8
8
  # Commit Workflow
9
9
 
@@ -4,7 +4,6 @@ description: Behavioral guidelines to reduce common LLM coding mistakes (think b
4
4
  license: MIT
5
5
  source: manual
6
6
  ---
7
- <!-- mustard:generated -->
8
7
 
9
8
  # Karpathy Guidelines
10
9