mustard-claude 3.1.1 → 3.1.3
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/bin/mustard.js +0 -0
- package/package.json +59 -59
- package/templates/CLAUDE.md +2 -1
- package/templates/commands/mustard/feature/SKILL.md +1 -1
- package/templates/commands/mustard/resume/SKILL.md +21 -2
- package/templates/commands/mustard/templates/agent-prompt/SKILL.md +19 -55
- package/templates/hooks/__tests__/hooks.test.js +210 -0
- package/templates/hooks/auto-format.js +1 -0
- package/templates/hooks/bash-safety.js +1 -0
- package/templates/hooks/enforce-registry.js +1 -0
- package/templates/hooks/file-guard.js +1 -0
- package/templates/hooks/guard-verify.js +1 -0
- package/templates/hooks/metrics-tracker.js +47 -15
- package/templates/hooks/pre-compact.js +1 -0
- package/templates/hooks/rtk-rewrite.js +1 -0
- package/templates/hooks/session-cleanup.js +1 -0
- package/templates/hooks/session-memory.js +1 -0
- package/templates/hooks/subagent-tracker.js +68 -5
- package/templates/scripts/memory-write.js +17 -6
- package/templates/settings.json +5 -0
package/bin/mustard.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,59 +1,59 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "mustard-claude",
|
|
3
|
-
"version": "3.1.
|
|
4
|
-
"description": "Framework-agnostic CLI for Claude Code project setup",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"mustard": "./bin/mustard.js"
|
|
8
|
-
},
|
|
9
|
-
"main": "dist/cli.js",
|
|
10
|
-
"types": "dist/cli.d.ts",
|
|
11
|
-
"files": [
|
|
12
|
-
"bin/",
|
|
13
|
-
"dist/",
|
|
14
|
-
"templates/"
|
|
15
|
-
],
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
}
|
|
59
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "mustard-claude",
|
|
3
|
+
"version": "3.1.3",
|
|
4
|
+
"description": "Framework-agnostic CLI for Claude Code project setup",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mustard": "./bin/mustard.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/cli.js",
|
|
10
|
+
"types": "dist/cli.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"bin/",
|
|
13
|
+
"dist/",
|
|
14
|
+
"templates/"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node bin/mustard.js",
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"clean": "rimraf dist",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"release": "npm version patch && npm run build && npm publish"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"claude",
|
|
26
|
+
"claude-code",
|
|
27
|
+
"ai",
|
|
28
|
+
"cli",
|
|
29
|
+
"scaffold"
|
|
30
|
+
],
|
|
31
|
+
"author": "rubensrpj",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/rubensrpj/mustard.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/rubensrpj/mustard#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/rubensrpj/mustard/issues"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"chalk": "^5.3.0",
|
|
46
|
+
"commander": "^12.1.0",
|
|
47
|
+
"inquirer": "^9.2.15",
|
|
48
|
+
"ora": "^8.0.1"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/inquirer": "^9.0.9",
|
|
55
|
+
"@types/node": "^25.2.1",
|
|
56
|
+
"rimraf": "^6.1.2",
|
|
57
|
+
"typescript": "^5.9.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/templates/CLAUDE.md
CHANGED
|
@@ -31,7 +31,7 @@ Guards always loaded via `{subproject}/CLAUDE.md`.
|
|
|
31
31
|
|
|
32
32
|
## Stack
|
|
33
33
|
|
|
34
|
-
Node.js (>=18), CommonJS, no external dependencies.
|
|
34
|
+
Node.js (>=18), CommonJS, no external dependencies. 16 lifecycle hooks, 10 scripts, 16 slash commands, 6 foundation skills.
|
|
35
35
|
|
|
36
36
|
## Commands
|
|
37
37
|
|
|
@@ -55,6 +55,7 @@ node scripts/sync-registry.js --force
|
|
|
55
55
|
- PreToolUse hooks use `permissionDecision` response format
|
|
56
56
|
- PostToolUse hooks use `decision` response format
|
|
57
57
|
- Every new hook must be registered in `settings.json` with a timeout
|
|
58
|
+
- Task dispatch failures (API overload) are logged to `pipeline-state.lastDispatchFailure`; `/resume` auto-recovers within 10 min
|
|
58
59
|
- Generated files must start with `<!-- mustard:generated -->` header
|
|
59
60
|
- Skills must have YAML frontmatter BEFORE the `<!-- mustard:generated -->` line
|
|
60
61
|
|
|
@@ -155,7 +155,7 @@ When user chooses "Approve and implement now":
|
|
|
155
155
|
6. Dispatch agents (wave rules: DB+Backend parallel, Frontend after Backend UNLESS spec marks task as `(parallel-safe)` — see `pipeline-config.md` Parallel Rules). Agent prompt includes `{recommended_skills}` as skill hints — agents read SKILL.md of relevant skills before implementing
|
|
156
156
|
7. Wave transitions between waves (from `pipeline-config.md`)
|
|
157
157
|
8. On return: validate (build/type-check), update spec `[ ]` → `[x]` (line-by-line edits, NEVER copy entire spec blocks as old_string)
|
|
158
|
-
8b. **Agent Memory:** After agents return and spec is updated, write agent memory: `
|
|
158
|
+
8b. **Agent Memory:** After agents return and spec is updated, write agent memory: `node .claude/scripts/memory-write.js --json '{"agent_type":"{type}","wave":{N},"pipeline":"{spec-name}","summary":"{what agent did}","details":{...}}'` — one per agent. Skip if single-wave pipeline (no downstream agents to benefit).
|
|
159
159
|
|
|
160
160
|
#### Escalation Status Handling
|
|
161
161
|
|
|
@@ -12,6 +12,25 @@ Resumes an interrupted pipeline. The main context BECOMES the Pipeline Runner
|
|
|
12
12
|
|
|
13
13
|
## Action
|
|
14
14
|
|
|
15
|
+
### Step 0: Dispatch Failure Pre-Check
|
|
16
|
+
|
|
17
|
+
Before the normal detect-and-confirm flow, scan the newest pipeline state for a recent dispatch failure flagged by `subagent-tracker` (PostToolUse on Task).
|
|
18
|
+
|
|
19
|
+
1. Glob `.claude/.pipeline-states/*.json` (exclude `*.metrics.json`) and pick the file with the newest mtime.
|
|
20
|
+
2. Read it and inspect the `lastDispatchFailure` field.
|
|
21
|
+
3. If present:
|
|
22
|
+
- Compute `ageMs = Date.now() - new Date(lastDispatchFailure.at).getTime()`.
|
|
23
|
+
- **If ageMs <= 10 * 60 * 1000** (≤10 min, fresh):
|
|
24
|
+
1. Inform the user: `Detected failed dispatch ({agentType}) due to {reason} at {at}. Re-dispatching with same prompt.`
|
|
25
|
+
2. Re-invoke the Task tool with:
|
|
26
|
+
- `subagent_type`: `lastDispatchFailure.agentType` (fallback: `general-purpose`)
|
|
27
|
+
- `description`: `lastDispatchFailure.description`
|
|
28
|
+
- `prompt`: `lastDispatchFailure.prompt`
|
|
29
|
+
3. After the re-dispatch returns, clear the flag: remove `lastDispatchFailure` from the state object and rewrite the pipeline-state JSON.
|
|
30
|
+
4. Fall through to Step 1 (normal resume flow continues from the updated state).
|
|
31
|
+
- **If ageMs > 10 * 60 * 1000** (stale): silently remove `lastDispatchFailure` from the state and rewrite the file, then continue to Step 1.
|
|
32
|
+
4. If `lastDispatchFailure` is absent, skip Step 0 entirely and proceed to Step 1.
|
|
33
|
+
|
|
15
34
|
### Step 1: Detect & Confirm
|
|
16
35
|
|
|
17
36
|
1. Glob `.claude/spec/active/*/spec.md` — if 0 specs → inform user and stop
|
|
@@ -103,7 +122,7 @@ Run `node .claude/scripts/diff-context.js` to capture the current git state. Inc
|
|
|
103
122
|
|
|
104
123
|
17b. **Agent Memory:** After each wave completes and spec checkboxes are updated, write agent memories for downstream waves:
|
|
105
124
|
```bash
|
|
106
|
-
|
|
125
|
+
node .claude/scripts/memory-write.js --json '{"agent_type":"{agent_type}","wave":{N},"pipeline":"{spec-name}","summary":"{1-line summary of what agent did}","details":{"files_modified":[...],"decisions":[...]}}'
|
|
107
126
|
```
|
|
108
127
|
One call per agent in the completed wave. Summary ≤300 chars (key facts: files created, patterns used, endpoints added). Skip if no downstream waves remain.
|
|
109
128
|
|
|
@@ -167,7 +186,7 @@ When a pipeline is paused (user leaves session or requests pause):
|
|
|
167
186
|
- Set `nextAction` to the specific next step (ONE sentence)
|
|
168
187
|
2. Write agent memory for carry-over:
|
|
169
188
|
```bash
|
|
170
|
-
|
|
189
|
+
node .claude/scripts/memory-write.js --json '{"agent_type":"orchestrator","wave":0,"pipeline":"{spec-name}","summary":"Paused at {phase}. Next: {nextAction}"}'
|
|
171
190
|
```
|
|
172
191
|
3. Confirm to user: "Pipeline paused. Next action saved: {nextAction}"
|
|
173
192
|
|
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
Orchestrator fills `{placeholders}` before dispatch. Agent receives the rendered version.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Single unified template for all dispatches:
|
|
6
|
+
- When `.claude/agents/{subproject}-impl.md` **exists**: orchestrator leaves `{role_block}` empty (role/boundary/validate/return already defined in the custom agent).
|
|
7
|
+
- When it **does NOT exist**: orchestrator fills `{role_block}` with `ROLE: {role} — {boundary}` / `Validate: {validate_command}` / `Return: {return_sections}`.
|
|
8
|
+
|
|
9
|
+
`{context_extras}` is optional (e.g. extra line to read `notes.md`); leave empty when unused.
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
---
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
## Dispatch Template
|
|
10
14
|
|
|
11
15
|
```
|
|
12
16
|
## CONTEXT
|
|
13
|
-
1. Read `{subproject}/CLAUDE.md` — guards, stack,
|
|
17
|
+
1. Read `{subproject}/CLAUDE.md` — guards, stack, paths
|
|
14
18
|
2. Read `{subproject}/.claude/commands/guards.md` — mandatory rules
|
|
19
|
+
{context_extras}
|
|
15
20
|
|
|
16
21
|
## REFERENCE
|
|
17
22
|
{reference_files}
|
|
@@ -20,67 +25,26 @@ Use when `.claude/agents/{subproject}-impl.md` exists. Role, boundary, return fo
|
|
|
20
25
|
{entity_info}
|
|
21
26
|
|
|
22
27
|
## SKILLS
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
If a skill has `references/` files, read them only when you need concrete code examples.
|
|
28
|
+
Available skills listed in system. Read SKILL.md only if task matches. Key: {recommended_skills}
|
|
29
|
+
Load references/ only for concrete examples.
|
|
26
30
|
|
|
27
31
|
## WEB VALIDATION
|
|
28
|
-
|
|
32
|
+
In doubt about API/version/pattern → search web for latest docs before implementing.
|
|
33
|
+
|
|
34
|
+
## ROLE
|
|
35
|
+
{role_block}
|
|
29
36
|
|
|
30
37
|
## EFFICIENCY
|
|
31
|
-
- Absolute paths
|
|
32
|
-
-
|
|
33
|
-
- Max 3 build attempts
|
|
38
|
+
- Absolute paths, no cd
|
|
39
|
+
- Read each file once
|
|
40
|
+
- Max 3 build attempts, then STOP + report
|
|
34
41
|
|
|
35
42
|
{retry_context}
|
|
36
43
|
|
|
37
44
|
## TASK
|
|
38
45
|
{task_steps}
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
## Full Template (fallback — general-purpose agent, no custom agent)
|
|
44
|
-
|
|
45
|
-
Use when `.claude/agents/{subproject}-impl.md` does NOT exist.
|
|
46
|
-
|
|
47
|
-
```
|
|
48
|
-
## STEP 0: READ CONTEXT
|
|
49
|
-
1. `{subproject}/CLAUDE.md` — guards, stack, key paths
|
|
50
|
-
2. `{subproject}/.claude/commands/guards.md` — mandatory rules
|
|
51
|
-
3. `{subproject}/.claude/commands/notes.md` — project-specific notes
|
|
52
|
-
|
|
53
|
-
## REFERENCE MODULE
|
|
54
|
-
{reference_files}
|
|
55
46
|
|
|
56
|
-
|
|
57
|
-
{guards_summary}
|
|
58
|
-
|
|
59
|
-
## ENTITY REGISTRY
|
|
60
|
-
{entity_info}
|
|
61
|
-
|
|
62
|
-
## SKILLS
|
|
63
|
-
Your available skills are listed in the system. Before implementing, check if any skill matches your task — read its SKILL.md for patterns and examples.
|
|
64
|
-
Key skills for this task: {recommended_skills}
|
|
65
|
-
If a skill has `references/` files, read them only when you need concrete code examples.
|
|
66
|
-
|
|
67
|
-
## WEB VALIDATION
|
|
68
|
-
When in doubt about API usage, library version, or implementation pattern: search the web for the latest documentation before implementing. Only proceed when 100% confident.
|
|
69
|
-
|
|
70
|
-
## ROLE: {role} — {boundary}
|
|
71
|
-
Validate: {validate_command}
|
|
72
|
-
Return: {return_sections}
|
|
73
|
-
|
|
74
|
-
## EFFICIENCY RULES
|
|
75
|
-
- Shell state does NOT persist between Bash calls — ALWAYS use absolute paths, NEVER cd
|
|
76
|
-
- Build: {build_command}
|
|
77
|
-
- Read each file ONCE — trust your edit
|
|
78
|
-
- Max 3 build attempts/step. After 3rd: STOP and report error.
|
|
79
|
-
|
|
80
|
-
{retry_context}
|
|
81
|
-
|
|
82
|
-
## TASK — Execute in order
|
|
83
|
-
{task_steps}
|
|
47
|
+
Guards carregados via CLAUDE.md acima — respeite sem exceção.
|
|
84
48
|
```
|
|
85
49
|
|
|
86
50
|
---
|
|
@@ -265,6 +265,25 @@ describe("memory-write.js", () => {
|
|
|
265
265
|
});
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
function runScriptArg(inputObj, opts = {}) {
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const cwd = opts.cwd || PROJECT_DIR;
|
|
271
|
+
const child = spawn(
|
|
272
|
+
process.execPath,
|
|
273
|
+
[path.join(SCRIPTS_DIR, "memory-write.js"), "--json", JSON.stringify(inputObj)],
|
|
274
|
+
{ cwd, stdio: ["ignore", "pipe", "pipe"] }
|
|
275
|
+
);
|
|
276
|
+
let stdout = "";
|
|
277
|
+
let stderr = "";
|
|
278
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
279
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
280
|
+
child.on("error", reject);
|
|
281
|
+
child.on("close", (code) => {
|
|
282
|
+
resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
268
287
|
it("should create memory entry and index", async () => {
|
|
269
288
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem-test-"));
|
|
270
289
|
const memDir = path.join(tmpDir, ".claude", ".agent-memory");
|
|
@@ -317,6 +336,32 @@ describe("memory-write.js", () => {
|
|
|
317
336
|
const result = await runScript("not valid json");
|
|
318
337
|
assert.equal(result.code, 0, "Should exit 0 even on bad input");
|
|
319
338
|
});
|
|
339
|
+
|
|
340
|
+
it("should accept input via --json arg (Windows-friendly mode)", async () => {
|
|
341
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem-test-arg-"));
|
|
342
|
+
const memDir = path.join(tmpDir, ".claude", ".agent-memory");
|
|
343
|
+
try {
|
|
344
|
+
const result = await runScriptArg({
|
|
345
|
+
cwd: tmpDir,
|
|
346
|
+
agent_type: "arg-impl",
|
|
347
|
+
wave: 2,
|
|
348
|
+
pipeline: "arg-pipeline",
|
|
349
|
+
summary: "Wrote via --json arg mode.",
|
|
350
|
+
details: { mode: "arg" },
|
|
351
|
+
});
|
|
352
|
+
assert.equal(result.code, 0, `Exit code should be 0, stderr: ${result.stderr}`);
|
|
353
|
+
assert.ok(fs.existsSync(memDir), "Memory dir should exist");
|
|
354
|
+
const indexPath = path.join(memDir, "_index.json");
|
|
355
|
+
assert.ok(fs.existsSync(indexPath), "Index file should exist");
|
|
356
|
+
const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
|
|
357
|
+
assert.equal(index.length, 1, "Index should have 1 entry");
|
|
358
|
+
assert.equal(index[0].agent_type, "arg-impl");
|
|
359
|
+
assert.equal(index[0].wave, 2);
|
|
360
|
+
assert.ok(index[0].summary.includes("arg mode"), "Summary should round-trip");
|
|
361
|
+
} finally {
|
|
362
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
320
365
|
});
|
|
321
366
|
|
|
322
367
|
// ─── subagent-tracker.js (memory injection) ─────────────────────────────────
|
|
@@ -384,3 +429,168 @@ describe("subagent-tracker.js memory injection", () => {
|
|
|
384
429
|
}
|
|
385
430
|
});
|
|
386
431
|
});
|
|
432
|
+
|
|
433
|
+
// ─── metrics-tracker.js (sidecar + no-recursion) ────────────────────────────
|
|
434
|
+
|
|
435
|
+
describe("metrics-tracker.js", () => {
|
|
436
|
+
const hook = "metrics-tracker.js";
|
|
437
|
+
|
|
438
|
+
function setupPipelineState(tmpDir) {
|
|
439
|
+
const statesDir = path.join(tmpDir, ".claude", ".pipeline-states");
|
|
440
|
+
fs.mkdirSync(statesDir, { recursive: true });
|
|
441
|
+
const pipelinePath = path.join(statesDir, "test-pipeline.json");
|
|
442
|
+
fs.writeFileSync(pipelinePath, JSON.stringify({
|
|
443
|
+
v: 1,
|
|
444
|
+
name: "test-pipeline",
|
|
445
|
+
phase: "EXECUTE",
|
|
446
|
+
phaseName: "EXECUTE",
|
|
447
|
+
status: "approved",
|
|
448
|
+
startedAt: "2026-04-05T10:00:00.000Z",
|
|
449
|
+
}), "utf8");
|
|
450
|
+
return { statesDir, pipelinePath };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
it("should write metrics to sidecar and leave pipeline-state untouched", async () => {
|
|
454
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "metrics-test-"));
|
|
455
|
+
const { statesDir, pipelinePath } = setupPipelineState(tmpDir);
|
|
456
|
+
const sidecarPath = path.join(statesDir, "test-pipeline.metrics.json");
|
|
457
|
+
try {
|
|
458
|
+
const mtimeBefore = fs.statSync(pipelinePath).mtimeMs;
|
|
459
|
+
// Wait a beat so any write would produce a different mtime
|
|
460
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
461
|
+
|
|
462
|
+
const result = await runHook(hook, {
|
|
463
|
+
tool_name: "Edit",
|
|
464
|
+
tool_input: { file_path: path.join(tmpDir, "src/foo.ts") },
|
|
465
|
+
cwd: tmpDir,
|
|
466
|
+
}, { cwd: tmpDir, projectDir: tmpDir });
|
|
467
|
+
|
|
468
|
+
assert.equal(result.code, 0);
|
|
469
|
+
const mtimeAfter = fs.statSync(pipelinePath).mtimeMs;
|
|
470
|
+
assert.equal(mtimeAfter, mtimeBefore, "pipeline-state.json must NOT be modified");
|
|
471
|
+
assert.ok(fs.existsSync(sidecarPath), "sidecar must be created");
|
|
472
|
+
const sidecar = JSON.parse(fs.readFileSync(sidecarPath, "utf8"));
|
|
473
|
+
assert.equal(sidecar.metrics.apiCalls, 1);
|
|
474
|
+
assert.equal(sidecar.metrics.toolBreakdown.Edit, 1);
|
|
475
|
+
assert.equal(sidecar.previousPhase, "EXECUTE");
|
|
476
|
+
assert.equal(sidecar.metrics.startedAt, "2026-04-05T10:00:00.000Z", "startedAt inherited from pipeline-state");
|
|
477
|
+
} finally {
|
|
478
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("should not create recursive .metrics.metrics.json sidecars across multiple calls", async () => {
|
|
483
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "metrics-recursion-"));
|
|
484
|
+
const { statesDir } = setupPipelineState(tmpDir);
|
|
485
|
+
try {
|
|
486
|
+
// Fire 5 PostToolUse events in sequence
|
|
487
|
+
for (let i = 0; i < 5; i++) {
|
|
488
|
+
const r = await runHook(hook, {
|
|
489
|
+
tool_name: "Write",
|
|
490
|
+
tool_input: { file_path: path.join(tmpDir, `src/file${i}.ts`) },
|
|
491
|
+
cwd: tmpDir,
|
|
492
|
+
}, { cwd: tmpDir, projectDir: tmpDir });
|
|
493
|
+
assert.equal(r.code, 0);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const files = fs.readdirSync(statesDir).sort();
|
|
497
|
+
assert.deepEqual(
|
|
498
|
+
files,
|
|
499
|
+
["test-pipeline.json", "test-pipeline.metrics.json"],
|
|
500
|
+
`Only 2 files expected, got: ${files.join(", ")}`
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const sidecar = JSON.parse(
|
|
504
|
+
fs.readFileSync(path.join(statesDir, "test-pipeline.metrics.json"), "utf8")
|
|
505
|
+
);
|
|
506
|
+
assert.equal(sidecar.metrics.apiCalls, 5, "All 5 calls must aggregate into the same sidecar");
|
|
507
|
+
assert.equal(sidecar.metrics.toolBreakdown.Write, 5);
|
|
508
|
+
} finally {
|
|
509
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ─── subagent-tracker.js (overload detection) ───────────────────────────────
|
|
515
|
+
|
|
516
|
+
describe("subagent-tracker.js overload detection", () => {
|
|
517
|
+
const hook = "subagent-tracker.js";
|
|
518
|
+
|
|
519
|
+
function setupPipelineState(tmpDir) {
|
|
520
|
+
const statesDir = path.join(tmpDir, ".claude", ".pipeline-states");
|
|
521
|
+
fs.mkdirSync(statesDir, { recursive: true });
|
|
522
|
+
const pipelinePath = path.join(statesDir, "p.json");
|
|
523
|
+
fs.writeFileSync(pipelinePath, JSON.stringify({
|
|
524
|
+
v: 1,
|
|
525
|
+
phase: "EXECUTE",
|
|
526
|
+
startedAt: "2026-04-05T10:00:00.000Z",
|
|
527
|
+
}), "utf8");
|
|
528
|
+
fs.mkdirSync(path.join(tmpDir, ".claude", ".agent-state"), { recursive: true });
|
|
529
|
+
return pipelinePath;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function dispatchTaskResult(tmpDir, toolResponse) {
|
|
533
|
+
return runHook(hook, {
|
|
534
|
+
hook_event_name: "PostToolUse",
|
|
535
|
+
tool_name: "Task",
|
|
536
|
+
tool_input: {
|
|
537
|
+
subagent_type: "general-purpose",
|
|
538
|
+
description: "test dispatch",
|
|
539
|
+
prompt: "Do something",
|
|
540
|
+
},
|
|
541
|
+
tool_response: toolResponse,
|
|
542
|
+
cwd: tmpDir,
|
|
543
|
+
}, { cwd: tmpDir, projectDir: tmpDir });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
it("should flag lastDispatchFailure on real overload (is_error=true + 529)", async () => {
|
|
547
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "overload-real-"));
|
|
548
|
+
const pipelinePath = setupPipelineState(tmpDir);
|
|
549
|
+
try {
|
|
550
|
+
const r = await dispatchTaskResult(tmpDir, {
|
|
551
|
+
is_error: true,
|
|
552
|
+
content: "Error: 529 overloaded",
|
|
553
|
+
});
|
|
554
|
+
assert.equal(r.code, 0);
|
|
555
|
+
const state = JSON.parse(fs.readFileSync(pipelinePath, "utf8"));
|
|
556
|
+
assert.ok(state.lastDispatchFailure, "flag must be set");
|
|
557
|
+
assert.equal(state.lastDispatchFailure.reason, "api_overload");
|
|
558
|
+
assert.equal(state.lastDispatchFailure.agentType, "general-purpose");
|
|
559
|
+
assert.equal(state.lastDispatchFailure.description, "test dispatch");
|
|
560
|
+
} finally {
|
|
561
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("should NOT flag on happy-path agent that merely documents rate limiting", async () => {
|
|
566
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "overload-docs-"));
|
|
567
|
+
const pipelinePath = setupPipelineState(tmpDir);
|
|
568
|
+
try {
|
|
569
|
+
const r = await dispatchTaskResult(tmpDir, {
|
|
570
|
+
is_error: false,
|
|
571
|
+
content: "Documented rate limiting, 429 and 529 handling, api error recovery.",
|
|
572
|
+
});
|
|
573
|
+
assert.equal(r.code, 0);
|
|
574
|
+
const state = JSON.parse(fs.readFileSync(pipelinePath, "utf8"));
|
|
575
|
+
assert.equal(state.lastDispatchFailure, undefined, "flag must NOT be set (false positive guard)");
|
|
576
|
+
} finally {
|
|
577
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("should NOT flag on unrelated error (is_error=true without overload keywords)", async () => {
|
|
582
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "overload-unrelated-"));
|
|
583
|
+
const pipelinePath = setupPipelineState(tmpDir);
|
|
584
|
+
try {
|
|
585
|
+
const r = await dispatchTaskResult(tmpDir, {
|
|
586
|
+
is_error: true,
|
|
587
|
+
content: "SyntaxError in src/foo.ts line 42",
|
|
588
|
+
});
|
|
589
|
+
assert.equal(r.code, 0);
|
|
590
|
+
const state = JSON.parse(fs.readFileSync(pipelinePath, "utf8"));
|
|
591
|
+
assert.equal(state.lastDispatchFailure, undefined, "unrelated failure must not be flagged as overload");
|
|
592
|
+
} finally {
|
|
593
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
2
3
|
/**
|
|
3
4
|
* METRICS-TRACKER: PostToolUse hook that tracks pipeline metrics
|
|
4
5
|
*
|
|
@@ -31,7 +32,7 @@ process.stdin.on('end', () => {
|
|
|
31
32
|
const statesDir = path.join(cwd, '.claude', '.pipeline-states');
|
|
32
33
|
if (!fs.existsSync(statesDir)) { process.exit(0); }
|
|
33
34
|
|
|
34
|
-
const files = fs.readdirSync(statesDir).filter(f => f.endsWith('.json'));
|
|
35
|
+
const files = fs.readdirSync(statesDir).filter(f => f.endsWith('.json') && !f.endsWith('.metrics.json'));
|
|
35
36
|
if (files.length === 0) { process.exit(0); }
|
|
36
37
|
|
|
37
38
|
// Update the most recently modified pipeline state
|
|
@@ -50,34 +51,64 @@ process.stdin.on('end', () => {
|
|
|
50
51
|
|
|
51
52
|
if (!newest) { process.exit(0); }
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
// Read pipeline-state.json READ-ONLY (to derive currentPhase, status, startedAt).
|
|
55
|
+
// Never write to it — metrics live in a sidecar to avoid "file modified since
|
|
56
|
+
// read" races with Edit/Write on the pipeline-state file.
|
|
57
|
+
let pipelineState = {};
|
|
58
|
+
try {
|
|
59
|
+
pipelineState = JSON.parse(fs.readFileSync(newest, 'utf8'));
|
|
60
|
+
} catch {}
|
|
61
|
+
|
|
62
|
+
const sidecarPath = newest.replace(/\.json$/, '.metrics.json');
|
|
63
|
+
let sidecar;
|
|
64
|
+
if (fs.existsSync(sidecarPath)) {
|
|
65
|
+
try {
|
|
66
|
+
sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
|
|
67
|
+
} catch {
|
|
68
|
+
sidecar = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!sidecar || typeof sidecar !== 'object') {
|
|
72
|
+
sidecar = {
|
|
73
|
+
v: 1,
|
|
74
|
+
metrics: {
|
|
75
|
+
apiCalls: 0,
|
|
76
|
+
toolBreakdown: {},
|
|
77
|
+
retries: 0,
|
|
78
|
+
startedAt: pipelineState.startedAt || new Date().toISOString(),
|
|
79
|
+
},
|
|
80
|
+
previousPhase: '',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!sidecar.metrics) {
|
|
84
|
+
sidecar.metrics = {
|
|
58
85
|
apiCalls: 0,
|
|
59
86
|
toolBreakdown: {},
|
|
60
87
|
retries: 0,
|
|
61
|
-
startedAt:
|
|
88
|
+
startedAt: pipelineState.startedAt || new Date().toISOString(),
|
|
62
89
|
};
|
|
63
90
|
}
|
|
91
|
+
if (!sidecar.metrics.toolBreakdown) sidecar.metrics.toolBreakdown = {};
|
|
92
|
+
|
|
93
|
+
// Alias for minimal churn below — all mutations go to the sidecar.
|
|
94
|
+
const state = sidecar;
|
|
64
95
|
|
|
65
96
|
// ── wave_reentry: track EXECUTE → PLAN transitions ──────────────────────
|
|
66
97
|
// previousPhase is updated on every write so we can detect phase changes.
|
|
67
|
-
const currentPhase =
|
|
68
|
-
const previousPhase =
|
|
98
|
+
const currentPhase = pipelineState.phaseName || pipelineState.phase || '';
|
|
99
|
+
const previousPhase = sidecar.previousPhase || '';
|
|
69
100
|
if (currentPhase === 'PLAN' && previousPhase === 'EXECUTE') {
|
|
70
101
|
state.metrics.wave_reentry = (state.metrics.wave_reentry || 0) + 1;
|
|
71
102
|
}
|
|
72
103
|
// Always update previousPhase to the current phase so the NEXT write can
|
|
73
104
|
// detect a transition.
|
|
74
|
-
|
|
105
|
+
sidecar.previousPhase = currentPhase;
|
|
75
106
|
|
|
76
107
|
// ── gate_saves: spec edits in PLAN phase after first /approve ────────────
|
|
77
|
-
// Proxy for "first approve recorded":
|
|
78
|
-
// /approve command). A spec file is any .md in .claude/spec/ or
|
|
79
|
-
// *spec*.md anywhere in the pipeline-states dir.
|
|
80
|
-
if ((toolName === 'Edit' || toolName === 'Write') && currentPhase === 'PLAN' &&
|
|
108
|
+
// Proxy for "first approve recorded": pipelineState.status === 'approved'
|
|
109
|
+
// (set by /approve command). A spec file is any .md in .claude/spec/ or
|
|
110
|
+
// matching *spec*.md anywhere in the pipeline-states dir.
|
|
111
|
+
if ((toolName === 'Edit' || toolName === 'Write') && currentPhase === 'PLAN' && pipelineState.status === 'approved') {
|
|
81
112
|
const toolFilePath = (data.tool_input || {}).file_path || (data.tool_input || {}).path || '';
|
|
82
113
|
const isSpecFile =
|
|
83
114
|
/[/\\]\.claude[/\\]spec[/\\]/.test(toolFilePath) ||
|
|
@@ -151,7 +182,8 @@ process.stdin.on('end', () => {
|
|
|
151
182
|
|
|
152
183
|
state.metrics.updatedAt = new Date().toISOString();
|
|
153
184
|
|
|
154
|
-
|
|
185
|
+
// Write ONLY the sidecar — never touch pipeline-state.json from this hook.
|
|
186
|
+
fs.writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), 'utf8');
|
|
155
187
|
|
|
156
188
|
process.exit(0);
|
|
157
189
|
} catch (err) {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
2
3
|
/**
|
|
3
4
|
* SUBAGENT TRACKER: Tracks active subagents for statusline display
|
|
4
5
|
*
|
|
5
|
-
* Handles
|
|
6
|
-
* - PreToolUse(Task):
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
6
|
+
* Handles 5 events:
|
|
7
|
+
* - PreToolUse(Task): queues description + type before agent starts
|
|
8
|
+
* - PostToolUse(Task): detects API overload / dispatch failures and flags pipeline state
|
|
9
|
+
* - SubagentStart: writes agent state file (consumes from queue)
|
|
10
|
+
* - SubagentStop: removes agent state file + prunes stale queue
|
|
11
|
+
* - SessionStart: cleans up stale state from previous sessions
|
|
10
12
|
*
|
|
11
13
|
* State dir: .claude/.agent-state/{agent_id}.json
|
|
12
14
|
* Queue: .claude/.agent-state/_queue.json
|
|
@@ -42,6 +44,8 @@ process.stdin.on('end', () => {
|
|
|
42
44
|
|
|
43
45
|
if (event === 'PreToolUse' && data.tool_name === 'Task') {
|
|
44
46
|
handlePreToolUse(data, stateDir);
|
|
47
|
+
} else if (event === 'PostToolUse' && data.tool_name === 'Task') {
|
|
48
|
+
handlePostToolUse(data, stateDir);
|
|
45
49
|
} else if (event === 'SubagentStart') {
|
|
46
50
|
handleStart(data, stateDir);
|
|
47
51
|
} else if (event === 'SubagentStop') {
|
|
@@ -177,6 +181,65 @@ function parseRecommendedSkills(prompt) {
|
|
|
177
181
|
return skills;
|
|
178
182
|
}
|
|
179
183
|
|
|
184
|
+
/**
|
|
185
|
+
* PostToolUse(Task): Detect API overload / dispatch failures in tool_response
|
|
186
|
+
* and flag the active pipeline state with `lastDispatchFailure` so /resume can
|
|
187
|
+
* auto-recover.
|
|
188
|
+
*
|
|
189
|
+
* We write to pipeline-state ONLY when a failure is detected — happy-path
|
|
190
|
+
* dispatches never touch the state file from here.
|
|
191
|
+
*/
|
|
192
|
+
function handlePostToolUse(data, stateDir) {
|
|
193
|
+
try {
|
|
194
|
+
if (isSelfDelegation(data)) { return; }
|
|
195
|
+
|
|
196
|
+
const toolResponse = data.tool_response || {};
|
|
197
|
+
const responseText = JSON.stringify(toolResponse).toLowerCase();
|
|
198
|
+
// Detect overload conservatively: require is_error=true (Claude Code sets
|
|
199
|
+
// this on Task tool failures) AND at least one overload keyword. This
|
|
200
|
+
// avoids false positives on agents that merely *document* rate limiting
|
|
201
|
+
// or error handling in their returned content.
|
|
202
|
+
const isOverload =
|
|
203
|
+
toolResponse.is_error === true &&
|
|
204
|
+
/overload|rate.?limit|\b429\b|\b529\b|throttl|too many requests/.test(responseText);
|
|
205
|
+
|
|
206
|
+
if (!isOverload) return;
|
|
207
|
+
|
|
208
|
+
const projectDir = path.resolve(stateDir, '..', '..');
|
|
209
|
+
const statesDir = path.join(projectDir, '.claude', '.pipeline-states');
|
|
210
|
+
if (!fs.existsSync(statesDir)) return;
|
|
211
|
+
|
|
212
|
+
const files = fs.readdirSync(statesDir)
|
|
213
|
+
.filter(f => f.endsWith('.json') && !f.endsWith('.metrics.json'));
|
|
214
|
+
if (files.length === 0) return;
|
|
215
|
+
|
|
216
|
+
let newest = null;
|
|
217
|
+
let newestMtime = 0;
|
|
218
|
+
for (const f of files) {
|
|
219
|
+
try {
|
|
220
|
+
const fp = path.join(statesDir, f);
|
|
221
|
+
const stat = fs.statSync(fp);
|
|
222
|
+
if (stat.mtimeMs > newestMtime) {
|
|
223
|
+
newestMtime = stat.mtimeMs;
|
|
224
|
+
newest = fp;
|
|
225
|
+
}
|
|
226
|
+
} catch {}
|
|
227
|
+
}
|
|
228
|
+
if (!newest) return;
|
|
229
|
+
|
|
230
|
+
const toolInput = data.tool_input || {};
|
|
231
|
+
const state = JSON.parse(fs.readFileSync(newest, 'utf8'));
|
|
232
|
+
state.lastDispatchFailure = {
|
|
233
|
+
at: new Date().toISOString(),
|
|
234
|
+
reason: 'api_overload',
|
|
235
|
+
agentType: toolInput.subagent_type || 'unknown',
|
|
236
|
+
description: toolInput.description || '',
|
|
237
|
+
prompt: (toolInput.prompt || '').slice(0, 2000),
|
|
238
|
+
};
|
|
239
|
+
fs.writeFileSync(newest, JSON.stringify(state, null, 2), 'utf8');
|
|
240
|
+
} catch {} // fail-open: failure detection is advisory
|
|
241
|
+
}
|
|
242
|
+
|
|
180
243
|
function handleStart(data, stateDir) {
|
|
181
244
|
const agentId = data.agent_id || `unknown-${Date.now()}`;
|
|
182
245
|
const agentType = data.agent_type || 'unknown';
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* memory-write.js
|
|
5
5
|
*
|
|
6
|
-
* Receives a JSON memory entry
|
|
6
|
+
* Receives a JSON memory entry and persists it to
|
|
7
7
|
* {projectDir}/.claude/.agent-memory/.
|
|
8
8
|
*
|
|
9
|
-
* Input
|
|
9
|
+
* Input (two modes):
|
|
10
|
+
* 1. --json '<JSON>' CLI arg (Windows-friendly, avoids shell echo pipe issues)
|
|
11
|
+
* 2. stdin piped JSON (POSIX)
|
|
12
|
+
*
|
|
13
|
+
* Input schema:
|
|
10
14
|
* {
|
|
11
15
|
* "agent_type": "templates-impl",
|
|
12
16
|
* "wave": 1,
|
|
@@ -110,17 +114,24 @@ function resolveSessionPrefix(projectDir) {
|
|
|
110
114
|
// ---------------------------------------------------------------------------
|
|
111
115
|
|
|
112
116
|
async function main() {
|
|
113
|
-
// Collect stdin.
|
|
114
117
|
let raw = "";
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
|
|
119
|
+
// --json arg mode (Windows-friendly: avoids shell echo pipe issues)
|
|
120
|
+
const jsonArgIdx = process.argv.indexOf("--json");
|
|
121
|
+
if (jsonArgIdx !== -1 && process.argv[jsonArgIdx + 1]) {
|
|
122
|
+
raw = process.argv[jsonArgIdx + 1];
|
|
123
|
+
} else {
|
|
124
|
+
// stdin fallback (POSIX)
|
|
125
|
+
for await (const chunk of process.stdin) {
|
|
126
|
+
raw += chunk;
|
|
127
|
+
}
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
let input;
|
|
120
131
|
try {
|
|
121
132
|
input = JSON.parse(raw);
|
|
122
133
|
} catch (err) {
|
|
123
|
-
process.stderr.write(`[memory-write] Failed to parse
|
|
134
|
+
process.stderr.write(`[memory-write] Failed to parse input JSON: ${err.message}\n`);
|
|
124
135
|
process.exit(0);
|
|
125
136
|
}
|
|
126
137
|
|
package/templates/settings.json
CHANGED
|
@@ -146,6 +146,11 @@
|
|
|
146
146
|
"type": "command",
|
|
147
147
|
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-knowledge-inc.js",
|
|
148
148
|
"timeout": 5
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"type": "command",
|
|
152
|
+
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/subagent-tracker.js",
|
|
153
|
+
"timeout": 3
|
|
149
154
|
}
|
|
150
155
|
]
|
|
151
156
|
}
|