claude-code-session-manager 0.120.0 → 0.122.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 (79) hide show
  1. package/dist/assets/{AgentLibrary-LBg3mVL8.js → AgentLibrary-BmJ8hIPw.js} +1 -1
  2. package/dist/assets/{Button-C4BN5ISR.js → Button-BS4kmz4p.js} +1 -1
  3. package/dist/assets/{DataModel-CHhjk61s.js → DataModel-DZHBZrfL.js} +1 -1
  4. package/dist/assets/History-pg00b2Gd.js +2 -0
  5. package/dist/assets/{Hooks-ByWVgXIf.js → Hooks-BTEs0UZY.js} +1 -1
  6. package/dist/assets/{Library-QvYVAEmY.js → Library-DuaJ-VSn.js} +1 -1
  7. package/dist/assets/{ListDetail-FvNe6fo5.js → ListDetail-Cud72s-O.js} +1 -1
  8. package/dist/assets/{McpServers-C7EmPm8B.js → McpServers-Dy-wnG_X.js} +1 -1
  9. package/dist/assets/{Memory-nyLR4Yw4.js → Memory-Bs1T1Jo7.js} +1 -1
  10. package/dist/assets/{Panel-thElCbLX.js → Panel-8UhLXEOW.js} +1 -1
  11. package/dist/assets/{Permissions-FQ9GM6Ca.js → Permissions-BfwzvmBS.js} +1 -1
  12. package/dist/assets/Plugins-McjyvvKh.js +2 -0
  13. package/dist/assets/{ProvenanceBadge-lAViG8yV.js → ProvenanceBadge-D0Ov_6WJ.js} +1 -1
  14. package/dist/assets/{RemoteMachines-AWpARk7N.js → RemoteMachines-gOlFTvWq.js} +1 -1
  15. package/dist/assets/{SaveBar-c15EEGB_.js → SaveBar-Cd6hJR6K.js} +1 -1
  16. package/dist/assets/Scheduler-BZXKqYJP.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-BLep1rtB.js → ScopeSwitcher-C24ysV1K.js} +1 -1
  18. package/dist/assets/{Settings-D1kTUIDB.js → Settings-DWnN2qxn.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-_40_f0t3.js → SkillReferenceGraph-BsWuiTbw.js} +1 -1
  20. package/dist/assets/{Skills-DaTkSOuU.js → Skills-6pHn6glu.js} +1 -1
  21. package/dist/assets/{SystemPrompt-DxNf-soM.js → SystemPrompt-DT0LBH-K.js} +1 -1
  22. package/dist/assets/{TagLibrary-L6KpBV-x.js → TagLibrary-DbJ0zbYL.js} +1 -1
  23. package/dist/assets/{TiptapBody-C_XjtJ82.js → TiptapBody-C4kIvIMH.js} +1 -1
  24. package/dist/assets/{Toggle-H0rERKNI.js → Toggle-pP19zlyk.js} +1 -1
  25. package/dist/assets/{ViewTabs-ZUWphDat.js → ViewTabs-CpzN8e5I.js} +1 -1
  26. package/dist/assets/{historyTotals-BxFKOztb.js → historyTotals-Caq78bb5.js} +1 -1
  27. package/dist/assets/{index-B5wj0DEX.js → index-Bjtguv6I.js} +434 -434
  28. package/dist/assets/{settingsSchema-BRcYkXJN.js → settingsSchema-CC2ySu3d.js} +1 -1
  29. package/dist/assets/{skillFrontmatter-DFGp4DST.js → skillFrontmatter-hDhNo8jE.js} +1 -1
  30. package/dist/assets/{useAgentsDirWatch-VvrgalfC.js → useAgentsDirWatch-BuOtY25U.js} +1 -1
  31. package/dist/assets/{useRemotePeers-mvXKNEE3.js → useRemotePeers-CNQmRBpt.js} +1 -1
  32. package/dist/index.html +1 -1
  33. package/package.json +1 -1
  34. package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -7
  35. package/plugins/session-manager-dev/skills/develop/standards.md +4 -10
  36. package/scripts/scheduler-mcp-server.cjs +2 -2
  37. package/src/main/build-info.json +4 -4
  38. package/src/main/chatRunner.cjs +56 -43
  39. package/src/main/docEdit.cjs +1 -0
  40. package/src/main/index.cjs +12 -13
  41. package/src/main/ipcSchemas.cjs +17 -3
  42. package/src/main/lib/classifyPromptTicket.cjs +1 -0
  43. package/src/main/lib/claude/effectiveModelInfo.cjs +9 -21
  44. package/src/main/lib/claude/personaToolScope.cjs +7 -1
  45. package/src/main/lib/claude/runClaudeP.cjs +1 -0
  46. package/src/main/lib/epic/delegationReadiness.cjs +75 -8
  47. package/src/main/lib/mcpToolCatalog.cjs +0 -1
  48. package/src/main/lib/persona-macro/macroLibrary.cjs +75 -0
  49. package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +11 -0
  50. package/src/main/lib/prd/prdCreate.cjs +28 -1
  51. package/src/main/lib/prd/prdSizing.cjs +1 -8
  52. package/src/main/lib/remoteTranscriptTail.cjs +69 -2
  53. package/src/main/lib/runSeeders.cjs +21 -0
  54. package/src/main/lib/scheduler/loopEfficiency.cjs +109 -0
  55. package/src/main/lib/scheduler/queueHistory.cjs +44 -1
  56. package/src/main/lib/scheduler/queueStore.cjs +4 -72
  57. package/src/main/lib/scheduler/rcaReport.cjs +1 -1
  58. package/src/main/lib/scheduler/refireCap.cjs +40 -0
  59. package/src/main/lib/scheduler/reviewNotice.cjs +1 -1
  60. package/src/main/lib/scheduler/schedulerConfig.cjs +1 -2
  61. package/src/main/lib/scheduler/schedulerPaths.cjs +0 -2
  62. package/src/main/lib/scheduler/sessionSlots.cjs +1 -1
  63. package/src/main/lib/telemetry/auditLog.cjs +65 -14
  64. package/src/main/loopEfficiencyHandlers.cjs +160 -0
  65. package/src/main/queueOps.cjs +4 -9
  66. package/src/main/runVerify.cjs +22 -454
  67. package/src/main/scheduler/reviewLadder.cjs +12 -2
  68. package/src/main/scheduler.cjs +103 -177
  69. package/src/main/seedDevPlugin.cjs +49 -22
  70. package/src/main/seedStatus.cjs +3 -2
  71. package/src/main/supervisor.cjs +1 -0
  72. package/src/main/templates/PRD_AUTHORING.md +16 -31
  73. package/src/main/webRemote.cjs +82 -91
  74. package/src/preload/api.d.ts +39 -6
  75. package/src/preload/index.cjs +3 -0
  76. package/src/seed/agents/dev-lead.md +2 -9
  77. package/dist/assets/History-CL-T6Lv3.js +0 -2
  78. package/dist/assets/Plugins-D9KE7XTI.js +0 -2
  79. package/dist/assets/Scheduler-FvduP1FG.js +0 -16
@@ -29,21 +29,21 @@
29
29
  const fs = require('node:fs');
30
30
  const path = require('node:path');
31
31
  const os = require('node:os');
32
- const { install, update } = require('./pluginInstall.cjs');
32
+ const { install, update, bundledMarketplaceDir } = require('./pluginInstall.cjs');
33
33
 
34
34
  const PLUGIN_SLUG = 'session-manager-dev';
35
35
  const MARKETPLACE = { add: 'bundled', name: 'session-manager' };
36
36
  const ENABLED_KEY = `${PLUGIN_SLUG}@${MARKETPLACE.name}`;
37
37
  const MAX_ATTEMPTS = 3; // give a transient first-boot failure a few chances
38
38
 
39
- function markerPath() {
40
- return path.join(os.homedir(), '.claude', 'session-manager', '.dev-plugin-seeded');
39
+ function markerPath(home = os.homedir()) {
40
+ return path.join(home, '.claude', 'session-manager', '.dev-plugin-seeded');
41
41
  }
42
42
 
43
43
  /** Read the marker → { done:boolean, attempts:number }. Absent = fresh. */
44
- function readMarker() {
44
+ function readMarker(home) {
45
45
  try {
46
- const raw = fs.readFileSync(markerPath(), 'utf8').trim();
46
+ const raw = fs.readFileSync(markerPath(home), 'utf8').trim();
47
47
  const m = JSON.parse(raw);
48
48
  return { done: !!m.done, attempts: Number(m.attempts) || 0 };
49
49
  } catch {
@@ -51,9 +51,9 @@ function readMarker() {
51
51
  }
52
52
  }
53
53
 
54
- function alreadyEnabled() {
54
+ function alreadyEnabled(home = os.homedir()) {
55
55
  try {
56
- const raw = fs.readFileSync(path.join(os.homedir(), '.claude', 'settings.json'), 'utf8');
56
+ const raw = fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8');
57
57
  const enabled = JSON.parse(raw)?.enabledPlugins;
58
58
  return !!(enabled && enabled[ENABLED_KEY]);
59
59
  } catch {
@@ -61,9 +61,9 @@ function alreadyEnabled() {
61
61
  }
62
62
  }
63
63
 
64
- function writeMarker(state) {
64
+ function writeMarker(state, home) {
65
65
  try {
66
- const p = markerPath();
66
+ const p = markerPath(home);
67
67
  fs.mkdirSync(path.dirname(p), { recursive: true });
68
68
  fs.writeFileSync(p, JSON.stringify({ ...state, ts: new Date().toISOString() }) + '\n');
69
69
  } catch (err) {
@@ -87,6 +87,24 @@ function readJson(p) {
87
87
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
88
88
  }
89
89
 
90
+ /**
91
+ * Pure check: is the registered 'session-manager' marketplace source missing on disk or
92
+ * different from `expectedDir`? Not registered at all → false (a deliberate uninstall stays).
93
+ * (Own reader: delegationReadiness's readDevMarketplacePath is not exported.)
94
+ */
95
+ function marketplaceIsStale({ homeDir = os.homedir(), expectedDir = bundledMarketplaceDir() } = {}) {
96
+ const m = readJson(path.join(homeDir, '.claude', 'plugins', 'known_marketplaces.json'))?.[MARKETPLACE.name];
97
+ const p = m?.source?.path ?? m?.installLocation;
98
+ if (typeof p !== 'string' || !p) return false;
99
+ if (path.resolve(p) !== path.resolve(expectedDir)) return true;
100
+ return !fs.existsSync(p);
101
+ }
102
+
103
+ /** True only when settings.json explicitly sets the plugin to false. */
104
+ function deliberatelyDisabled(home) {
105
+ return readJson(path.join(home, '.claude', 'settings.json'))?.enabledPlugins?.[ENABLED_KEY] === false;
106
+ }
107
+
90
108
  function bundledVersion() {
91
109
  const m = readJson(path.join(__dirname, '..', '..', 'plugins', PLUGIN_SLUG, '.claude-plugin', 'plugin.json'));
92
110
  return typeof m?.version === 'string' ? m.version : null;
@@ -131,23 +149,32 @@ async function upgradeIfStale({
131
149
  }
132
150
  }
133
151
 
134
- async function seedDevPlugin({ logger = console, writeLog = () => {} } = {}) {
152
+ async function seedDevPlugin({
153
+ logger = console,
154
+ writeLog = () => {},
155
+ home = os.homedir(),
156
+ expectedDir = bundledMarketplaceDir(),
157
+ runInstall = install,
158
+ runUpdate = update,
159
+ } = {}) {
135
160
  if (process.env.SM_SEED_DEV_PLUGIN_DISABLE === '1') return;
136
- await upgradeIfStale({ logger, writeLog });
137
- const marker = readMarker();
138
- if (marker.done) return; // succeeded before — leave it alone.
139
- if (marker.attempts >= MAX_ATTEMPTS) return; // gave up — manual install only.
161
+ await upgradeIfStale({ logger, writeLog, home, runUpdate });
162
+ const marker = readMarker(home);
163
+ // Repair: the registered marketplace dir vanished or points at an older package dir.
164
+ const repair = marker.done && !deliberatelyDisabled(home) && marketplaceIsStale({ homeDir: home, expectedDir });
165
+ if (marker.done && !repair) return; // succeeded before — leave it alone.
166
+ if (!marker.done && marker.attempts >= MAX_ATTEMPTS) return; // gave up — manual install only.
140
167
 
141
168
  try {
142
- if (alreadyEnabled()) {
143
- writeMarker({ done: true, attempts: marker.attempts });
169
+ if (!repair && alreadyEnabled(home)) {
170
+ writeMarker({ done: true, attempts: marker.attempts }, home);
144
171
  return;
145
172
  }
146
- logger.log?.('[seedDevPlugin] installing bundled session-manager-dev plugin…');
147
- const r = await install({ slug: PLUGIN_SLUG, marketplace: MARKETPLACE });
173
+ logger.log?.(`[seedDevPlugin] ${repair ? 're-registering' : 'installing'} bundled session-manager-dev plugin…`);
174
+ const r = await runInstall({ slug: PLUGIN_SLUG, marketplace: MARKETPLACE });
148
175
  if (r.ok) {
149
176
  logger.log?.('[seedDevPlugin] installed session-manager-dev@session-manager');
150
- writeMarker({ done: true, attempts: marker.attempts });
177
+ writeMarker({ done: true, attempts: marker.attempts }, home);
151
178
  } else {
152
179
  // Failure: bump the attempt counter so the next boot can retry (bounded).
153
180
  logger.warn?.(`[seedDevPlugin] install failed (exit ${r.exitCode})${r.error ? ` — ${r.error}` : ''}; attempt ${marker.attempts + 1}/${MAX_ATTEMPTS}`);
@@ -157,7 +184,7 @@ async function seedDevPlugin({ logger = console, writeLog = () => {} } = {}) {
157
184
  message: 'install failed',
158
185
  meta: { exitCode: r.exitCode, error: r.error, attempt: marker.attempts + 1, maxAttempts: MAX_ATTEMPTS },
159
186
  });
160
- writeMarker({ done: false, attempts: marker.attempts + 1 });
187
+ writeMarker({ done: false, attempts: marker.attempts + 1 }, home);
161
188
  }
162
189
  } catch (err) {
163
190
  logger.warn?.('[seedDevPlugin] error:', err?.message ?? err);
@@ -167,8 +194,8 @@ async function seedDevPlugin({ logger = console, writeLog = () => {} } = {}) {
167
194
  message: 'seed error',
168
195
  meta: { error: err?.message ?? String(err), attempt: marker.attempts + 1, maxAttempts: MAX_ATTEMPTS },
169
196
  });
170
- writeMarker({ done: false, attempts: marker.attempts + 1 });
197
+ writeMarker({ done: false, attempts: marker.attempts + 1 }, home);
171
198
  }
172
199
  }
173
200
 
174
- module.exports = { seedDevPlugin, upgradeIfStale, compareSemver, markerPath, MAX_ATTEMPTS };
201
+ module.exports = { seedDevPlugin, upgradeIfStale, marketplaceIsStale, compareSemver, markerPath, MAX_ATTEMPTS };
@@ -22,14 +22,15 @@ const SEEDERS = {
22
22
  'agent-personas': seedAgentPersonas,
23
23
  };
24
24
 
25
- /** One seeder's marker → 'done' | 'pending' | 'exhausted'. */
25
+ /** One seeder's marker → 'done' | 'pending' | 'failed' | 'exhausted'. */
26
26
  function statusFromMarker(mod) {
27
27
  try {
28
28
  const raw = fs.readFileSync(mod.markerPath(), 'utf8').trim();
29
29
  const m = JSON.parse(raw);
30
30
  if (m && m.done) return 'done';
31
31
  const attempts = Number(m && m.attempts) || 0;
32
- return attempts >= mod.MAX_ATTEMPTS ? 'exhausted' : 'pending';
32
+ if (attempts >= mod.MAX_ATTEMPTS) return 'exhausted';
33
+ return attempts > 0 ? 'failed' : 'pending';
33
34
  } catch {
34
35
  return 'pending'; // absent or unparseable — never surfaced as an error.
35
36
  }
@@ -254,6 +254,7 @@ async function runProbe(claudeBin, prompt) {
254
254
  '--max-budget-usd', '0.50',
255
255
  '--dangerously-skip-permissions',
256
256
  ...headlessPermissionArgs(),
257
+ '--strict-mcp-config',
257
258
  '--allowedTools', 'Bash',
258
259
  ], { env: withProcRole(process.env, 'aux'), stdio: ['ignore', 'pipe', 'pipe'], ...(target.argv0 ? { argv0: target.argv0 } : {}) });
259
260
  } catch (e) {
@@ -1,10 +1,10 @@
1
- <!-- PRD_AUTHORING.md v7 -->
1
+ <!-- PRD_AUTHORING.md v9 -->
2
2
  # PRD Authoring Guide — Scheduler Safety Rules
3
3
 
4
4
  Rule: every PRD you queue follows the rules below.
5
5
  Why: two real stuck jobs (fizzpop poll-hang, etch-engine post-AC overrun) cost hours of wall-clock time and real money.
6
6
 
7
- Before queueing, run the §15 checklist at the bottom. It is last on purpose — check it last.
7
+ Before queueing, run the §14 checklist at the bottom. It is last on purpose — check it last.
8
8
 
9
9
  ---
10
10
 
@@ -69,7 +69,7 @@ if (!found) {
69
69
  Rule: every PRD needs these fields.
70
70
  1. `title` — one line, plain English.
71
71
  2. `cwd` — the Epic's project root, as an absolute path or `~/…`. The API always sets it to the Epic's project, whatever you pass. To work in another project, open an Epic in that project. The folder must exist when you queue.
72
- 3. `estimateMinutes` — a realistic integer. Most PRDs take 5–10 minutes (§8) — don't inflate it.
72
+ 3. `estimateMinutes` — optional; omit it. A run without one gets the 45-minute kill-budget floor. Pass one only if the run legitimately needs longer. Most PRDs take 5–10 minutes (§8).
73
73
 
74
74
  `parallelGroup` is deprecated and ignored. `dependsOn: [<slug>, ...]` is the only ordering primitive.
75
75
 
@@ -125,7 +125,7 @@ A short Epic-context digest is prepended automatically for orientation only —
125
125
 
126
126
  The executor can't ask you anything: no `ScheduleWakeup`, Cron, Monitor, `AskUserQuestion`, plan mode, or worktree tools; background tasks are off (a `timeout` command is always on PATH — the app installs a shim on macOS). It must never stop to ask a question — it makes the safest reasonable call and reports it, so write the PRD so that call is obvious.
127
127
 
128
- Worktree reach: every PRD runs in an isolated git worktree holding tracked files plus `.venv`/`venv` (and `node_modules` for non-pnpm projects) symlinked from the main checkout; pnpm projects (pnpm-lock.yaml) get a worktree-local `node_modules` installed offline from the store. Why: a pnpm install through the symlink purged the main checkout's node_modules (Bilko-Game-Academy 2026-10-18). A step that needs gitignored runtime state (databases, `downloads/`, browser profiles, logs) or a live side effect must address the main checkout by absolute path (e.g. `$HOME/Projects/<repo>/...`), be artifact-only (a deliverable artifact + `artifactPaths`, no tracked edits), and `dependsOn` the PRD that lands its tracked edits. Host crontab installs stay human-only (see §15).
128
+ Worktree reach: every PRD runs in an isolated git worktree holding tracked files plus `.venv`/`venv` (and `node_modules` for non-pnpm projects) symlinked from the main checkout; pnpm projects (pnpm-lock.yaml) get a worktree-local `node_modules` installed offline from the store. Why: a pnpm install through the symlink purged the main checkout's node_modules (Bilko-Game-Academy 2026-10-18). A step that needs gitignored runtime state (databases, `downloads/`, browser profiles, logs) or a live side effect must address the main checkout by absolute path (e.g. `$HOME/Projects/<repo>/...`), be artifact-only (a deliverable artifact + `artifactPaths`, no tracked edits), and `dependsOn` the PRD that lands its tracked edits. Host crontab installs stay human-only (see §14).
129
129
  Why: burrow PRD 1516 halted because its relative `.venv` python against `downloads/fb-tracker.db`, `scripts/install-cron.sh` and `pipeline-cron.sh --now` resolved inside the worktree (no runtime state; install-cron would bake a temporary worktree path into crontab), while sibling 1494 succeeded the same day using an absolute main-checkout path and artifact-only output. `scheduler_create_prd` warns on relative gitignored paths.
130
130
 
131
131
  ---
@@ -155,7 +155,7 @@ Note: a `rateLimited` exit-1 is the scheduler's own benign auto-pause (it resume
155
155
  ## §10 Negative-assertion checks must exit 0 on the clean case
156
156
 
157
157
  Rule: a check that asserts something is ABSENT must exit 0 when it's absent. Write it as `if <detector>; then echo HALT...; exit 1; fi` — never leave the no-match path carrying the non-zero exit.
158
- Why: `grep` exits 1 when it finds nothing. PRD `62-x-trader-doctrine` cleaned up correctly, but its sanity `grep` for a banned phrase found nothing, exited 1, and a perfect run was flagged `needs_review` by the verifier's `transcript_errors` check.
158
+ Why: `grep` exits 1 when it finds nothing. PRD `62-x-trader-doctrine` cleaned up correctly, but its sanity `grep` for a banned phrase found nothing, exited 1, and the gate command itself failed on a clean tree.
159
159
 
160
160
  ```bash
161
161
  if grep -rniE "banned phrase" path/; then
@@ -168,10 +168,10 @@ Applies to `grep`, `rg`, `diff` (exits 1 on any difference), and any custom dete
168
168
 
169
169
  ---
170
170
 
171
- ## §11 End green, and trust the verdict line
171
+ ## §11 End green
172
172
 
173
- Rule: order the run so the acceptance/test gate is the LAST command. Run any intentionally-failing step (a TDD red test, an expected-nonzero probe) EARLY, and capture its output (`2>&1 | tail` inside a conditional) so a bare `Traceback`/`is_error` never lands in the final part of the transcript.
174
- Why: the post-run verifier scans the transcript and downgrades to `needs_review` on error markers — it can't tell an intentional failure from a real one.
173
+ Rule: order the run so the acceptance/test gate is the LAST command. Run any intentionally-failing step (a TDD red test, an expected-nonzero probe) EARLY, and capture its output (`2>&1 | tail`).
174
+ Why: the last command's result is the run's real outcome; a red step left at the end hides whether the work is green.
175
175
 
176
176
  ```bash
177
177
  # RIGHT — red demo first and captured, green gate last
@@ -180,38 +180,24 @@ timeout 120 python -m pytest tests/test_repro.py::test_bug 2>&1 | tail -3 || tru
180
180
  timeout 300 pytest -q # LAST thing the run does
181
181
  ```
182
182
 
183
- The scheduler appends a finish protocol after your PRD's own steps — you never write it: review, then the `# Gate` commands (§6), then commit only the exact paths you created or changed for this PRD, then the verdict line — `SCHEDULER_VERDICT: PASS` once the gate is green and the commit landed, else `SCHEDULER_VERDICT: FAIL <one-line reason>`. The verifier trusts a truthful `PASS` plus a landed commit over stray transcript markers. Never print `PASS` on a red gate.
183
+ The scheduler appends a finish protocol after your PRD's own steps — you never write it: review, then the `# Gate` commands (§6), then commit only the exact paths you created or changed for this PRD, then the verdict line — `SCHEDULER_VERDICT: PASS` once the gate is green and the commit landed, else `SCHEDULER_VERDICT: FAIL <one-line reason>`. Never print `PASS` on a red gate.
184
184
 
185
185
  ---
186
186
 
187
- ## §12 Don't strand mid-run probe errors; annotate expected timeouts
188
-
189
- Rule: a throwaway probe that errors (a bad quote, a wrong kwarg) must be re-run corrected right after, or annotated `# expected/handled: <why>` on the next line — never left stranded. Prefer a temp `.py` file over a fragile inline `python -c` one-liner.
190
-
191
- Rule: an *expected* `timeout` cap (a long ingest/scan you expect to hit it) is success-with-note, not a bare `Exit code 124` — branch on it explicitly:
192
- ```bash
193
- timeout 120 python -m project.ingest --all || { rc=$?
194
- [ $rc -eq 124 ] && echo "hit time cap — partial, rows persist; OK" \
195
- || { echo "HALT: ingest failed rc=$rc"; exit 1; }; }
196
- ```
197
- Why: both a stranded probe traceback and a bare `Exit code 124` read as failure to the verifier even when the committed work is correct and green (PRDs 77, 80 — 2026-06-13).
198
-
199
- ---
200
-
201
- ## §13 Queueing PRDs from external automation
187
+ ## §12 Queueing PRDs from external automation
202
188
 
203
189
  Decision: is the session-manager app running on this machine right now?
204
- - Yes → call the `scheduler_create_prd` MCP tool, with the usual fields (`title`, `cwd`, `estimateMinutes`, `sourcePromptId`, `goal`, `acceptanceCriteria`, `implementationNotes`) plus `gate` and `files` (§6).
190
+ - Yes → call the `scheduler_create_prd` MCP tool, with the usual fields (`title`, `cwd`, `sourcePromptId`, `goal`, `acceptanceCriteria`, `implementationNotes`) plus `gate` and `files` (§6).
205
191
  - No → stop. Ask the human to start the app, then call the tool again. Never write the PRD file by hand: the scheduler quarantines a file the API did not write, and it never runs.
206
192
 
207
193
  It only works while the app is running — closing it removes the admin port/token, and the call fails with `session-manager app is not running (admin API unreachable)`.
208
194
 
209
195
  ---
210
196
 
211
- ## §14 A parked job may resolve itself now
197
+ ## §13 A parked job may resolve itself now
212
198
 
213
- Rule: if a job parks `needs_review` with verdict `transcript_errors`, `no_verdict_sentinel`, or `abandoned_background_task`, do nothing first. The scheduler re-runs the gate on its own and completes the job once the gate is green, the commit is on HEAD, and the tracked tree is clean.
214
- Why: those three verdicts mean the transcript looked noisy, not that the work was wrong.
199
+ Rule: if a job parks `needs_review` with verdict `no_verdict_sentinel` or `abandoned_background_task`, do nothing first. The scheduler re-runs the gate on its own and completes the job once the gate is green, the commit is on HEAD, and the tracked tree is clean.
200
+ Why: those verdicts mean the run ended without a clean verdict line, not that the work was wrong.
215
201
 
216
202
  Rule: a park caused by a DIFFERENT actor already finishing the same objective (a sibling PRD, a human) does not self-heal this way. Why: the job has no landed commit of its own. Confirm in the tree that the work is really done, then call `scheduler_archive_prd` with the slug. Archiving marks the job completed and frees the PRDs that depend on it. Never hand-edit `queue.json`: the scheduler and the watchdog both write it. Archiving clears a stale label; it does not fix a real bug.
217
203
 
@@ -219,7 +205,7 @@ Known gap: `pass_no_commit` ("already correct, nothing to commit") isn't yet in
219
205
 
220
206
  ---
221
207
 
222
- ## §15 Pre-queue checklist (the litany)
208
+ ## §14 Pre-queue checklist (the litany)
223
209
 
224
210
  Before queueing a new PRD, verify each of these:
225
211
 
@@ -228,7 +214,7 @@ Before queueing a new PRD, verify each of these:
228
214
  - [ ] **§2 No bonus work:** the AC list is the only source of work.
229
215
  - [ ] **§3 Verify, don't poll:** every deploy/migration step is followed by a test command that exits 1 on failure; the AC test command ran green once before you declare done.
230
216
  - [ ] **§4 Bounded generators:** any search/seed loop has an explicit max and surfaces failure on exhaustion.
231
- - [ ] **§5 Frontmatter:** `title`, `cwd` (exists on this machine), `estimateMinutes` present; `dependsOn` used for ordering, not `parallelGroup`.
217
+ - [ ] **§5 Frontmatter:** `title`, `cwd` (exists on this machine); `estimateMinutes` omitted (optional); `dependsOn` used for ordering, not `parallelGroup`.
232
218
  - [ ] **§6 Gate and files:** `gate` follows every rule in §6 (or is `["none"]` alone); `files` is 1–50 repo-relative paths, no overlap with a concurrent PRD.
233
219
  - [ ] **§7 Self-contained:** no reference to "the conversation" or outside context; paths/identifiers inline; clean UTF-8, no NUL bytes (`grep -qP '\x00' file && echo BAD`).
234
220
  - [ ] **§7 Worktree reach:** no relative path to a gitignored file; live-run steps use absolute main-checkout paths in an artifact-only PRD. Why: the job worktree has no gitignored runtime state, so relative paths fail or bake a temporary path into host config (burrow 1516).
@@ -236,6 +222,5 @@ Before queueing a new PRD, verify each of these:
236
222
  - [ ] **§9 Failure surfacing:** errors exit 1 with a diagnostic line; no silent `|| true`.
237
223
  - [ ] **§10 Negative assertions:** every "should find nothing" check is inverted to exit 0 on the clean case.
238
224
  - [ ] **§11 End green:** the gate runs last; any intentional failure runs early and is captured.
239
- - [ ] **§12 No stranded probes:** a probe error is corrected or annotated; an expected `timeout` cap branches on exit 124.
240
225
  - [ ] **Gate baseline:** every gate command ran green once at HEAD in the main checkout; a red suite is scoped to the PRD's own test files and the PRD notes say why. Why: a gate already red on base parks as `sentinel_fail`.
241
226
  - [ ] **No human-only gates:** no gate or criterion needs a human or the passage of time (cron install, live post wait, live payment, dashboard action); those end `SCHEDULER_VERDICT: NEEDS_HUMAN <question>`. Why: no executor can pass them.
@@ -29,7 +29,6 @@ const logs = require('./logs.cjs');
29
29
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
30
30
  const { claudeHome } = require('./lib/scheduler/schedulerPaths.cjs');
31
31
  const { schemas } = require('./ipcSchemas.cjs');
32
- const { summarize: _sharedSummarize } = require('./lib/summarize.cjs');
33
32
  const { makeState, confirmSas: confirmSasLogic } = require('./lib/e2eStateMachine.cjs');
34
33
  const { createClientSessions, LEGACY_CLIENT_KEY } = require('./lib/feedback-remote/remoteClientSessions.cjs');
35
34
 
@@ -497,12 +496,11 @@ async function handleTokenRevoked(deviceId) {
497
496
  sendIfAlive(_window, 'webRemote:token-revoked', { deviceId });
498
497
  }
499
498
 
500
- // ─── v2 mobile: session live state + summary push ────────────────────────────
499
+ // ─── v2 mobile: session live state push ────────────────────────────
501
500
  //
502
501
  // For each subscribed tab the agent tails its transcript JSONL (reusing the
503
502
  // canonical classifyLine + transcriptPath from transcripts.cjs — single source of
504
503
  // truth), derives a coarse state, and pushes event:session:state on change.
505
- // The last completed assistant turn drives the Haiku summary (see maybeSummarize).
506
504
 
507
505
  const SESSION_POLL_MS = 1500;
508
506
  const SESSION_INIT_TAIL_BYTES = 512 * 1024; // bound the initial read
@@ -524,7 +522,7 @@ function pushEventTo(client, type, payload) {
524
522
  }
525
523
  }
526
524
 
527
- /** Authenticated clients subscribed to a tab's session state/summary. */
525
+ /** Authenticated clients subscribed to a tab's session state. */
528
526
  function subscribersOf(tabId) {
529
527
  return _clients.authenticated().filter((c) => c.subscriptions.has(tabId));
530
528
  }
@@ -551,15 +549,6 @@ function teardownAllClients() {
551
549
  return n;
552
550
  }
553
551
 
554
- /** Extract concatenated text from an assistant transcript line, or '' if none. */
555
- function extractAssistantText(raw) {
556
- const msg = raw?.message || raw;
557
- const content = msg?.content;
558
- if (typeof content === 'string') return content;
559
- if (!Array.isArray(content)) return '';
560
- return content.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n').trim();
561
- }
562
-
563
552
  /** Map a classified transcript event to a coarse session state. */
564
553
  function deriveState(ev, raw) {
565
554
  // API/usage errors surface as a flagged message line.
@@ -613,6 +602,19 @@ async function tailLines(filePath, fromOffset) {
613
602
  }
614
603
 
615
604
  async function pollSessionWatcher(w) {
605
+ // The transcript may not exist yet (new session) or may live under another
606
+ // project/worktree encoding: re-resolve while the watched file is missing.
607
+ if (!fs.existsSync(w.filePath)) {
608
+ try {
609
+ const next = require('./transcripts.cjs').transcriptPath(w.cwd, w.sessionId);
610
+ if (next && next !== w.filePath) {
611
+ validatePath(next);
612
+ w.filePath = next;
613
+ w.offset = null;
614
+ w.inode = undefined;
615
+ }
616
+ } catch { /* keep the old path */ }
617
+ }
616
618
  let res;
617
619
  try {
618
620
  res = await tailLines(w.filePath, w.offset);
@@ -627,8 +629,6 @@ async function pollSessionWatcher(w) {
627
629
  w.inode = res.inode;
628
630
 
629
631
  let nextState = null;
630
- let newAssistantText = null;
631
- let newMsgId = null;
632
632
  for (const line of res.lines) {
633
633
  let obj;
634
634
  try { obj = JSON.parse(line); } catch { continue; }
@@ -636,10 +636,6 @@ async function pollSessionWatcher(w) {
636
636
  for (const ev of events) {
637
637
  const s = deriveState(ev, obj);
638
638
  if (s) nextState = s;
639
- if (ev.kind === 'assistant') {
640
- const text = extractAssistantText(obj);
641
- if (text) { newAssistantText = text; newMsgId = obj.uuid || obj.message?.id || `${w.tabId}:${res.size}`; }
642
- }
643
639
  }
644
640
  }
645
641
 
@@ -650,33 +646,20 @@ async function pollSessionWatcher(w) {
650
646
  pushEventTo(c, 'event:session:state', { tabId: w.tabId, state: w.state, since: Date.now() });
651
647
  }
652
648
  }
653
- if (newAssistantText && newMsgId !== w.lastMsgId) {
654
- w.lastAssistantText = newAssistantText;
655
- w.lastMsgId = newMsgId;
656
- }
657
- // Summarize only a COMPLETED turn (state idle) — not assistant text mid-turn that
658
- // is followed by a tool call. Cache by msgId so re-subscribe doesn't re-bill.
659
- if (w.state === 'idle' && w.lastAssistantText && w.lastMsgId !== w.summarizedMsgId) {
660
- w.summarizedMsgId = w.lastMsgId;
661
- maybeSummarize(w).catch(() => {});
662
- }
663
649
  }
664
650
 
665
- function startSessionWatch(tabId, cwd) {
651
+ function startSessionWatch(tabId, cwd, sessionId = tabId) {
666
652
  if (_sessionWatchers.has(tabId)) return;
667
- const filePath = require('./transcripts.cjs').transcriptPath(cwd, tabId);
653
+ const filePath = require('./transcripts.cjs').transcriptPath(cwd, sessionId);
668
654
  // Defense in depth: the schema restricts tabId charset, but re-validate the
669
655
  // FINAL joined path against the home-dir boundary (validatePath resolves
670
656
  // symlinks + rejects escapes) before any fs read. Throws → dispatch drops it.
671
657
  validatePath(filePath);
672
658
  const w = {
673
- tabId, cwd, filePath,
659
+ tabId, cwd, sessionId, filePath,
674
660
  offset: null, // null → first poll reads a bounded tail then tracks EOF
675
661
  inode: undefined,
676
662
  state: 'idle',
677
- lastAssistantText: null,
678
- lastMsgId: null,
679
- summarizedMsgId: null,
680
663
  timer: null,
681
664
  };
682
665
  _sessionWatchers.set(tabId, w);
@@ -732,44 +715,6 @@ function buildRemoteSessionList(tabs, readIndex) {
732
715
  return out;
733
716
  }
734
717
 
735
- // ─── SM-V2-03: mobile summary via Claude Haiku 4.5 ───────────────────────────
736
- // Summarization logic lives in lib/summarize.cjs (shared with exchanges.cjs).
737
-
738
- const SUMMARY_MIN_CHARS = 280; // below this, push raw — not worth an API call
739
-
740
- /**
741
- * Produce a mobile summary of the watcher's last completed assistant turn and push it.
742
- * Short turns are pushed raw (no API call). If no API key is configured, degrades to
743
- * the raw message so core remote control is never blocked. Cost: Haiku in+out per
744
- * completed turn per subscribed tab (~$1/$5 per 1M tokens).
745
- */
746
- async function maybeSummarize(w) {
747
- // Guard: summaries (session transcript content) go only to SAS-confirmed subscribers.
748
- if (subscribersOf(w.tabId).length === 0) return;
749
- const text = w.lastAssistantText;
750
- if (!text) return;
751
- const ofMessageId = w.lastMsgId;
752
-
753
- if (text.length < SUMMARY_MIN_CHARS) {
754
- for (const c of subscribersOf(w.tabId)) {
755
- pushEventTo(c, 'event:session:summary', { tabId: w.tabId, summary: text, ofMessageId, model: 'raw', ts: Date.now() });
756
- }
757
- return;
758
- }
759
-
760
- const { summary, model, degraded } = await _sharedSummarize(text);
761
- if (degraded) {
762
- logs.writeLine({ scope: 'webRemote', level: 'warn', message: `summary degraded: ${degraded}`, meta: {} });
763
- }
764
- for (const c of subscribersOf(w.tabId)) {
765
- pushEventTo(c, 'event:session:summary', {
766
- tabId: w.tabId, summary, ofMessageId, model,
767
- ...(degraded ? { degraded } : {}),
768
- ts: Date.now(),
769
- });
770
- }
771
- }
772
-
773
718
  /**
774
719
  * Shared finish-up for any path that transitions a client's e2e state into 'authenticated'
775
720
  * (manual SAS confirmation or pinned-key auto-trust): log, broadcast the new
@@ -1010,7 +955,7 @@ async function dispatchEnvelope(envelope, device, client) {
1010
955
  let result;
1011
956
  try {
1012
957
  result = await dispatchCommand(type, payload ?? {}, client);
1013
- await auditLog(ts, type, device.deviceId, id, 'ok', client);
958
+ await auditLog(ts, type, device.deviceId, id, auditResultFor(result), client);
1014
959
  } catch (e) {
1015
960
  const code = e?.name === 'ZodError' ? 'schema_invalid' : 'dispatch_error';
1016
961
  await auditLog(ts, type, device.deviceId, id, `error:${code}`, client);
@@ -1020,6 +965,14 @@ async function dispatchEnvelope(envelope, device, client) {
1020
965
  respond(id, result, undefined, client);
1021
966
  }
1022
967
 
968
+ /** Audit result string for a handler result: structured refusals log as refused[:reason]. */
969
+ function auditResultFor(result) {
970
+ if (result && result.ok === false && typeof result.disposition === 'string') {
971
+ return typeof result.reason === 'string' && result.reason ? `refused:${result.reason}` : 'refused';
972
+ }
973
+ return 'ok';
974
+ }
975
+
1023
976
  function respond(msgId, payload, typeOverride, client) {
1024
977
  if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
1025
978
  const responseType = typeOverride || (msgId ? `resp:${msgId}` : undefined);
@@ -1102,26 +1055,34 @@ function getDispatchMap() {
1102
1055
  const parsed = schemas.remoteSessionTranscript.parse(payload);
1103
1056
  const { readActiveIndex } = require('./lib/epic/epicMint.cjs');
1104
1057
  const { resolveEpicTranscriptPath } = require('./lib/epic/epicTranscriptPath.cjs');
1105
- const { readRecentChatMessages } = require('./lib/remoteTranscriptTail.cjs');
1058
+ const { readRecentChatMessages, buildRemoteConversation } = require('./lib/remoteTranscriptTail.cjs');
1059
+ const { listExchanges } = require('./exchanges.cjs');
1106
1060
  const data = await sessionsStore.load();
1107
1061
  const tab = (data?.tabs ?? []).find((t) => t && t.cwd === parsed.cwd);
1108
1062
  if (!tab) throw new Error('cwd is not an open project');
1109
1063
  validatePath(parsed.cwd);
1064
+ const limit = parsed.limit ?? 10;
1065
+ const before = parsed.before ?? 0;
1110
1066
  let filePath = null;
1111
- if (parsed.sessionKey === (tab.id ?? tab.sessionId)) {
1112
- filePath = require('./transcripts.cjs').transcriptPath(parsed.cwd, parsed.sessionKey);
1067
+ let sessionId = null;
1068
+ if (parsed.sessionKey === tab.id || parsed.sessionKey === tab.sessionId) {
1069
+ sessionId = tab.sessionId || parsed.sessionKey;
1070
+ filePath = require('./transcripts.cjs').transcriptPath(parsed.cwd, sessionId);
1113
1071
  } else {
1114
1072
  const sessions = readActiveIndex(parsed.cwd)?.sessions ?? {};
1115
1073
  const epic = Object.values(sessions).find(
1116
1074
  (e) => e && (e.id === parsed.sessionKey || e.claudeSessionId === parsed.sessionKey),
1117
1075
  );
1118
1076
  if (epic && epic.claudeSessionId) {
1119
- filePath = resolveEpicTranscriptPath({ cwd: parsed.cwd, claudeSessionId: epic.claudeSessionId }).path;
1077
+ sessionId = epic.claudeSessionId;
1078
+ filePath = resolveEpicTranscriptPath({ cwd: parsed.cwd, claudeSessionId: sessionId }).path;
1120
1079
  }
1121
1080
  }
1122
- if (!filePath) return { messages: [] };
1081
+ if (!filePath || !sessionId) return { messages: [], hasMore: false, nextBefore: null };
1123
1082
  validatePath(filePath);
1124
- return { messages: await readRecentChatMessages(filePath, { limit: parsed.limit }) };
1083
+ const exchanges = await listExchanges({ cwd: parsed.cwd, sessionId, limit: limit + 1, offset: before });
1084
+ const tail = before === 0 ? await readRecentChatMessages(filePath, { limit: 30 }) : [];
1085
+ return buildRemoteConversation({ exchanges, tail, limit, before });
1125
1086
  },
1126
1087
 
1127
1088
  'cmd:session:prds': async (payload) => {
@@ -1209,12 +1170,15 @@ function getDispatchMap() {
1209
1170
  'cmd:app:info': async () =>
1210
1171
  ({ version: app.getVersion(), platform: process.platform }),
1211
1172
 
1212
- // v2 mobile: start/stop pushing live state + summary for a session.
1173
+ // v2 mobile: start/stop pushing live state for a session.
1213
1174
  'cmd:session:subscribe': async (payload, reqClient) => {
1214
1175
  const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
1215
1176
  const parsed = schemas.sessionSubscribe.parse(payload);
1216
1177
  validatePath(parsed.cwd); // home-dir boundary before any fs access
1217
- startSessionWatch(parsed.tabId, parsed.cwd);
1178
+ // The phone's Main row passes the tab `id`, but the transcript is keyed by the tab's `sessionId`.
1179
+ const { tabs } = await require('./sessionsStore.cjs').load();
1180
+ const tab = (Array.isArray(tabs) ? tabs : []).find((t) => t && t.id === parsed.tabId && t.cwd === parsed.cwd);
1181
+ startSessionWatch(parsed.tabId, parsed.cwd, tab?.sessionId || parsed.tabId);
1218
1182
  client.subscriptions.add(parsed.tabId);
1219
1183
  return { ok: true };
1220
1184
  },
@@ -1236,23 +1200,28 @@ function getDispatchMap() {
1236
1200
  const data = await sessionsStore.load();
1237
1201
  const tabs = data?.tabs ?? [];
1238
1202
  // Resolve the target in main: an open tab, or an ACTIVE Epic (by id or
1239
- // claudeSessionId) of an open project. Anything else fails loudly.
1203
+ // claudeSessionId) of an open project. Anything else is a structured refusal.
1204
+ const outcome = (r) => ({
1205
+ ok: r.disposition !== 'refused',
1206
+ disposition: r.disposition,
1207
+ ...(r.reason ? { reason: r.reason } : {}),
1208
+ });
1240
1209
  if (tabs.some((t) => t.sessionId === parsed.tabId || t.id === parsed.tabId)) {
1241
- chatRunner.enqueueExternalPrompt(parsed.tabId, parsed.prompt);
1242
- return { ok: true };
1210
+ return outcome(await chatRunner.enqueueExternalPrompt(parsed.tabId, parsed.prompt));
1243
1211
  }
1244
1212
  for (const tab of tabs) {
1245
1213
  const sessions = Object.values(readActiveIndex(tab.cwd)?.sessions ?? {});
1246
1214
  const epic = sessions.find((e) => e && (e.id === parsed.tabId || e.claudeSessionId === parsed.tabId));
1247
1215
  if (!epic) continue;
1248
- if (epic.status !== 'active' || !epic.claudeSessionId) throw new Error('target is not an active session');
1249
- chatRunner.enqueueExternalPrompt(epic.id, parsed.prompt, {
1216
+ if (epic.status !== 'active' || !epic.claudeSessionId) {
1217
+ return { ok: false, disposition: 'refused', reason: epic.status === 'proposed' ? 'proposed' : 'not-active' };
1218
+ }
1219
+ return outcome(await chatRunner.enqueueExternalPrompt(epic.id, parsed.prompt, {
1250
1220
  sessionId: epic.claudeSessionId,
1251
1221
  cwd: epic.cwd || tab.cwd,
1252
- });
1253
- return { ok: true };
1222
+ }));
1254
1223
  }
1255
- throw new Error('unknown chat target');
1224
+ return { ok: false, disposition: 'refused', reason: 'unknown-target' };
1256
1225
  },
1257
1226
 
1258
1227
  'cmd:session:options': async () => {
@@ -1266,6 +1235,7 @@ function getDispatchMap() {
1266
1235
  agents: personas.map((a) => ({ name: a.name, description: a.description })),
1267
1236
  tags: EpicTagSchema.options,
1268
1237
  macros: await macroLibrary.listMacros(),
1238
+ macroInputs: true,
1269
1239
  };
1270
1240
  },
1271
1241
 
@@ -1278,7 +1248,23 @@ function getDispatchMap() {
1278
1248
  if (!tabs.some((t) => t.cwd === parsed.cwd)) throw new Error('cwd is not an open project');
1279
1249
  const personas = await agentLibrary.listPersonas();
1280
1250
  if (!personas.some((a) => a.name === parsed.agentName)) throw new Error('unknown agent');
1281
- chatRunner.requestExternalNewSession(parsed);
1251
+ let prompt = parsed.prompt;
1252
+ if (parsed.macroId) {
1253
+ const macroLibrary = require('./lib/persona-macro/macroLibrary.cjs');
1254
+ const macros = await macroLibrary.listMacros();
1255
+ const macro = macros.find((m) => m.id === parsed.macroId);
1256
+ if (!macro || !macroLibrary.macroVisibleIn(macro, parsed.cwd)) throw new Error('unknown macro');
1257
+ const result = macroLibrary.renderMacroPrompt(parsed.prompt, macro.inputs, parsed.inputs ?? {});
1258
+ if (!result.ok) throw new Error(result.error);
1259
+ prompt = result.prompt;
1260
+ }
1261
+ chatRunner.requestExternalNewSession({
1262
+ cwd: parsed.cwd,
1263
+ agentName: parsed.agentName,
1264
+ tag: parsed.tag,
1265
+ title: parsed.title,
1266
+ prompt,
1267
+ });
1282
1268
  return { ok: true };
1283
1269
  },
1284
1270
  };
@@ -1551,6 +1537,11 @@ module.exports = {
1551
1537
  },
1552
1538
  __test: {
1553
1539
  getDispatchMap,
1540
+ auditResultFor,
1541
+ startSessionWatch,
1542
+ pollSessionWatcher,
1543
+ getSessionWatcher: (tabId) => _sessionWatchers.get(tabId),
1544
+ stopAllSessionWatches,
1554
1545
  buildRemoteSessionList,
1555
1546
  setWs: (ws) => { _ws = ws; },
1556
1547
  connect,