claude-code-session-manager 0.95.0 → 0.97.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 (114) hide show
  1. package/dist/assets/{DataModel-DZK9QZpV.js → DataModel-D93FoaOm.js} +1 -1
  2. package/dist/assets/History-CXxxqNP1.js +2 -0
  3. package/dist/assets/{Hooks-CmK6l0uf.js → Hooks-CzeYFQjq.js} +3 -3
  4. package/dist/assets/{HostBilko-D8TIx-Bx.js → HostBilko-nESrTRIg.js} +1 -1
  5. package/dist/assets/{Library-DTxpOjx8.js → Library-D2zPkwOC.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-BPJyrevq.js → MarkdownEditor-DbinP78A.js} +1 -1
  7. package/dist/assets/{McpServers-B_rvIYDs.js → McpServers-Bu_sVGLr.js} +2 -2
  8. package/dist/assets/Memory-BgiI6LZ_.js +8 -0
  9. package/dist/assets/{Permissions-DhMMp27n.js → Permissions-C1MMzZRm.js} +3 -3
  10. package/dist/assets/Plugins-Dhu6wAZi.js +2 -0
  11. package/dist/assets/{ProvenanceBadge-ptlItdCK.js → ProvenanceBadge-C3AcWKMG.js} +1 -1
  12. package/dist/assets/{SaveBar-CSbNSl2W.js → SaveBar-plwYm0iO.js} +1 -1
  13. package/dist/assets/Scheduler-BVYTeh39.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-BJof-GWH.js → ScopeSwitcher-DFy0vv_o.js} +1 -1
  15. package/dist/assets/Settings-DIhGgdfs.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-CGl4B89-.js → SkillReferenceGraph-DsI7hRPB.js} +1 -1
  17. package/dist/assets/Skills-3E748UfB.js +3 -0
  18. package/dist/assets/{SystemPrompt-DKj-q39J.js → SystemPrompt-BqmfUJOd.js} +1 -1
  19. package/dist/assets/TagLibrary-CPz6fO8Q.js +1 -0
  20. package/dist/assets/{TiptapBody-BbcJL9az.js → TiptapBody-D9EoBQ1P.js} +1 -1
  21. package/dist/assets/{Toggle-DAslvnnv.js → Toggle-BHAmZNDn.js} +1 -1
  22. package/dist/assets/index-tn5JIVPj.js +3079 -0
  23. package/dist/assets/{settingsSchema-pzp0TgvN.js → settingsSchema-Crqy_aNz.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +8 -2
  26. package/plugins/CLAUDE.md +3 -3
  27. package/plugins/session-manager-dev/skills/develop/SKILL.md +90 -67
  28. package/plugins/session-manager-dev/skills/develop/standards.md +5 -11
  29. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +5 -15
  30. package/scripts/README.md +1 -0
  31. package/scripts/hooks/guard-destructive-git.cjs +9 -374
  32. package/scripts/hooks/guard-inline-implementation.cjs +9 -128
  33. package/scripts/hooks/guard-prd-writes.cjs +9 -74
  34. package/scripts/hooks/guard-self-schedule.cjs +9 -61
  35. package/scripts/hooks/lib/guard-destructive-git-policy.cjs +410 -0
  36. package/scripts/hooks/lib/guard-inline-implementation-policy.cjs +159 -0
  37. package/scripts/hooks/lib/guard-prd-writes-policy.cjs +100 -0
  38. package/scripts/hooks/lib/guard-self-schedule-policy.cjs +89 -0
  39. package/scripts/scheduler-mcp-server.cjs +8 -1
  40. package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +59 -0
  41. package/src/main/__tests__/exchanges.test.cjs +41 -96
  42. package/src/main/__tests__/exchangesPromptId.test.cjs +16 -25
  43. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  44. package/src/main/__tests__/health-credentials.test.cjs +81 -0
  45. package/src/main/__tests__/ipcSchemas-dependsOn.test.cjs +39 -0
  46. package/src/main/__tests__/memoryAggregate.test.cjs +40 -1
  47. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +1 -1
  48. package/src/main/__tests__/planValidator.test.cjs +125 -0
  49. package/src/main/__tests__/prdCreate.test.cjs +3 -1
  50. package/src/main/__tests__/prdSizing.test.cjs +106 -0
  51. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +46 -0
  52. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-looks-done.test.cjs +40 -0
  54. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +81 -0
  55. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +6 -1
  56. package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
  57. package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
  58. package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
  59. package/src/main/__tests__/telemetryClient.test.cjs +2 -2
  60. package/src/main/__tests__/telemetryContract.test.cjs +2 -2
  61. package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
  62. package/src/main/__tests__/validationSentinels.test.cjs +84 -0
  63. package/src/main/build-info.json +4 -4
  64. package/src/main/config.cjs +26 -14
  65. package/src/main/exchanges.cjs +26 -21
  66. package/src/main/health.cjs +66 -4
  67. package/src/main/index.cjs +1 -1
  68. package/src/main/ipcSchemas.cjs +15 -7
  69. package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
  70. package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
  71. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
  72. package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
  73. package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
  74. package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
  75. package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
  76. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
  77. package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
  78. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
  79. package/src/main/lib/agentModelResolve.cjs +1 -0
  80. package/src/main/lib/credentials.cjs +62 -0
  81. package/src/main/lib/cwdClassify.cjs +15 -0
  82. package/src/main/lib/definitionOfDone.cjs +9 -13
  83. package/src/main/lib/effectiveModelInfo.cjs +2 -9
  84. package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
  85. package/src/main/lib/loadGate.cjs +29 -5
  86. package/src/main/lib/opsOwnership.cjs +23 -0
  87. package/src/main/lib/planValidator.cjs +80 -0
  88. package/src/main/lib/prdCreate.cjs +8 -4
  89. package/src/main/lib/prdSizing.cjs +83 -0
  90. package/src/main/lib/runLogRetention.cjs +2 -8
  91. package/src/main/lib/telemetryBacklog.cjs +9 -3
  92. package/src/main/lib/telemetryClient.cjs +12 -3
  93. package/src/main/lib/telemetrySettings.cjs +8 -2
  94. package/src/main/lib/validationSentinels.cjs +29 -0
  95. package/src/main/memoryAggregate.cjs +36 -11
  96. package/src/main/otelSettings.cjs +8 -2
  97. package/src/main/scheduler.cjs +215 -31
  98. package/src/main/seedAgentPersonas.cjs +1 -1
  99. package/src/main/sessionsStore.cjs +8 -2
  100. package/src/main/supervisor.cjs +3 -3
  101. package/src/main/templates/PRD_AUTHORING.md +9 -9
  102. package/src/main/voiceSettings.cjs +8 -2
  103. package/src/preload/api.d.ts +3 -3
  104. package/src/preload/index.cjs +5 -5
  105. package/src/seed/agents/dev-lead.md +24 -15
  106. package/src/seed/agents/validator.md +33 -0
  107. package/dist/assets/History-BDyJUGtR.js +0 -2
  108. package/dist/assets/Memory-B-9GlHqe.js +0 -8
  109. package/dist/assets/Plugins-DKfAlmMY.js +0 -2
  110. package/dist/assets/Scheduler--rn1QBYc.js +0 -16
  111. package/dist/assets/Settings-Dsx6XZP5.js +0 -3
  112. package/dist/assets/Skills-D522Zfol.js +0 -3
  113. package/dist/assets/TagLibrary-BH7CG7-6.js +0 -1
  114. package/dist/assets/index-Yf8e7VCB.js +0 -3079
@@ -90,11 +90,7 @@
90
90
  */
91
91
  'use strict';
92
92
 
93
- const ALWAYS_DENY_TOOLS = new Set(['ScheduleWakeup', 'CronCreate']);
94
- const BACKGROUNDABLE_AGENT_TOOLS = new Set(['Task', 'Agent']);
95
-
96
- const STANDARDS_QUOTE = 'You ARE the executor — never re-queue or self-schedule.';
97
- const STANDARDS_CITE = 'plugins/session-manager-dev/skills/develop/standards.md:85 (Execution discipline)';
93
+ const { decide, parsePayload } = require('./lib/guard-self-schedule-policy.cjs');
98
94
 
99
95
  function readStdin() {
100
96
  return new Promise((resolve) => {
@@ -107,69 +103,21 @@ function readStdin() {
107
103
  });
108
104
  }
109
105
 
110
- function allow() {
111
- process.stdout.write(JSON.stringify({ continue: true }));
112
- process.exit(0);
113
- }
114
-
115
- function deny(reason) {
116
- process.stdout.write(JSON.stringify({
117
- continue: true,
118
- decision: 'block',
119
- reason,
120
- hookSpecificOutput: {
121
- hookEventName: 'PreToolUse',
122
- permissionDecision: 'deny',
123
- permissionDecisionReason: reason,
124
- },
125
- }));
106
+ function emit(result) {
107
+ process.stdout.write(JSON.stringify(result));
126
108
  process.exit(0);
127
109
  }
128
110
 
129
111
  async function main() {
130
112
  const raw = await readStdin();
131
- let payload;
132
- try {
133
- payload = raw ? JSON.parse(raw) : {};
134
- } catch (e) {
135
- console.error(`[guard-self-schedule] malformed stdin JSON, failing open: ${e?.message}`);
136
- return allow();
137
- }
138
-
139
- try {
140
- const jobSlug = process.env.SM_SCHEDULER_JOB_SLUG;
141
- if (!jobSlug) return allow(); // interactive session — this guard is inert here
142
-
143
- const toolName = payload.tool_name;
144
-
145
- if (ALWAYS_DENY_TOOLS.has(toolName)) {
146
- const reason = [
147
- `Blocked: \`${toolName}\` from inside a headless scheduler-spawned run (job '${jobSlug}').`,
148
- `${STANDARDS_CITE}: "${STANDARDS_QUOTE}"`,
149
- 'A headless run has no next turn — nothing will ever deliver this wakeup, so the job exits with its work stranded and parks in needs_review even if it was already correct.',
150
- 'Do it now instead: commit what you have, then run any required review synchronously inline (call the reviewer and read its result in this same turn) before the finish protocol — never schedule it for later.',
151
- ].join(' ');
152
- return deny(reason);
153
- }
154
-
155
- if (BACKGROUNDABLE_AGENT_TOOLS.has(toolName) && payload.tool_input && payload.tool_input.run_in_background) {
156
- const reason = [
157
- `Blocked: a backgrounded ${toolName} call (run_in_background: true) from inside a headless scheduler-spawned run (job '${jobSlug}').`,
158
- `${STANDARDS_CITE}: "${STANDARDS_QUOTE}"`,
159
- 'A headless run has no later turn to collect a background agent\'s result, so waiting on it (ListAgents, Monitor, ScheduleWakeup) strands the job in needs_review with its work already done but uncommitted.',
160
- 'Call it synchronously instead — omit run_in_background (or set it to false) and read its result in this same turn, before the finish protocol.',
161
- ].join(' ');
162
- return deny(reason);
163
- }
164
-
165
- return allow();
166
- } catch (e) {
167
- console.error(`[guard-self-schedule] internal error, failing open: ${e?.message}`);
168
- return allow();
169
- }
113
+ return emit(decide(parsePayload(raw)));
170
114
  }
171
115
 
116
+ // Runs as a require-time side effect, NOT gated on `require.main === module`
117
+ // — see src/main/lib/guardShims.cjs's header: the installed shim invokes this
118
+ // script via a plain `require()`, so gating on `require.main` would silently
119
+ // stop the shim from ever running the guard.
172
120
  main().catch((e) => {
173
121
  console.error(`[guard-self-schedule] unhandled error, failing open: ${e?.message}`);
174
- allow();
122
+ emit({ continue: true });
175
123
  });
@@ -0,0 +1,410 @@
1
+ /**
2
+ * guard-destructive-git-policy.cjs — decision logic for
3
+ * scripts/hooks/guard-destructive-git.cjs, split out so it can be
4
+ * `require()`d in-process (by tests, notably) with no CLI-level side effects.
5
+ *
6
+ * guard-destructive-git.cjs itself runs its CLI as a require-time side
7
+ * effect (stdin -> decision -> process.exit, not gated on
8
+ * `require.main === module` — see src/main/lib/guardShims.cjs's header for
9
+ * why: the installed shim invokes the real script via a plain `require()`,
10
+ * so gating on `require.main` would silently stop the shim from ever running
11
+ * the guard). Requiring THIS module never reads stdin or calls
12
+ * `process.exit`, so it's safe to load from a test process — but `decide()`
13
+ * is not fully I/O-free: for a destructive verdict outside a
14
+ * path-recognized managed worktree, it shells out to a real `git
15
+ * rev-parse` (see `isInsideManagedWorktreeByBranch` below) before returning.
16
+ * Most `expectDenied(...)` cases in the test file hit this, so most test
17
+ * runs still spawn a `git` child process per denied case — fewer spawns
18
+ * than the old ~50-CLI-process-per-run shape, not zero.
19
+ */
20
+ 'use strict';
21
+
22
+ const path = require('node:path');
23
+ const os = require('node:os');
24
+ const { execFileSync } = require('node:child_process');
25
+
26
+ // Duplicated from src/main/lib/gitWorktree.cjs's KIND_CONFIG — see
27
+ // guard-destructive-git.cjs's header for why this is a deliberate
28
+ // duplication, not a require. Same SM_WORKTREE_ROOT-else-tmpdir base as
29
+ // schedulerPaths.worktreeBase().
30
+ const WORKTREE_BASE = process.env.SM_WORKTREE_ROOT || os.tmpdir();
31
+ const WORKTREE_ROOTS = ['job', 'epic'].map((k) => path.join(WORKTREE_BASE, `session-manager-${k}-worktrees`));
32
+ const BRANCH_PREFIXES = ['sm-job/', 'sm-epic/'];
33
+
34
+ const MAX_SH_C_DEPTH = 5;
35
+
36
+ /** Quote-aware split on top-level `&&`, `||`, `;`, `|`, and newlines. */
37
+ function splitTopLevel(cmd) {
38
+ const parts = [];
39
+ let cur = '';
40
+ let inSingle = false;
41
+ let inDouble = false;
42
+ for (let i = 0; i < cmd.length; i++) {
43
+ const c = cmd[i];
44
+ if (inSingle) {
45
+ cur += c;
46
+ if (c === "'") inSingle = false;
47
+ continue;
48
+ }
49
+ if (inDouble) {
50
+ cur += c;
51
+ if (c === '"' && cmd[i - 1] !== '\\') inDouble = false;
52
+ continue;
53
+ }
54
+ if (c === "'") { inSingle = true; cur += c; continue; }
55
+ if (c === '"') { inDouble = true; cur += c; continue; }
56
+ const two = cmd.slice(i, i + 2);
57
+ if (two === '&&' || two === '||') { parts.push(cur); cur = ''; i++; continue; }
58
+ if (c === ';' || c === '|' || c === '\n') { parts.push(cur); cur = ''; continue; }
59
+ cur += c;
60
+ }
61
+ parts.push(cur);
62
+ return { parts: parts.map((s) => s.trim()).filter(Boolean), unterminatedQuote: inSingle || inDouble };
63
+ }
64
+
65
+ /** Quote-aware whitespace tokenizer. Returns null on an unterminated quote. */
66
+ function tokenize(str) {
67
+ const tokens = [];
68
+ let cur = '';
69
+ let started = false;
70
+ let inSingle = false;
71
+ let inDouble = false;
72
+ for (let i = 0; i < str.length; i++) {
73
+ const c = str[i];
74
+ if (inSingle) {
75
+ if (c === "'") inSingle = false; else cur += c;
76
+ started = true;
77
+ continue;
78
+ }
79
+ if (inDouble) {
80
+ if (c === '"') inDouble = false; else cur += c;
81
+ started = true;
82
+ continue;
83
+ }
84
+ if (c === "'") { inSingle = true; started = true; continue; }
85
+ if (c === '"') { inDouble = true; started = true; continue; }
86
+ if (/\s/.test(c)) {
87
+ if (started) { tokens.push(cur); cur = ''; started = false; }
88
+ continue;
89
+ }
90
+ cur += c;
91
+ started = true;
92
+ }
93
+ if (started) tokens.push(cur);
94
+ if (inSingle || inDouble) return null;
95
+ return tokens;
96
+ }
97
+
98
+ function isFlagWithChar(rest, longForms, shortChar) {
99
+ for (const t of rest) {
100
+ if (longForms.includes(t)) return true;
101
+ if (shortChar && t.startsWith('-') && !t.startsWith('--') && t.includes(shortChar)) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ /** Skip git's global options (`-C <dir>`, `-c <k>=<v>`, `--git-dir=...`, ...) to find the subcommand. */
107
+ function subcommandOf(args) {
108
+ const OPTS_WITH_SEPARATE_ARG = new Set(['-C', '-c', '--namespace', '--super-prefix']);
109
+ let i = 0;
110
+ while (i < args.length) {
111
+ const t = args[i];
112
+ if (OPTS_WITH_SEPARATE_ARG.has(t)) { i += 2; continue; }
113
+ if (t.startsWith('-')) { i += 1; continue; }
114
+ return { subcommand: t, rest: args.slice(i + 1) };
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /** Extract an explicit `git -C <dir>` argument (or `git -C=<dir>`), if present, from the tokens AFTER `git`. */
120
+ function gitDashCDirOf(args) {
121
+ for (let i = 0; i < args.length; i++) {
122
+ const t = args[i];
123
+ if (t === '-C' && args[i + 1] != null) return args[i + 1];
124
+ if (t.startsWith('-C') && t.length > 2) return t.slice(2);
125
+ if (!t.startsWith('-')) break; // reached the subcommand — no more global opts
126
+ }
127
+ return null;
128
+ }
129
+
130
+ /**
131
+ * Text-level check for whether an UNPARSABLE segment mentions a verb this
132
+ * guard actually polices. Used only to narrow the unparsable-command fallback
133
+ * — an unparsable command that mentions none of these is allowed through
134
+ * rather than blanket-blocked, since blocking every command merely containing
135
+ * the substring "git" (e.g. a heredoc `git commit -m "$(cat <<'EOF' ...)"`)
136
+ * was itself a false-positive incident.
137
+ */
138
+ function mentionsPolicedDestructiveToken(text) {
139
+ if (/\b(stash|reset|checkout|restore|clean)\b/.test(text)) return true;
140
+ if (/\badd\s+(-A\b|--all\b|\.(?:\s|$))/.test(text)) return true;
141
+ if (/\bcommit\b.*(-a\b|--all\b)/.test(text)) return true;
142
+ return false;
143
+ }
144
+
145
+ /**
146
+ * Evaluate one `git <...>` invocation (args = tokens AFTER `git`). Returns
147
+ * `{ verb, alt }` when destructive, or null when this invocation is fine
148
+ * (read-only, or a form this guard doesn't restrict).
149
+ */
150
+ function evaluateGit(args) {
151
+ const parsed = subcommandOf(args);
152
+ if (!parsed) return null;
153
+ const { subcommand, rest } = parsed;
154
+
155
+ switch (subcommand) {
156
+ case 'stash': {
157
+ const sub = rest.find((a) => !a.startsWith('-'));
158
+ if (sub === 'list' || sub === 'show') return null;
159
+ return {
160
+ verb: 'git stash',
161
+ 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',
162
+ };
163
+ }
164
+ case 'reset': {
165
+ const hasHard = rest.includes('--hard');
166
+ const positional = rest.filter((a) => !a.startsWith('-'));
167
+ if (hasHard) {
168
+ return {
169
+ verb: 'git reset --hard',
170
+ 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',
171
+ };
172
+ }
173
+ if (positional.length > 0) {
174
+ return {
175
+ verb: 'git reset <path>',
176
+ 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',
177
+ };
178
+ }
179
+ return null;
180
+ }
181
+ case 'checkout': {
182
+ const dashIdx = rest.indexOf('--');
183
+ if (dashIdx !== -1 && rest.slice(dashIdx + 1).length > 0) {
184
+ return {
185
+ verb: 'git checkout -- <path>',
186
+ 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',
187
+ };
188
+ }
189
+ // `.` is never a valid branch/commit name, so `git checkout .` (no
190
+ // `--` at all) is unambiguously the pathspec form — and, like
191
+ // `checkout -- .`, discards every dirty tracked file in the tree. Git
192
+ // accepts this bare form; requiring `--` before flagging it let this
193
+ // exact incident shape (real precedent: `starry-night-ships`, see
194
+ // guard-destructive-git.cjs's header) walk straight past the guard.
195
+ const positional = rest.filter((a) => a !== '--' && !a.startsWith('-'));
196
+ if (positional.includes('.')) {
197
+ return {
198
+ verb: 'git checkout .',
199
+ 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',
200
+ };
201
+ }
202
+ return null;
203
+ }
204
+ case 'restore': {
205
+ if (rest.includes('-n') || rest.includes('--dry-run')) return null;
206
+ const positional = rest.filter((a) => !a.startsWith('-'));
207
+ if (positional.length > 0) {
208
+ return {
209
+ verb: 'git restore',
210
+ alt: 'never `git restore` tracked paths on a shared tree — that discards whatever is currently dirty there, which may not be yours',
211
+ };
212
+ }
213
+ return null;
214
+ }
215
+ case 'clean': {
216
+ if (isFlagWithChar(rest, ['--dry-run'], 'n')) return null;
217
+ const hasF = isFlagWithChar(rest, ['--force'], 'f');
218
+ const hasD = isFlagWithChar(rest, [], 'd');
219
+ const hasX = isFlagWithChar(rest, [], 'x');
220
+ if (hasF || hasD || hasX) {
221
+ return {
222
+ verb: 'git clean -f/-d/-x',
223
+ 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',
224
+ };
225
+ }
226
+ return null;
227
+ }
228
+ case 'add': {
229
+ const hasBlanket = rest.some((a) => a === '-A' || a === '--all' || a === '.');
230
+ if (hasBlanket) {
231
+ return {
232
+ verb: 'git add -A/./--all',
233
+ 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',
234
+ };
235
+ }
236
+ return null;
237
+ }
238
+ case 'commit': {
239
+ const hasAll = isFlagWithChar(rest, ['--all'], 'a');
240
+ if (hasAll) {
241
+ return {
242
+ verb: 'git commit -a',
243
+ 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',
244
+ };
245
+ }
246
+ return null;
247
+ }
248
+ default:
249
+ return null;
250
+ }
251
+ }
252
+
253
+ const SH_WRAPPERS = new Set(['sh', 'bash', 'zsh', 'dash']);
254
+
255
+ /**
256
+ * Returns the first destructive verdict found anywhere in `command`, or null.
257
+ * `baseCwd` is the shell's starting cwd; a `cd <dir>` segment updates the
258
+ * EFFECTIVE cwd for every subsequent segment in the same top-level command
259
+ * (segments share one shell), and an explicit `git -C <dir>` overrides it for
260
+ * that one invocation only. The returned verdict carries the effective cwd at
261
+ * the point the destructive call was found, so the caller checks exemption
262
+ * against where the command actually runs, not just `payload.cwd`.
263
+ */
264
+ function findDestructiveVerdict(command, baseCwd, depth = 0) {
265
+ if (depth > MAX_SH_C_DEPTH) return null;
266
+ const { parts, unterminatedQuote } = splitTopLevel(command);
267
+ if (unterminatedQuote) {
268
+ if (mentionsPolicedDestructiveToken(command)) {
269
+ 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 };
270
+ }
271
+ return null;
272
+ }
273
+
274
+ let effectiveCwd = baseCwd;
275
+
276
+ for (const segment of parts) {
277
+ const tokens = tokenize(segment);
278
+ if (tokens === null) {
279
+ if (mentionsPolicedDestructiveToken(segment)) {
280
+ 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 };
281
+ }
282
+ continue;
283
+ }
284
+ if (tokens.length === 0) continue;
285
+
286
+ // Strip leading env assignments (FOO=bar git ...).
287
+ let i = 0;
288
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
289
+ const cmdTokens = tokens.slice(i);
290
+ if (cmdTokens.length === 0) continue;
291
+
292
+ const head = path.basename(cmdTokens[0]);
293
+
294
+ if (head === 'cd' && cmdTokens[1] != null) {
295
+ const target = cmdTokens[1];
296
+ effectiveCwd = path.isAbsolute(target) ? target : path.resolve(effectiveCwd || process.cwd(), target);
297
+ continue;
298
+ }
299
+
300
+ if (SH_WRAPPERS.has(head)) {
301
+ const cIdx = cmdTokens.indexOf('-c');
302
+ if (cIdx !== -1 && cmdTokens[cIdx + 1] != null) {
303
+ const nested = findDestructiveVerdict(cmdTokens[cIdx + 1], effectiveCwd, depth + 1);
304
+ if (nested) return nested;
305
+ }
306
+ continue;
307
+ }
308
+
309
+ if (head === 'git') {
310
+ const gitArgs = cmdTokens.slice(1);
311
+ const verdict = evaluateGit(gitArgs);
312
+ if (verdict) {
313
+ const dashC = gitDashCDirOf(gitArgs);
314
+ const invocationCwd = dashC
315
+ ? (path.isAbsolute(dashC) ? dashC : path.resolve(effectiveCwd || process.cwd(), dashC))
316
+ : effectiveCwd;
317
+ return { ...verdict, cwd: invocationCwd };
318
+ }
319
+ }
320
+ }
321
+ return null;
322
+ }
323
+
324
+ function isInsideManagedWorktreeByPath(cwd) {
325
+ if (!cwd) return false;
326
+ const resolved = path.resolve(cwd);
327
+ return WORKTREE_ROOTS.some((root) => resolved === root || resolved.startsWith(root + path.sep));
328
+ }
329
+
330
+ function isInsideManagedWorktreeByBranch(cwd) {
331
+ if (!cwd) return false;
332
+ try {
333
+ const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
334
+ cwd,
335
+ timeout: 3_000,
336
+ encoding: 'utf8',
337
+ windowsHide: true,
338
+ stdio: ['ignore', 'pipe', 'ignore'],
339
+ }).trim();
340
+ return BRANCH_PREFIXES.some((p) => branch.startsWith(p));
341
+ } catch {
342
+ return false;
343
+ }
344
+ }
345
+
346
+ function isExemptSharedTree(cwd) {
347
+ return isInsideManagedWorktreeByPath(cwd) || isInsideManagedWorktreeByBranch(cwd);
348
+ }
349
+
350
+ function buildAllow() {
351
+ return { continue: true };
352
+ }
353
+
354
+ function buildDeny(reason) {
355
+ return {
356
+ continue: true,
357
+ decision: 'block',
358
+ reason,
359
+ hookSpecificOutput: {
360
+ hookEventName: 'PreToolUse',
361
+ permissionDecision: 'deny',
362
+ permissionDecisionReason: reason,
363
+ },
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Decision function: PreToolUse payload in, hook-response object out. No
369
+ * stdin/stdout and no `process.exit` — safe to call in-process (e.g. from
370
+ * tests) — but for a destructive verdict outside a path-recognized managed
371
+ * worktree it does shell out to `git rev-parse` (see
372
+ * `isInsideManagedWorktreeByBranch`); it is not I/O-free in that case.
373
+ */
374
+ function decide(payload) {
375
+ try {
376
+ if (payload.tool_name !== 'Bash') return buildAllow();
377
+
378
+ const command = payload?.tool_input?.command;
379
+ if (!command || typeof command !== 'string') return buildAllow();
380
+
381
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
382
+ const verdict = findDestructiveVerdict(command, cwd);
383
+ if (!verdict) return buildAllow();
384
+
385
+ const effectiveCwd = verdict.cwd || cwd;
386
+ if (isExemptSharedTree(effectiveCwd)) return buildAllow();
387
+
388
+ const reason = [
389
+ `Blocked: \`${verdict.verb}\` in what this hook believes is a SHARED working tree (${effectiveCwd}).`,
390
+ verdict.alt,
391
+ '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.',
392
+ ].join(' ');
393
+ return buildDeny(reason);
394
+ } catch (e) {
395
+ console.error(`[guard-destructive-git] internal error, failing open: ${e?.message}`);
396
+ return buildAllow();
397
+ }
398
+ }
399
+
400
+ /** Mirrors the CLI's stdin-JSON handling so it can be exercised without a spawn. */
401
+ function parsePayload(raw) {
402
+ try {
403
+ return raw ? JSON.parse(raw) : {};
404
+ } catch (e) {
405
+ console.error(`[guard-destructive-git] malformed stdin JSON, failing open: ${e?.message}`);
406
+ return {};
407
+ }
408
+ }
409
+
410
+ module.exports = { decide, parsePayload };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * guard-inline-implementation-policy.cjs — decision logic for
3
+ * scripts/hooks/guard-inline-implementation.cjs, split out so it can be
4
+ * `require()`d in-process (by tests, notably) with no CLI-level side effects.
5
+ *
6
+ * guard-inline-implementation.cjs itself runs its CLI as a require-time side
7
+ * effect (stdin -> decision -> process.exit, not gated on
8
+ * `require.main === module` — see its header for why). Requiring THIS module
9
+ * never reads stdin or calls `process.exit`, so it's safe to load from a test
10
+ * process — but `decide()` is not I/O-free: it reads
11
+ * `<cwd>/session-manager-operations/prompt-sessions/active-index.json` off
12
+ * disk and, when the Epic runs in a linked worktree, shells out to a real
13
+ * `git rev-parse` to resolve the ops root (see `resolveOpsRootCwd` below).
14
+ * Calling it directly from a test still removes the outer CLI-process spawn
15
+ * per case, exercising real filesystem/git behavior instead of mocking it.
16
+ */
17
+ 'use strict';
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
22
+ // Pure fs helper only — deliberately NOT gitWorktree (keeps this guard light).
23
+ const { nearestGitEntry } = require('../../../src/main/lib/cwdClassify.cjs');
24
+
25
+ const SOURCE_DIRS = ['src', 'scripts', 'plugins', 'bin'];
26
+ const DENY_TAGS = new Set(['feature', 'bug']);
27
+
28
+ function targetPathFor(toolName, toolInput) {
29
+ if (!toolInput || typeof toolInput !== 'object') return null;
30
+ if (toolName === 'NotebookEdit') return toolInput.notebook_path ?? null;
31
+ return toolInput.file_path ?? null;
32
+ }
33
+
34
+ function isApplicationSource(cwd, absPath) {
35
+ const rel = path.relative(cwd, absPath);
36
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false; // outside cwd
37
+ const topLevel = rel.split(path.sep)[0];
38
+ return SOURCE_DIRS.includes(topLevel);
39
+ }
40
+
41
+ /**
42
+ * The PreToolUse payload's `cwd` is the CLI process's actual spawn cwd. For
43
+ * an Epic running in its default isolated `git worktree` (gitWorktree.cjs,
44
+ * epicSpawnCwd.cjs — "both the Terminal PTY and headless Chat spawn cwd
45
+ * resolve to it"), that is a throwaway checkout under os.tmpdir(), which
46
+ * never contains `session-manager-operations/` — the ops root always lives
47
+ * in the real project tree (see gitWorktree.cjs's "ops-root hazard" header:
48
+ * "spawn cwd = worktree dir, ops-root cwd = real project cwd, always").
49
+ * Resolve back to the real project cwd via git's shared `.git` dir before
50
+ * giving up, so this guard isn't silently inert for every Epic running in
51
+ * its default worktree.
52
+ */
53
+ function resolveOpsRootCwd(rawCwd) {
54
+ if (fs.existsSync(path.join(rawCwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'))) {
55
+ return rawCwd;
56
+ }
57
+ try {
58
+ // Only a `.git` FILE (linked worktree) needs git to find the shared dir; a
59
+ // `.git` directory IS the project root, so skip the spawn.
60
+ const entry = nearestGitEntry(rawCwd);
61
+ if (!entry) return rawCwd;
62
+ if (!entry.isFile) return entry.dir;
63
+ const commonDir = execFileSync(
64
+ 'git',
65
+ ['rev-parse', '--path-format=absolute', '--git-common-dir'],
66
+ { cwd: rawCwd, encoding: 'utf8', timeout: 5000 },
67
+ ).trim();
68
+ if (!commonDir) return rawCwd;
69
+ return path.basename(commonDir) === '.git' ? path.dirname(commonDir) : commonDir;
70
+ } catch {
71
+ return rawCwd;
72
+ }
73
+ }
74
+
75
+ function resolveEpicTag(cwd, sessionId) {
76
+ if (!sessionId) return null;
77
+ const indexPath = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
78
+ if (!fs.existsSync(indexPath)) return null;
79
+ const raw = fs.readFileSync(indexPath, 'utf8');
80
+ const index = JSON.parse(raw);
81
+ const sessions = index && typeof index === 'object' ? index.sessions : null;
82
+ if (!sessions || typeof sessions !== 'object') return null;
83
+ for (const record of Object.values(sessions)) {
84
+ if (record && record.claudeSessionId === sessionId) return record;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function buildAllow() {
90
+ return { continue: true };
91
+ }
92
+
93
+ function buildDeny(reason) {
94
+ return {
95
+ continue: true,
96
+ decision: 'block',
97
+ reason,
98
+ hookSpecificOutput: {
99
+ hookEventName: 'PreToolUse',
100
+ permissionDecision: 'deny',
101
+ permissionDecisionReason: reason,
102
+ },
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Decision function: PreToolUse payload in, hook-response object out. No
108
+ * stdin/stdout and no `process.exit` — safe to call in-process (e.g. from
109
+ * tests) — but does real filesystem reads and, in the linked-worktree case, a
110
+ * real `git rev-parse` spawn (see `resolveOpsRootCwd`).
111
+ */
112
+ function decide(payload) {
113
+ try {
114
+ if (process.env.SM_ALLOW_INLINE_IMPLEMENTATION === '1') return buildAllow();
115
+
116
+ const toolName = payload.tool_name;
117
+ if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'NotebookEdit') {
118
+ return buildAllow();
119
+ }
120
+
121
+ const rawPath = targetPathFor(toolName, payload.tool_input);
122
+ if (!rawPath || typeof rawPath !== 'string') return buildAllow();
123
+
124
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
125
+ const absPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
126
+
127
+ if (!isApplicationSource(cwd, absPath)) return buildAllow();
128
+
129
+ const sessionId = typeof payload.session_id === 'string' ? payload.session_id : null;
130
+ const opsRootCwd = resolveOpsRootCwd(cwd);
131
+ const epic = resolveEpicTag(opsRootCwd, sessionId);
132
+ if (!epic) return buildAllow(); // unresolvable session — allow by construction
133
+
134
+ if (epic.allowInlineImplementation) return buildAllow();
135
+ if (!DENY_TAGS.has(epic.tag)) return buildAllow();
136
+
137
+ const reason = [
138
+ `Direct ${toolName} to application source ("${path.relative(cwd, absPath)}") is discouraged inside a "${epic.tag}" Epic.`,
139
+ '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.',
140
+ '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.',
141
+ ].join(' ');
142
+ return buildDeny(reason);
143
+ } catch (e) {
144
+ console.error(`[guard-inline-implementation] internal error, failing open: ${e?.message}`);
145
+ return buildAllow();
146
+ }
147
+ }
148
+
149
+ /** Mirrors the CLI's stdin-JSON handling so it can be exercised without a spawn. */
150
+ function parsePayload(raw) {
151
+ try {
152
+ return raw ? JSON.parse(raw) : {};
153
+ } catch (e) {
154
+ console.error(`[guard-inline-implementation] malformed stdin JSON, failing open: ${e?.message}`);
155
+ return {};
156
+ }
157
+ }
158
+
159
+ module.exports = { decide, parsePayload };