claude-code-session-manager 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/dist/assets/{TiptapBody-BsI_35c5.js → TiptapBody-CJ6GK5CM.js} +1 -1
  3. package/dist/assets/index-DtQ4LzuV.js +3534 -0
  4. package/dist/assets/index-Dwb94Uxm.css +32 -0
  5. package/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/plugins/session-manager-dev/.claude-plugin/plugin.json +19 -0
  8. package/plugins/session-manager-dev/skills/develop/SKILL.md +112 -0
  9. package/plugins/session-manager-dev/skills/develop/standards.md +67 -0
  10. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +192 -0
  11. package/plugins/session-manager-dev/skills/explain-to-me/assets/style-reference.html +163 -0
  12. package/plugins/session-manager-dev/skills/local-project-health/SKILL.md +58 -0
  13. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +132 -0
  14. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +289 -0
  15. package/plugins/session-manager-dev/skills/prd/SKILL.md +134 -0
  16. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +183 -0
  17. package/plugins/session-manager-dev/skills/project-status/SKILL.md +244 -0
  18. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +105 -0
  19. package/plugins/session-manager-dev/skills/requesting-code-review/code-reviewer.md +146 -0
  20. package/plugins/session-manager-dev/skills/security-review/SKILL.md +105 -0
  21. package/src/main/__tests__/scheduler-autofix-select.test.cjs +95 -0
  22. package/src/main/__tests__/scheduler-autopromote.test.cjs +33 -0
  23. package/src/main/hives.cjs +96 -36
  24. package/src/main/scheduler.cjs +135 -11
  25. package/src/main/templates/PRD_AUTHORING.md +316 -0
  26. package/src/preload/api.d.ts +8 -7
  27. package/dist/assets/index-BKkf6gtA.css +0 -32
  28. package/dist/assets/index-C61eFtxs.js +0 -3525
@@ -221,9 +221,27 @@ const ENV_CAP = process.env.SM_SCHEDULER_MAX_CONCURRENCY
221
221
  ? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 3))
222
222
  : null;
223
223
 
224
- // Each headless claude -p process can grow past 1 GB; require 1.5 GB headroom
225
- // per running+pending slot to avoid OOM (incident 2026-06-10).
226
- const MIN_FREE_MB_PER_JOB = 1500;
224
+ // Each headless claude -p job can shell out to tsc/vite/pytest and grow well
225
+ // past 1 GB at peak; reserve 2.5 GB per running+pending slot. Raised from 1.5 GB
226
+ // after the 2026-06-16 OOM: 3 concurrent cross-project jobs + their build
227
+ // subprocesses pushed a 24 GB host ~10 GB into swap and the OOM killer took
228
+ // Electron — every pty got SIGHUP (code=0 signal=1).
229
+ const MIN_FREE_MB_PER_JOB = 2500;
230
+
231
+ // Absolute headroom kept free for the Electron host (main + renderer + GPU) and
232
+ // the OS — NEVER lent to jobs. Without it the gate green-lights a job whenever
233
+ // MemAvailable is just above per-job need, starving the very process that owns
234
+ // the ptys; that's the one the OOM killer then reaps. Subtracted from
235
+ // MemAvailable before the per-job gate runs. See availableForJobs().
236
+ const RESERVED_HOST_MB = 3000;
237
+
238
+ // oom_score_adj applied to each spawned claude -p job (range -1000..1000;
239
+ // Electron inherits the default 0). A positive bias makes the kernel OOM killer
240
+ // prefer a disposable, restartable job over Electron — whose death SIGHUPs every
241
+ // pty and drops the sleep inhibitor. The job's build subprocesses (tsc/vite)
242
+ // inherit it, so the actual memory hogs are the preferred victims. The gate caps
243
+ // how many START; this decides who dies if a spike slips through anyway.
244
+ const OOM_SCORE_ADJ_JOB = 500;
227
245
 
228
246
  const DEFAULT_CONFIG = {
229
247
  offsetMinutes: 15,
@@ -277,6 +295,33 @@ function memoryLimitedBatchSize(availableMb, minPerJob, runningCount, batchLen)
277
295
  return allowed;
278
296
  }
279
297
 
298
+ /**
299
+ * Memory available to LAUNCH jobs with: MemAvailable minus the host reserve,
300
+ * floored at 0. Keeping the host reserve out of the job budget is what stops the
301
+ * gate from green-lighting a job into an OOM that kills Electron. Fails open
302
+ * (Infinity) on non-Linux where MemAvailable is unknown. Exported for tests.
303
+ */
304
+ function availableForJobs(availableMb, reservedHostMb) {
305
+ if (availableMb === Infinity) return Infinity;
306
+ return Math.max(0, availableMb - reservedHostMb);
307
+ }
308
+
309
+ /**
310
+ * Bias a spawned job's oom_score_adj up so the kernel OOM killer sacrifices the
311
+ * (restartable) job before Electron. Raising a child's OWN score is privilege-
312
+ * free; lowering Electron's would need CAP_SYS_RESOURCE. Linux-only, best-effort
313
+ * — the job may have already exited (write ENOENTs), which is fine. See the
314
+ * 2026-06-16 OOM-kills-Electron incident.
315
+ */
316
+ function biasJobOomScore(pid) {
317
+ if (process.platform !== 'linux' || !pid) return;
318
+ try {
319
+ fs.writeFileSync(`/proc/${pid}/oom_score_adj`, String(OOM_SCORE_ADJ_JOB));
320
+ } catch {
321
+ /* job already exited, or /proc unavailable — best-effort hardening only */
322
+ }
323
+ }
324
+
280
325
  // ---------- fs helpers ----------
281
326
 
282
327
  /**
@@ -292,9 +337,21 @@ function safeSlugPath(slug) {
292
337
  return resolved;
293
338
  }
294
339
 
340
+ // Bundled authoring guide seeded into the scheduler dir so the session-manager-dev
341
+ // plugin's /develop and /prd skills — which reference this stable `~`-absolute
342
+ // path — work on any user's machine, not just the author's.
343
+ const PRD_AUTHORING_TEMPLATE = path.join(__dirname, 'templates', 'PRD_AUTHORING.md');
344
+ const PRD_AUTHORING_DEST = path.join(ROOT, 'PRD_AUTHORING.md');
345
+
295
346
  function ensureDirs() {
296
347
  fs.mkdirSync(PRDS_DIR, { recursive: true });
297
348
  fs.mkdirSync(RUNS_DIR, { recursive: true });
349
+ // Seed the authoring guide once; never clobber a user's edited copy.
350
+ try {
351
+ if (!fs.existsSync(PRD_AUTHORING_DEST) && fs.existsSync(PRD_AUTHORING_TEMPLATE)) {
352
+ fs.copyFileSync(PRD_AUTHORING_TEMPLATE, PRD_AUTHORING_DEST);
353
+ }
354
+ } catch { /* non-fatal: the guide is a convenience, not load-bearing for a run */ }
298
355
  }
299
356
 
300
357
  // Atomic JSON write helpers delegate to config.cjs's shared implementation.
@@ -1038,6 +1095,8 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
1038
1095
 
1039
1096
  if (child) {
1040
1097
  safeLog(`[scheduler] spawned pid=${child.pid} sessionId=${sessionId} (process group)\n\n`);
1098
+ // Make this job the OOM killer's preferred victim over Electron.
1099
+ biasJobOomScore(child.pid);
1041
1100
  // Fire-and-forget pid persistence — best effort.
1042
1101
  if (onPid) onPid(child.pid, sessionId, cwd).catch(() => {});
1043
1102
  }
@@ -1057,6 +1116,15 @@ function isFixPlanSlug(slug) {
1057
1116
  return /^\d+-fix-/.test(slug);
1058
1117
  }
1059
1118
 
1119
+ /**
1120
+ * Returns true for statuses that a fix-plan completion should promote
1121
+ * (clear) on the original job. Both 'failed' and 'needs_review' are
1122
+ * recoverable via a fix-plan; 'completed', 'running', 'pending' are not.
1123
+ */
1124
+ function isPromotableOriginal(status) {
1125
+ return status === 'failed' || status === 'needs_review';
1126
+ }
1127
+
1060
1128
  /**
1061
1129
  * Spawn an Opus investigation session for a failed job. The investigator's job
1062
1130
  * is to read the failure log + original PRD, identify the root cause, and write
@@ -1399,13 +1467,17 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1399
1467
  // though the auto-recovery did its job.
1400
1468
  if (effectiveStatus === 'completed' && isFixPlanSlug(job.slug)) {
1401
1469
  const originalSlug = job.slug.replace(/^(\d+)-fix-/, '$1-');
1402
- const orig = s.jobs.findIndex((x) => x.slug === originalSlug && x.status === 'failed');
1470
+ const orig = s.jobs.findIndex((x) => x.slug === originalSlug && isPromotableOriginal(x.status));
1403
1471
  if (orig >= 0) {
1404
- console.log(`[scheduler] auto-promote: ${originalSlug} (failed) → completed because ${job.slug} succeeded`);
1472
+ const priorStatus = s.jobs[orig].status;
1473
+ console.log(`[scheduler] auto-promote: ${originalSlug} (${priorStatus}) → completed because ${job.slug} succeeded`);
1405
1474
  s.jobs[orig].status = 'completed';
1406
1475
  s.jobs[orig].exitCode = 0;
1407
1476
  s.jobs[orig].error = null;
1408
1477
  s.jobs[orig].completedBy = job.slug;
1478
+ if (priorStatus === 'needs_review') {
1479
+ delete s.jobs[orig].verifierVerdict;
1480
+ }
1409
1481
  }
1410
1482
  }
1411
1483
  }
@@ -1483,17 +1555,21 @@ function tickQueue() {
1483
1555
  }
1484
1556
 
1485
1557
  const availableMb = getAvailableMemMb();
1486
- const allowed = memoryLimitedBatchSize(availableMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length);
1558
+ // Reserve a fixed slice for the Electron host before the per-job gate, so a
1559
+ // job is never started into the host's own headroom (that path OOM-kills
1560
+ // Electron and SIGHUPs every pty — 2026-06-16 incident).
1561
+ const jobBudgetMb = availableForJobs(availableMb, RESERVED_HOST_MB);
1562
+ const allowed = memoryLimitedBatchSize(jobBudgetMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length);
1487
1563
  if (allowed === 0) {
1488
- const threshold = MIN_FREE_MB_PER_JOB * (runningSet.size + 1);
1489
- console.log(`[scheduler] memory gate: available=${availableMb} MB < threshold=${threshold} MB — deferring ${batch.length} job(s)`);
1564
+ const threshold = RESERVED_HOST_MB + MIN_FREE_MB_PER_JOB * (runningSet.size + 1);
1565
+ console.log(`[scheduler] memory gate: available=${availableMb} MB < threshold=${threshold} MB (host reserve ${RESERVED_HOST_MB} + ${MIN_FREE_MB_PER_JOB}/job × ${runningSet.size + 1}) — deferring ${batch.length} job(s)`);
1490
1566
  lastMemGate = { availableMb, threshold, deferred: true, at: new Date().toISOString() };
1491
1567
  return;
1492
1568
  }
1493
1569
  const gatedBatch = batch.slice(0, allowed);
1494
1570
  if (gatedBatch.length < batch.length) {
1495
- console.log(`[scheduler] memory gate: available=${availableMb} MB — clamped batch ${batch.length} → ${gatedBatch.length}`);
1496
- lastMemGate = { availableMb, threshold: MIN_FREE_MB_PER_JOB * (runningSet.size + gatedBatch.length), deferred: false, clamped: true, at: new Date().toISOString() };
1571
+ console.log(`[scheduler] memory gate: available=${availableMb} MB — clamped batch ${batch.length} → ${gatedBatch.length} (host reserve ${RESERVED_HOST_MB} + ${MIN_FREE_MB_PER_JOB}/job)`);
1572
+ lastMemGate = { availableMb, threshold: RESERVED_HOST_MB + MIN_FREE_MB_PER_JOB * (runningSet.size + gatedBatch.length), deferred: false, clamped: true, at: new Date().toISOString() };
1497
1573
  } else {
1498
1574
  // Ungated full batch: clear stale gate snapshot so status doesn't show
1499
1575
  // a stale deferral from a previous tick.
@@ -1739,6 +1815,31 @@ function isRescanCandidate(job) {
1739
1815
  *
1740
1816
  * @returns {Promise<{rescanned:number, healed:string[]}>}
1741
1817
  */
1818
+ /**
1819
+ * Pure helper — no I/O. Returns the subset of jobs eligible for automatic
1820
+ * fix-plan authoring after a reverify pass leaves them still in needs_review.
1821
+ *
1822
+ * Exclusion rules (all must pass):
1823
+ * - status === 'needs_review'
1824
+ * - truthy runId (need a run log to investigate)
1825
+ * - autoFixAttempted !== true (1-attempt cap)
1826
+ * - not itself a fix-plan slug (avoids infinite recursion)
1827
+ * - no fix sibling on disk (fixSlugExists) or already in the queue
1828
+ */
1829
+ function selectAutoFixTargets(jobs, { fixSlugExists }) {
1830
+ const slugsInQueue = new Set(jobs.map((j) => j.slug));
1831
+ return jobs.filter((job) => {
1832
+ if (job.status !== 'needs_review') return false;
1833
+ if (!job.runId) return false;
1834
+ if (job.autoFixAttempted) return false;
1835
+ if (isFixPlanSlug(job.slug)) return false;
1836
+ const fixSlug = `${String(job.parallelGroup ?? 99).padStart(2, '0')}-fix-${job.slug.replace(/^\d+-/, '')}`;
1837
+ if (fixSlugExists(fixSlug)) return false;
1838
+ if (slugsInQueue.has(fixSlug)) return false;
1839
+ return true;
1840
+ });
1841
+ }
1842
+
1742
1843
  async function reverifyNeedsReview() {
1743
1844
  const snap = await readQueue();
1744
1845
  const candidates = snap.jobs.filter(isRescanCandidate);
@@ -1786,6 +1887,29 @@ async function reverifyNeedsReview() {
1786
1887
  const detail = leftForReview.map((e) => `${e.slug} (${e.reason})`).join(', ');
1787
1888
  console.log(`[scheduler] boot reverify: left for review: ${detail}`);
1788
1889
  }
1890
+
1891
+ // Auto-fix: spawn a fix-plan investigation for each job still in
1892
+ // needs_review after the heal pass (kill-switch: SM_AUTOFIX_DISABLE=1).
1893
+ if (process.env.SM_AUTOFIX_DISABLE !== '1') {
1894
+ const afterHeal = await readQueue();
1895
+ const targets = selectAutoFixTargets(afterHeal.jobs, {
1896
+ fixSlugExists: (s) => fs.existsSync(path.join(PRDS_DIR, `${s}.md`)),
1897
+ });
1898
+ for (const job of targets) {
1899
+ const runDir = path.join(RUNS_DIR, job.runId);
1900
+ // Persist cap BEFORE spawning — a crash mid-investigation still counts
1901
+ // the attempt (mirrors orphanRetries pattern).
1902
+ await mutate((s) => {
1903
+ const j = s.jobs.find((x) => x.slug === job.slug);
1904
+ if (j) j.autoFixAttempted = true;
1905
+ });
1906
+ console.log(`[scheduler] auto-fix: needs_review ${job.slug} → authoring fix-plan (1/1)`);
1907
+ spawnInvestigation(job, runDir).catch((e) => {
1908
+ console.error('[scheduler] auto-fix spawnInvestigation error', job.slug, e);
1909
+ });
1910
+ }
1911
+ }
1912
+
1789
1913
  return { rescanned: candidates.length, healed, leftForReview };
1790
1914
  }
1791
1915
 
@@ -2302,4 +2426,4 @@ const remote = {
2302
2426
  },
2303
2427
  };
2304
2428
 
2305
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, reverifyNeedsReview, isRescanCandidate };
2429
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets };
@@ -0,0 +1,316 @@
1
+ # PRD Authoring Guide — Scheduler Safety Rules
2
+
3
+ This guide codifies lessons from two stuck-job incidents (fizzpop poll-hang, etch-engine post-AC overrun) into enforceable rules every PRD MUST follow. Violating these rules costs real money and wastes hours waiting at a terminal.
4
+
5
+ Before queueing a new PRD, run through the §10 checklist at the bottom.
6
+
7
+ ---
8
+
9
+ ## §1 Bounded waits, never unbounded polls
10
+
11
+ **Summary:** Every `until`/`while` loop that makes network calls or waits for external state MUST have a hard iteration cap that surfaces non-zero on exhaustion.
12
+
13
+ **Anti-example** (verbatim from `106-fizzpop-publish.md`):
14
+ ```bash
15
+ # WRONG — what 106-fizzpop-publish did
16
+ PREV=288694
17
+ until [ "$(curl -s https://bilko.run/api/health | jq .uptime)" -lt "$PREV" ]; do
18
+ sleep 15
19
+ done
20
+ # Outcome: 2h47m hang because static-content Render deploys never restart the Node API
21
+ # so `uptime` never dropped. Loop hung until the 4h watchdog SIGKILLed.
22
+ ```
23
+
24
+ **Recommended pattern:**
25
+ ```bash
26
+ # RIGHT — bounded, surfaces failure cleanly
27
+ for i in $(seq 1 20); do
28
+ if curl -sf https://bilko.run/projects/fizzpop/ > /dev/null; then
29
+ echo "deploy live (attempt $i)"; break
30
+ fi
31
+ echo "waiting for deploy ($i/20)..."
32
+ sleep 15
33
+ done
34
+ # Smoke test downstream will diagnose the real failure if the deploy didn't land.
35
+ ```
36
+
37
+ **Rule:** Every `until`/`while` network-or-state poll MUST use `for i in $(seq 1 N); do ...; done` with a hard cap. Recommended cap: 20 × 15 s = 5 min for HTTP polls. On exhaustion, print a diagnostic line and continue (let the smoke test below catch the real failure).
38
+
39
+ ---
40
+
41
+ ## §2 Don't add work past the acceptance checklist
42
+
43
+ **Summary:** Once every AC line is ticked, write the result and exit. Do not add polish, fixtures, generators, or "while we're here" improvements not enumerated in the PRD.
44
+
45
+ **Anti-example** (verbatim from `112-etch-engine.md`):
46
+ ```bash
47
+ # WRONG — what 112-etch-engine did after declaring success
48
+ for (let seed = 100; seed < 50000 && !found; seed++) {
49
+ const result = generateRandom({ size, rng, maxAttempts: 3 });
50
+ if (result.ok && result.solution) { found = true; ... }
51
+ }
52
+ # Outcome: 2h44m of token burn looking for fixtures the AC did not require.
53
+ # The agent had emitted result-success at 17:44 UTC; the bonus loop ran until 20:28.
54
+ ```
55
+
56
+ **Rule:** Once every AC line is checked, write the result and exit 0. If bonus work seems genuinely valuable, write a follow-up PRD and reference it in the result. Do not add any work not explicitly enumerated in an AC line.
57
+
58
+ ---
59
+
60
+ ## §3 Smoke tests verify; spin-waits don't
61
+
62
+ **Summary:** Prefer "do thing, run a test that asserts thing happened" over "do thing, spin until I detect thing happened."
63
+
64
+ **Rule:** After a deployment, migration, or build step, run an actual test command (`curl -sf`, `npm test`, `pnpm typecheck`) that exits non-zero on failure. A spin-wait that polls for a condition hides the failure mode; a test command surfaces the exact error.
65
+
66
+ **Pattern:**
67
+ ```bash
68
+ # Deploy step above (bounded, §1)
69
+ # Smoke test — will exit 1 with a clear message if deploy failed:
70
+ curl -sf https://bilko.run/projects/fizzpop/ > /dev/null || { echo "smoke test FAILED: fizzpop not reachable"; exit 1; }
71
+ ```
72
+
73
+ ---
74
+
75
+ ## §4 Bound any generator/search loop with max-attempts and surface-on-exhaustion
76
+
77
+ **Summary:** When iterating a search space (fixtures, seeds, brute-force), declare the maximum search size in the PRD AC and surface and HALT on exhaustion.
78
+
79
+ **Anti-example:** Same etch-engine fixture generator from §2 — `for (let seed = 100; seed < 50000 ...)` with no AC line constraining it.
80
+
81
+ **Rule:** When iterating a search space, the PRD AC MUST state the bound explicitly ("up to 1000 seeds; if exhausted, surface and HALT"). The executor knows when to give up and moves on rather than burning tokens indefinitely.
82
+
83
+ **Pattern:**
84
+ ```ts
85
+ let found = false;
86
+ for (let seed = 0; seed < MAX_SEEDS && !found; seed++) {
87
+ // ...
88
+ }
89
+ if (!found) {
90
+ console.error(`HALT: exhausted ${MAX_SEEDS} seeds without finding a valid fixture`);
91
+ process.exit(1);
92
+ }
93
+ ```
94
+
95
+ ---
96
+
97
+ ## §5 Render/deploy waits are bounded and always followed by a smoke test
98
+
99
+ **Summary:** Render (and similar) deploys may take 2–10 minutes and may silently fail. Always bound the wait and follow it with a live endpoint test.
100
+
101
+ **Pattern:**
102
+ ```bash
103
+ DEPLOY_OK=0
104
+ for i in $(seq 1 20); do
105
+ if curl -sf https://bilko.run/projects/<slug>/ > /dev/null; then
106
+ DEPLOY_OK=1; echo "deploy live (attempt $i)"; break
107
+ fi
108
+ echo "waiting for deploy ($i/20)..."
109
+ sleep 15
110
+ done
111
+ # Continue regardless. Smoke test below catches the real failure.
112
+ if [ $DEPLOY_OK -eq 0 ]; then
113
+ echo "WARNING: deploy not detected after 20 attempts; continuing to smoke test"
114
+ fi
115
+ curl -sf https://bilko.run/projects/<slug>/ > /dev/null || { echo "SMOKE TEST FAILED"; exit 1; }
116
+ ```
117
+
118
+ **Rule:** A static-content deploy on Render does NOT restart the backend API. Never use `uptime` or process-restart signals to detect a static-content deploy — use the actual URL that should be live.
119
+
120
+ ---
121
+
122
+ ## §6 Frontmatter rules
123
+
124
+ **Summary:** Required keys are `title`, `cwd` (absolute path), `estimateMinutes`. Default to letting the filename `NN-` prefix drive grouping; include `parallelGroup` ONLY when you are deliberately interleaving across project streams.
125
+
126
+ **Required frontmatter:**
127
+ ```yaml
128
+ ---
129
+ title: <one line, plain English>
130
+ cwd: ~/Projects/<target-repo>
131
+ estimateMinutes: 60
132
+ ---
133
+ ```
134
+
135
+ **Cross-machine portability:** Write `cwd` as `~/Projects/<name>` — the parser expands `~` to `os.homedir()` at ingest time, so the same PRD file works on Linux (`/home/<u>/...`) and macOS (`/Users/<u>/...`). Absolute paths (e.g. `/home/bilko/Projects/foo`) are passed through unchanged and will break on any machine with a different home directory.
136
+
137
+ **Rules:**
138
+ - `cwd` MUST point to the target project. Prefer `~/...` for portability; only use an absolute path if you have a specific reason to pin to one machine.
139
+ - **`cwd` MUST already exist on disk at queue time.** The scheduler runs a dead-cwd guard (`fs.accessSync(cwd, fs.constants.X_OK)` in `src/main/scheduler.cjs:669-680`) *before* spawning the child, so a PRD whose `cwd` references a not-yet-created directory will exit with `-1: cwd no longer exists` and the body will never run — even if the first step of the body would have created the directory. If the PRD's purpose is to create a brand-new sibling project at `~/Projects/<new-slug>/`, point `cwd` at the parent (`~/Projects`) and make the first executable step `mkdir -p ~/Projects/<new-slug> && cd ~/Projects/<new-slug>`.
140
+ - `estimateMinutes` is used for ETA display; include a realistic estimate (note: empirical median is ~10 min, p90 ~20 min — avoid wildly inflated estimates that hide real outliers).
141
+ - `parallelGroup` in frontmatter, when present, IS honored by the scheduler (`pickNextBatch` reads `parallelGroup ?? 99` and overrides the filename NN). Use this only for cross-stream interleaving — e.g., the cellar series `122-`, `123-`, `124-` overrides to groups `113`, `114`, `115` so cellar steps fire alongside the parallel etch steps. Do NOT use it to reorder within a single stream; rename the file instead.
142
+
143
+ ---
144
+
145
+ ## §7 Self-containment
146
+
147
+ **Summary:** The PRD body is the executor's entire context. It runs as `claude -p "<body>"` with no conversation history.
148
+
149
+ **Rule:** Include exact file paths, function signatures if they save a Read, library versions, and the name of any sibling PRD the executor must NOT duplicate. Do not reference "the conversation", "what we discussed", "the design doc", or any other external context. If the executor would need to search for something, include the answer.
150
+
151
+ ---
152
+
153
+ ## §8 Scope sizing — target ~15 min, ceiling 30 (data-driven, 2026-06)
154
+
155
+ **Summary:** One PRD ≈ **~15 wall-clock minutes** of work. Empirically (400+ runs) median real run = **~7 min**, p90 = **21 min**; authored estimates ran 5–8× too high. **If you project >30 min, SPLIT.**
156
+
157
+ **Rule:** Split larger work into sequential PRDs; reference the dependency in `# Implementation notes`. PRDs in the same `NN-` group run in parallel — don't put dependent work in the same group. e2e/publish work is the failure tail: **shard test suites to one spec per PRD; never run a full suite or an endpoint-polling publish in a single PRD** (§1/§5).
158
+
159
+ ---
160
+
161
+ ## §9 Failure surfacing
162
+
163
+ **Summary:** Prefer `exit 1` with a one-line diagnosis over silent retries. Note: a `rateLimited` exit-1 is the scheduler's benign auto-pause (it auto-resumes at the next 5h reset), NOT an authoring failure — don't engineer retry logic for it.
164
+
165
+ **Rule:** When a step fails, print a single diagnostic line and exit 1. The scheduler marks the job `failed` and the investigator Claude reads the log. A clean failure message is worth more than a 15-minute silent retry loop. Do not swallow errors with `|| true` unless the failure is genuinely non-fatal and you explain why.
166
+
167
+ ```bash
168
+ # RIGHT
169
+ npm test || { echo "HALT: npm test failed — see above"; exit 1; }
170
+
171
+ # WRONG
172
+ npm test || true # silently continues even if tests are broken
173
+ ```
174
+
175
+ ---
176
+
177
+ ## §10 Pre-queue checklist (the litany)
178
+
179
+ Before queueing a new PRD, verify each of these:
180
+
181
+ - [ ] **§1 Bounded waits:** Every `until`/`while` poll has a `for i in $(seq 1 N)` cap ≤ 20 iterations.
182
+ - [ ] **Every command bounded:** Every test/build/dev-server/deploy command is wrapped in `timeout` (typecheck/unit 300s, e2e 120s, `curl --max-time 15`). No bare `playwright test` / `vite` / `pnpm dev` / `curl … | head`.
183
+ - [ ] **§2 No bonus work:** AC list is the only source of work. No "while we're here" additions.
184
+ - [ ] **§3 Smoke tests + verify-before-done:** Every deploy/migration step is followed by a test command that exits 1 on failure. Run the AC test command once before declaring done; never end the run on a red test.
185
+ - [ ] **§4 Bounded generators:** Any search/seed loop has an explicit `MAX_ATTEMPTS` constant and surfaces failure on exhaustion.
186
+ - [ ] **§5 Render deploys:** Deploy waits use a live URL check, not uptime/restart signals.
187
+ - [ ] **§6 Frontmatter:** `title`, `cwd` (`~/Projects/<name>` preferred; path MUST exist on this machine), `estimateMinutes` present. `parallelGroup` only if intentionally interleaving cross-stream.
188
+ - [ ] **§7 Self-contained:** No references to "the conversation" or external context. Paths and identifiers are inline. Body is clean UTF-8 — **no NUL/control bytes** (paste-from-PDF crashes the spawn). Quick check: `grep -qP '\x00' file && echo BAD`.
189
+ - [ ] **§8 Scope:** Targets ~15 min, ceiling 30. If projected larger, split. e2e/publish sharded to one spec per PRD.
190
+ - [ ] **§9 Failure surfacing:** Errors exit 1 with a diagnostic line. No silent `|| true` swallows. (`rateLimited` exit-1 is benign auto-pause, not a failure.)
191
+ - [ ] **§11 Negative-assertion checks:** Any "this should produce NO output / NO match" check (a `grep` that should find nothing, a "no leftover X" guard) is written as an inverted conditional that exits 0 on the clean case. A bare `grep` whose success is "no match" exits 1 and trips the verifier `transcript_errors` downgrade even when the run is perfect.
192
+ - [ ] **§12 End green:** The acceptance/test gate is the LAST thing the run does; any intentionally-failing step (TDD red test, expected-nonzero probe) runs EARLY, never after the gate, and is captured (`2>&1 | tail` inside a conditional) so it doesn't surface as a bare `is_error`/`Traceback` in the final portion of the transcript.
193
+ - [ ] **§13 Recover/annotate errors & expected timeouts:** A throwaway probe that errors is re-run corrected (or annotated `# expected/handled`) right after — never left stranded; prefer a temp `.py` over a fragile inline `python -c`. An *expected* `timeout` cap (a long ingest/scan) handles exit 124 explicitly as success-with-note, not a bare `Exit code 124`. Both prevent the `transcript_errors` downgrade of a green deliverable.
194
+
195
+ ---
196
+
197
+ ## §11 Negative-assertion checks must exit 0 on the clean case
198
+
199
+ **Summary:** A check that asserts the *absence* of something must return exit 0 when the
200
+ thing is absent. The classic trap is `grep`: it exits **1 when it finds no match**. If your
201
+ AC says "verify no banned phrase remains" and you write a bare `grep`, the *success* path
202
+ (nothing found) surfaces as `is_error=true` in the transcript — and the verifier's
203
+ `transcript_errors` heuristic downgrades the whole run to `needs_review` even though it did
204
+ everything right.
205
+
206
+ **This actually happened** (PRD `62-x-trader-doctrine`, 2026-06-13): the doctrine was cleaned
207
+ correctly and committed, but the AC's sanity grep —
208
+ `grep -rniE "building in public|..." data/pipelines/x_session/` — found nothing, exited 1,
209
+ and a perfect run was flagged for review. Self-inflicted, by the PRD author.
210
+
211
+ ```bash
212
+ # WRONG — exits 1 (is_error) exactly when the check PASSES
213
+ grep -rniE "building in public|indie hacker" data/pipelines/x_session/
214
+
215
+ # RIGHT — inverted: "found banned phrase" is the failure, "clean" exits 0
216
+ if grep -rniE "building in public|indie hacker" data/pipelines/x_session/; then
217
+ echo "HALT: banned builder framing still present (see matches above)"; exit 1
218
+ fi
219
+ echo "clean: no banned framing"
220
+
221
+ # ALSO RIGHT — grep -q with negation, when you don't need to see the matches
222
+ grep -rqniE "building in public|indie hacker" data/pipelines/x_session/ \
223
+ && { echo "HALT: banned framing present"; exit 1; } || echo "clean"
224
+ ```
225
+
226
+ **Rule:** Whenever an AC line is phrased as "verify there are no…", "confirm X does not
227
+ appear", "no leftover…", write it as `if <detector>; then echo HALT…; exit 1; fi`. Never let
228
+ the no-match/empty-output path be the one that carries a non-zero exit. Applies to `grep`,
229
+ `rg`, `find ... | grep`, `diff` (exits 1 on differences), and any custom detector.
230
+
231
+ ---
232
+
233
+ ## §12 End green, and trust the verdict sentinel
234
+
235
+ **Summary:** The post-run verifier (`runVerify.cjs`) scans the transcript and downgrades to
236
+ `needs_review` on error markers (`Traceback`+`Error`, `FAIL`/`FATAL`, a tool `is_error` in the
237
+ final portion of the run). It cannot tell an *intentional* failure from a real one. Two rules
238
+ keep legitimate runs from false-tripping it.
239
+
240
+ **12a — Run the green gate LAST.** Order the run so the final command is the acceptance/test
241
+ gate. Do any intentionally-failing step EARLY:
242
+
243
+ ```bash
244
+ # WRONG — red test reproduced AFTER the work; its Traceback lands late in the transcript
245
+ pytest -q # all green
246
+ python -m pytest tests/test_repro.py::test_bug # ← TDD red demo, errors, trips verifier
247
+
248
+ # RIGHT — red demo first (and captured), green gate last
249
+ python -m pytest tests/test_repro.py::test_bug 2>&1 | tail -3 || true # expected red, captured
250
+ # ... implement the fix ...
251
+ timeout 300 pytest -q # ← LAST thing the run does; ends green
252
+ ```
253
+
254
+ If you must show a failure late, capture it (`… 2>&1 | tail` inside a conditional, or assert
255
+ on the captured text) so a raw `Traceback`/`is_error` never hits the transcript bare.
256
+
257
+ **12b — The `SCHEDULER_VERDICT` sentinel is authoritative; emit it truthfully.** The scheduler's
258
+ FINISH PROTOCOL ends by printing `SCHEDULER_VERDICT: PASS` once the AC gate is green AND the
259
+ commit landed (else `SCHEDULER_VERDICT: FAIL <reason>` + `exit 1`). The verifier treats
260
+ `PASS` + a commit landed during the run as the **authoritative** signal and overrides incidental
261
+ transcript markers — this is what lets a deliberately-reproduced red test (PRD 77) or a grep
262
+ result containing "Error" (PRD 68) finish `completed` instead of `needs_review`. **Never print
263
+ `PASS` on a red gate.** The sentinel is only a safety net while it tells the truth; a lying
264
+ `PASS` converts the verifier from "catches false failures" into "ships silent failures."
265
+
266
+ **This actually happened** (PRDs 68 + 77, 2026-06-13): both committed correct work with green
267
+ suites, but 77's `systematic-debugging` red-test repro and 68's grep-"Error" substring each
268
+ tripped `transcript_errors → needs_review`, and the self-heal pass kept re-deriving the same
269
+ verdict from the immutable log — stuck indefinitely. §12 (end-green + authoritative sentinel)
270
+ is the structural fix.
271
+
272
+ ---
273
+
274
+ ## §13 Don't strand mid-run probe errors; annotate expected timeouts
275
+
276
+ **Summary:** §12 keeps the *final* portion of the transcript green. §13 covers the *middle* —
277
+ two executor habits that strand a bare `Traceback`/`Error`/`Exit code` the verifier then flags,
278
+ even when the deliverable is correct and committed.
279
+
280
+ **13a — A throwaway probe that errors must recover or be annotated in place.** Exploratory
281
+ `python -c`/`bash` probes that error (a quoting/f-string slip, a wrong kwarg, a bad path) leave a
282
+ bare traceback. Re-run the corrected probe immediately, or print `# expected/handled: <why>` on
283
+ the next line, so recovery is adjacent (the heuristic looks for recovery within ~10 lines).
284
+ Prefer a small temp `.py` file over a fragile multi-quote `python -c` one-liner — inline
285
+ f-string/quoting errors are the top source of stranded probe tracebacks.
286
+
287
+ ```bash
288
+ # WRONG — inline f-string slip strands a SyntaxError, then you move on
289
+ python -c 'print(f"{p["title"]!r[:40]}")' # SyntaxError, bare in transcript
290
+
291
+ # RIGHT — write the probe to a temp file (no shell-quote minefield), or annotate
292
+ cat > /tmp/probe.py <<'PY'
293
+ print(repr(p["title"])[:40])
294
+ PY
295
+ python /tmp/probe.py || echo "# expected/handled: probe only, not part of the deliverable"
296
+ ```
297
+
298
+ **13b — An *expected* `timeout` cap is success-with-note, not a bare `Exit code 124`.** Capping a
299
+ genuinely long task you expect to hit the cap (a full-universe ingest, a long scan) is the
300
+ correct §1/§8 behavior — but a bare `Exit code 124` reads as failure to the verifier. Branch on
301
+ 124 explicitly:
302
+
303
+ ```bash
304
+ timeout 120 python -m project.ingest --all || { rc=$?
305
+ [ $rc -eq 124 ] && echo "hit time cap — idempotent/partial; rows persist incrementally; OK" \
306
+ || { echo "HALT: ingest failed rc=$rc"; exit 1; }; }
307
+ ```
308
+
309
+ For work that legitimately needs longer than a safe cap, run it in the background and poll a
310
+ bounded number of times (§1) rather than capping the foreground command.
311
+
312
+ **This actually happened** (PRDs 77 + 80, 2026-06-13): 77 stranded an inline-`python -c` f-string
313
+ `SyntaxError` from a throwaway permalink probe; 80 surfaced a bare `Exit code 124` from a
314
+ `timeout`-capped full-universe EDGAR ingest. Both committed correct, green, AC-complete work
315
+ (77's cursor-hold fix; 80's 6 EDGAR rows + installed cron) yet were downgraded to `needs_review`
316
+ on the incidental middle-of-run markers. 13a/13b keep the middle of the transcript clean.
@@ -533,16 +533,17 @@ export interface RepoAnalyzeResult {
533
533
  }
534
534
  export interface RepoAnalyzeError { ok: false; error: string }
535
535
 
536
- export interface HiveRole { label: string; prompt: string }
537
- export interface Hive {
536
+ export interface RecipeStep { agentName: string; note?: string }
537
+ export interface Recipe {
538
538
  slug: string;
539
539
  name: string;
540
540
  description: string;
541
- roles: HiveRole[];
542
- defaultPlan?: string;
541
+ brief?: string;
542
+ steps: RecipeStep[];
543
543
  }
544
- export interface HiveListResult { hives: Hive[]; error: string | null }
545
- export interface HiveGetResult { hive: Hive | null; error: string | null }
544
+ // Channel keeps legacy "hives" name; concept is now "recipe".
545
+ export interface HiveListResult { hives: Recipe[]; error: string | null }
546
+ export interface HiveGetResult { hive: Recipe | null; error: string | null }
546
547
  export interface HiveMutationResult { ok: boolean; error: string | null }
547
548
 
548
549
  export interface WatcherInfo {
@@ -975,7 +976,7 @@ export interface SessionManagerAPI {
975
976
  hives: {
976
977
  list: () => Promise<HiveListResult>;
977
978
  get: (slug: string) => Promise<HiveGetResult>;
978
- save: (slug: string, hive: Hive) => Promise<HiveMutationResult>;
979
+ save: (slug: string, hive: Recipe) => Promise<HiveMutationResult>;
979
980
  delete: (slug: string) => Promise<HiveMutationResult>;
980
981
  };
981
982
  history: {