claude-code-session-manager 0.94.0 → 0.96.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/{DataModel-B0LDnnSL.js → DataModel-T0D00BpK.js} +1 -1
- package/dist/assets/History-DuAovTr7.js +2 -0
- package/dist/assets/{Hooks-CwLnp0Z_.js → Hooks-CgP9jsAN.js} +3 -3
- package/dist/assets/{HostBilko-CwEHEKYk.js → HostBilko-DHfz3Mxa.js} +1 -1
- package/dist/assets/{Library-BU9np05E.js → Library--poVP-d1.js} +1 -1
- package/dist/assets/{MarkdownEditor-B74ogK2f.js → MarkdownEditor-C56-Pc4c.js} +1 -1
- package/dist/assets/{McpServers-BLM3ftzP.js → McpServers-D-ykP4Vi.js} +2 -2
- package/dist/assets/Memory-BrkJjRL8.js +8 -0
- package/dist/assets/{Permissions-3pGLJmxQ.js → Permissions-BMpAaxJu.js} +3 -3
- package/dist/assets/Plugins-OuEe5S_T.js +2 -0
- package/dist/assets/{ProvenanceBadge-BnjBQiV9.js → ProvenanceBadge-4eNbDYe8.js} +1 -1
- package/dist/assets/{SaveBar-Zq2NfcPf.js → SaveBar-BQ8y9Lkv.js} +1 -1
- package/dist/assets/Scheduler-DcMT5sT_.js +16 -0
- package/dist/assets/{ScopeSwitcher-Cnojv22D.js → ScopeSwitcher-XdxtVKP-.js} +1 -1
- package/dist/assets/Settings-FdaPUq2t.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BRDiE6Hv.js → SkillReferenceGraph-CJzvvc0_.js} +1 -1
- package/dist/assets/Skills-Wvm9OE1e.js +3 -0
- package/dist/assets/{SystemPrompt-tFr19Od6.js → SystemPrompt--LYw_69j.js} +1 -1
- package/dist/assets/TagLibrary-Be6HbMMx.js +1 -0
- package/dist/assets/{TiptapBody-DGzSg3BO.js → TiptapBody-DzFlWMgO.js} +1 -1
- package/dist/assets/{Toggle-DzoROibf.js → Toggle-BxtAFE1i.js} +1 -1
- package/dist/assets/index-XkrLB67D.js +3079 -0
- package/dist/assets/{settingsSchema-BYKVe1WM.js → settingsSchema-BIvDZ1Cj.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +10 -2
- package/plugins/CLAUDE.md +3 -3
- package/plugins/session-manager-dev/skills/develop/SKILL.md +83 -67
- package/plugins/session-manager-dev/skills/develop/standards.md +5 -11
- package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +5 -15
- package/scripts/README.md +4 -0
- package/scripts/hooks/guard-destructive-git.cjs +9 -374
- package/scripts/hooks/guard-inline-implementation.cjs +9 -128
- package/scripts/hooks/guard-prd-writes.cjs +9 -74
- package/scripts/hooks/guard-self-schedule.cjs +9 -61
- package/scripts/hooks/lib/guard-destructive-git-policy.cjs +410 -0
- package/scripts/hooks/lib/guard-inline-implementation-policy.cjs +159 -0
- package/scripts/hooks/lib/guard-prd-writes-policy.cjs +100 -0
- package/scripts/hooks/lib/guard-self-schedule-policy.cjs +89 -0
- package/scripts/scheduler-mcp-server.cjs +8 -1
- package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +59 -0
- package/src/main/__tests__/exchanges.test.cjs +41 -96
- package/src/main/__tests__/exchangesPromptId.test.cjs +16 -25
- package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
- package/src/main/__tests__/health-credentials.test.cjs +81 -0
- package/src/main/__tests__/health-tick-liveness.test.cjs +12 -0
- package/src/main/__tests__/memoryAggregate.test.cjs +40 -1
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +1 -1
- package/src/main/__tests__/planValidator.test.cjs +71 -0
- package/src/main/__tests__/prdCreate.test.cjs +3 -1
- package/src/main/__tests__/prdSizing.test.cjs +106 -0
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +46 -0
- package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +10 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +40 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +81 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +15 -0
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +6 -1
- package/src/main/__tests__/scheduler-utilization-hold.test.cjs +89 -0
- package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
- package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
- package/src/main/__tests__/telemetryClient.test.cjs +2 -2
- package/src/main/__tests__/telemetryContract.test.cjs +2 -2
- package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
- package/src/main/__tests__/validationSentinels.test.cjs +84 -0
- package/src/main/build-info.json +4 -4
- package/src/main/config.cjs +26 -14
- package/src/main/crashDiagnostics.cjs +9 -0
- package/src/main/exchanges.cjs +26 -21
- package/src/main/health.cjs +91 -5
- package/src/main/index.cjs +1 -1
- package/src/main/ipcSchemas.cjs +5 -1
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
- package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
- package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
- package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
- package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
- package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
- package/src/main/lib/__tests__/loopDelay.test.cjs +68 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
- package/src/main/lib/__tests__/usageCircuit.test.cjs +86 -17
- package/src/main/lib/agentModelResolve.cjs +1 -0
- package/src/main/lib/credentials.cjs +62 -0
- package/src/main/lib/cwdClassify.cjs +15 -0
- package/src/main/lib/definitionOfDone.cjs +9 -13
- package/src/main/lib/effectiveModelInfo.cjs +2 -9
- package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
- package/src/main/lib/loadGate.cjs +29 -5
- package/src/main/lib/loopDelay.cjs +44 -0
- package/src/main/lib/opsOwnership.cjs +23 -0
- package/src/main/lib/planValidator.cjs +28 -0
- package/src/main/lib/prdCreate.cjs +8 -4
- package/src/main/lib/prdSizing.cjs +83 -0
- package/src/main/lib/runLogRetention.cjs +2 -8
- package/src/main/lib/telemetryBacklog.cjs +9 -3
- package/src/main/lib/telemetryClient.cjs +12 -3
- package/src/main/lib/telemetrySettings.cjs +8 -2
- package/src/main/lib/usageCircuit.cjs +40 -12
- package/src/main/lib/validationSentinels.cjs +29 -0
- package/src/main/memoryAggregate.cjs +36 -11
- package/src/main/otelSettings.cjs +8 -2
- package/src/main/scheduler.cjs +264 -34
- package/src/main/seedAgentPersonas.cjs +1 -1
- package/src/main/sessionsStore.cjs +8 -2
- package/src/main/supervisor.cjs +3 -3
- package/src/main/templates/PRD_AUTHORING.md +9 -9
- package/src/main/usage.cjs +9 -1
- package/src/main/voiceSettings.cjs +8 -2
- package/src/preload/api.d.ts +20 -5
- package/src/preload/index.cjs +5 -5
- package/src/seed/agents/dev-lead.md +24 -15
- package/src/seed/agents/validator.md +33 -0
- package/dist/assets/History-C685Kytt.js +0 -2
- package/dist/assets/Memory-DAP1t8bA.js +0 -8
- package/dist/assets/Plugins-Q1KoLEzn.js +0 -2
- package/dist/assets/Scheduler-D488ebKm.js +0 -16
- package/dist/assets/Settings-D5Lhj7ga.js +0 -3
- package/dist/assets/Skills-BQDqp-EN.js +0 -3
- package/dist/assets/TagLibrary-BjyrnaRE.js +0 -1
- package/dist/assets/index-CCl4tz-u.js +0 -3079
|
@@ -92,18 +92,7 @@
|
|
|
92
92
|
*/
|
|
93
93
|
'use strict';
|
|
94
94
|
|
|
95
|
-
const
|
|
96
|
-
const os = require('node:os');
|
|
97
|
-
const { execFileSync } = require('node:child_process');
|
|
98
|
-
|
|
99
|
-
// Duplicated from src/main/lib/gitWorktree.cjs's KIND_CONFIG — see this
|
|
100
|
-
// file's header for why this is a deliberate duplication, not a require.
|
|
101
|
-
// Same SM_WORKTREE_ROOT-else-tmpdir base as schedulerPaths.worktreeBase().
|
|
102
|
-
const WORKTREE_BASE = process.env.SM_WORKTREE_ROOT || os.tmpdir();
|
|
103
|
-
const WORKTREE_ROOTS = ['job', 'epic'].map((k) => path.join(WORKTREE_BASE, `session-manager-${k}-worktrees`));
|
|
104
|
-
const BRANCH_PREFIXES = ['sm-job/', 'sm-epic/'];
|
|
105
|
-
|
|
106
|
-
const MAX_SH_C_DEPTH = 5;
|
|
95
|
+
const { decide, parsePayload } = require('./lib/guard-destructive-git-policy.cjs');
|
|
107
96
|
|
|
108
97
|
function readStdin() {
|
|
109
98
|
return new Promise((resolve) => {
|
|
@@ -116,375 +105,21 @@ function readStdin() {
|
|
|
116
105
|
});
|
|
117
106
|
}
|
|
118
107
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const parts = [];
|
|
122
|
-
let cur = '';
|
|
123
|
-
let inSingle = false;
|
|
124
|
-
let inDouble = false;
|
|
125
|
-
for (let i = 0; i < cmd.length; i++) {
|
|
126
|
-
const c = cmd[i];
|
|
127
|
-
if (inSingle) {
|
|
128
|
-
cur += c;
|
|
129
|
-
if (c === "'") inSingle = false;
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
if (inDouble) {
|
|
133
|
-
cur += c;
|
|
134
|
-
if (c === '"' && cmd[i - 1] !== '\\') inDouble = false;
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
if (c === "'") { inSingle = true; cur += c; continue; }
|
|
138
|
-
if (c === '"') { inDouble = true; cur += c; continue; }
|
|
139
|
-
const two = cmd.slice(i, i + 2);
|
|
140
|
-
if (two === '&&' || two === '||') { parts.push(cur); cur = ''; i++; continue; }
|
|
141
|
-
if (c === ';' || c === '|' || c === '\n') { parts.push(cur); cur = ''; continue; }
|
|
142
|
-
cur += c;
|
|
143
|
-
}
|
|
144
|
-
parts.push(cur);
|
|
145
|
-
return { parts: parts.map((s) => s.trim()).filter(Boolean), unterminatedQuote: inSingle || inDouble };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Quote-aware whitespace tokenizer. Returns null on an unterminated quote. */
|
|
149
|
-
function tokenize(str) {
|
|
150
|
-
const tokens = [];
|
|
151
|
-
let cur = '';
|
|
152
|
-
let started = false;
|
|
153
|
-
let inSingle = false;
|
|
154
|
-
let inDouble = false;
|
|
155
|
-
for (let i = 0; i < str.length; i++) {
|
|
156
|
-
const c = str[i];
|
|
157
|
-
if (inSingle) {
|
|
158
|
-
if (c === "'") inSingle = false; else cur += c;
|
|
159
|
-
started = true;
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
if (inDouble) {
|
|
163
|
-
if (c === '"') inDouble = false; else cur += c;
|
|
164
|
-
started = true;
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
if (c === "'") { inSingle = true; started = true; continue; }
|
|
168
|
-
if (c === '"') { inDouble = true; started = true; continue; }
|
|
169
|
-
if (/\s/.test(c)) {
|
|
170
|
-
if (started) { tokens.push(cur); cur = ''; started = false; }
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
cur += c;
|
|
174
|
-
started = true;
|
|
175
|
-
}
|
|
176
|
-
if (started) tokens.push(cur);
|
|
177
|
-
if (inSingle || inDouble) return null;
|
|
178
|
-
return tokens;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function isFlagWithChar(rest, longForms, shortChar) {
|
|
182
|
-
for (const t of rest) {
|
|
183
|
-
if (longForms.includes(t)) return true;
|
|
184
|
-
if (shortChar && t.startsWith('-') && !t.startsWith('--') && t.includes(shortChar)) return true;
|
|
185
|
-
}
|
|
186
|
-
return false;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Skip git's global options (`-C <dir>`, `-c <k>=<v>`, `--git-dir=...`, ...) to find the subcommand. */
|
|
190
|
-
function subcommandOf(args) {
|
|
191
|
-
const OPTS_WITH_SEPARATE_ARG = new Set(['-C', '-c', '--namespace', '--super-prefix']);
|
|
192
|
-
let i = 0;
|
|
193
|
-
while (i < args.length) {
|
|
194
|
-
const t = args[i];
|
|
195
|
-
if (OPTS_WITH_SEPARATE_ARG.has(t)) { i += 2; continue; }
|
|
196
|
-
if (t.startsWith('-')) { i += 1; continue; }
|
|
197
|
-
return { subcommand: t, rest: args.slice(i + 1) };
|
|
198
|
-
}
|
|
199
|
-
return null;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Extract an explicit `git -C <dir>` argument (or `git -C=<dir>`), if present, from the tokens AFTER `git`. */
|
|
203
|
-
function gitDashCDirOf(args) {
|
|
204
|
-
for (let i = 0; i < args.length; i++) {
|
|
205
|
-
const t = args[i];
|
|
206
|
-
if (t === '-C' && args[i + 1] != null) return args[i + 1];
|
|
207
|
-
if (t.startsWith('-C') && t.length > 2) return t.slice(2);
|
|
208
|
-
if (!t.startsWith('-')) break; // reached the subcommand — no more global opts
|
|
209
|
-
}
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Text-level check for whether an UNPARSABLE segment mentions a verb this
|
|
215
|
-
* guard actually polices. Used only to narrow the unparsable-command fallback
|
|
216
|
-
* — an unparsable command that mentions none of these is allowed through
|
|
217
|
-
* rather than blanket-blocked, since blocking every command merely containing
|
|
218
|
-
* the substring "git" (e.g. a heredoc `git commit -m "$(cat <<'EOF' ...)"`)
|
|
219
|
-
* was itself a false-positive incident.
|
|
220
|
-
*/
|
|
221
|
-
function mentionsPolicedDestructiveToken(text) {
|
|
222
|
-
if (/\b(stash|reset|checkout|restore|clean)\b/.test(text)) return true;
|
|
223
|
-
if (/\badd\s+(-A\b|--all\b|\.(?:\s|$))/.test(text)) return true;
|
|
224
|
-
if (/\bcommit\b.*(-a\b|--all\b)/.test(text)) return true;
|
|
225
|
-
return false;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Evaluate one `git <...>` invocation (args = tokens AFTER `git`). Returns
|
|
230
|
-
* `{ verb, alt }` when destructive, or null when this invocation is fine
|
|
231
|
-
* (read-only, or a form this guard doesn't restrict).
|
|
232
|
-
*/
|
|
233
|
-
function evaluateGit(args) {
|
|
234
|
-
const parsed = subcommandOf(args);
|
|
235
|
-
if (!parsed) return null;
|
|
236
|
-
const { subcommand, rest } = parsed;
|
|
237
|
-
|
|
238
|
-
switch (subcommand) {
|
|
239
|
-
case 'stash': {
|
|
240
|
-
const sub = rest.find((a) => !a.startsWith('-'));
|
|
241
|
-
if (sub === 'list' || sub === 'show') return null;
|
|
242
|
-
return {
|
|
243
|
-
verb: 'git stash',
|
|
244
|
-
alt: 'do not stash on a shared tree — a stash you never restore silently strands someone else\'s work; commit or discard only paths you created, or stop and report',
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
case 'reset': {
|
|
248
|
-
const hasHard = rest.includes('--hard');
|
|
249
|
-
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
250
|
-
if (hasHard) {
|
|
251
|
-
return {
|
|
252
|
-
verb: 'git reset --hard',
|
|
253
|
-
alt: 'never hard-reset a shared tree — this discards a sibling job\'s or the human\'s uncommitted work outright; undo only your own change with `git checkout -- <your-path>` inside your own sm-job/ or sm-epic/ worktree, or stop and report',
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
if (positional.length > 0) {
|
|
257
|
-
return {
|
|
258
|
-
verb: 'git reset <path>',
|
|
259
|
-
alt: 'do not `git reset` a path on a shared tree — that can unstage a sibling job\'s or the human\'s in-progress staging, not just your own',
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
return null;
|
|
263
|
-
}
|
|
264
|
-
case 'checkout': {
|
|
265
|
-
const dashIdx = rest.indexOf('--');
|
|
266
|
-
if (dashIdx !== -1 && rest.slice(dashIdx + 1).length > 0) {
|
|
267
|
-
return {
|
|
268
|
-
verb: 'git checkout -- <path>',
|
|
269
|
-
alt: 'never discard tracked-file edits on a shared tree with `checkout --` — that can erase a sibling job\'s or the human\'s uncommitted work; scope changes to files you created, or stop and report',
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
// `.` is never a valid branch/commit name, so `git checkout .` (no
|
|
273
|
-
// `--` at all) is unambiguously the pathspec form — and, like
|
|
274
|
-
// `checkout -- .`, discards every dirty tracked file in the tree. Git
|
|
275
|
-
// accepts this bare form; requiring `--` before flagging it let this
|
|
276
|
-
// exact incident shape (real precedent: `starry-night-ships`, see this
|
|
277
|
-
// file's header) walk straight past the guard.
|
|
278
|
-
const positional = rest.filter((a) => a !== '--' && !a.startsWith('-'));
|
|
279
|
-
if (positional.includes('.')) {
|
|
280
|
-
return {
|
|
281
|
-
verb: 'git checkout .',
|
|
282
|
-
alt: 'never discard tracked-file edits on a shared tree with `checkout .` — that can erase a sibling job\'s or the human\'s uncommitted work; scope changes to files you created, or stop and report',
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
case 'restore': {
|
|
288
|
-
if (rest.includes('-n') || rest.includes('--dry-run')) return null;
|
|
289
|
-
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
290
|
-
if (positional.length > 0) {
|
|
291
|
-
return {
|
|
292
|
-
verb: 'git restore',
|
|
293
|
-
alt: 'never `git restore` tracked paths on a shared tree — that discards whatever is currently dirty there, which may not be yours',
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
-
case 'clean': {
|
|
299
|
-
if (isFlagWithChar(rest, ['--dry-run'], 'n')) return null;
|
|
300
|
-
const hasF = isFlagWithChar(rest, ['--force'], 'f');
|
|
301
|
-
const hasD = isFlagWithChar(rest, [], 'd');
|
|
302
|
-
const hasX = isFlagWithChar(rest, [], 'x');
|
|
303
|
-
if (hasF || hasD || hasX) {
|
|
304
|
-
return {
|
|
305
|
-
verb: 'git clean -f/-d/-x',
|
|
306
|
-
alt: 'never force-clean a shared tree — that deletes files git doesn\'t track, which may be a sibling job\'s scratch output or the human\'s untracked WIP',
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
case 'add': {
|
|
312
|
-
const hasBlanket = rest.some((a) => a === '-A' || a === '--all' || a === '.');
|
|
313
|
-
if (hasBlanket) {
|
|
314
|
-
return {
|
|
315
|
-
verb: 'git add -A/./--all',
|
|
316
|
-
alt: 'stage only the exact paths you created or modified — `git add <path> [<path>...]` — a blanket add on a shared tree captures a sibling job\'s or the human\'s in-progress edits and mis-attributes them to your commit',
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
return null;
|
|
320
|
-
}
|
|
321
|
-
case 'commit': {
|
|
322
|
-
const hasAll = isFlagWithChar(rest, ['--all'], 'a');
|
|
323
|
-
if (hasAll) {
|
|
324
|
-
return {
|
|
325
|
-
verb: 'git commit -a',
|
|
326
|
-
alt: 'commit only the paths you explicitly `git add`ed — `git commit -a`/`--all` on a shared tree sweeps up whatever a sibling job or the human currently has dirty',
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
return null;
|
|
330
|
-
}
|
|
331
|
-
default:
|
|
332
|
-
return null;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const SH_WRAPPERS = new Set(['sh', 'bash', 'zsh', 'dash']);
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Returns the first destructive verdict found anywhere in `command`, or null.
|
|
340
|
-
* `baseCwd` is the shell's starting cwd; a `cd <dir>` segment updates the
|
|
341
|
-
* EFFECTIVE cwd for every subsequent segment in the same top-level command
|
|
342
|
-
* (segments share one shell), and an explicit `git -C <dir>` overrides it for
|
|
343
|
-
* that one invocation only. The returned verdict carries the effective cwd at
|
|
344
|
-
* the point the destructive call was found, so the caller checks exemption
|
|
345
|
-
* against where the command actually runs, not just `payload.cwd`.
|
|
346
|
-
*/
|
|
347
|
-
function findDestructiveVerdict(command, baseCwd, depth = 0) {
|
|
348
|
-
if (depth > MAX_SH_C_DEPTH) return null;
|
|
349
|
-
const { parts, unterminatedQuote } = splitTopLevel(command);
|
|
350
|
-
if (unterminatedQuote) {
|
|
351
|
-
if (mentionsPolicedDestructiveToken(command)) {
|
|
352
|
-
return { verb: 'an unparsable command mentioning git', alt: 'this hook could not confidently parse this command\'s quoting to confirm it is safe — split it into a plain, simply-quoted git invocation, or stop and report', cwd: baseCwd };
|
|
353
|
-
}
|
|
354
|
-
return null;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
let effectiveCwd = baseCwd;
|
|
358
|
-
|
|
359
|
-
for (const segment of parts) {
|
|
360
|
-
const tokens = tokenize(segment);
|
|
361
|
-
if (tokens === null) {
|
|
362
|
-
if (mentionsPolicedDestructiveToken(segment)) {
|
|
363
|
-
return { verb: 'an unparsable command mentioning git', alt: 'this hook could not confidently parse this command\'s quoting to confirm it is safe — split it into a plain, simply-quoted git invocation, or stop and report', cwd: effectiveCwd };
|
|
364
|
-
}
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
if (tokens.length === 0) continue;
|
|
368
|
-
|
|
369
|
-
// Strip leading env assignments (FOO=bar git ...).
|
|
370
|
-
let i = 0;
|
|
371
|
-
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
|
|
372
|
-
const cmdTokens = tokens.slice(i);
|
|
373
|
-
if (cmdTokens.length === 0) continue;
|
|
374
|
-
|
|
375
|
-
const head = path.basename(cmdTokens[0]);
|
|
376
|
-
|
|
377
|
-
if (head === 'cd' && cmdTokens[1] != null) {
|
|
378
|
-
const target = cmdTokens[1];
|
|
379
|
-
effectiveCwd = path.isAbsolute(target) ? target : path.resolve(effectiveCwd || process.cwd(), target);
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
if (SH_WRAPPERS.has(head)) {
|
|
384
|
-
const cIdx = cmdTokens.indexOf('-c');
|
|
385
|
-
if (cIdx !== -1 && cmdTokens[cIdx + 1] != null) {
|
|
386
|
-
const nested = findDestructiveVerdict(cmdTokens[cIdx + 1], effectiveCwd, depth + 1);
|
|
387
|
-
if (nested) return nested;
|
|
388
|
-
}
|
|
389
|
-
continue;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (head === 'git') {
|
|
393
|
-
const gitArgs = cmdTokens.slice(1);
|
|
394
|
-
const verdict = evaluateGit(gitArgs);
|
|
395
|
-
if (verdict) {
|
|
396
|
-
const dashC = gitDashCDirOf(gitArgs);
|
|
397
|
-
const invocationCwd = dashC
|
|
398
|
-
? (path.isAbsolute(dashC) ? dashC : path.resolve(effectiveCwd || process.cwd(), dashC))
|
|
399
|
-
: effectiveCwd;
|
|
400
|
-
return { ...verdict, cwd: invocationCwd };
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
return null;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function isInsideManagedWorktreeByPath(cwd) {
|
|
408
|
-
if (!cwd) return false;
|
|
409
|
-
const resolved = path.resolve(cwd);
|
|
410
|
-
return WORKTREE_ROOTS.some((root) => resolved === root || resolved.startsWith(root + path.sep));
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function isInsideManagedWorktreeByBranch(cwd) {
|
|
414
|
-
if (!cwd) return false;
|
|
415
|
-
try {
|
|
416
|
-
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
417
|
-
cwd,
|
|
418
|
-
timeout: 3_000,
|
|
419
|
-
encoding: 'utf8',
|
|
420
|
-
windowsHide: true,
|
|
421
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
422
|
-
}).trim();
|
|
423
|
-
return BRANCH_PREFIXES.some((p) => branch.startsWith(p));
|
|
424
|
-
} catch {
|
|
425
|
-
return false;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function isExemptSharedTree(cwd) {
|
|
430
|
-
return isInsideManagedWorktreeByPath(cwd) || isInsideManagedWorktreeByBranch(cwd);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function allow() {
|
|
434
|
-
process.stdout.write(JSON.stringify({ continue: true }));
|
|
435
|
-
process.exit(0);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function deny(reason) {
|
|
439
|
-
process.stdout.write(JSON.stringify({
|
|
440
|
-
continue: true,
|
|
441
|
-
decision: 'block',
|
|
442
|
-
reason,
|
|
443
|
-
hookSpecificOutput: {
|
|
444
|
-
hookEventName: 'PreToolUse',
|
|
445
|
-
permissionDecision: 'deny',
|
|
446
|
-
permissionDecisionReason: reason,
|
|
447
|
-
},
|
|
448
|
-
}));
|
|
108
|
+
function emit(result) {
|
|
109
|
+
process.stdout.write(JSON.stringify(result));
|
|
449
110
|
process.exit(0);
|
|
450
111
|
}
|
|
451
112
|
|
|
452
113
|
async function main() {
|
|
453
114
|
const raw = await readStdin();
|
|
454
|
-
|
|
455
|
-
try {
|
|
456
|
-
payload = raw ? JSON.parse(raw) : {};
|
|
457
|
-
} catch (e) {
|
|
458
|
-
console.error(`[guard-destructive-git] malformed stdin JSON, failing open: ${e?.message}`);
|
|
459
|
-
return allow();
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
try {
|
|
463
|
-
if (payload.tool_name !== 'Bash') return allow();
|
|
464
|
-
|
|
465
|
-
const command = payload?.tool_input?.command;
|
|
466
|
-
if (!command || typeof command !== 'string') return allow();
|
|
467
|
-
|
|
468
|
-
const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
469
|
-
const verdict = findDestructiveVerdict(command, cwd);
|
|
470
|
-
if (!verdict) return allow();
|
|
471
|
-
|
|
472
|
-
const effectiveCwd = verdict.cwd || cwd;
|
|
473
|
-
if (isExemptSharedTree(effectiveCwd)) return allow();
|
|
474
|
-
|
|
475
|
-
const reason = [
|
|
476
|
-
`Blocked: \`${verdict.verb}\` in what this hook believes is a SHARED working tree (${effectiveCwd}).`,
|
|
477
|
-
verdict.alt,
|
|
478
|
-
'You are normally inside your own sm-job/<slug> or sm-epic/<epicId> worktree for this kind of operation — run `git rev-parse --git-common-dir` to check; this guard permits the same command there.',
|
|
479
|
-
].join(' ');
|
|
480
|
-
return deny(reason);
|
|
481
|
-
} catch (e) {
|
|
482
|
-
console.error(`[guard-destructive-git] internal error, failing open: ${e?.message}`);
|
|
483
|
-
return allow();
|
|
484
|
-
}
|
|
115
|
+
return emit(decide(parsePayload(raw)));
|
|
485
116
|
}
|
|
486
117
|
|
|
118
|
+
// Runs as a require-time side effect, NOT gated on `require.main === module`
|
|
119
|
+
// — see src/main/lib/guardShims.cjs's header: the installed shim invokes this
|
|
120
|
+
// script via a plain `require()`, so gating on `require.main` would silently
|
|
121
|
+
// stop the shim from ever running the guard.
|
|
487
122
|
main().catch((e) => {
|
|
488
123
|
console.error(`[guard-destructive-git] unhandled error, failing open: ${e?.message}`);
|
|
489
|
-
|
|
124
|
+
emit({ continue: true });
|
|
490
125
|
});
|
|
@@ -74,14 +74,7 @@
|
|
|
74
74
|
*/
|
|
75
75
|
'use strict';
|
|
76
76
|
|
|
77
|
-
const
|
|
78
|
-
const path = require('node:path');
|
|
79
|
-
const { execFileSync } = require('node:child_process');
|
|
80
|
-
// Pure fs helper only — deliberately NOT gitWorktree (keeps this hook light).
|
|
81
|
-
const { nearestGitEntry } = require('../../src/main/lib/cwdClassify.cjs');
|
|
82
|
-
|
|
83
|
-
const SOURCE_DIRS = ['src', 'scripts', 'plugins', 'bin'];
|
|
84
|
-
const DENY_TAGS = new Set(['feature', 'bug']);
|
|
77
|
+
const { decide, parsePayload } = require('./lib/guard-inline-implementation-policy.cjs');
|
|
85
78
|
|
|
86
79
|
function readStdin() {
|
|
87
80
|
return new Promise((resolve) => {
|
|
@@ -93,133 +86,21 @@ function readStdin() {
|
|
|
93
86
|
});
|
|
94
87
|
}
|
|
95
88
|
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
if (toolName === 'NotebookEdit') return toolInput.notebook_path ?? null;
|
|
99
|
-
return toolInput.file_path ?? null;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function isApplicationSource(cwd, absPath) {
|
|
103
|
-
const rel = path.relative(cwd, absPath);
|
|
104
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) return false; // outside cwd
|
|
105
|
-
const topLevel = rel.split(path.sep)[0];
|
|
106
|
-
return SOURCE_DIRS.includes(topLevel);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* The PreToolUse payload's `cwd` is the CLI process's actual spawn cwd. For
|
|
111
|
-
* an Epic running in its default isolated `git worktree` (gitWorktree.cjs,
|
|
112
|
-
* epicSpawnCwd.cjs — "both the Terminal PTY and headless Chat spawn cwd
|
|
113
|
-
* resolve to it"), that is a throwaway checkout under os.tmpdir(), which
|
|
114
|
-
* never contains `session-manager-operations/` — the ops root always lives
|
|
115
|
-
* in the real project tree (see gitWorktree.cjs's "ops-root hazard" header:
|
|
116
|
-
* "spawn cwd = worktree dir, ops-root cwd = real project cwd, always").
|
|
117
|
-
* Resolve back to the real project cwd via git's shared `.git` dir before
|
|
118
|
-
* giving up, so this guard isn't silently inert for every Epic running in
|
|
119
|
-
* its default worktree.
|
|
120
|
-
*/
|
|
121
|
-
function resolveOpsRootCwd(rawCwd) {
|
|
122
|
-
if (fs.existsSync(path.join(rawCwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'))) {
|
|
123
|
-
return rawCwd;
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
// Only a `.git` FILE (linked worktree) needs git to find the shared dir; a
|
|
127
|
-
// `.git` directory IS the project root, so skip the spawn.
|
|
128
|
-
const entry = nearestGitEntry(rawCwd);
|
|
129
|
-
if (!entry) return rawCwd;
|
|
130
|
-
if (!entry.isFile) return entry.dir;
|
|
131
|
-
const commonDir = execFileSync(
|
|
132
|
-
'git',
|
|
133
|
-
['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
134
|
-
{ cwd: rawCwd, encoding: 'utf8', timeout: 5000 },
|
|
135
|
-
).trim();
|
|
136
|
-
if (!commonDir) return rawCwd;
|
|
137
|
-
return path.basename(commonDir) === '.git' ? path.dirname(commonDir) : commonDir;
|
|
138
|
-
} catch {
|
|
139
|
-
return rawCwd;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function resolveEpicTag(cwd, sessionId) {
|
|
144
|
-
if (!sessionId) return null;
|
|
145
|
-
const indexPath = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
|
|
146
|
-
if (!fs.existsSync(indexPath)) return null;
|
|
147
|
-
const raw = fs.readFileSync(indexPath, 'utf8');
|
|
148
|
-
const index = JSON.parse(raw);
|
|
149
|
-
const sessions = index && typeof index === 'object' ? index.sessions : null;
|
|
150
|
-
if (!sessions || typeof sessions !== 'object') return null;
|
|
151
|
-
for (const record of Object.values(sessions)) {
|
|
152
|
-
if (record && record.claudeSessionId === sessionId) return record;
|
|
153
|
-
}
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function allow() {
|
|
158
|
-
process.stdout.write(JSON.stringify({ continue: true }));
|
|
159
|
-
process.exit(0);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function deny(reason) {
|
|
163
|
-
process.stdout.write(JSON.stringify({
|
|
164
|
-
continue: true,
|
|
165
|
-
decision: 'block',
|
|
166
|
-
reason,
|
|
167
|
-
hookSpecificOutput: {
|
|
168
|
-
hookEventName: 'PreToolUse',
|
|
169
|
-
permissionDecision: 'deny',
|
|
170
|
-
permissionDecisionReason: reason,
|
|
171
|
-
},
|
|
172
|
-
}));
|
|
89
|
+
function emit(result) {
|
|
90
|
+
process.stdout.write(JSON.stringify(result));
|
|
173
91
|
process.exit(0);
|
|
174
92
|
}
|
|
175
93
|
|
|
176
94
|
async function main() {
|
|
177
95
|
const raw = await readStdin();
|
|
178
|
-
|
|
179
|
-
try {
|
|
180
|
-
payload = raw ? JSON.parse(raw) : {};
|
|
181
|
-
} catch (e) {
|
|
182
|
-
console.error(`[guard-inline-implementation] malformed stdin JSON, failing open: ${e?.message}`);
|
|
183
|
-
return allow();
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
try {
|
|
187
|
-
if (process.env.SM_ALLOW_INLINE_IMPLEMENTATION === '1') return allow();
|
|
188
|
-
|
|
189
|
-
const toolName = payload.tool_name;
|
|
190
|
-
if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'NotebookEdit') {
|
|
191
|
-
return allow();
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const rawPath = targetPathFor(toolName, payload.tool_input);
|
|
195
|
-
if (!rawPath || typeof rawPath !== 'string') return allow();
|
|
196
|
-
|
|
197
|
-
const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
198
|
-
const absPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
|
|
199
|
-
|
|
200
|
-
if (!isApplicationSource(cwd, absPath)) return allow();
|
|
201
|
-
|
|
202
|
-
const sessionId = typeof payload.session_id === 'string' ? payload.session_id : null;
|
|
203
|
-
const opsRootCwd = resolveOpsRootCwd(cwd);
|
|
204
|
-
const epic = resolveEpicTag(opsRootCwd, sessionId);
|
|
205
|
-
if (!epic) return allow(); // unresolvable session — allow by construction
|
|
206
|
-
|
|
207
|
-
if (epic.allowInlineImplementation) return allow();
|
|
208
|
-
if (!DENY_TAGS.has(epic.tag)) return allow();
|
|
209
|
-
|
|
210
|
-
const reason = [
|
|
211
|
-
`Direct ${toolName} to application source ("${path.relative(cwd, absPath)}") is discouraged inside a "${epic.tag}" Epic.`,
|
|
212
|
-
'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.',
|
|
213
|
-
'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.',
|
|
214
|
-
].join(' ');
|
|
215
|
-
return deny(reason);
|
|
216
|
-
} catch (e) {
|
|
217
|
-
console.error(`[guard-inline-implementation] internal error, failing open: ${e?.message}`);
|
|
218
|
-
return allow();
|
|
219
|
-
}
|
|
96
|
+
return emit(decide(parsePayload(raw)));
|
|
220
97
|
}
|
|
221
98
|
|
|
99
|
+
// Runs as a require-time side effect, NOT gated on `require.main === module`
|
|
100
|
+
// — see src/main/lib/guardShims.cjs's header: the installed shim invokes this
|
|
101
|
+
// script via a plain `require()`, so gating on `require.main` would silently
|
|
102
|
+
// stop the shim from ever running the guard.
|
|
222
103
|
main().catch((e) => {
|
|
223
104
|
console.error(`[guard-inline-implementation] unhandled error, failing open: ${e?.message}`);
|
|
224
|
-
|
|
105
|
+
emit({ continue: true });
|
|
225
106
|
});
|
|
@@ -73,10 +73,7 @@
|
|
|
73
73
|
*/
|
|
74
74
|
'use strict';
|
|
75
75
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
const SCHEDULER_SEGMENT = `${path.sep}session-manager-operations${path.sep}scheduler${path.sep}`;
|
|
79
|
-
const FIX_PLAN_RE = /^\d+-fix-.*\.md$/;
|
|
76
|
+
const { decide, parsePayload } = require('./lib/guard-prd-writes-policy.cjs');
|
|
80
77
|
|
|
81
78
|
function readStdin() {
|
|
82
79
|
return new Promise((resolve) => {
|
|
@@ -89,83 +86,21 @@ function readStdin() {
|
|
|
89
86
|
});
|
|
90
87
|
}
|
|
91
88
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (!toolInput || typeof toolInput !== 'object') return null;
|
|
95
|
-
if (toolName === 'NotebookEdit') return toolInput.notebook_path ?? null;
|
|
96
|
-
return toolInput.file_path ?? null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** MCP tool name to point the agent at, based on the attempted operation. */
|
|
100
|
-
function suggestedToolFor(toolName, absPath) {
|
|
101
|
-
const isArchived = absPath.includes(`${path.sep}prds-archived${path.sep}`);
|
|
102
|
-
if (isArchived) return 'mcp__session-manager-scheduler__scheduler_archive_prd';
|
|
103
|
-
// Write = the file doesn't exist yet from the agent's point of view (a
|
|
104
|
-
// brand-new PRD); Edit/NotebookEdit = mutating an existing file in place.
|
|
105
|
-
if (toolName === 'Write') return 'mcp__session-manager-scheduler__scheduler_create_prd';
|
|
106
|
-
return 'mcp__session-manager-scheduler__scheduler_update_prd';
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function allow() {
|
|
110
|
-
process.stdout.write(JSON.stringify({ continue: true }));
|
|
111
|
-
process.exit(0);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function deny(reason) {
|
|
115
|
-
process.stdout.write(JSON.stringify({
|
|
116
|
-
continue: true,
|
|
117
|
-
decision: 'block',
|
|
118
|
-
reason,
|
|
119
|
-
hookSpecificOutput: {
|
|
120
|
-
hookEventName: 'PreToolUse',
|
|
121
|
-
permissionDecision: 'deny',
|
|
122
|
-
permissionDecisionReason: reason,
|
|
123
|
-
},
|
|
124
|
-
}));
|
|
89
|
+
function emit(result) {
|
|
90
|
+
process.stdout.write(JSON.stringify(result));
|
|
125
91
|
process.exit(0);
|
|
126
92
|
}
|
|
127
93
|
|
|
128
94
|
async function main() {
|
|
129
95
|
const raw = await readStdin();
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
payload = raw ? JSON.parse(raw) : {};
|
|
133
|
-
} catch (e) {
|
|
134
|
-
console.error(`[guard-prd-writes] malformed stdin JSON, failing open: ${e?.message}`);
|
|
135
|
-
return allow();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
const toolName = payload.tool_name;
|
|
140
|
-
if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'NotebookEdit') {
|
|
141
|
-
return allow();
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const rawPath = targetPathFor(toolName, payload.tool_input);
|
|
145
|
-
if (!rawPath || typeof rawPath !== 'string') return allow();
|
|
146
|
-
|
|
147
|
-
const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
148
|
-
const absPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
|
|
149
|
-
|
|
150
|
-
if (!absPath.includes(SCHEDULER_SEGMENT)) return allow();
|
|
151
|
-
|
|
152
|
-
const base = path.basename(absPath);
|
|
153
|
-
if (FIX_PLAN_RE.test(base)) return allow();
|
|
154
|
-
|
|
155
|
-
const suggested = suggestedToolFor(toolName, absPath);
|
|
156
|
-
const reason = [
|
|
157
|
-
`Direct ${toolName} to a session-manager-operations/scheduler/ path is blocked.`,
|
|
158
|
-
`PRDs must be authored through the scheduler API, not a raw file write — use the "${suggested}" MCP tool instead.`,
|
|
159
|
-
'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.',
|
|
160
|
-
].join(' ');
|
|
161
|
-
return deny(reason);
|
|
162
|
-
} catch (e) {
|
|
163
|
-
console.error(`[guard-prd-writes] internal error, failing open: ${e?.message}`);
|
|
164
|
-
return allow();
|
|
165
|
-
}
|
|
96
|
+
return emit(decide(parsePayload(raw)));
|
|
166
97
|
}
|
|
167
98
|
|
|
99
|
+
// Runs as a require-time side effect, NOT gated on `require.main === module`
|
|
100
|
+
// — see src/main/lib/guardShims.cjs's header: the installed shim invokes this
|
|
101
|
+
// script via a plain `require()`, so gating on `require.main` would silently
|
|
102
|
+
// stop the shim from ever running the guard.
|
|
168
103
|
main().catch((e) => {
|
|
169
104
|
console.error(`[guard-prd-writes] unhandled error, failing open: ${e?.message}`);
|
|
170
|
-
|
|
105
|
+
emit({ continue: true });
|
|
171
106
|
});
|