claude-code-session-manager 0.88.1 → 0.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/assets/{AgentLibrary-kQF8_wAw.js → AgentLibrary-CrmoSn9-.js} +1 -1
  2. package/dist/assets/DataModel-SPLQTR8E.js +1 -0
  3. package/dist/assets/{History-Sji2brb0.js → History-DT6S3abz.js} +2 -2
  4. package/dist/assets/{Hooks-CtqpO-p5.js → Hooks-CC2iv5Oq.js} +3 -3
  5. package/dist/assets/{HostBilko-DN0ydOCr.js → HostBilko-DqY7dA2a.js} +1 -1
  6. package/dist/assets/{Library-CoiLZTKh.js → Library-CUoOC77T.js} +1 -1
  7. package/dist/assets/{ListDetail-CmqiQ_io.js → ListDetail-DgrG72f4.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C8dB3ZWU.js +1 -0
  9. package/dist/assets/{McpServers-Bj1X3_NT.js → McpServers-DupdjvFt.js} +2 -2
  10. package/dist/assets/{Memory-1PKjsM3b.js → Memory-C_4LhC2r.js} +6 -6
  11. package/dist/assets/{Panel-I1s6ghPo.js → Panel-DWmE5Fu9.js} +1 -1
  12. package/dist/assets/{Permissions-BhiiZjHQ.js → Permissions-hxiN2IWM.js} +3 -3
  13. package/dist/assets/{Plugins-gv3_fkPX.js → Plugins-fttEgZqy.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BGcgBd8V.js → ProvenanceBadge-DROCzlbx.js} +1 -1
  15. package/dist/assets/{SaveBar-BGr9eZ4g.js → SaveBar-BZs0IQHq.js} +1 -1
  16. package/dist/assets/Scheduler-Bi4oZ8gg.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-ukl0qWaA.js → ScopeSwitcher-C0538XU8.js} +1 -1
  18. package/dist/assets/{Settings-DJkFj22G.js → Settings-CcW6x-wu.js} +2 -2
  19. package/dist/assets/{SkillReferenceGraph-0t1Qblp6.js → SkillReferenceGraph-BtCcPAYO.js} +1 -1
  20. package/dist/assets/{Skills-CeVdBGE-.js → Skills-Daf_1sQR.js} +2 -2
  21. package/dist/assets/SystemPrompt-rYIla_wt.js +1 -0
  22. package/dist/assets/{TagLibrary-DAzNaUPd.js → TagLibrary-81Mr1RLU.js} +1 -1
  23. package/dist/assets/{TiptapBody--S_rGXoA.js → TiptapBody-BxuLNg1e.js} +1 -1
  24. package/dist/assets/{Toggle-Dszm0xnp.js → Toggle-CN8EVEai.js} +1 -1
  25. package/dist/assets/{index-Dp4Rc--q.js → index-A9TWmB7_.js} +345 -345
  26. package/dist/assets/{index-BHTX4OTc.css → index-Dh7BLxdA.css} +1 -1
  27. package/dist/assets/{settingsSchema-BLbssMYo.js → settingsSchema-C9I9a0jv.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/README.md +1 -0
  31. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +159 -0
  32. package/src/main/__tests__/health-delegation-chain.test.cjs +3 -1
  33. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +16 -4
  34. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
  35. package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +41 -0
  36. package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
  37. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +23 -0
  38. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
  39. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
  40. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
  41. package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
  42. package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
  43. package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
  44. package/src/main/build-info.json +4 -4
  45. package/src/main/chatRunner.cjs +108 -45
  46. package/src/main/health.cjs +20 -3
  47. package/src/main/ipcSchemas.cjs +3 -0
  48. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
  49. package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
  50. package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
  51. package/src/main/lib/__tests__/gitWorktree.test.cjs +172 -0
  52. package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
  53. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
  54. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
  55. package/src/main/lib/effectiveModelInfo.cjs +3 -3
  56. package/src/main/lib/epicDelegationStats.cjs +3 -2
  57. package/src/main/lib/epicSpawnCwd.cjs +36 -5
  58. package/src/main/lib/epicSpawnPlan.cjs +119 -0
  59. package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
  60. package/src/main/lib/epicTranscriptPath.cjs +184 -0
  61. package/src/main/lib/gitWorktree.cjs +196 -4
  62. package/src/main/lib/mcpToolCatalog.cjs +6 -1
  63. package/src/main/lib/prdCreate.cjs +27 -1
  64. package/src/main/lib/prdFrontmatter.cjs +16 -5
  65. package/src/main/lib/rcaReport.cjs +35 -4
  66. package/src/main/lib/schedulerPaths.cjs +27 -4
  67. package/src/main/lib/telemetryClient.cjs +11 -4
  68. package/src/main/lib/terminalRunOutcome.cjs +1 -0
  69. package/src/main/pty.cjs +1 -1
  70. package/src/main/runVerify.cjs +89 -1
  71. package/src/main/scheduler.cjs +106 -18
  72. package/src/main/templates/PRD_AUTHORING.md +11 -0
  73. package/src/main/transcripts.cjs +28 -7
  74. package/src/preload/api.d.ts +2 -0
  75. package/src/preload/index.cjs +1 -0
  76. package/dist/assets/DataModel-CY_mMnsr.js +0 -1
  77. package/dist/assets/MarkdownEditor-HPoNeKhT.js +0 -1
  78. package/dist/assets/Scheduler-Bdo_awPq.js +0 -14
  79. package/dist/assets/SystemPrompt-CfKvNHi3.js +0 -1
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * epicTranscriptPath.cjs — answers "where does this Epic session's JSONL
5
+ * transcript actually live?".
6
+ *
7
+ * The CLI writes `~/.claude/projects/<encodeCwd(spawn cwd)>/<id>.jsonl`, and an
8
+ * Epic's spawn cwd is its isolated worktree dir (chatRunner.cjs via
9
+ * resolveEpicSpawnCwd), NOT the project cwd. Turn 1 can still land under the
10
+ * project encoding (the worktree mint is fire-and-forget), and the worktree dir
11
+ * itself is routinely swept from /tmp while its transcript survives under
12
+ * ~/.claude/projects. So candidates are built from the recorded `worktree.dir`
13
+ * even when that directory no longer exists.
14
+ *
15
+ * Bounded: never enumerates ~/.claude/projects, only stats its candidate list.
16
+ * The active-index.json read and the archive scan are memoised per cwd on
17
+ * mtimeMs + size (same shape as transcripts.cjs's usageCache) because
18
+ * usageFor() can call this for up to 500 ids in one IPC.
19
+ *
20
+ * Ops-root hazard (see epicSpawnCwd.cjs): `cwd` here is the PROJECT cwd used
21
+ * for ops-root reads; this returns transcript paths only, never a spawn cwd.
22
+ */
23
+
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+ const { encodeCwd } = require('./encodeCwd.cjs');
28
+ const { readActiveIndex } = require('./epicMint.cjs');
29
+ const { resolveEpicSpawnCwd } = require('./epicSpawnCwd.cjs');
30
+
31
+ /** Map<cwd, { key, sessions }> — active-index.json parse, keyed on mtime+size. */
32
+ const indexCache = new Map();
33
+ /** Map<cwd, { dirKey, files: Map<name,{key,sid,dir}>, byId: Map<sid,string[]> }> */
34
+ const archiveCache = new Map();
35
+
36
+ function __resetCacheForTests() {
37
+ indexCache.clear();
38
+ archiveCache.clear();
39
+ }
40
+
41
+ function emptyResult() {
42
+ return { path: null, candidates: [], existsAnywhere: false, existingPaths: [] };
43
+ }
44
+
45
+ function statKey(statSync, p) {
46
+ try {
47
+ const s = statSync(p);
48
+ return `${s.mtimeMs}:${s.size}`;
49
+ } catch {
50
+ return 'missing';
51
+ }
52
+ }
53
+
54
+ function opsFile(cwd, ...segments) {
55
+ const { opsPath } = require('./opsOwnership.cjs');
56
+ return opsPath(cwd, 'prompt-sessions', ...segments);
57
+ }
58
+
59
+ /** Sessions map from active-index.json; O(1) stat when unchanged. */
60
+ function cachedSessions(cwd, deps, statSync) {
61
+ const key = statKey(statSync, opsFile(cwd, 'active-index.json'));
62
+ const hit = indexCache.get(cwd);
63
+ if (hit && hit.key === key) return hit.sessions;
64
+ let sessions = {};
65
+ try {
66
+ sessions = (deps.readActiveIndex || readActiveIndex)(cwd).sessions || {};
67
+ } catch {
68
+ sessions = {};
69
+ }
70
+ indexCache.set(cwd, { key, sessions });
71
+ return sessions;
72
+ }
73
+
74
+ /** Worktree dirs recorded for `sid` across archived per-Epic JSON files. */
75
+ function archivedWorktreeDirs(cwd, sid, statSync) {
76
+ let dirPath;
77
+ try {
78
+ dirPath = opsFile(cwd);
79
+ } catch {
80
+ return [];
81
+ }
82
+ const dirKey = statKey(statSync, dirPath);
83
+ let entry = archiveCache.get(cwd);
84
+ if (entry && entry.dirKey === dirKey) return entry.byId.get(sid) || [];
85
+ const prevFiles = entry ? entry.files : new Map();
86
+ const files = new Map();
87
+ let names = [];
88
+ try {
89
+ names = fs.readdirSync(dirPath);
90
+ } catch {
91
+ names = [];
92
+ }
93
+ for (const name of names) {
94
+ if (!name.endsWith('.json') || name === 'active-index.json') continue;
95
+ const file = path.join(dirPath, name);
96
+ const key = statKey(statSync, file);
97
+ const prev = prevFiles.get(name);
98
+ if (prev && prev.key === key) {
99
+ files.set(name, prev);
100
+ continue;
101
+ }
102
+ let rec = { key, sid: null, dir: null };
103
+ try {
104
+ const s = JSON.parse(fs.readFileSync(file, 'utf8'))?.session;
105
+ if (s && typeof s.claudeSessionId === 'string' && typeof s.worktree?.dir === 'string') {
106
+ rec = { key, sid: s.claudeSessionId, dir: s.worktree.dir };
107
+ }
108
+ } catch {
109
+ // malformed archive → no hint
110
+ }
111
+ files.set(name, rec);
112
+ }
113
+ const byId = new Map();
114
+ for (const rec of files.values()) {
115
+ if (!rec.sid || !rec.dir) continue;
116
+ const list = byId.get(rec.sid) || [];
117
+ list.push(rec.dir);
118
+ byId.set(rec.sid, list);
119
+ }
120
+ entry = { dirKey, files, byId };
121
+ archiveCache.set(cwd, entry);
122
+ return byId.get(sid) || [];
123
+ }
124
+
125
+ /**
126
+ * @param {{ cwd: string, claudeSessionId: string, deps?: object }} opts
127
+ * @returns {{ path: string|null, candidates: string[], existsAnywhere: boolean, existingPaths: string[] }}
128
+ */
129
+ function resolveEpicTranscriptPath({ cwd, claudeSessionId, deps = {} } = {}) {
130
+ if (!cwd || typeof cwd !== 'string' || !claudeSessionId || typeof claudeSessionId !== 'string') {
131
+ return emptyResult();
132
+ }
133
+ // The id becomes a filename — refuse anything that could escape the project dir.
134
+ if (/[\\/\0]/.test(claudeSessionId) || claudeSessionId === '.' || claudeSessionId === '..') {
135
+ return emptyResult();
136
+ }
137
+ const statSync = deps.statSync || fs.statSync;
138
+ const projectsDir = path.join(deps.homeDir || os.homedir(), '.claude', 'projects');
139
+ const file = (dir) => path.join(projectsDir, encodeCwd(dir), `${claudeSessionId}.jsonl`);
140
+
141
+ const worktreeDirs = [];
142
+ try {
143
+ const sessions = cachedSessions(cwd, deps, statSync);
144
+ let inIndex = false;
145
+ for (const s of Object.values(sessions)) {
146
+ if (s && s.claudeSessionId === claudeSessionId) {
147
+ inIndex = true;
148
+ if (typeof s.worktree?.dir === 'string' && s.worktree.dir) worktreeDirs.push(s.worktree.dir);
149
+ }
150
+ }
151
+ if (!inIndex) worktreeDirs.push(...archivedWorktreeDirs(cwd, claudeSessionId, statSync));
152
+ } catch {
153
+ // unreadable ops state → fall through to whatever candidates we have
154
+ }
155
+
156
+ let spawnCwd = cwd;
157
+ try {
158
+ spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId, deps: { ...deps, readActiveIndex: () => ({ sessions: cachedSessions(cwd, deps, statSync) }) } }) || cwd;
159
+ } catch {
160
+ spawnCwd = cwd;
161
+ }
162
+
163
+ const candidates = [...new Set([file(spawnCwd), ...worktreeDirs.map(file), file(cwd)])];
164
+
165
+ const existing = [];
166
+ for (const p of candidates) {
167
+ try {
168
+ const st = statSync(p);
169
+ if (st.isFile()) existing.push({ p, mtimeMs: st.mtimeMs, size: st.size });
170
+ } catch {
171
+ // absent
172
+ }
173
+ }
174
+ const existingPaths = existing.map((e) => e.p);
175
+ const newest = (list) => list.reduce((a, b) => (b.mtimeMs > a.mtimeMs ? b : a));
176
+ const nonEmpty = existing.filter((e) => e.size > 0);
177
+ let chosen = candidates[0];
178
+ if (nonEmpty.length) chosen = newest(nonEmpty).p;
179
+ else if (existing.length) chosen = existing[0].p;
180
+
181
+ return { path: chosen, candidates, existsAnywhere: existing.length > 0, existingPaths };
182
+ }
183
+
184
+ module.exports = { resolveEpicTranscriptPath, __resetCacheForTests };
@@ -330,7 +330,7 @@ async function removeWorktreeDir(cwd, dir) {
330
330
  /* best-effort */
331
331
  }
332
332
  const parentDir = path.dirname(dir);
333
- // Guard against ever rmdir-ing the worktree base (os.tmpdir() or SM_WORKTREE_ROOT) itself — every real caller's
333
+ // Guard against ever rmdir-ing the worktree base (persistent state dir or SM_WORKTREE_ROOT) itself — every real caller's
334
334
  // `dir` is `<kind root>/<hash>/<key>`, so `parentDir` is always the hash
335
335
  // dir, never the tmpdir root, but this keeps the guarantee explicit rather
336
336
  // than relying solely on call-site discipline.
@@ -940,6 +940,167 @@ function parseBlockingMergePaths(stderrText) {
940
940
  return { tracked: Array.from(new Set(tracked)), untracked: Array.from(new Set(untracked)) };
941
941
  }
942
942
 
943
+ /**
944
+ * Classifies a failed `git merge` from its stderr — additive metadata for
945
+ * integrateBranch's `ok: false` results (the `reason` string is untouched).
946
+ * 'blocking_paths' = git refused up-front over dirty/untracked paths
947
+ * (auto-resolvable, PRD 1125); 'content_conflict' = a genuine 3-way
948
+ * `CONFLICT (` (deterministic on every retry); 'other' = anything else.
949
+ * For content_conflict, reads the unmerged paths — MUST be called before
950
+ * `git merge --abort`, which clears them. Never throws; a failed read
951
+ * yields `conflictedPaths: []`. O(p log p) in the conflicted-path count.
952
+ */
953
+ async function classifyMergeFailure({ cwd, stderrText, stdoutText }) {
954
+ const blocking = parseBlockingMergePaths(stderrText);
955
+ if (blocking) return { failureKind: 'blocking_paths', blockingPaths: blocking };
956
+ // git prints `CONFLICT (...)` lines on STDOUT, not stderr.
957
+ if (/CONFLICT \(/m.test(`${stdoutText || ''}\n${stderrText || ''}`)) {
958
+ let conflictedPaths = [];
959
+ try {
960
+ const out = await execGit(['diff', '--name-only', '--diff-filter=U'], { cwd, timeout: 10_000 });
961
+ conflictedPaths = Array.from(new Set(out.split('\n').map((l) => l.trim()).filter(Boolean))).sort();
962
+ } catch { /* best effort — abort must still run */ }
963
+ return { failureKind: 'content_conflict', conflictedPaths };
964
+ }
965
+ return { failureKind: 'other' };
966
+ }
967
+
968
+ // Extensions eligible for the pure-addition auto-resolve. All-or-nothing across
969
+ // a merge: one conflicted path outside this list means no auto-resolve at all.
970
+ const AUTO_RESOLVE_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.rst']);
971
+
972
+ /**
973
+ * Renders `git merge-file -p --diff3` over the index's three conflict stages
974
+ * of `p`. Returns the merged text (with markers), or null when any stage is
975
+ * missing/unreadable, a stage is not valid UTF-8 (the round-trip would not be
976
+ * byte-exact), or git itself errors. `merge-file` exits with the conflict
977
+ * count (1..127) on a clean run — only >127 or a signal is an error. Never
978
+ * throws. O(file size).
979
+ */
980
+ async function renderStagesDiff3({ cwd, p }) {
981
+ let tmp = null;
982
+ try {
983
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-mergefile-'));
984
+ const files = [];
985
+ for (const stage of [2, 1, 3]) { // ours, base, theirs — merge-file's argument order
986
+ const content = await execGit(['cat-file', 'blob', `:${stage}:${p}`], { cwd, timeout: 10_000 });
987
+ if (content.includes('�')) return null;
988
+ const f = path.join(tmp, `stage${stage}`);
989
+ await fsp.writeFile(f, content, 'utf8');
990
+ files.push(f);
991
+ }
992
+ try {
993
+ return await execGit(['merge-file', '-p', '--diff3', '-L', 'ours', '-L', 'base', '-L', 'theirs', ...files], { cwd, timeout: 10_000 });
994
+ } catch (e) {
995
+ if (e && typeof e.code === 'number' && e.code >= 1 && e.code <= 127 && typeof e.stdoutText === 'string') return e.stdoutText;
996
+ return null;
997
+ }
998
+ } catch {
999
+ return null;
1000
+ } finally {
1001
+ if (tmp) { try { await fsp.rm(tmp, { recursive: true, force: true }); } catch { /* best effort */ } }
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Parses diff3 merge-file output into segments: `{ text }` for pass-through
1007
+ * regions and `{ ours, base, theirs }` (arrays of lines) for conflict hunks.
1008
+ * Returns null on any out-of-order marker or an unterminated hunk. A marker
1009
+ * prefix is only meaningful in its expected state; `=======` inside the ours
1010
+ * or theirs section (e.g. a markdown setext underline) is treated as
1011
+ * unparseable rather than guessed at. O(n) in output lines.
1012
+ */
1013
+ function parseDiff3Output(output) {
1014
+ const lines = output.split('\n');
1015
+ const segments = [];
1016
+ let plain = [];
1017
+ let hunk = null;
1018
+ let state = 'plain';
1019
+ for (const line of lines) {
1020
+ const isOpen = line.startsWith('<<<<<<< ');
1021
+ const isBase = line.startsWith('||||||| ');
1022
+ const isSep = line.startsWith('=======');
1023
+ const isClose = line.startsWith('>>>>>>> ');
1024
+ if (state === 'plain') {
1025
+ if (isBase || isClose) return null;
1026
+ if (isOpen) {
1027
+ segments.push({ lines: plain });
1028
+ plain = [];
1029
+ hunk = { ours: [], base: [], theirs: [] };
1030
+ state = 'ours';
1031
+ } else {
1032
+ plain.push(line);
1033
+ }
1034
+ } else if (state === 'ours') {
1035
+ if (isOpen || isSep || isClose) return null;
1036
+ if (isBase) state = 'base'; else hunk.ours.push(line);
1037
+ } else if (state === 'base') {
1038
+ if (isOpen || isBase || isClose) return null;
1039
+ if (isSep && line === '=======') state = 'theirs'; else if (isSep) return null; else hunk.base.push(line);
1040
+ } else {
1041
+ if (isOpen || isBase || isSep) return null;
1042
+ if (isClose) { segments.push({ hunk }); hunk = null; state = 'plain'; } else hunk.theirs.push(line);
1043
+ }
1044
+ }
1045
+ if (state !== 'plain') return null;
1046
+ segments.push({ lines: plain });
1047
+ return segments;
1048
+ }
1049
+
1050
+ /**
1051
+ * True ONLY when, for EVERY path, the index holds all three conflict stages
1052
+ * and every diff3 conflict hunk has an EMPTY base section (both sides purely
1053
+ * added text where the merge base had nothing). Proven from the bytes, never
1054
+ * assumed; fails toward false on every error path. Never throws.
1055
+ */
1056
+ async function isPureAdditionConflict({ cwd, paths }) {
1057
+ try {
1058
+ if (!Array.isArray(paths) || !paths.length) return false;
1059
+ for (const p of paths) {
1060
+ const out = await renderStagesDiff3({ cwd, p });
1061
+ if (out === null) return false;
1062
+ const segments = parseDiff3Output(out);
1063
+ if (!segments) return false;
1064
+ const hunks = segments.filter((s) => s.hunk);
1065
+ if (!hunks.length) return false;
1066
+ if (hunks.some((s) => s.hunk.base.length !== 0)) return false;
1067
+ }
1068
+ return true;
1069
+ } catch {
1070
+ return false;
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * Rewrites each conflicted path in the working tree with every conflict hunk
1076
+ * replaced by OURS then THEIRS (no markers, base, reordering, or dedup);
1077
+ * non-conflicted regions pass through unchanged. Renders ALL paths before
1078
+ * writing any so a late failure leaves nothing half-rewritten. Throws on any
1079
+ * failure — the caller aborts the merge.
1080
+ */
1081
+ async function resolveByConcatenation({ cwd, paths }) {
1082
+ const rendered = [];
1083
+ for (const p of paths) {
1084
+ const out = await renderStagesDiff3({ cwd, p });
1085
+ const segments = out === null ? null : parseDiff3Output(out);
1086
+ if (!segments) throw new Error(`cannot render conflict for ${p}`);
1087
+ rendered.push({ p, text: joinSegments(segments) });
1088
+ }
1089
+ for (const { p, text } of rendered) {
1090
+ await fsp.writeFile(path.join(cwd, p), text, 'utf8');
1091
+ }
1092
+ }
1093
+
1094
+ /** Rebuilds file text from parsed segments, hunks contributing ours+theirs lines. */
1095
+ function joinSegments(segments) {
1096
+ const out = [];
1097
+ for (const seg of segments) {
1098
+ if (seg.hunk) out.push(...seg.hunk.ours, ...seg.hunk.theirs);
1099
+ else out.push(...seg.lines);
1100
+ }
1101
+ return out.join('\n');
1102
+ }
1103
+
943
1104
  /**
944
1105
  * Resolves `cwd`'s default branch, without ever hardcoding `main`: prefers
945
1106
  * the remote-tracked default (`origin/HEAD`, set by `git clone`/`git remote
@@ -949,6 +1110,16 @@ function parseBlockingMergePaths(stderrText) {
949
1110
  * this chain (no remote, no config, no matching local branch) simply falls
950
1111
  * through to the next source.
951
1112
  */
1113
+ // HEAD sha of `cwd` (null on any failure) — stamped on a failed integration so
1114
+ // mechanical recovery can tell whether the base tree has moved since.
1115
+ async function readHeadSha(cwd) {
1116
+ try {
1117
+ return (await execGit(['rev-parse', 'HEAD'], { cwd, timeout: 10_000 })).trim() || null;
1118
+ } catch {
1119
+ return null;
1120
+ }
1121
+ }
1122
+
952
1123
  async function resolveDefaultBranch(cwd) {
953
1124
  try {
954
1125
  const out = (await execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd, timeout: 10_000 })).trim();
@@ -1112,15 +1283,36 @@ async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
1112
1283
  resolvedPaths: allPaths,
1113
1284
  };
1114
1285
  } catch (retryErr) {
1115
- try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
1116
1286
  const retryText = (retryErr && (retryErr.stderrText || retryErr.message)) || String(retryErr);
1117
- return { ok: false, reason: `merge failed (likely a real content conflict): ${retryText}` };
1287
+ const retryClass = await classifyMergeFailure({ cwd, stderrText: retryText, stdoutText: retryErr && retryErr.stdoutText });
1288
+ try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
1289
+ return { ok: false, reason: `merge failed (likely a real content conflict): ${retryText}`, ...retryClass, baseHeadSha: await readHeadSha(cwd) };
1118
1290
  }
1119
1291
  }
1120
1292
  }
1293
+ // Read the conflicted paths BEFORE the abort — it clears the index stages.
1294
+ const failureClass = await classifyMergeFailure({ cwd, stderrText, stdoutText: e && e.stdoutText });
1295
+ // Pure-addition auto-resolve: two jobs appended different text to the same
1296
+ // doc. All-or-nothing; any doubt or failure falls through to the abort.
1297
+ if (
1298
+ process.env.SM_PURE_ADDITION_MERGE_DISABLE !== '1'
1299
+ && failureClass.failureKind === 'content_conflict'
1300
+ && failureClass.conflictedPaths.length
1301
+ && failureClass.conflictedPaths.every((p) => AUTO_RESOLVE_EXTENSIONS.has(path.extname(p).toLowerCase()))
1302
+ ) {
1303
+ try {
1304
+ const paths = failureClass.conflictedPaths;
1305
+ if (await isPureAdditionConflict({ cwd, paths })) {
1306
+ await resolveByConcatenation({ cwd, paths });
1307
+ await execGit(['add', ...paths], { cwd, timeout: 30_000 });
1308
+ await execGit(['commit', '-m', mergeMessage], { cwd, timeout: 30_000 });
1309
+ return { ok: true, integrated: true, mergeCommit: true, autoResolved: 'pure_addition_concat', resolvedPaths: paths };
1310
+ }
1311
+ } catch { /* fall through to the abort */ }
1312
+ }
1121
1313
  // Abort a half-applied merge so `cwd` isn't left in a mid-merge state.
1122
1314
  try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
1123
- return { ok: false, reason: `merge failed (likely a real content conflict): ${stderrText}` };
1315
+ return { ok: false, reason: `merge failed (likely a real content conflict): ${stderrText}`, ...failureClass, baseHeadSha: await readHeadSha(cwd) };
1124
1316
  }
1125
1317
  }
1126
1318
 
@@ -96,7 +96,12 @@ const MCP_TOOL_CATALOG = [
96
96
  + 'and is rejected at write time if it does not name a real persona file. '
97
97
  + 'If this PRD has no dependsOn of its own AND the target Epic already has incomplete PRDs, '
98
98
  + '`disposition` ("append" or "new-head") is REQUIRED — the write is refused without it (a '
99
- + 'headless job caller instead gets a logged "append" default, since no human is present to ask).',
99
+ + 'headless job caller instead gets a logged "append" default, since no human is present to ask). '
100
+ + '`deliverable` ("artifact") + `artifactPaths` declare an ARTIFACT-ONLY PRD: `artifactPaths` are the '
101
+ + 'files this PRD produces that are NOT meant to be committed (git-excluded), they are stat-checked on '
102
+ + 'disk by the verifier, and declaring them is the ONLY way an artifact-only PRD can pass the finish '
103
+ + 'protocol without a commit. Each requires the other (the write is refused if only one is given), and '
104
+ + 'no path may contain "..".',
100
105
  whenToUse: 'Use whenever new work should be queued into an already-approved Epic — this is the /develop path.',
101
106
  whenNotToUse: 'TWO DISTINCT FAILURE MODES if this tool is not usable — do not conflate them: '
102
107
  + '(a) this tool call is PRESENT in your tool list but ERRORS as app-not-running / admin '
@@ -75,7 +75,7 @@ function deriveSlugFromTitle(title) {
75
75
  function buildPrdBody(input) {
76
76
  const {
77
77
  title, cwd, estimateMinutes, goal, acceptanceCriteria,
78
- implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine, disposition,
78
+ implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine, disposition, deliverable, artifactPaths,
79
79
  } = input;
80
80
 
81
81
  // No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
@@ -114,6 +114,9 @@ function buildPrdBody(input) {
114
114
  // site above); a first-ever PRD in an Epic, or one with its own explicit
115
115
  // dependsOn, has nothing to decide against and omits this key.
116
116
  if (disposition) fmLines.push(`disposition: ${disposition}`);
117
+ // Artifact-only declaration — validated by createPrd() before this runs.
118
+ if (deliverable) fmLines.push(`deliverable: ${deliverable}`);
119
+ if (artifactPaths && artifactPaths.length) fmLines.push(`artifactPaths: [${artifactPaths.join(', ')}]`);
117
120
  // Opt-in exclusive-lease flag (PRD 1107): serializes this job against
118
121
  // every other job machine-wide for its run, for a PRD whose acceptance
119
122
  // criteria are wall-clock/timing measurements that CPU contention from
@@ -317,6 +320,29 @@ async function createPrd(input, remote) {
317
320
  if (input.parallelGroup != null) {
318
321
  console.warn(`[prdCreate] parallelGroup input is deprecated and ignored (got ${input.parallelGroup}) — numbers are unique per project; use dependsOn for ordering`);
319
322
  }
323
+ // Artifact-only declaration (deliverable + artifactPaths): fail-closed,
324
+ // checked BEFORE the NN allocation so a refusal burns no number.
325
+ const hasArtifactPaths = Array.isArray(input.artifactPaths) && input.artifactPaths.length > 0;
326
+ if (input.deliverable != null && input.deliverable !== 'artifact') {
327
+ return { ok: false, status: 400, error: 'deliverable must be exactly "artifact" when given' };
328
+ }
329
+ if (input.deliverable === 'artifact' && !hasArtifactPaths) {
330
+ return { ok: false, status: 400, error: 'deliverable: "artifact" requires a non-empty artifactPaths — name every git-excluded file this PRD produces' };
331
+ }
332
+ if (input.artifactPaths != null && input.deliverable !== 'artifact') {
333
+ return { ok: false, status: 400, error: 'artifactPaths requires deliverable: "artifact" — artifactPaths given without it' };
334
+ }
335
+ if (hasArtifactPaths) {
336
+ for (const ap of input.artifactPaths) {
337
+ if (typeof ap !== 'string' || !ap.trim() || /[\r\n,\[\]]/.test(ap)) {
338
+ return { ok: false, status: 400, error: `artifactPaths entry ${JSON.stringify(ap)} must be a non-empty single-line path without commas or brackets` };
339
+ }
340
+ if (ap.includes('..')) {
341
+ return { ok: false, status: 400, error: `artifactPaths entry "${ap}" must not contain ".."` };
342
+ }
343
+ }
344
+ }
345
+
320
346
  const nn = await remote.allocateParallelGroup(input.cwd);
321
347
  const filenameSlug = `${nn}-${slug}`;
322
348
 
@@ -77,8 +77,8 @@ function splitFrontmatter(raw) {
77
77
  * never emitted, which is how `scheduler_update_prd` clears a dependency.
78
78
  */
79
79
 
80
- const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition']);
81
- const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition'];
80
+ const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition', 'deliverable', 'artifactPaths']);
81
+ const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition', 'deliverable', 'artifactPaths'];
82
82
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
83
83
 
84
84
  function indentOf(line) {
@@ -185,6 +185,17 @@ function applyKey(fm, key, after) {
185
185
  // full rationale. Only these two values are recognized.
186
186
  if (v === 'append' || v === 'new-head') fm.disposition = v;
187
187
  return;
188
+ case 'deliverable':
189
+ // Artifact-only PRD declaration — only the literal `artifact` is
190
+ // recognized; anything else is dropped so a typo can never opt in.
191
+ if (v === 'artifact') fm.deliverable = v;
192
+ return;
193
+ case 'artifactPaths': {
194
+ // Inline `[a, b]` list, same shape as dependsOn.
195
+ const list = parseInlineList(after);
196
+ if (list) fm.artifactPaths = list;
197
+ return;
198
+ }
188
199
  }
189
200
  }
190
201
 
@@ -220,10 +231,10 @@ function parsePrdFile(text) {
220
231
 
221
232
  if (RECOGNIZED_KEYS.has(key)) {
222
233
  applyKey(fm, key, after);
223
- if (key === 'dependsOn') {
224
- if (fm.dependsOn) {
234
+ if (key === 'dependsOn' || key === 'artifactPaths') {
235
+ if (fm[key]) {
225
236
  if (!fm._raw) fm._raw = {};
226
- fm._raw[key] = { line, parsed: fm.dependsOn };
237
+ fm._raw[key] = { line, parsed: fm[key] };
227
238
  }
228
239
  } else {
229
240
  const parsed = parseScalar(after);
@@ -150,13 +150,35 @@ function extractRcaBlock(text) {
150
150
 
151
151
  const ALREADY_SHIPPED_RE = /already (fully )?(satisfied|implemented|committed|done|shipped)|was (already )?(implemented|committed) in|nothing (new )?to commit|no (code )?changes were needed/i;
152
152
  const SELF_QUEUE_SKILL_RE = /Launching skill: session-manager-dev:(develop|process-feedback)/;
153
- const SELF_QUEUE_WAKEUP_RE = /ScheduleWakeup/;
153
+ // SELF_QUEUE rules must match an actual INVOCATION, never a bare tool-name
154
+ // mention. Incident: sigma `816-prepare-157-copy-citation-extras-patch`, run
155
+ // `2026-09-20T18-17-03-476Z` — a 23 KB log (well under the 64 KB `readTail`
156
+ // window) whose single `ScheduleWakeup` occurrence was the harness's
157
+ // `{"type":"system","subtype":"init",...,"tools":[...]}` tool list — was
158
+ // classified `failure-class: self-queue` in its root-cause report. The wakeup
159
+ // rule now requires a stream-json tool_use `"name":"ScheduleWakeup"` field, and
160
+ // classifyFailure strips init events before any rule runs.
161
+ const SELF_QUEUE_WAKEUP_RE = /"name"\s*:\s*"ScheduleWakeup"/;
154
162
  const STUCK_LOOP_RE = /\b(until\s|while\s+true|sleep\s)/i;
155
163
  const AC_CHECKBOX_RE = /^\s*[-*]\s*\[[xX]\]/;
156
164
  const SENTINEL_PASS_RE = /SCHEDULER_VERDICT:\s*PASS/;
157
165
  const STUCK_LOOP_WINDOW = 40; // "final lines" per AC
158
166
  const POST_AC_OVERRUN_MIN_TAIL_FRACTION = 0.3;
159
167
 
168
+ /**
169
+ * Drop every stream-json harness init event (`"type":"system"` +
170
+ * `"subtype":"init"`) from a log tail. That line names every available tool
171
+ * and would otherwise false-match tool-name/word rules. Other system events
172
+ * are kept. Pure; single pass, O(n) in characters.
173
+ */
174
+ function stripHarnessInitEvents(logTail) {
175
+ if (!logTail) return '';
176
+ return logTail
177
+ .split('\n')
178
+ .filter((l) => !(l.includes('"subtype":"init"') && l.includes('"type":"system"') && l.trimStart().startsWith('{')))
179
+ .join('\n');
180
+ }
181
+
160
182
  /**
161
183
  * Classify why a job likely landed in needs_review, from the log tail plus
162
184
  * the verifier verdict. Pure/deterministic — no LLM, no I/O.
@@ -164,8 +186,9 @@ const POST_AC_OVERRUN_MIN_TAIL_FRACTION = 0.3;
164
186
  * Complexity: O(n) over log-tail lines (n bounded — callers pass a tail, not
165
187
  * a full log).
166
188
  */
167
- function classifyFailure({ verdict, logTail }) {
168
- const lines = (logTail || '').split('\n');
189
+ function classifyFailure({ verdict, logTail: rawLogTail }) {
190
+ const logTail = stripHarnessInitEvents(rawLogTail);
191
+ const lines = logTail.split('\n');
169
192
 
170
193
  // Checked before every other rule: an unambiguous, materially-checked
171
194
  // verdict (the scheduler itself validated the executor's claimed paths
@@ -295,9 +318,16 @@ function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, i
295
318
  const durationMs = meta?.durationMs != null ? `${meta.durationMs}ms` : 'unknown';
296
319
  const prevention = PREVENTION_HINTS[failureClass] ?? PREVENTION_HINTS[FAILURE_CLASSES.UNKNOWN];
297
320
 
321
+ const integrationLine = job.integrationFailureKind
322
+ ? `\n\nIntegration failure subtype: ${job.integrationFailureKind}` +
323
+ (job.integrationFailureKind === 'content_conflict' && Array.isArray(job.integrationConflictPaths)
324
+ ? ` (conflicted paths: ${job.integrationConflictPaths.join(', ') || 'unknown'})`
325
+ : '')
326
+ : '';
327
+
298
328
  const sections = [
299
329
  `# What happened\n\nJob \`${job.slug}\` was parked in \`needs_review\` with verifier verdict ` +
300
- `\`${verdict}\` (${humanVerdict(verdict)}).${job.error ? `\n\n${job.error}` : ''}`,
330
+ `\`${verdict}\` (${humanVerdict(verdict)}).${job.error ? `\n\n${job.error}` : ''}${integrationLine}`,
301
331
  `# Evidence\n\n- Exit code: ${exitCode}\n- Duration: ${durationMs}\n\nLast 60 log lines:\n\n\`\`\`\n${last60 || '(no log available)'}\n\`\`\``,
302
332
  `# The PRD's acceptance criteria\n\n${acText || '(acceptance criteria not found — original PRD may have been archived or removed)'}`,
303
333
  `# Likely failure class\n\n**${failureClass}**\n\nPrevention: ${prevention}`,
@@ -392,6 +422,7 @@ module.exports = {
392
422
  // Pure helpers, exported for unit tests.
393
423
  humanVerdict,
394
424
  classifyFailure,
425
+ stripHarnessInitEvents,
395
426
  extractAcceptanceCriteria,
396
427
  extractRcaBlock,
397
428
  buildRcaMarkdown,
@@ -13,11 +13,12 @@
13
13
  * executor jobs BY DESIGN (children inherit env), so a redirected app runs
14
14
  * redirected jobs.
15
15
  *
16
- * Worktree roots are NOT under schedulerHome (they live on the tmp filesystem)
17
- * but are resolved here all the same, via SM_WORKTREE_ROOT (else os.tmpdir()),
16
+ * Worktree roots are NOT under schedulerHome (they live in a per-user state dir)
17
+ * but are resolved here all the same, via SM_WORKTREE_ROOT (else persistentWorktreeBase()),
18
18
  * so the job and epic roots always move together under one override.
19
19
  */
20
20
 
21
+ const fs = require('node:fs');
21
22
  const os = require('node:os');
22
23
  const path = require('node:path');
23
24
 
@@ -109,8 +110,29 @@ function instanceLockPath() { return path.join(schedulerHome(), 'scheduler-owner
109
110
  /** Where heap snapshots are written — the scheduler home itself. */
110
111
  function heapSnapshotDir() { return schedulerHome(); }
111
112
 
112
- /** SM_WORKTREE_ROOT, else os.tmpdir() — the parent of every managed worktree root. */
113
- function worktreeBase() { return assertNotLiveRoot(process.env.SM_WORKTREE_ROOT || os.tmpdir(), 'worktreeBase'); }
113
+ /**
114
+ * Persistent per-user default for worktrees: $XDG_STATE_HOME/session-manager (absolute values only,
115
+ * per the XDG spec) else ~/.local/state/session-manager. NOT os.tmpdir(): a systemd tmp.conf `D /tmp`
116
+ * empties /tmp at boot, and the Claude CLI keys an Epic's transcript to its SPAWN cwd (the worktree),
117
+ * so a tmp-resident worktree took the transcript's directory with it ("lost sessions").
118
+ * It is also deliberately outside every project repo and outside ~/.claude/projects: what /tmp bought
119
+ * (a checkout never nested in the repo, so the repo's globs, `git status` and packaging never see it)
120
+ * is kept by living in a per-user state dir instead of beside the project.
121
+ */
122
+ function persistentWorktreeBase() {
123
+ const xdg = process.env.XDG_STATE_HOME;
124
+ const stateHome = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), '.local', 'state');
125
+ return path.join(stateHome, 'session-manager');
126
+ }
127
+
128
+ /** SM_WORKTREE_ROOT, else persistentWorktreeBase() (created 0700 on demand) — parent of every managed worktree root. */
129
+ function worktreeBase() {
130
+ if (process.env.SM_WORKTREE_ROOT) return assertNotLiveRoot(process.env.SM_WORKTREE_ROOT, 'worktreeBase');
131
+ // Guard BEFORE mkdir: under vitest an unset SM_WORKTREE_ROOT must throw, never create a live dir.
132
+ const base = assertNotLiveRoot(persistentWorktreeBase(), 'worktreeBase');
133
+ try { fs.mkdirSync(base, { recursive: true, mode: 0o700 }); } catch { /* best-effort; createWorktree surfaces a real failure */ }
134
+ return base;
135
+ }
114
136
 
115
137
  /** Managed worktree root for `kind` ('job' | 'epic'), resolved fresh on every call. */
116
138
  function worktreeRoot(kind) {
@@ -158,6 +180,7 @@ module.exports = {
158
180
  adminTokenPath,
159
181
  machineStateLogCwd,
160
182
  worktreeBase,
183
+ persistentWorktreeBase,
161
184
  worktreeRoot,
162
185
  watchdogLogsDir,
163
186
  restartRequestPath,