claude-code-session-manager 0.89.0 → 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 (76) hide show
  1. package/dist/assets/{AgentLibrary-tuj-j1kQ.js → AgentLibrary-CrmoSn9-.js} +1 -1
  2. package/dist/assets/DataModel-SPLQTR8E.js +1 -0
  3. package/dist/assets/{History-DylcasUk.js → History-DT6S3abz.js} +2 -2
  4. package/dist/assets/{Hooks--2xoag9a.js → Hooks-CC2iv5Oq.js} +3 -3
  5. package/dist/assets/{HostBilko-oaw8paAh.js → HostBilko-DqY7dA2a.js} +1 -1
  6. package/dist/assets/{Library-DzKA09Iy.js → Library-CUoOC77T.js} +1 -1
  7. package/dist/assets/{ListDetail-CEdcO80v.js → ListDetail-DgrG72f4.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C8dB3ZWU.js +1 -0
  9. package/dist/assets/{McpServers-BB7ayW7W.js → McpServers-DupdjvFt.js} +2 -2
  10. package/dist/assets/{Memory-BOdYvrL4.js → Memory-C_4LhC2r.js} +6 -6
  11. package/dist/assets/{Panel-Yf3SxsRv.js → Panel-DWmE5Fu9.js} +1 -1
  12. package/dist/assets/{Permissions-BYCpHNy5.js → Permissions-hxiN2IWM.js} +3 -3
  13. package/dist/assets/{Plugins-B0lXiYqV.js → Plugins-fttEgZqy.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-T7x8LWcV.js → ProvenanceBadge-DROCzlbx.js} +1 -1
  15. package/dist/assets/{SaveBar-BdVb4DbR.js → SaveBar-BZs0IQHq.js} +1 -1
  16. package/dist/assets/Scheduler-Bi4oZ8gg.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-WVhmNsL5.js → ScopeSwitcher-C0538XU8.js} +1 -1
  18. package/dist/assets/{Settings-Bt4dVsl6.js → Settings-CcW6x-wu.js} +2 -2
  19. package/dist/assets/{SkillReferenceGraph-R-0YykV8.js → SkillReferenceGraph-BtCcPAYO.js} +1 -1
  20. package/dist/assets/{Skills-DXV_LdJP.js → Skills-Daf_1sQR.js} +2 -2
  21. package/dist/assets/SystemPrompt-rYIla_wt.js +1 -0
  22. package/dist/assets/{TagLibrary-B9OuqU6M.js → TagLibrary-81Mr1RLU.js} +1 -1
  23. package/dist/assets/{TiptapBody-HKKmB0AE.js → TiptapBody-BxuLNg1e.js} +1 -1
  24. package/dist/assets/{Toggle-CBggPonH.js → Toggle-CN8EVEai.js} +1 -1
  25. package/dist/assets/{index-BcJqLDiY.js → index-A9TWmB7_.js} +345 -345
  26. package/dist/assets/{index-BHTX4OTc.css → index-Dh7BLxdA.css} +1 -1
  27. package/dist/assets/{settingsSchema-BYE9DVw_.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-looks-done.test.cjs +9 -2
  36. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
  37. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
  38. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
  39. package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
  40. package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
  41. package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
  42. package/src/main/build-info.json +4 -4
  43. package/src/main/chatRunner.cjs +108 -45
  44. package/src/main/health.cjs +20 -3
  45. package/src/main/ipcSchemas.cjs +3 -0
  46. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
  47. package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
  48. package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
  49. package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
  50. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
  51. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
  52. package/src/main/lib/effectiveModelInfo.cjs +3 -3
  53. package/src/main/lib/epicDelegationStats.cjs +3 -2
  54. package/src/main/lib/epicSpawnCwd.cjs +36 -5
  55. package/src/main/lib/epicSpawnPlan.cjs +119 -0
  56. package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
  57. package/src/main/lib/epicTranscriptPath.cjs +184 -0
  58. package/src/main/lib/gitWorktree.cjs +1 -1
  59. package/src/main/lib/mcpToolCatalog.cjs +6 -1
  60. package/src/main/lib/prdCreate.cjs +27 -1
  61. package/src/main/lib/prdFrontmatter.cjs +16 -5
  62. package/src/main/lib/rcaReport.cjs +27 -3
  63. package/src/main/lib/schedulerPaths.cjs +27 -4
  64. package/src/main/lib/telemetryClient.cjs +11 -4
  65. package/src/main/lib/terminalRunOutcome.cjs +1 -0
  66. package/src/main/pty.cjs +1 -1
  67. package/src/main/runVerify.cjs +89 -1
  68. package/src/main/scheduler.cjs +17 -4
  69. package/src/main/templates/PRD_AUTHORING.md +11 -0
  70. package/src/main/transcripts.cjs +28 -7
  71. package/src/preload/api.d.ts +2 -0
  72. package/src/preload/index.cjs +1 -0
  73. package/dist/assets/DataModel-DOim5SXW.js +0 -1
  74. package/dist/assets/MarkdownEditor-BMyo0F7s.js +0 -1
  75. package/dist/assets/Scheduler-DlOCWioV.js +0 -14
  76. package/dist/assets/SystemPrompt-DiQRo_WF.js +0 -1
@@ -80,11 +80,14 @@ test('every counter event still yields appVersion + machineDigest once persisted
80
80
  counters.trackEpicCreate(deps);
81
81
  counters.trackSchedulerJobFinish({ status: 'completed' }, deps);
82
82
 
83
- // track() ingress does real (albeit fast) fs I/O before it resolves; wait a
84
- // tick since the counter functions fire it without awaiting the promise.
85
- await new Promise((r) => setTimeout(r, 50));
86
-
87
- const recs = await readQueueLines(client);
83
+ // track() ingress does real fs I/O and the counter functions fire it without
84
+ // awaiting the promise, so poll (bounded) for all 4 records rather than a fixed
85
+ // sleep — a fixed 50ms lost the race under CPU contention.
86
+ let recs = [];
87
+ for (let i = 0; i < 200 && recs.length < 4; i++) {
88
+ await new Promise((r) => setTimeout(r, 25));
89
+ recs = await readQueueLines(client);
90
+ }
88
91
  expect(recs.length).toBe(4);
89
92
  for (const rec of recs) {
90
93
  const meta = asPersistedMetadata(rec);
@@ -27,7 +27,7 @@ const fs = require('node:fs');
27
27
  const path = require('node:path');
28
28
  const os = require('node:os');
29
29
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
30
- const { encodeCwd } = require('./encodeCwd.cjs');
30
+ const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
31
31
 
32
32
  // Mirrors rawSessionModel.ts's RAW_MODELS — duplicated rather than imported
33
33
  // because that file is a renderer ES module and this is a main-process CJS
@@ -252,10 +252,10 @@ function findLatestTranscriptModel(cwd, agentType, deps) {
252
252
  matches.sort((a, b) => (Date.parse(b.createdAt || 0) || 0) - (Date.parse(a.createdAt || 0) || 0));
253
253
 
254
254
  const homeDir = deps.homeDir || os.homedir();
255
- const encodeCwdFn = deps.encodeCwd || encodeCwd;
256
255
  const re = /"model":"([^"]+)"/g;
257
256
  for (const session of matches) {
258
- const filePath = path.join(homeDir, '.claude', 'projects', encodeCwdFn(cwd), `${session.claudeSessionId}.jsonl`);
257
+ const filePath = resolveEpicTranscriptPath({ cwd, claudeSessionId: session.claudeSessionId, deps: { homeDir } }).path;
258
+ if (!filePath) continue;
259
259
  const tail = readTail(filePath, MAX_TRANSCRIPT_TAIL_BYTES, deps);
260
260
  if (!tail) continue;
261
261
  let match;
@@ -27,7 +27,7 @@
27
27
  const fs = require('node:fs');
28
28
  const os = require('node:os');
29
29
  const path = require('node:path');
30
- const { encodeCwd } = require('./encodeCwd.cjs');
30
+ const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
31
31
  const { classifyLine } = require('./classifyTranscriptLine.cjs');
32
32
 
33
33
  // Same cap transcripts.cjs's readDelta uses for a single pass — bounds
@@ -85,7 +85,8 @@ function countInlineEdits(cwd, claudeSessionId, deps = {}) {
85
85
  const readFn = deps.readTranscriptTail || readTranscriptTail;
86
86
  const homeDir = deps.homeDir || os.homedir();
87
87
  try {
88
- const filePath = path.join(homeDir, '.claude', 'projects', encodeCwd(cwd), `${claudeSessionId}.jsonl`);
88
+ const filePath = resolveEpicTranscriptPath({ cwd, claudeSessionId, deps: { homeDir } }).path;
89
+ if (!filePath) return 0;
89
90
  const text = readFn(filePath, deps);
90
91
  let count = 0;
91
92
  for (const line of text.split('\n')) {
@@ -27,6 +27,8 @@
27
27
  */
28
28
 
29
29
  const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const { execFileSync } = require('node:child_process');
30
32
  const { readActiveIndex } = require('./epicMint.cjs');
31
33
 
32
34
  /**
@@ -61,6 +63,26 @@ function isUsableDir(dir, statImpl) {
61
63
  }
62
64
  }
63
65
 
66
+ /**
67
+ * Re-attach `branch` at `dir` (`git worktree add`). Sync + bounded; never throws. `dir` comes from
68
+ * a git-tracked file, so it is only honoured when it sits in a `session-manager-epic-worktrees`
69
+ * segment and the branch is an `sm-epic/` one — nothing else may be materialised from that record.
70
+ * @returns {boolean} true when `dir` now exists as a checkout.
71
+ */
72
+ function restoreEpicWorktree({ dir, branch, baseCwd }) {
73
+ try {
74
+ if (!path.isAbsolute(dir) || !path.isAbsolute(baseCwd)) return false;
75
+ if (!path.resolve(dir).split(path.sep).includes('session-manager-epic-worktrees')) return false;
76
+ if (typeof branch !== 'string' || !/^sm-epic\/[A-Za-z0-9._-]+$/.test(branch)) return false;
77
+ const run = (args) => execFileSync('git', args, { cwd: baseCwd, stdio: 'ignore', timeout: 15_000 });
78
+ run(['worktree', 'prune']);
79
+ run(['worktree', 'add', dir, branch]);
80
+ return isUsableDir(dir, fs.statSync);
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
64
86
  /**
65
87
  * @param {{ cwd: string, claudeSessionId: string, deps?: object }} opts
66
88
  * @returns {string} `worktree.dir` when the matching Epic has one AND it still
@@ -77,10 +99,19 @@ function resolveEpicSpawnCwd({ cwd, claudeSessionId, deps = {} } = {}) {
77
99
  const dir = session.worktree?.dir;
78
100
  if (!dir) return cwd;
79
101
  if (!isUsableDir(dir, statSync)) {
80
- // Falling back is strictly better than failing the spawn: the
81
- // session still runs, just un-isolated, which is the same degraded
82
- // mode a project that never had a worktree runs in.
83
- console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — falling back to ${cwd}`);
102
+ // NEVER fall back to the project cwd: the CLI files the transcript under
103
+ // encodeCwd(spawn cwd), so a resumed session started in a different dir splits
104
+ // its transcript across two encodings ("lost session"). Instead re-attach the
105
+ // Epic's own branch at the SAME recorded path (encoding unchanged); if that is
106
+ // impossible, return the recorded dir anyway so the spawn fails loudly (ENOENT)
107
+ // rather than silently forking the transcript. Read-side callers (no `restore`)
108
+ // keep the old cwd answer — they never spawn and must never mutate git state.
109
+ if (deps.restore) {
110
+ const restored = (deps.restoreWorktree || restoreEpicWorktree)({ dir, branch: session.worktree?.branch, baseCwd: cwd });
111
+ console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — ${restored ? 'restored at same path' : 'restore failed; spawn will fail rather than split the transcript'}`);
112
+ return dir;
113
+ }
114
+ console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — read-side falls back to ${cwd}`);
84
115
  return cwd;
85
116
  }
86
117
  return dir;
@@ -92,4 +123,4 @@ function resolveEpicSpawnCwd({ cwd, claudeSessionId, deps = {} } = {}) {
92
123
  return cwd;
93
124
  }
94
125
 
95
- module.exports = { resolveEpicSpawnCwd };
126
+ module.exports = { resolveEpicSpawnCwd, restoreEpicWorktree };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * epicSpawnPlan.cjs — decides the spawn cwd AND the --resume/--session-id flag for an Epic's
5
+ * chat run TOGETHER (PRD 1319). They used to be computed independently: the flag from "does a
6
+ * transcript exist under ANY encoding" and the cwd separately, so a transcript stranded under a
7
+ * swept /tmp worktree encoding produced `--resume` from a cwd that cannot see it ("No
8
+ * conversation found"), the flag-swap retry then hit `--session-id` ("already in use"), and both
9
+ * doors were locked.
10
+ *
11
+ * Chosen fix: (a) first — resolveEpicSpawnCwd({restore:true}) re-attaches the Epic's own branch
12
+ * at the SAME recorded path (encoding unchanged), so the CLI sees the transcript again. When that
13
+ * is impossible, (b): refuse BEFORE spawning with an actionable message naming the missing
14
+ * worktree path, never claim a flag that cannot work. Reachability = "some existing transcript
15
+ * lives under encodeCwd(spawn cwd)" and the spawn cwd is a real directory.
16
+ *
17
+ * Also refuses requests routed to a closed (`completed`) Epic, and keeps a per-session circuit
18
+ * breaker: once a session is found unreachable, later requests are refused after ONE stat (is the
19
+ * worktree dir still missing?) without re-running the restore git commands.
20
+ *
21
+ * Ops-root hazard (epicSpawnCwd.cjs): `execCwd` returned here is for the spawn cwd option ONLY;
22
+ * ops reads/writes keep using the project `cwd` (projectRootOf / resolveProjectRoot).
23
+ */
24
+
25
+ const fs = require('node:fs');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+ const { encodeCwd } = require('./encodeCwd.cjs');
29
+ const { readActiveIndex } = require('./epicMint.cjs');
30
+ const { resolveEpicSpawnCwd } = require('./epicSpawnCwd.cjs');
31
+ const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
32
+
33
+ /** Map<sessionId, { dir, message }> — sessions found unreachable; valid while `dir` stays missing. */
34
+ const unreachable = new Map();
35
+
36
+ function __resetForTests() {
37
+ unreachable.clear();
38
+ }
39
+
40
+ function isDir(dir, statSync) {
41
+ try {
42
+ return statSync(dir).isDirectory();
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** The Epic record for a session id: active-index first, else a bounded scan of archived `<id>.json`. */
49
+ function findEpic(cwd, claudeSessionId, deps) {
50
+ try {
51
+ for (const s of Object.values((deps.readActiveIndex || readActiveIndex)(cwd).sessions || {})) {
52
+ if (s && s.claudeSessionId === claudeSessionId) return s;
53
+ }
54
+ } catch { /* unreadable index → try archives */ }
55
+ try {
56
+ const { opsPath } = require('./opsOwnership.cjs');
57
+ const dir = opsPath(cwd, 'prompt-sessions');
58
+ for (const name of fs.readdirSync(dir)) {
59
+ if (!name.endsWith('.json') || name === 'active-index.json') continue;
60
+ try {
61
+ const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))?.session;
62
+ if (s && s.claudeSessionId === claudeSessionId) return s;
63
+ } catch { /* malformed archive */ }
64
+ }
65
+ } catch { /* no ops dir */ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * @param {{ cwd: string, claudeSessionId: string, fallbackResume?: boolean, deps?: object }} opts
71
+ * @returns {{ ok: true, execCwd: string, useResume: boolean, transcriptPath: string|null }
72
+ * | { ok: false, code: 'epic_closed'|'session_unreachable', message: string }}
73
+ */
74
+ function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {} } = {}) {
75
+ const statSync = deps.statSync || fs.statSync;
76
+ const epic = findEpic(cwd, claudeSessionId, deps);
77
+
78
+ if (epic && epic.status === 'completed') {
79
+ return {
80
+ ok: false,
81
+ code: 'epic_closed',
82
+ message: `This Epic is closed (completed) — it no longer accepts requests. Start a new Epic instead of routing work into session ${claudeSessionId}.`,
83
+ };
84
+ }
85
+
86
+ const brokenDir = epic?.worktree?.dir;
87
+ const tripped = unreachable.get(claudeSessionId);
88
+ if (tripped && !isDir(tripped.dir, statSync)) return { ok: false, code: 'session_unreachable', message: tripped.message };
89
+ unreachable.delete(claudeSessionId);
90
+
91
+ let useResume = !!fallbackResume;
92
+ let resolved = null;
93
+ try {
94
+ resolved = resolveEpicTranscriptPath({ cwd, claudeSessionId, deps });
95
+ useResume = !!resolved.existsAnywhere;
96
+ } catch { /* keep the caller-supplied flag */ }
97
+
98
+ const execCwd = (deps.resolveSpawnCwd || resolveEpicSpawnCwd)({ cwd, claudeSessionId, deps: { restore: true, readActiveIndex: deps.readActiveIndex, statSync: deps.statSync, restoreWorktree: deps.restoreWorktree } });
99
+
100
+ if (resolved && resolved.existsAnywhere) {
101
+ const projectsDir = path.join(deps.homeDir || os.homedir(), '.claude', 'projects');
102
+ const home = path.join(projectsDir, encodeCwd(execCwd));
103
+ const reachable = isDir(execCwd, statSync) && resolved.existingPaths.some((p) => path.dirname(p) === home);
104
+ if (!reachable) {
105
+ const missing = brokenDir || execCwd;
106
+ const message =
107
+ `Session ${claudeSessionId} cannot be resumed: its transcript is at ${resolved.existingPaths[0]}, ` +
108
+ `which the CLI only finds when run from ${missing}, and that worktree no longer exists and could not be ` +
109
+ `re-attached (likely swept from /tmp). Recreate it (git worktree add "${missing}" ${epic?.worktree?.branch || '<sm-epic branch>'}) ` +
110
+ `or start a new Epic.`;
111
+ unreachable.set(claudeSessionId, { dir: missing, message });
112
+ return { ok: false, code: 'session_unreachable', message };
113
+ }
114
+ }
115
+
116
+ return { ok: true, execCwd, useResume, transcriptPath: resolved?.path ?? null };
117
+ }
118
+
119
+ module.exports = { planEpicSpawn, __resetForTests };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * epicTranscriptDiagnostic.cjs — read-only scan for Epics whose claude
5
+ * transcript is not where the project-scoped lookup would put it.
6
+ *
7
+ * mislocated — exactly one transcript exists and it is not under
8
+ * <encodeCwd(project cwd)> (i.e. only a worktree encoding).
9
+ * duplicated — the same session id has transcripts under 2+ encodings; the
10
+ * state in which `claude --resume` fails from any third cwd.
11
+ *
12
+ * Bounded: walks Epic records of the supplied project cwds and stats each
13
+ * Epic's candidate paths via resolveEpicTranscriptPath. Never enumerates
14
+ * ~/.claude/projects. Writes nothing. Complexity: O(epics x candidates).
15
+ */
16
+
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+ const { encodeCwd } = require('./encodeCwd.cjs');
21
+ const { readActiveIndex } = require('./epicMint.cjs');
22
+ const { opsPath } = require('./opsOwnership.cjs');
23
+ const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
24
+
25
+ /** [epicId, session] pairs: active-index rows plus archived per-Epic `<id>.json` records. */
26
+ function epicRecords(cwd) {
27
+ const out = new Map();
28
+ try {
29
+ for (const [id, s] of Object.entries(readActiveIndex(cwd).sessions || {})) out.set(id, s);
30
+ } catch { /* unreadable index → archives only */ }
31
+ let dir;
32
+ let names = [];
33
+ try {
34
+ dir = opsPath(cwd, 'prompt-sessions');
35
+ names = fs.readdirSync(dir);
36
+ } catch { /* no ops dir → skip */ }
37
+ for (const name of names) {
38
+ if (!name.endsWith('.json') || name === 'active-index.json') continue;
39
+ const id = name.slice(0, -5);
40
+ if (out.has(id)) continue;
41
+ try {
42
+ const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))?.session;
43
+ if (s && typeof s === 'object') out.set(id, s);
44
+ } catch { /* malformed record → skip */ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * @param {{ cwds: string[], homeDir?: string }} opts
51
+ * @returns {Array<{ sessionId: string, epicId: string, cwd: string, paths: string[], classification: 'mislocated'|'duplicated' }>}
52
+ */
53
+ function scanEpicTranscripts({ cwds, homeDir = os.homedir() }) {
54
+ const findings = [];
55
+ const projectsDir = path.join(homeDir, '.claude', 'projects');
56
+ for (const cwd of cwds || []) {
57
+ const sessions = epicRecords(cwd);
58
+ const home = path.join(projectsDir, encodeCwd(cwd));
59
+ for (const [epicId, s] of sessions) {
60
+ const sessionId = s && s.claudeSessionId;
61
+ if (!sessionId || typeof sessionId !== 'string') continue;
62
+ let res;
63
+ try {
64
+ res = resolveEpicTranscriptPath({ cwd, claudeSessionId: sessionId, deps: { homeDir } });
65
+ } catch {
66
+ continue;
67
+ }
68
+ const paths = res.existingPaths;
69
+ if (paths.length > 1) {
70
+ findings.push({ sessionId, epicId, cwd, paths, classification: 'duplicated' });
71
+ } else if (paths.length === 1 && path.dirname(paths[0]) !== home) {
72
+ findings.push({ sessionId, epicId, cwd, paths, classification: 'mislocated' });
73
+ }
74
+ }
75
+ }
76
+ return findings;
77
+ }
78
+
79
+ module.exports = { scanEpicTranscripts };
@@ -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.
@@ -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);