claude-code-session-manager 0.83.0 → 0.84.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 (92) hide show
  1. package/dist/assets/{AgentLibrary-B-OM-4gk.js → AgentLibrary-CCVIpoSz.js} +1 -1
  2. package/dist/assets/{DataModel-BC6ppcBr.js → DataModel-BltREYde.js} +1 -1
  3. package/dist/assets/{History-D3Qw2BWn.js → History-BZxFkOp6.js} +2 -2
  4. package/dist/assets/{Hooks-OxBQ0nIc.js → Hooks-Bznlfaaa.js} +1 -1
  5. package/dist/assets/{HostBilko-BNFC6bNZ.js → HostBilko-DUG5YHA_.js} +1 -1
  6. package/dist/assets/{Library-DAMyrUtY.js → Library-Bi2Fn3w9.js} +1 -1
  7. package/dist/assets/{ListDetail-BwywrpvE.js → ListDetail-4VBvXKrz.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-HEwjN1bp.js → MarkdownEditor-D2ft_v5j.js} +1 -1
  9. package/dist/assets/{McpServers-D1VZF7Ix.js → McpServers-FDekKkyE.js} +1 -1
  10. package/dist/assets/{Memory-CgYJ_QqD.js → Memory-Dkl80uj_.js} +6 -6
  11. package/dist/assets/{Panel-dTNWC59Q.js → Panel-LurG5VfD.js} +1 -1
  12. package/dist/assets/{Permissions-qI_NKi1b.js → Permissions-D2wHBCFA.js} +1 -1
  13. package/dist/assets/{Plugins-DZ0_CHiF.js → Plugins-CTw_wwbI.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-B2i_uxCu.js → ProvenanceBadge-CW6HNUv6.js} +1 -1
  15. package/dist/assets/{SaveBar-4tNMCjUd.js → SaveBar-alDHG6cP.js} +1 -1
  16. package/dist/assets/{Scheduler-CUen18Gc.js → Scheduler-DxiPcaiW.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-Co3tkLTT.js → ScopeSwitcher-DzFXUKLZ.js} +1 -1
  18. package/dist/assets/Settings-B6H3v2am.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-BKcHtMPc.js → SkillReferenceGraph-B8EZalpN.js} +1 -1
  20. package/dist/assets/{Skills-DHb4INtF.js → Skills-DwuHuA08.js} +2 -2
  21. package/dist/assets/SystemPrompt-CMMqpGYn.js +1 -0
  22. package/dist/assets/{TagLibrary-xLP5dJvh.js → TagLibrary-C2N5C1n9.js} +1 -1
  23. package/dist/assets/{TiptapBody-T-ULTjK4.js → TiptapBody-btlID-dQ.js} +1 -1
  24. package/dist/assets/{Toggle-B-5M2hGe.js → Toggle-BGOn3DZj.js} +1 -1
  25. package/dist/assets/{index-CzAxC432.js → index-QLRf0epp.js} +316 -314
  26. package/dist/assets/{index-CYhdtisq.css → index-mnjNDpb1.css} +1 -1
  27. package/dist/assets/{settingsSchema-C_nsemcF.js → settingsSchema-DA3N2Up3.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +4 -1
  30. package/scripts/hooks/guard-destructive-git.cjs +514 -0
  31. package/scripts/hooks/guard-inline-implementation.cjs +219 -0
  32. package/scripts/hooks/guard-prd-writes.cjs +200 -0
  33. package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
  34. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  35. package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
  36. package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
  37. package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
  38. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +143 -0
  39. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
  40. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  41. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
  42. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
  43. package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
  44. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
  45. package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
  46. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
  47. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
  49. package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
  50. package/src/main/__tests__/telemetryClient.test.cjs +66 -0
  51. package/src/main/__tests__/telemetryContract.test.cjs +883 -0
  52. package/src/main/crashDiagnostics.cjs +29 -1
  53. package/src/main/health.cjs +197 -3
  54. package/src/main/index.cjs +65 -6
  55. package/src/main/ipcSchemas.cjs +19 -2
  56. package/src/main/lib/__tests__/crashTelemetry.test.cjs +97 -0
  57. package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
  58. package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
  59. package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
  60. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
  61. package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
  62. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
  63. package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
  64. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +620 -0
  65. package/src/main/lib/__tests__/telemetryBoot.test.cjs +125 -0
  66. package/src/main/lib/__tests__/telemetryConsent.test.cjs +130 -0
  67. package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
  68. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +89 -0
  69. package/src/main/lib/crashTelemetry.cjs +37 -0
  70. package/src/main/lib/delegationReadiness.cjs +119 -1
  71. package/src/main/lib/epicMint.cjs +2 -0
  72. package/src/main/lib/gitWorktree.cjs +427 -17
  73. package/src/main/lib/jobWorktree.cjs +2 -1
  74. package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
  75. package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
  76. package/src/main/lib/opsErrorLog.cjs +78 -25
  77. package/src/main/lib/queueStore.cjs +233 -20
  78. package/src/main/lib/reaperHelpers.cjs +23 -1
  79. package/src/main/lib/scheduleJobSchema.cjs +7 -0
  80. package/src/main/lib/scheduleJobTransitions.cjs +12 -0
  81. package/src/main/lib/telemetryBacklog.cjs +601 -0
  82. package/src/main/lib/telemetryBoot.cjs +71 -0
  83. package/src/main/lib/telemetryClient.cjs +31 -0
  84. package/src/main/lib/telemetryConsent.cjs +34 -0
  85. package/src/main/lib/telemetryCounters.cjs +45 -0
  86. package/src/main/promptSessionTranscript.cjs +0 -0
  87. package/src/main/pty.cjs +2 -0
  88. package/src/main/scheduler.cjs +481 -33
  89. package/src/preload/api.d.ts +84 -4
  90. package/src/preload/index.cjs +9 -0
  91. package/dist/assets/Settings-DXVgKLUx.js +0 -3
  92. package/dist/assets/SystemPrompt-BjqFOHSk.js +0 -1
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * guard-inline-implementation.cjs — PreToolUse hook that denies a raw
4
+ * Write/Edit/NotebookEdit tool call against application source (`src/`,
5
+ * `scripts/`, `plugins/`, `bin/`) when the calling session resolves to an
6
+ * Epic whose Mission tag is `feature` or `bug`.
7
+ *
8
+ * Why this exists: CLAUDE.md already tells an Actor to decompose feature/bug
9
+ * work into scheduled PRDs via /develop rather than implementing inline, but
10
+ * a prompt-level instruction is advisory — nothing stops an Epic from
11
+ * reaching for the Write tool directly anyway. guard-prd-writes.cjs proved
12
+ * the lever: a PreToolUse hook sits OUTSIDE the app and can intercept the
13
+ * tool call before it touches disk, which an in-app ownership check
14
+ * structurally cannot do. This is the sibling guard for the same reason,
15
+ * applied to application source instead of scheduler/ PRDs.
16
+ *
17
+ * This is a NUDGE, not an ownership law — unlike guard-prd-writes.cjs (which
18
+ * is deliberately fail-closed), this hook fails OPEN twice over: on any
19
+ * internal error, and whenever the calling Epic can't be resolved to a
20
+ * feature/bug tag at all. A false deny blocks a human who deliberately asked
21
+ * for an inline fix, which is worse than an occasional missed catch.
22
+ *
23
+ * ── Escape hatches ───────────────────────────────────────────────────────
24
+ * - Set `SM_ALLOW_INLINE_IMPLEMENTATION=1` in the environment the session
25
+ * runs under to allow everything, for the whole process.
26
+ * - Set a truthy `allowInlineImplementation` field on the Epic's record in
27
+ * `session-manager-operations/prompt-sessions/active-index.json` to allow
28
+ * everything for that one Epic only.
29
+ *
30
+ * ── Install (per-project, NOT machine-wide) ─────────────────────────────
31
+ * Adopting project: press "Fix it" on the "Inline-implementation guard hook
32
+ * installed" row of the New Epic readiness banner — it runs
33
+ * installInlineImplementationGuard() (src/main/lib/delegationReadiness.cjs),
34
+ * which merges a hook referencing THIS repo's own absolute script path into
35
+ * `<your project>/.claude/settings.json`'s `Write|Edit|NotebookEdit` matcher,
36
+ * the same reference-not-vendor path guard-prd-writes.cjs uses. Hand-editing
37
+ * settings.json (below) is a degraded last resort for when the app isn't
38
+ * running — add the command alongside guard-prd-writes.cjs in the SAME
39
+ * `Write|Edit|NotebookEdit` matcher's `hooks` array:
40
+ *
41
+ * {
42
+ * "hooks": {
43
+ * "PreToolUse": [
44
+ * {
45
+ * "matcher": "Write|Edit|NotebookEdit",
46
+ * "hooks": [
47
+ * { "type": "command", "command": "node scripts/hooks/guard-prd-writes.cjs" },
48
+ * { "type": "command", "command": "node scripts/hooks/guard-inline-implementation.cjs" }
49
+ * ]
50
+ * }
51
+ * ]
52
+ * }
53
+ * }
54
+ *
55
+ * ── Uninstall ────────────────────────────────────────────────────────────
56
+ * Remove this hook's entry from `.claude/settings.json`. The script is inert
57
+ * with no settings.json entry pointing at it.
58
+ *
59
+ * ── Contract ─────────────────────────────────────────────────────────────
60
+ * - Reads a PreToolUse payload on stdin: { session_id, tool_name, tool_input,
61
+ * cwd, ... }.
62
+ * - Only inspects Write/Edit/NotebookEdit; every other tool is allowed by
63
+ * construction.
64
+ * - Resolves `session_id` against `claudeSessionId` in
65
+ * `<cwd>/session-manager-operations/prompt-sessions/active-index.json`'s
66
+ * `sessions` map. An unresolvable session is ALLOWED — this hook only ever
67
+ * denies when it can positively confirm a feature/bug Epic.
68
+ * - Only denies when the resolved Epic's `tag` is exactly `feature` or `bug`.
69
+ * Any other tag (`discussion`, `build`, etc.) is ALLOWED.
70
+ * - Only denies when the target path resolves under the project's `src/`,
71
+ * `scripts/`, `plugins/`, or `bin/` directories. Paths outside the project
72
+ * cwd, and anything under `session-manager-operations/`, are ALLOWED.
73
+ * - Fails OPEN: any internal error (malformed stdin JSON, unreadable index,
74
+ * unexpected shape) is logged to stderr and allowed through.
75
+ */
76
+ 'use strict';
77
+
78
+ const fs = require('node:fs');
79
+ const path = require('node:path');
80
+ const { execFileSync } = require('node:child_process');
81
+
82
+ const SOURCE_DIRS = ['src', 'scripts', 'plugins', 'bin'];
83
+ const DENY_TAGS = new Set(['feature', 'bug']);
84
+
85
+ function readStdin() {
86
+ return new Promise((resolve) => {
87
+ let data = '';
88
+ process.stdin.setEncoding('utf8');
89
+ process.stdin.on('data', (chunk) => { data += chunk; });
90
+ process.stdin.on('end', () => resolve(data));
91
+ process.stdin.on('error', () => resolve(data));
92
+ });
93
+ }
94
+
95
+ function targetPathFor(toolName, toolInput) {
96
+ if (!toolInput || typeof toolInput !== 'object') return null;
97
+ if (toolName === 'NotebookEdit') return toolInput.notebook_path ?? null;
98
+ return toolInput.file_path ?? null;
99
+ }
100
+
101
+ function isApplicationSource(cwd, absPath) {
102
+ const rel = path.relative(cwd, absPath);
103
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false; // outside cwd
104
+ const topLevel = rel.split(path.sep)[0];
105
+ return SOURCE_DIRS.includes(topLevel);
106
+ }
107
+
108
+ /**
109
+ * The PreToolUse payload's `cwd` is the CLI process's actual spawn cwd. For
110
+ * an Epic running in its default isolated `git worktree` (gitWorktree.cjs,
111
+ * epicSpawnCwd.cjs — "both the Terminal PTY and headless Chat spawn cwd
112
+ * resolve to it"), that is a throwaway checkout under os.tmpdir(), which
113
+ * never contains `session-manager-operations/` — the ops root always lives
114
+ * in the real project tree (see gitWorktree.cjs's "ops-root hazard" header:
115
+ * "spawn cwd = worktree dir, ops-root cwd = real project cwd, always").
116
+ * Resolve back to the real project cwd via git's shared `.git` dir before
117
+ * giving up, so this guard isn't silently inert for every Epic running in
118
+ * its default worktree.
119
+ */
120
+ function resolveOpsRootCwd(rawCwd) {
121
+ if (fs.existsSync(path.join(rawCwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'))) {
122
+ return rawCwd;
123
+ }
124
+ try {
125
+ const commonDir = execFileSync(
126
+ 'git',
127
+ ['rev-parse', '--path-format=absolute', '--git-common-dir'],
128
+ { cwd: rawCwd, encoding: 'utf8', timeout: 5000 },
129
+ ).trim();
130
+ if (!commonDir) return rawCwd;
131
+ return path.basename(commonDir) === '.git' ? path.dirname(commonDir) : commonDir;
132
+ } catch {
133
+ return rawCwd;
134
+ }
135
+ }
136
+
137
+ function resolveEpicTag(cwd, sessionId) {
138
+ if (!sessionId) return null;
139
+ const indexPath = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
140
+ if (!fs.existsSync(indexPath)) return null;
141
+ const raw = fs.readFileSync(indexPath, 'utf8');
142
+ const index = JSON.parse(raw);
143
+ const sessions = index && typeof index === 'object' ? index.sessions : null;
144
+ if (!sessions || typeof sessions !== 'object') return null;
145
+ for (const record of Object.values(sessions)) {
146
+ if (record && record.claudeSessionId === sessionId) return record;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function allow() {
152
+ process.stdout.write(JSON.stringify({ continue: true }));
153
+ process.exit(0);
154
+ }
155
+
156
+ function deny(reason) {
157
+ process.stdout.write(JSON.stringify({
158
+ continue: true,
159
+ decision: 'block',
160
+ reason,
161
+ hookSpecificOutput: {
162
+ hookEventName: 'PreToolUse',
163
+ permissionDecision: 'deny',
164
+ permissionDecisionReason: reason,
165
+ },
166
+ }));
167
+ process.exit(0);
168
+ }
169
+
170
+ async function main() {
171
+ const raw = await readStdin();
172
+ let payload;
173
+ try {
174
+ payload = raw ? JSON.parse(raw) : {};
175
+ } catch (e) {
176
+ console.error(`[guard-inline-implementation] malformed stdin JSON, failing open: ${e?.message}`);
177
+ return allow();
178
+ }
179
+
180
+ try {
181
+ if (process.env.SM_ALLOW_INLINE_IMPLEMENTATION === '1') return allow();
182
+
183
+ const toolName = payload.tool_name;
184
+ if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'NotebookEdit') {
185
+ return allow();
186
+ }
187
+
188
+ const rawPath = targetPathFor(toolName, payload.tool_input);
189
+ if (!rawPath || typeof rawPath !== 'string') return allow();
190
+
191
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
192
+ const absPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
193
+
194
+ if (!isApplicationSource(cwd, absPath)) return allow();
195
+
196
+ const sessionId = typeof payload.session_id === 'string' ? payload.session_id : null;
197
+ const opsRootCwd = resolveOpsRootCwd(cwd);
198
+ const epic = resolveEpicTag(opsRootCwd, sessionId);
199
+ if (!epic) return allow(); // unresolvable session — allow by construction
200
+
201
+ if (epic.allowInlineImplementation) return allow();
202
+ if (!DENY_TAGS.has(epic.tag)) return allow();
203
+
204
+ const reason = [
205
+ `Direct ${toolName} to application source ("${path.relative(cwd, absPath)}") is discouraged inside a "${epic.tag}" Epic.`,
206
+ 'Feature and bug work should be decomposed and queued via /develop rather than implemented inline, so it runs through the scheduler with acceptance criteria and review.',
207
+ 'If this inline edit is deliberate, set SM_ALLOW_INLINE_IMPLEMENTATION=1 in the environment, or set allowInlineImplementation: true on this Epic\'s record in active-index.json, and retry.',
208
+ ].join(' ');
209
+ return deny(reason);
210
+ } catch (e) {
211
+ console.error(`[guard-inline-implementation] internal error, failing open: ${e?.message}`);
212
+ return allow();
213
+ }
214
+ }
215
+
216
+ main().catch((e) => {
217
+ console.error(`[guard-inline-implementation] unhandled error, failing open: ${e?.message}`);
218
+ allow();
219
+ });
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * guard-prd-writes.cjs — PreToolUse hook that denies a raw Write/Edit/
4
+ * NotebookEdit tool call whose target path falls under
5
+ * `**\/session-manager-operations/scheduler/**`.
6
+ *
7
+ * Why this exists: opsOwnership.cjs's assertOpsWrite already enforces the
8
+ * single-writer law for scheduler/ (see src/main/lib/opsOwnership.cjs), but
9
+ * it can only guard the app's OWN in-process write helpers — it is
10
+ * structurally incapable of stopping an agent that reaches for the Write
11
+ * tool directly, which is exactly what CLAUDE.md itself documents as the
12
+ * degraded last-resort path. A PreToolUse hook is the one lever that sits
13
+ * OUTSIDE the app and can intercept that call before it ever touches disk:
14
+ * the harness adjudicates the tool call, not the app.
15
+ *
16
+ * Together with the create-time provenance stamp (prdCreate.cjs's
17
+ * buildPrdBody) and reconcile()'s quarantine of any PRD missing that stamp
18
+ * (scheduler.cjs), this makes the scheduler_create_prd/scheduler_update_prd/
19
+ * scheduler_archive_prd MCP tools the only path IN, from outside the app
20
+ * (this hook) and from inside it (the provenance gate) alike.
21
+ *
22
+ * ── Install (per-project, NOT machine-wide) ─────────────────────────────
23
+ * Add to this project's `.claude/settings.json` (not `~/.claude/settings.json`
24
+ * — project scope only, so the guard applies to sessions run against THIS
25
+ * repo, not every project on the machine):
26
+ *
27
+ * {
28
+ * "hooks": {
29
+ * "PreToolUse": [
30
+ * {
31
+ * "matcher": "Write|Edit|NotebookEdit",
32
+ * "hooks": [
33
+ * { "type": "command", "command": "node scripts/hooks/guard-prd-writes.cjs" }
34
+ * ]
35
+ * }
36
+ * ]
37
+ * }
38
+ * }
39
+ *
40
+ * ── Adopting this hook in a DIFFERENT (non-session-manager) project ──────
41
+ * DON'T HAND-WRITE THIS. Session Manager installs it: open New Session in
42
+ * that project and press "Fix it" on the readiness banner's PRD-write-guard
43
+ * row (src/main/lib/delegationReadiness.cjs's installPrdWriteGuard, which
44
+ * merges into any existing hooks block instead of clobbering it).
45
+ *
46
+ * The standardized approach is REFERENCE, not vendor — do NOT copy this file
47
+ * into the adopting repo. Vendoring drifts (it already did, with exactly one
48
+ * adopter), and what is being guarded is session-manager-operations/scheduler/,
49
+ * Session Manager's own territory inside someone else's repo, so the
50
+ * enforcement logic belongs with the owner.
51
+ *
52
+ * This file lives in the session-manager repo, so a relative
53
+ * `node scripts/hooks/guard-prd-writes.cjs` command only resolves when the
54
+ * hook runs with THIS repo as cwd. Pasting that relative string into another
55
+ * project yields a hook that exits non-zero WITHOUT code 2 — a non-blocking
56
+ * error — so it silently guards nothing. Any other project's
57
+ * `.claude/settings.json` must reference this file by its ABSOLUTE path:
58
+ *
59
+ * {
60
+ * "hooks": {
61
+ * "PreToolUse": [
62
+ * {
63
+ * "matcher": "Write|Edit|NotebookEdit",
64
+ * "hooks": [
65
+ * {
66
+ * "type": "command",
67
+ * "command": "node /home/bilko/Projects/session-manager/scripts/hooks/guard-prd-writes.cjs"
68
+ * }
69
+ * ]
70
+ * }
71
+ * ]
72
+ * }
73
+ * }
74
+ *
75
+ * This is still opt-in per project, same as above — never auto-install this
76
+ * into every project's settings without the human choosing to add it there.
77
+ *
78
+ * Roll out to another project by copying this file + the same settings.json
79
+ * entry into that project — deliberately not published as a global/user-
80
+ * scoped hook so a project can opt in individually (see this PRD's
81
+ * Implementation notes: "scope the hook to the session-manager repo first").
82
+ *
83
+ * ── Uninstall ────────────────────────────────────────────────────────────
84
+ * Remove the `PreToolUse` entry above from `.claude/settings.json`. The
85
+ * script is inert with no settings.json entry pointing at it — deleting the
86
+ * file itself is optional and has no other effect.
87
+ *
88
+ * ── Contract ─────────────────────────────────────────────────────────────
89
+ * - Reads a PreToolUse payload on stdin: { tool_name, tool_input, cwd, ... }.
90
+ * - Reads (any tool that isn't a mutation, and any Read/Grep/Glob/Bash) is
91
+ * NEVER denied — this hook only inspects Write/Edit/NotebookEdit calls;
92
+ * everything else is allowed by construction (the matcher already scopes
93
+ * this hook to those three tools, but the tool_name check below is a
94
+ * second, defense-in-depth gate in case the matcher is ever widened).
95
+ * - A fix-plan PRD (`NN-fix-*.md`) is exempt: scheduler.cjs's own
96
+ * spawnInvestigation probe writes that file directly by design (a trusted,
97
+ * scheduler-spawned internal loop, not an agent/human authoring a PRD) —
98
+ * see scheduler.cjs's isFixPlanSlug and reconcile()'s matching exemption.
99
+ * - Fails OPEN: any internal error (malformed stdin JSON, an unreadable
100
+ * payload, an unexpected shape) is logged to stderr and allowed through —
101
+ * a broken guard must never brick the whole Write/Edit tool.
102
+ */
103
+ 'use strict';
104
+
105
+ const path = require('node:path');
106
+
107
+ const SCHEDULER_SEGMENT = `${path.sep}session-manager-operations${path.sep}scheduler${path.sep}`;
108
+ const FIX_PLAN_RE = /^\d+-fix-.*\.md$/;
109
+
110
+ function readStdin() {
111
+ return new Promise((resolve) => {
112
+ let data = '';
113
+ process.stdin.setEncoding('utf8');
114
+ process.stdin.on('data', (chunk) => { data += chunk; });
115
+ process.stdin.on('end', () => resolve(data));
116
+ // Fail open if stdin never closes/errors for some reason.
117
+ process.stdin.on('error', () => resolve(data));
118
+ });
119
+ }
120
+
121
+ /** Extract the single filesystem path a Write/Edit/NotebookEdit call targets. */
122
+ function targetPathFor(toolName, toolInput) {
123
+ if (!toolInput || typeof toolInput !== 'object') return null;
124
+ if (toolName === 'NotebookEdit') return toolInput.notebook_path ?? null;
125
+ return toolInput.file_path ?? null;
126
+ }
127
+
128
+ /** MCP tool name to point the agent at, based on the attempted operation. */
129
+ function suggestedToolFor(toolName, absPath) {
130
+ const isArchived = absPath.includes(`${path.sep}prds-archived${path.sep}`);
131
+ if (isArchived) return 'mcp__session-manager-scheduler__scheduler_archive_prd';
132
+ // Write = the file doesn't exist yet from the agent's point of view (a
133
+ // brand-new PRD); Edit/NotebookEdit = mutating an existing file in place.
134
+ if (toolName === 'Write') return 'mcp__session-manager-scheduler__scheduler_create_prd';
135
+ return 'mcp__session-manager-scheduler__scheduler_update_prd';
136
+ }
137
+
138
+ function allow() {
139
+ process.stdout.write(JSON.stringify({ continue: true }));
140
+ process.exit(0);
141
+ }
142
+
143
+ function deny(reason) {
144
+ process.stdout.write(JSON.stringify({
145
+ continue: true,
146
+ decision: 'block',
147
+ reason,
148
+ hookSpecificOutput: {
149
+ hookEventName: 'PreToolUse',
150
+ permissionDecision: 'deny',
151
+ permissionDecisionReason: reason,
152
+ },
153
+ }));
154
+ process.exit(0);
155
+ }
156
+
157
+ async function main() {
158
+ const raw = await readStdin();
159
+ let payload;
160
+ try {
161
+ payload = raw ? JSON.parse(raw) : {};
162
+ } catch (e) {
163
+ console.error(`[guard-prd-writes] malformed stdin JSON, failing open: ${e?.message}`);
164
+ return allow();
165
+ }
166
+
167
+ try {
168
+ const toolName = payload.tool_name;
169
+ if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'NotebookEdit') {
170
+ return allow();
171
+ }
172
+
173
+ const rawPath = targetPathFor(toolName, payload.tool_input);
174
+ if (!rawPath || typeof rawPath !== 'string') return allow();
175
+
176
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
177
+ const absPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
178
+
179
+ if (!absPath.includes(SCHEDULER_SEGMENT)) return allow();
180
+
181
+ const base = path.basename(absPath);
182
+ if (FIX_PLAN_RE.test(base)) return allow();
183
+
184
+ const suggested = suggestedToolFor(toolName, absPath);
185
+ const reason = [
186
+ `Direct ${toolName} to a session-manager-operations/scheduler/ path is blocked.`,
187
+ `PRDs must be authored through the scheduler API, not a raw file write — use the "${suggested}" MCP tool instead.`,
188
+ 'If the Session Manager app is not running (the admin API this MCP tool talks to only exists while it is), the degraded fallback documented in the develop skill applies: hand-write the file as a last resort and say so visibly in your report.',
189
+ ].join(' ');
190
+ return deny(reason);
191
+ } catch (e) {
192
+ console.error(`[guard-prd-writes] internal error, failing open: ${e?.message}`);
193
+ return allow();
194
+ }
195
+ }
196
+
197
+ main().catch((e) => {
198
+ console.error(`[guard-prd-writes] unhandled error, failing open: ${e?.message}`);
199
+ allow();
200
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * epicMintTelemetryTap.test.cjs — ensureEpic() fires the 'epic.create'
3
+ * counter exactly once on a genuine mint (created: true), and not when an
4
+ * existing open Epic is joined instead.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/epicMintTelemetryTap.test.cjs
7
+ */
8
+ 'use strict';
9
+
10
+ import { test, expect, afterEach, beforeEach } from 'vitest';
11
+ const fsp = require('node:fs/promises');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ const countersPath = require.resolve('../lib/telemetryCounters.cjs');
16
+ const epicMintPath = require.resolve('../lib/epicMint.cjs');
17
+
18
+ let calls;
19
+ const tmpDirs = [];
20
+
21
+ beforeEach(() => {
22
+ calls = [];
23
+ require.cache[countersPath] = {
24
+ id: countersPath,
25
+ filename: countersPath,
26
+ loaded: true,
27
+ exports: { trackEpicCreate: () => calls.push(true) },
28
+ };
29
+ delete require.cache[epicMintPath];
30
+ });
31
+
32
+ afterEach(async () => {
33
+ delete require.cache[countersPath];
34
+ delete require.cache[epicMintPath];
35
+ while (tmpDirs.length) {
36
+ const d = tmpDirs.pop();
37
+ await fsp.rm(d, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ async function mkCwd() {
42
+ const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-epicmint-telemetry-cwd-'));
43
+ tmpDirs.push(cwd);
44
+ return cwd;
45
+ }
46
+
47
+ test('a genuine mint fires epic.create exactly once', async () => {
48
+ const { ensureEpic, MINT_AUTHORITY_NEW_EPIC_UI } = require('../lib/epicMint.cjs');
49
+ const cwd = await mkCwd();
50
+ const result = await ensureEpic(cwd, { mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI, goalText: 'do the thing' });
51
+ expect(result.created).toBe(true);
52
+ expect(calls).toEqual([true]);
53
+ });
54
+
55
+ test('joining an existing open Epic does NOT fire epic.create', async () => {
56
+ const { ensureEpic, MINT_AUTHORITY_NEW_EPIC_UI } = require('../lib/epicMint.cjs');
57
+ const cwd = await mkCwd();
58
+ const minted = await ensureEpic(cwd, { mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI, goalText: 'do the thing' });
59
+ calls.length = 0;
60
+
61
+ const joined = await ensureEpic(cwd, { epicId: minted.epicId, goalText: 'do the thing' });
62
+ expect(joined.created).toBe(false);
63
+ expect(calls).toEqual([]);
64
+ });
@@ -86,7 +86,7 @@ test('failing check with no fix omits the "(fix: ...)" suffix', () => {
86
86
  test('end-to-end: check() reports delegation_chain for this repo, expected to pass here', async () => {
87
87
  const status = await check();
88
88
  expect(status.components.delegation_chain).toBeDefined();
89
- expect(status.components.delegation_chain.checks).toHaveLength(7);
89
+ expect(status.components.delegation_chain.checks).toHaveLength(8);
90
90
  const ids = status.components.delegation_chain.checks.map((c) => c.id);
91
91
  expect(ids).toEqual([
92
92
  'scheduler-mcp',
@@ -96,6 +96,7 @@ test('end-to-end: check() reports delegation_chain for this repo, expected to pa
96
96
  'agent-personas',
97
97
  'prd-write-guard',
98
98
  'destructive-git-guard',
99
+ 'inline-implementation-guard',
99
100
  ]);
100
101
  // This repo registers the scheduler MCP at user scope (~/.claude.json) and
101
102
  // wires guard-prd-writes in .claude/settings.json, and no longer carries a
@@ -0,0 +1,84 @@
1
+ /**
2
+ * health-queue-dispatch.test.cjs — `npm run health` must report non-GREEN
3
+ * when there is at least one DISPATCHABLE pending job (reusing
4
+ * classifyQueueStarvation's own 'starved' verdict, not merely "pending"),
5
+ * nothing running, and no dispatch attempt in a long time — the exact shape
6
+ * of the 2026-09-11 incident (27 pending across two projects, 0 running, for
7
+ * days, every other health surface reporting green). A dependsOn-blocked
8
+ * queue must NOT trip this the same way: ticking can't fix a blocked chain,
9
+ * so it stays GREEN (reported as 'blocked' instead, distinctly).
10
+ *
11
+ * Exercises evaluateQueueDispatchHealth() directly — pure, no fs — matching
12
+ * every other evaluate* helper's test pattern in this file (see
13
+ * health-usage-poller.test.cjs's header).
14
+ *
15
+ * Run: timeout 120 npx vitest run src/main/__tests__/health-queue-dispatch.test.cjs
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ import { test, expect } from 'vitest';
21
+ const { evaluateQueueDispatchHealth, DISPATCH_STALL_THRESHOLD_MS } = require('../health.cjs');
22
+
23
+ const CWD = '/home/bilko/Projects/starry-night-ships';
24
+ const NOW = Date.parse('2026-09-11T12:00:00.000Z');
25
+ const idleFor = (ms) => new Date(NOW - ms).toISOString();
26
+
27
+ test('a dispatchable pending job, 0 running, stale past the threshold — non-GREEN, starved', () => {
28
+ const state = {
29
+ jobs: [{ slug: 'a', cwd: CWD, status: 'pending', dependsOn: [] }],
30
+ paused: null,
31
+ lastDispatchAttemptAt: idleFor(DISPATCH_STALL_THRESHOLD_MS + 60_000),
32
+ };
33
+ const result = evaluateQueueDispatchHealth(state, 0, NOW);
34
+ expect(result.ok).toBe(false);
35
+ expect(result.starved).toBe(true);
36
+ expect(result.dispatchable).toBe(1);
37
+ expect(result.message).toMatch(/dispatch appears stuck/);
38
+ });
39
+
40
+ test('every pending row behind a blocked dependsOn chain stays GREEN, reported as blocked', () => {
41
+ const state = {
42
+ jobs: [
43
+ { slug: 'dead', cwd: CWD, status: 'failed', dependsOn: [] },
44
+ { slug: 'blocked-child', cwd: CWD, status: 'pending', dependsOn: ['dead'] },
45
+ ],
46
+ paused: null,
47
+ lastDispatchAttemptAt: idleFor(DISPATCH_STALL_THRESHOLD_MS + 60_000),
48
+ };
49
+ const result = evaluateQueueDispatchHealth(state, 0, NOW);
50
+ expect(result.ok).toBe(true);
51
+ expect(result.blocked).toBe(true);
52
+ expect(result.starved).toBeUndefined();
53
+ expect(result.message).toMatch(/blocked/);
54
+ });
55
+
56
+ test('healthy queue (recent dispatch attempt) is GREEN with no verdict', () => {
57
+ const state = {
58
+ jobs: [{ slug: 'a', cwd: CWD, status: 'pending', dependsOn: [] }],
59
+ paused: null,
60
+ lastDispatchAttemptAt: idleFor(60_000),
61
+ };
62
+ const result = evaluateQueueDispatchHealth(state, 0, NOW);
63
+ expect(result).toEqual({ ok: true });
64
+ });
65
+
66
+ test('nothing pending is GREEN regardless of how stale the dispatch timestamp is', () => {
67
+ const state = {
68
+ jobs: [{ slug: 'a', cwd: CWD, status: 'completed' }],
69
+ paused: null,
70
+ lastDispatchAttemptAt: idleFor(DISPATCH_STALL_THRESHOLD_MS + 60_000),
71
+ };
72
+ const result = evaluateQueueDispatchHealth(state, 0, NOW);
73
+ expect(result).toEqual({ ok: true });
74
+ });
75
+
76
+ test('work already running is GREEN — the queue is flowing', () => {
77
+ const state = {
78
+ jobs: [{ slug: 'a', cwd: CWD, status: 'pending', dependsOn: [] }],
79
+ paused: null,
80
+ lastDispatchAttemptAt: idleFor(DISPATCH_STALL_THRESHOLD_MS + 60_000),
81
+ };
82
+ const result = evaluateQueueDispatchHealth(state, 1, NOW);
83
+ expect(result).toEqual({ ok: true });
84
+ });