mustard-claude 3.1.1 → 3.1.2

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 CHANGED
File without changes
package/package.json CHANGED
@@ -1,59 +1,59 @@
1
- {
2
- "name": "mustard-claude",
3
- "version": "3.1.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
- "keywords": [
17
- "claude",
18
- "claude-code",
19
- "ai",
20
- "cli",
21
- "scaffold"
22
- ],
23
- "author": "rubensrpj",
24
- "license": "MIT",
25
- "repository": {
26
- "type": "git",
27
- "url": "git+https://github.com/rubensrpj/mustard.git"
28
- },
29
- "homepage": "https://github.com/rubensrpj/mustard#readme",
30
- "bugs": {
31
- "url": "https://github.com/rubensrpj/mustard/issues"
32
- },
33
- "publishConfig": {
34
- "access": "public"
35
- },
36
- "dependencies": {
37
- "chalk": "^5.3.0",
38
- "commander": "^12.1.0",
39
- "inquirer": "^9.2.15",
40
- "ora": "^8.0.1"
41
- },
42
- "engines": {
43
- "node": ">=18.0.0"
44
- },
45
- "devDependencies": {
46
- "@types/inquirer": "^9.0.9",
47
- "@types/node": "^25.2.1",
48
- "rimraf": "^6.1.2",
49
- "typescript": "^5.9.3"
50
- },
51
- "scripts": {
52
- "start": "node bin/mustard.js",
53
- "build": "tsc",
54
- "clean": "rimraf dist",
55
- "typecheck": "tsc --noEmit",
56
- "test": "node --test",
57
- "release": "npm version patch && npm run build && npm publish"
58
- }
59
- }
1
+ {
2
+ "name": "mustard-claude",
3
+ "version": "3.1.2",
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
+ }
@@ -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: `echo '{"agent_type":"{type}","wave":{N},"pipeline":"{spec-name}","summary":"{what agent did}","details":{...}}' | node .claude/scripts/memory-write.js` — one per agent. Skip if single-wave pipeline (no downstream agents to benefit).
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
- echo '{"agent_type":"{agent_type}","wave":{N},"pipeline":"{spec-name}","summary":"{1-line summary of what agent did}","details":{"files_modified":[...],"decisions":[...]}}' | node .claude/scripts/memory-write.js
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
- echo '{"agent_type":"orchestrator","wave":0,"pipeline":"{spec-name}","summary":"Paused at {phase}. Next: {nextAction}"}' | node .claude/scripts/memory-write.js
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
- ## Compact Template (custom agent — role already defined in agent)
11
+ ---
8
12
 
9
- Use when `.claude/agents/{subproject}-impl.md` exists. Role, boundary, return format, and validation are already in the agent definition — prompt only needs references + entity + task.
13
+ ## Dispatch Template
10
14
 
11
15
  ```
12
16
  ## CONTEXT
13
- 1. Read `{subproject}/CLAUDE.md` — guards, stack, key paths
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
- 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.
24
- Key skills for this task: {recommended_skills}
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
- 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.
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 NEVER cd
32
- - Chain kill+build in single Bash
33
- - Max 3 build attempts STOP + report
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
- ## GUARDS (verify in return)
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
+ });
@@ -31,7 +31,7 @@ process.stdin.on('end', () => {
31
31
  const statesDir = path.join(cwd, '.claude', '.pipeline-states');
32
32
  if (!fs.existsSync(statesDir)) { process.exit(0); }
33
33
 
34
- const files = fs.readdirSync(statesDir).filter(f => f.endsWith('.json'));
34
+ const files = fs.readdirSync(statesDir).filter(f => f.endsWith('.json') && !f.endsWith('.metrics.json'));
35
35
  if (files.length === 0) { process.exit(0); }
36
36
 
37
37
  // Update the most recently modified pipeline state
@@ -50,34 +50,64 @@ process.stdin.on('end', () => {
50
50
 
51
51
  if (!newest) { process.exit(0); }
52
52
 
53
- const state = JSON.parse(fs.readFileSync(newest, 'utf8'));
54
-
55
- // Initialize metrics if not present
56
- if (!state.metrics) {
57
- state.metrics = {
53
+ // Read pipeline-state.json READ-ONLY (to derive currentPhase, status, startedAt).
54
+ // Never write to it — metrics live in a sidecar to avoid "file modified since
55
+ // read" races with Edit/Write on the pipeline-state file.
56
+ let pipelineState = {};
57
+ try {
58
+ pipelineState = JSON.parse(fs.readFileSync(newest, 'utf8'));
59
+ } catch {}
60
+
61
+ const sidecarPath = newest.replace(/\.json$/, '.metrics.json');
62
+ let sidecar;
63
+ if (fs.existsSync(sidecarPath)) {
64
+ try {
65
+ sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
66
+ } catch {
67
+ sidecar = null;
68
+ }
69
+ }
70
+ if (!sidecar || typeof sidecar !== 'object') {
71
+ sidecar = {
72
+ v: 1,
73
+ metrics: {
74
+ apiCalls: 0,
75
+ toolBreakdown: {},
76
+ retries: 0,
77
+ startedAt: pipelineState.startedAt || new Date().toISOString(),
78
+ },
79
+ previousPhase: '',
80
+ };
81
+ }
82
+ if (!sidecar.metrics) {
83
+ sidecar.metrics = {
58
84
  apiCalls: 0,
59
85
  toolBreakdown: {},
60
86
  retries: 0,
61
- startedAt: state.startedAt || new Date().toISOString(),
87
+ startedAt: pipelineState.startedAt || new Date().toISOString(),
62
88
  };
63
89
  }
90
+ if (!sidecar.metrics.toolBreakdown) sidecar.metrics.toolBreakdown = {};
91
+
92
+ // Alias for minimal churn below — all mutations go to the sidecar.
93
+ const state = sidecar;
64
94
 
65
95
  // ── wave_reentry: track EXECUTE → PLAN transitions ──────────────────────
66
96
  // previousPhase is updated on every write so we can detect phase changes.
67
- const currentPhase = state.phaseName || state.phase || '';
68
- const previousPhase = state.previousPhase || '';
97
+ const currentPhase = pipelineState.phaseName || pipelineState.phase || '';
98
+ const previousPhase = sidecar.previousPhase || '';
69
99
  if (currentPhase === 'PLAN' && previousPhase === 'EXECUTE') {
70
100
  state.metrics.wave_reentry = (state.metrics.wave_reentry || 0) + 1;
71
101
  }
72
102
  // Always update previousPhase to the current phase so the NEXT write can
73
103
  // detect a transition.
74
- state.previousPhase = currentPhase;
104
+ sidecar.previousPhase = currentPhase;
75
105
 
76
106
  // ── gate_saves: spec edits in PLAN phase after first /approve ────────────
77
- // Proxy for "first approve recorded": state.status === 'approved' (set by
78
- // /approve command). A spec file is any .md in .claude/spec/ or matching
79
- // *spec*.md anywhere in the pipeline-states dir.
80
- if ((toolName === 'Edit' || toolName === 'Write') && currentPhase === 'PLAN' && state.status === 'approved') {
107
+ // Proxy for "first approve recorded": pipelineState.status === 'approved'
108
+ // (set by /approve command). A spec file is any .md in .claude/spec/ or
109
+ // matching *spec*.md anywhere in the pipeline-states dir.
110
+ if ((toolName === 'Edit' || toolName === 'Write') && currentPhase === 'PLAN' && pipelineState.status === 'approved') {
81
111
  const toolFilePath = (data.tool_input || {}).file_path || (data.tool_input || {}).path || '';
82
112
  const isSpecFile =
83
113
  /[/\\]\.claude[/\\]spec[/\\]/.test(toolFilePath) ||
@@ -151,7 +181,8 @@ process.stdin.on('end', () => {
151
181
 
152
182
  state.metrics.updatedAt = new Date().toISOString();
153
183
 
154
- fs.writeFileSync(newest, JSON.stringify(state, null, 2), 'utf8');
184
+ // Write ONLY the sidecar — never touch pipeline-state.json from this hook.
185
+ fs.writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), 'utf8');
155
186
 
156
187
  process.exit(0);
157
188
  } catch (err) {
@@ -2,11 +2,12 @@
2
2
  /**
3
3
  * SUBAGENT TRACKER: Tracks active subagents for statusline display
4
4
  *
5
- * Handles 4 events:
6
- * - PreToolUse(Task): queues description + type before agent starts
7
- * - SubagentStart: writes agent state file (consumes from queue)
8
- * - SubagentStop: removes agent state file + prunes stale queue
9
- * - SessionStart: cleans up stale state from previous sessions
5
+ * Handles 5 events:
6
+ * - PreToolUse(Task): queues description + type before agent starts
7
+ * - PostToolUse(Task): detects API overload / dispatch failures and flags pipeline state
8
+ * - SubagentStart: writes agent state file (consumes from queue)
9
+ * - SubagentStop: removes agent state file + prunes stale queue
10
+ * - SessionStart: cleans up stale state from previous sessions
10
11
  *
11
12
  * State dir: .claude/.agent-state/{agent_id}.json
12
13
  * Queue: .claude/.agent-state/_queue.json
@@ -42,6 +43,8 @@ process.stdin.on('end', () => {
42
43
 
43
44
  if (event === 'PreToolUse' && data.tool_name === 'Task') {
44
45
  handlePreToolUse(data, stateDir);
46
+ } else if (event === 'PostToolUse' && data.tool_name === 'Task') {
47
+ handlePostToolUse(data, stateDir);
45
48
  } else if (event === 'SubagentStart') {
46
49
  handleStart(data, stateDir);
47
50
  } else if (event === 'SubagentStop') {
@@ -177,6 +180,65 @@ function parseRecommendedSkills(prompt) {
177
180
  return skills;
178
181
  }
179
182
 
183
+ /**
184
+ * PostToolUse(Task): Detect API overload / dispatch failures in tool_response
185
+ * and flag the active pipeline state with `lastDispatchFailure` so /resume can
186
+ * auto-recover.
187
+ *
188
+ * We write to pipeline-state ONLY when a failure is detected — happy-path
189
+ * dispatches never touch the state file from here.
190
+ */
191
+ function handlePostToolUse(data, stateDir) {
192
+ try {
193
+ if (isSelfDelegation(data)) { return; }
194
+
195
+ const toolResponse = data.tool_response || {};
196
+ const responseText = JSON.stringify(toolResponse).toLowerCase();
197
+ // Detect overload conservatively: require is_error=true (Claude Code sets
198
+ // this on Task tool failures) AND at least one overload keyword. This
199
+ // avoids false positives on agents that merely *document* rate limiting
200
+ // or error handling in their returned content.
201
+ const isOverload =
202
+ toolResponse.is_error === true &&
203
+ /overload|rate.?limit|\b429\b|\b529\b|throttl|too many requests/.test(responseText);
204
+
205
+ if (!isOverload) return;
206
+
207
+ const projectDir = path.resolve(stateDir, '..', '..');
208
+ const statesDir = path.join(projectDir, '.claude', '.pipeline-states');
209
+ if (!fs.existsSync(statesDir)) return;
210
+
211
+ const files = fs.readdirSync(statesDir)
212
+ .filter(f => f.endsWith('.json') && !f.endsWith('.metrics.json'));
213
+ if (files.length === 0) return;
214
+
215
+ let newest = null;
216
+ let newestMtime = 0;
217
+ for (const f of files) {
218
+ try {
219
+ const fp = path.join(statesDir, f);
220
+ const stat = fs.statSync(fp);
221
+ if (stat.mtimeMs > newestMtime) {
222
+ newestMtime = stat.mtimeMs;
223
+ newest = fp;
224
+ }
225
+ } catch {}
226
+ }
227
+ if (!newest) return;
228
+
229
+ const toolInput = data.tool_input || {};
230
+ const state = JSON.parse(fs.readFileSync(newest, 'utf8'));
231
+ state.lastDispatchFailure = {
232
+ at: new Date().toISOString(),
233
+ reason: 'api_overload',
234
+ agentType: toolInput.subagent_type || 'unknown',
235
+ description: toolInput.description || '',
236
+ prompt: (toolInput.prompt || '').slice(0, 2000),
237
+ };
238
+ fs.writeFileSync(newest, JSON.stringify(state, null, 2), 'utf8');
239
+ } catch {} // fail-open: failure detection is advisory
240
+ }
241
+
180
242
  function handleStart(data, stateDir) {
181
243
  const agentId = data.agent_id || `unknown-${Date.now()}`;
182
244
  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 from stdin and persists it to
6
+ * Receives a JSON memory entry and persists it to
7
7
  * {projectDir}/.claude/.agent-memory/.
8
8
  *
9
- * Input schema (stdin):
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
- for await (const chunk of process.stdin) {
116
- raw += chunk;
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 stdin JSON: ${err.message}\n`);
134
+ process.stderr.write(`[memory-write] Failed to parse input JSON: ${err.message}\n`);
124
135
  process.exit(0);
125
136
  }
126
137
 
@@ -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
  }