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.
- package/dist/assets/{AgentLibrary-kQF8_wAw.js → AgentLibrary-CrmoSn9-.js} +1 -1
- package/dist/assets/DataModel-SPLQTR8E.js +1 -0
- package/dist/assets/{History-Sji2brb0.js → History-DT6S3abz.js} +2 -2
- package/dist/assets/{Hooks-CtqpO-p5.js → Hooks-CC2iv5Oq.js} +3 -3
- package/dist/assets/{HostBilko-DN0ydOCr.js → HostBilko-DqY7dA2a.js} +1 -1
- package/dist/assets/{Library-CoiLZTKh.js → Library-CUoOC77T.js} +1 -1
- package/dist/assets/{ListDetail-CmqiQ_io.js → ListDetail-DgrG72f4.js} +1 -1
- package/dist/assets/MarkdownEditor-C8dB3ZWU.js +1 -0
- package/dist/assets/{McpServers-Bj1X3_NT.js → McpServers-DupdjvFt.js} +2 -2
- package/dist/assets/{Memory-1PKjsM3b.js → Memory-C_4LhC2r.js} +6 -6
- package/dist/assets/{Panel-I1s6ghPo.js → Panel-DWmE5Fu9.js} +1 -1
- package/dist/assets/{Permissions-BhiiZjHQ.js → Permissions-hxiN2IWM.js} +3 -3
- package/dist/assets/{Plugins-gv3_fkPX.js → Plugins-fttEgZqy.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BGcgBd8V.js → ProvenanceBadge-DROCzlbx.js} +1 -1
- package/dist/assets/{SaveBar-BGr9eZ4g.js → SaveBar-BZs0IQHq.js} +1 -1
- package/dist/assets/Scheduler-Bi4oZ8gg.js +16 -0
- package/dist/assets/{ScopeSwitcher-ukl0qWaA.js → ScopeSwitcher-C0538XU8.js} +1 -1
- package/dist/assets/{Settings-DJkFj22G.js → Settings-CcW6x-wu.js} +2 -2
- package/dist/assets/{SkillReferenceGraph-0t1Qblp6.js → SkillReferenceGraph-BtCcPAYO.js} +1 -1
- package/dist/assets/{Skills-CeVdBGE-.js → Skills-Daf_1sQR.js} +2 -2
- package/dist/assets/SystemPrompt-rYIla_wt.js +1 -0
- package/dist/assets/{TagLibrary-DAzNaUPd.js → TagLibrary-81Mr1RLU.js} +1 -1
- package/dist/assets/{TiptapBody--S_rGXoA.js → TiptapBody-BxuLNg1e.js} +1 -1
- package/dist/assets/{Toggle-Dszm0xnp.js → Toggle-CN8EVEai.js} +1 -1
- package/dist/assets/{index-Dp4Rc--q.js → index-A9TWmB7_.js} +345 -345
- package/dist/assets/{index-BHTX4OTc.css → index-Dh7BLxdA.css} +1 -1
- package/dist/assets/{settingsSchema-BLbssMYo.js → settingsSchema-C9I9a0jv.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/README.md +1 -0
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +159 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +3 -1
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +16 -4
- package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
- package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +41 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +23 -0
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
- package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
- package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
- package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +108 -45
- package/src/main/health.cjs +20 -3
- package/src/main/ipcSchemas.cjs +3 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
- package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
- package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +172 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
- package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
- package/src/main/lib/effectiveModelInfo.cjs +3 -3
- package/src/main/lib/epicDelegationStats.cjs +3 -2
- package/src/main/lib/epicSpawnCwd.cjs +36 -5
- package/src/main/lib/epicSpawnPlan.cjs +119 -0
- package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
- package/src/main/lib/epicTranscriptPath.cjs +184 -0
- package/src/main/lib/gitWorktree.cjs +196 -4
- package/src/main/lib/mcpToolCatalog.cjs +6 -1
- package/src/main/lib/prdCreate.cjs +27 -1
- package/src/main/lib/prdFrontmatter.cjs +16 -5
- package/src/main/lib/rcaReport.cjs +35 -4
- package/src/main/lib/schedulerPaths.cjs +27 -4
- package/src/main/lib/telemetryClient.cjs +11 -4
- package/src/main/lib/terminalRunOutcome.cjs +1 -0
- package/src/main/pty.cjs +1 -1
- package/src/main/runVerify.cjs +89 -1
- package/src/main/scheduler.cjs +106 -18
- package/src/main/templates/PRD_AUTHORING.md +11 -0
- package/src/main/transcripts.cjs +28 -7
- package/src/preload/api.d.ts +2 -0
- package/src/preload/index.cjs +1 -0
- package/dist/assets/DataModel-CY_mMnsr.js +0 -1
- package/dist/assets/MarkdownEditor-HPoNeKhT.js +0 -1
- package/dist/assets/Scheduler-Bdo_awPq.js +0 -14
- 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 (
|
|
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
|
-
|
|
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
|
|
234
|
+
if (key === 'dependsOn' || key === 'artifactPaths') {
|
|
235
|
+
if (fm[key]) {
|
|
225
236
|
if (!fm._raw) fm._raw = {};
|
|
226
|
-
fm._raw[key] = { line, parsed: fm
|
|
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
|
-
|
|
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
|
|
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
|
|
17
|
-
* but are resolved here all the same, via SM_WORKTREE_ROOT (else
|
|
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
|
-
/**
|
|
113
|
-
|
|
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,
|