claude-code-session-manager 0.82.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.
- package/dist/assets/{AgentLibrary-pwlkAFb3.js → AgentLibrary-CCVIpoSz.js} +1 -1
- package/dist/assets/{DataModel-BZK9PXFD.js → DataModel-BltREYde.js} +1 -1
- package/dist/assets/{History-BVRjxJjS.js → History-BZxFkOp6.js} +2 -2
- package/dist/assets/{Hooks-CNuwHeGx.js → Hooks-Bznlfaaa.js} +1 -1
- package/dist/assets/{HostBilko-cjwNodhV.js → HostBilko-DUG5YHA_.js} +1 -1
- package/dist/assets/{Library-YPNm9W92.js → Library-Bi2Fn3w9.js} +1 -1
- package/dist/assets/{ListDetail-CY4GM1Om.js → ListDetail-4VBvXKrz.js} +1 -1
- package/dist/assets/{MarkdownEditor-BF4y2Jiz.js → MarkdownEditor-D2ft_v5j.js} +1 -1
- package/dist/assets/{McpServers-CmBdWtX_.js → McpServers-FDekKkyE.js} +1 -1
- package/dist/assets/{Memory-CvkIXNl1.js → Memory-Dkl80uj_.js} +6 -6
- package/dist/assets/{Panel-D93o-sxe.js → Panel-LurG5VfD.js} +1 -1
- package/dist/assets/{Permissions-DQipg16I.js → Permissions-D2wHBCFA.js} +1 -1
- package/dist/assets/{Plugins-B6NwzPfK.js → Plugins-CTw_wwbI.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DACVJhrB.js → ProvenanceBadge-CW6HNUv6.js} +1 -1
- package/dist/assets/{SaveBar-Cg4lbChb.js → SaveBar-alDHG6cP.js} +1 -1
- package/dist/assets/{Scheduler-Dr5ZcBLe.js → Scheduler-DxiPcaiW.js} +7 -7
- package/dist/assets/{ScopeSwitcher-C-locvy0.js → ScopeSwitcher-DzFXUKLZ.js} +1 -1
- package/dist/assets/Settings-B6H3v2am.js +3 -0
- package/dist/assets/{SkillReferenceGraph-DDzuYgSK.js → SkillReferenceGraph-B8EZalpN.js} +1 -1
- package/dist/assets/{Skills-DFvhiOAQ.js → Skills-DwuHuA08.js} +2 -2
- package/dist/assets/SystemPrompt-CMMqpGYn.js +1 -0
- package/dist/assets/{TagLibrary-DruUYaAc.js → TagLibrary-C2N5C1n9.js} +1 -1
- package/dist/assets/{TiptapBody-Dr4a--42.js → TiptapBody-btlID-dQ.js} +1 -1
- package/dist/assets/{Toggle-B_EH2TFb.js → Toggle-BGOn3DZj.js} +1 -1
- package/dist/assets/{index-DApB4DHS.js → index-QLRf0epp.js} +316 -314
- package/dist/assets/{index-CYhdtisq.css → index-mnjNDpb1.css} +1 -1
- package/dist/assets/{settingsSchema-BKa-xk8g.js → settingsSchema-DA3N2Up3.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -1
- package/scripts/hooks/guard-destructive-git.cjs +514 -0
- package/scripts/hooks/guard-inline-implementation.cjs +219 -0
- package/scripts/hooks/guard-prd-writes.cjs +200 -0
- package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
- package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
- package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +143 -0
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
- package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
- package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
- package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
- package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
- package/src/main/__tests__/telemetryClient.test.cjs +810 -0
- package/src/main/__tests__/telemetryContract.test.cjs +883 -0
- package/src/main/crashDiagnostics.cjs +29 -1
- package/src/main/health.cjs +197 -3
- package/src/main/index.cjs +65 -6
- package/src/main/ipcSchemas.cjs +19 -2
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +97 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
- package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
- package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
- package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
- package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +620 -0
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +125 -0
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +130 -0
- package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +89 -0
- package/src/main/lib/crashTelemetry.cjs +37 -0
- package/src/main/lib/delegationReadiness.cjs +119 -1
- package/src/main/lib/epicMint.cjs +2 -0
- package/src/main/lib/gitWorktree.cjs +427 -17
- package/src/main/lib/jobWorktree.cjs +2 -1
- package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
- package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
- package/src/main/lib/opsErrorLog.cjs +78 -25
- package/src/main/lib/queueStore.cjs +233 -20
- package/src/main/lib/reaperHelpers.cjs +23 -1
- package/src/main/lib/scheduleJobSchema.cjs +7 -0
- package/src/main/lib/scheduleJobTransitions.cjs +12 -0
- package/src/main/lib/telemetryBacklog.cjs +601 -0
- package/src/main/lib/telemetryBoot.cjs +71 -0
- package/src/main/lib/telemetryClient.cjs +653 -0
- package/src/main/lib/telemetryConsent.cjs +34 -0
- package/src/main/lib/telemetryCounters.cjs +45 -0
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +2 -0
- package/src/main/scheduler.cjs +481 -33
- package/src/preload/api.d.ts +84 -4
- package/src/preload/index.cjs +9 -0
- package/dist/assets/Settings-BVrAle90.js +0 -3
- package/dist/assets/SystemPrompt-8PiTyUyL.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(
|
|
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
|
+
});
|