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
@@ -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
@@ -399,6 +422,7 @@ module.exports = {
399
422
  // Pure helpers, exported for unit tests.
400
423
  humanVerdict,
401
424
  classifyFailure,
425
+ stripHarnessInitEvents,
402
426
  extractAcceptanceCriteria,
403
427
  extractRcaBlock,
404
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,
@@ -374,12 +374,19 @@ async function persistSent() {
374
374
  await config.writeJson(sentPath(), { ids: S.sentOrder, updatedAt: new Date().toISOString() }, { mode: 0o600 });
375
375
  }
376
376
 
377
+ // Records are immutable once queued, so their serialized size is computed once. Without
378
+ // this, enforceCap() re-stringified the whole queue on every append: O(n^2) stringifies
379
+ // (~15s idle for 5000 appends) and the reason the cap tests blew their budget under load.
380
+ const recordBytesCache = new WeakMap();
377
381
  function recordBytes(r) {
382
+ const hit = recordBytesCache.get(r);
383
+ if (hit !== undefined) return hit;
384
+ let n = 0;
378
385
  try {
379
- return Buffer.byteLength(JSON.stringify(r), 'utf8');
380
- } catch {
381
- return 0;
382
- }
386
+ n = Buffer.byteLength(JSON.stringify(r), 'utf8');
387
+ } catch { /* unserializable -> 0 */ }
388
+ recordBytesCache.set(r, n);
389
+ return n;
383
390
  }
384
391
 
385
392
  /** Oldest-first eviction, bounded by count and total bytes. O(n) per call, n <= QUEUE_CAP_COUNT. */
@@ -21,6 +21,7 @@ const COMPLETED_EQUIVALENT_VERDICTS = new Set([
21
21
  'pass_no_commit_target_verified',
22
22
  'pass_no_commit_already_shipped',
23
23
  'pass_no_commit_prior_run_verified',
24
+ 'pass_no_commit_artifact_verified',
24
25
  ]);
25
26
 
26
27
  // Bounds the scan: only the newest few run dirs are stat'd per slug, never
package/src/main/pty.cjs CHANGED
@@ -181,7 +181,7 @@ class PtyManager {
181
181
  // returns its isolated worktree dir when one exists — `cwd` itself is
182
182
  // NEVER repointed; only this actual PTY spawn option is. See
183
183
  // epicSpawnCwd.cjs's header comment (the ops-root hazard) for why.
184
- const spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId: tabId });
184
+ const spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId: tabId, deps: { restore: true } });
185
185
  if (spawnCwd !== cwd) {
186
186
  console.log(`[pty] tabId=${tabId} isolated in worktree ${spawnCwd}`);
187
187
  }
@@ -40,6 +40,7 @@
40
40
  const fs = require('node:fs');
41
41
  const path = require('node:path');
42
42
  const { execFileSync } = require('node:child_process');
43
+ const { parsePrdFile } = require('./lib/prdFrontmatter.cjs');
43
44
 
44
45
  const VERDICTS_SCHEMA_VERSION = 1;
45
46
 
@@ -778,6 +779,57 @@ function allDeliverablesAlreadyTracked({ cwd, paths, execImpl = execFileSync, ti
778
779
  }
779
780
  }
780
781
 
782
+
783
+ /**
784
+ * Material check for a DECLARED artifact-only PRD (`deliverable: artifact` +
785
+ * `artifactPaths`). Returns true only when every declared path resolves inside
786
+ * `cwd`, is a non-empty regular file, and has an mtime inside the run window
787
+ * (startedAt - 60s .. (finishedAt ?? now) + 120s). Never throws: any doubt
788
+ * (no paths, `..`, escape, missing, non-file, empty, stale, fs error) → false.
789
+ * `cwd` may be a list of roots; a path is accepted if it verifies under any.
790
+ * Complexity O(paths).
791
+ */
792
+ function artifactsVerifiedOnDisk({ cwd, paths, startedAt, finishedAt, fsImpl = fs } = {}) {
793
+ return artifactsCheck({ cwd, paths, startedAt, finishedAt, fsImpl }).ok;
794
+ }
795
+
796
+ function artifactsCheck({ cwd, paths, startedAt, finishedAt, fsImpl = fs }) {
797
+ const fail = { ok: false, verified: [] };
798
+ try {
799
+ const roots = (Array.isArray(cwd) ? cwd : [cwd]).filter((r) => typeof r === 'string' && r);
800
+ if (!roots.length || !Array.isArray(paths) || paths.length === 0) return fail;
801
+ const start = Date.parse(startedAt ?? '');
802
+ if (Number.isNaN(start)) return fail;
803
+ const endParsed = finishedAt ? Date.parse(finishedAt) : NaN;
804
+ const end = Number.isNaN(endParsed) ? Date.now() : endParsed;
805
+ const lo = start - 60_000;
806
+ const hi = end + 120_000;
807
+ const verified = [];
808
+ for (const p of paths) {
809
+ if (typeof p !== 'string' || !p || p.includes('\0')) return fail;
810
+ if (p.split(/[\\/]/).includes('..')) return fail;
811
+ let hit = null;
812
+ for (const root of roots) {
813
+ const base = path.resolve(root);
814
+ const abs = path.resolve(base, p);
815
+ const rel = path.relative(base, abs);
816
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
817
+ let st;
818
+ try { st = fsImpl.statSync(abs); } catch { continue; }
819
+ if (!st.isFile() || st.size <= 0) continue;
820
+ if (st.mtimeMs < lo || st.mtimeMs > hi) continue;
821
+ hit = { path: p, size: st.size, mtime: new Date(st.mtimeMs).toISOString() };
822
+ break;
823
+ }
824
+ if (!hit) return fail;
825
+ verified.push(hit);
826
+ }
827
+ return { ok: true, verified };
828
+ } catch {
829
+ return fail;
830
+ }
831
+ }
832
+
781
833
  // ─── main verifier ────────────────────────────────────────────────────────────
782
834
 
783
835
  /**
@@ -830,7 +882,7 @@ function allDeliverablesAlreadyTracked({ cwd, paths, execImpl = execFileSync, ti
830
882
  */
831
883
  async function verifyRun({
832
884
  runDir, prdPath, queueEntry, allJobs = [], committedDuringRun = false, allowPreSentinelHeal = false,
833
- ghExecImpl, priorLandedCommit = null, jobLandedCommitThisRun = null, exitCode = null,
885
+ ghExecImpl, priorLandedCommit = null, jobLandedCommitThisRun = null, exitCode = null, worktreeDir = null,
834
886
  }) {
835
887
  const { slug } = queueEntry;
836
888
  const logPath = path.join(runDir, `${slug}.log`);
@@ -1202,7 +1254,42 @@ async function verifyRun({
1202
1254
  );
1203
1255
  }
1204
1256
  }
1257
+ // EXEMPTION: a PRD that DECLARES `deliverable: artifact` + `artifactPaths`
1258
+ // (all deliverables live in a git-excluded dir, so "no commit" is
1259
+ // correct). Reads the declared list from frontmatter — never scrapes
1260
+ // prose — and stat-checks each file on disk (non-empty, in the run
1261
+ // window). Paths resolve against queueEntry.cwd, then retry against the
1262
+ // run's worktree dir when the caller supplies one. Any doubt falls
1263
+ // through to pass_no_commit below. The commit guard separately requires
1264
+ // a clean tree for this verdict (scheduler.cjs commitGuardVerdict).
1265
+ let artifactVerified = false;
1205
1266
  if (!mergeMainVerified && !priorRunVerified && !alreadyShipped) {
1267
+ let fm = null;
1268
+ try { fm = parsePrdFile(prdFullText).frontmatter; } catch { fm = null; }
1269
+ if (fm && fm.deliverable === 'artifact' && Array.isArray(fm.artifactPaths) && fm.artifactPaths.length > 0) {
1270
+ const check = artifactsCheck({
1271
+ cwd: [queueEntry?.cwd, worktreeDir],
1272
+ paths: fm.artifactPaths,
1273
+ startedAt: queueEntry?.startedAt,
1274
+ finishedAt: queueEntry?.finishedAt,
1275
+ });
1276
+ if (check.ok) {
1277
+ artifactVerified = true;
1278
+ return conclude(
1279
+ 'pass_no_commit_artifact_verified',
1280
+ 'SCHEDULER_VERDICT: PASS with no commit, but every declared artifact exists, is non-empty, and was '
1281
+ + `written inside the run window (${check.verified.map((v) => v.path).join(', ')})`,
1282
+ null,
1283
+ {
1284
+ ...(annotations.length ? { annotations } : {}),
1285
+ sentinel,
1286
+ verifiedArtifacts: check.verified,
1287
+ },
1288
+ );
1289
+ }
1290
+ }
1291
+ }
1292
+ if (!mergeMainVerified && !priorRunVerified && !alreadyShipped && !artifactVerified) {
1206
1293
  issues.push({
1207
1294
  verdict: 'pass_no_commit',
1208
1295
  reason: 'SCHEDULER_VERDICT: PASS but no commit landed during the run window — the run claims success but produced no code change',
@@ -1294,6 +1381,7 @@ module.exports = {
1294
1381
  checkMergeablePr,
1295
1382
  extractPrdDeliverablePaths,
1296
1383
  allDeliverablesAlreadyTracked,
1384
+ artifactsVerifiedOnDisk,
1297
1385
  isAncestorCommit,
1298
1386
  isGitRepo,
1299
1387
  hasAbandonedBackgroundTask,
@@ -4540,11 +4540,18 @@ async function findSatisfyingCommitOnMain(job) {
4540
4540
  * newlyDirty is empty: a fix-plan job that left real dirt behind is
4541
4541
  * still a genuine finish-protocol violation (incident:
4542
4542
  * 523-fix-bounded-fix-plan-retry, 2026-07-12).
4543
- */
4544
- function commitGuardVerdict({ newlyDirty, siblingRunning, ranInWorktree, jobSelfCommitted, legitimateNoOp, isFixPlanJob, verifyResult, salvagePatch }) {
4543
+ * - artifactVerified (zero-edit case only): runVerify.cjs proved a PRD that
4544
+ * DECLARED `deliverable: artifact` wrote its git-excluded artifacts inside
4545
+ * the run window (pass_no_commit_artifact_verified). That verdict is
4546
+ * deliberately NOT blanket-excused via legitimateNoOp (the call site
4547
+ * excludes it) — it stands the guard down only on a clean tree, like the
4548
+ * fix-plan carve-out, so stray uncommitted tracked edits still park as
4549
+ * uncommitted_changes.
4550
+ */
4551
+ function commitGuardVerdict({ newlyDirty, siblingRunning, ranInWorktree, jobSelfCommitted, legitimateNoOp, isFixPlanJob, artifactVerified, verifyResult, salvagePatch }) {
4545
4552
  if ((siblingRunning && !ranInWorktree) || jobSelfCommitted || legitimateNoOp) return null;
4546
4553
  const dirty = newlyDirty || [];
4547
- if (dirty.length === 0 && isFixPlanJob) return null;
4554
+ if (dirty.length === 0 && (isFixPlanJob || artifactVerified)) return null;
4548
4555
 
4549
4556
  const carried = [...(verifyResult?.annotations ?? [])];
4550
4557
  if (verifyResult && verifyResult.verdict !== 'clean') {
@@ -7175,6 +7182,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null) {
7175
7182
  priorLandedCommit,
7176
7183
  jobLandedCommitThisRun,
7177
7184
  exitCode: res.exitCode,
7185
+ worktreeDir: worktree.ok ? worktree.dir : null,
7178
7186
  }).catch((e) => ({
7179
7187
  verdict: 'verify_unavailable',
7180
7188
  reason: `verifier threw: ${e?.message ?? String(e)}`,
@@ -7209,7 +7217,8 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null) {
7209
7217
  // annotation, so a real "finish protocol incomplete" is distinguishable from
7210
7218
  // transcript noise in the queue (feedback 2026-06-10 addendum).
7211
7219
  const guardWillRefire = verifyResult && verifyResult.downgradeTo === 'pending';
7212
- const guardIsLegitimateNoOp = verifyResult && COMPLETED_EQUIVALENT_VERDICTS.has(verifyResult.verdict);
7220
+ const guardArtifactVerified = verifyResult?.verdict === 'pass_no_commit_artifact_verified';
7221
+ const guardIsLegitimateNoOp = verifyResult && !guardArtifactVerified && COMPLETED_EQUIVALENT_VERDICTS.has(verifyResult.verdict);
7213
7222
  if (res.exitCode === 0 && !res.rateLimited && !guardWillRefire && !guardIsLegitimateNoOp) {
7214
7223
  // afterGuardCwd === null means non-git cwd (or git errored) —
7215
7224
  // best-effort skip, same as always; only a git-status result (even an
@@ -7230,6 +7239,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null) {
7230
7239
  jobSelfCommitted,
7231
7240
  legitimateNoOp: guardIsLegitimateNoOp,
7232
7241
  isFixPlanJob: resolveIsFixPlan(job.slug, job.isFixPlan),
7242
+ artifactVerified: guardArtifactVerified,
7233
7243
  verifyResult,
7234
7244
  salvagePatch,
7235
7245
  });
@@ -11056,6 +11066,9 @@ function registerScheduleHandlers() {
11056
11066
  // configuration tab.
11057
11067
  ipcMain.handle('schedule:session-slots', () => sessionSlots.snapshot());
11058
11068
 
11069
+ // Read-only: where Epic/job git worktrees (and so their CLI transcript encodings) live.
11070
+ ipcMain.handle('schedule:worktree-base', () => require('./lib/schedulerPaths.cjs').worktreeBase());
11071
+
11059
11072
  // Home-tab control for the same pool: user-set cap in [0, 10], default 5.
11060
11073
  // 0 pauses new claude -p launches machine-wide without killing anything
11061
11074
  // already running. SM_SESSION_SLOTS (if set) still overrides this at read
@@ -141,6 +141,17 @@ estimateMinutes: 60
141
141
  - `estimateMinutes` is used for ETA display; include a realistic estimate (note: empirical median is ~10 min, p90 ~20 min — avoid wildly inflated estimates that hide real outliers).
142
142
  - `parallelGroup` in frontmatter, when present, IS honored by the scheduler (`pickNextBatch` reads `parallelGroup ?? 99` and overrides the filename NN). Use this only for cross-stream interleaving — e.g., the cellar series `122-`, `123-`, `124-` overrides to groups `113`, `114`, `115` so cellar steps fire alongside the parallel etch steps. Do NOT use it to reorder within a single stream; rename the file instead.
143
143
 
144
+ ### Artifact-only PRDs
145
+
146
+ Use `deliverable: artifact` + `artifactPaths: [a, b]` (both passed to `scheduler_create_prd`) ONLY when every deliverable is a file the target repo deliberately git-excludes (e.g. patches/notes under `session-manager-operations/review-records/`, matched by `.git/info/exclude`), so "no commit" is the correct outcome.
147
+
148
+ - Every artifact path must be named in `artifactPaths`; an unlisted file is invisible to the verifier. Each path is relative, never contains `..`.
149
+ - The artifact must be non-empty and written during the run window — the verifier stat-checks it on disk (verdict `pass_no_commit_artifact_verified`; window = start−60s..finish+120s).
150
+ - The run must still leave the working tree clean — declaring artifacts excuses the missing commit, not stray tracked edits. The commit guard enforces this: the `pass_no_commit_artifact_verified` verdict stands the guard down only when nothing tracked was left dirty; any uncommitted tracked change still parks as `uncommitted_changes`.
151
+ - Both fields are required together: the write is refused if only one is given.
152
+
153
+ Incident: sigma PRD `816-prepare-157-copy-citation-extras-patch` (2026-09-20) wrote its patch into a git-excluded folder; with no way to declare that, two runs both parked in `needs_review`.
154
+
144
155
  ---
145
156
 
146
157
  ## §7 Self-containment
@@ -38,9 +38,22 @@ function attachWindow(w) {
38
38
 
39
39
  const { encodeCwd } = require('./lib/encodeCwd.cjs');
40
40
  const { classifyLine } = require('./lib/classifyTranscriptLine.cjs');
41
+ const { resolveEpicTranscriptPath } = require('./lib/epicTranscriptPath.cjs');
42
+
43
+ /**
44
+ * Where the CLI's JSONL for this session lives. Epic sessions run in a
45
+ * worktree, so the resolver checks the worktree encoding as well as the
46
+ * project one; a non-Epic tab resolves to the plain project-encoded path.
47
+ * Sync + absolute always (falls back to the plain path on invalid input).
48
+ */
49
+ function resolveTranscript(cwd, sessionUuid) {
50
+ const r = resolveEpicTranscriptPath({ cwd, claudeSessionId: sessionUuid });
51
+ const filePath = r.path || path.join(os.homedir(), '.claude', 'projects', encodeCwd(cwd), `${sessionUuid}.jsonl`);
52
+ return { filePath, existsAnywhere: !!r.existsAnywhere };
53
+ }
41
54
 
42
55
  function transcriptPath(cwd, sessionUuid) {
43
- return path.join(os.homedir(), '.claude', 'projects', encodeCwd(cwd), `${sessionUuid}.jsonl`);
56
+ return resolveTranscript(cwd, sessionUuid).filePath;
44
57
  }
45
58
 
46
59
  // Cap bytes read per readDelta pass. Bounds memory on first attach to a very
@@ -191,8 +204,11 @@ async function readPage(sub, startLine, endLine) {
191
204
  const from = Math.max(0, Math.min(startLine, total - 1));
192
205
  const to = Math.max(0, Math.min(endLine, total - 1));
193
206
  if (total === 0 || from > to) return { events: [], totalLines: total };
194
- const first = sub.lineIndex[from];
195
- const last = sub.lineIndex[to];
207
+ // Snapshot the entries synchronously: the awaits below can interleave with a
208
+ // rotation/truncation rebuild that shrinks sub.lineIndex, so never re-index it after.
209
+ const entries = sub.lineIndex.slice(from, to + 1);
210
+ const first = entries[0];
211
+ const last = entries[entries.length - 1];
196
212
  const spanOffset = first.byteOffset;
197
213
  const spanLength = last.byteOffset + last.byteLength - spanOffset;
198
214
  const events = [];
@@ -202,7 +218,7 @@ async function readPage(sub, startLine, endLine) {
202
218
  const buf = Buffer.alloc(spanLength);
203
219
  await fd.read(buf, 0, spanLength, spanOffset);
204
220
  for (let lineNo = from; lineNo <= to; lineNo++) {
205
- const entry = sub.lineIndex[lineNo];
221
+ const entry = entries[lineNo - from];
206
222
  const relOffset = entry.byteOffset - spanOffset;
207
223
  const text = buf.toString('utf8', relOffset, relOffset + entry.byteLength);
208
224
  let obj;
@@ -361,9 +377,14 @@ async function subscribe({ tabId, cwd, sessionUuid }) {
361
377
  });
362
378
  return { ok: false, path: null, error: 'too many active subscriptions' };
363
379
  }
364
- const filePath = transcriptPath(cwd, sessionUuid);
365
- const dir = path.dirname(filePath);
366
- await fsp.mkdir(dir, { recursive: true }).catch(() => {});
380
+ // Known limitation: filePath is latched here and never re-resolved. Fine
381
+ // because nothing relocates a transcript under an open subscription.
382
+ const { filePath, existsAnywhere } = resolveTranscript(cwd, sessionUuid);
383
+ // Only create the dir when no transcript exists anywhere (it is the dir the
384
+ // CLI is about to write); otherwise we'd mint empty project dirs it never made.
385
+ if (!existsAnywhere) {
386
+ await fsp.mkdir(path.dirname(filePath), { recursive: true }).catch(() => {});
387
+ }
367
388
  const sub = {
368
389
  tabId,
369
390
  cwd,
@@ -1780,6 +1780,8 @@ export interface SessionManagerAPI {
1780
1780
  total: number; inUse: number; holders: { owner: string; at: string }[];
1781
1781
  min: number; max: number; default: number; envOverride: boolean;
1782
1782
  }>;
1783
+ /** Resolved base dir of managed Epic/job worktrees (SM_WORKTREE_ROOT, else the persistent per-user state dir). */
1784
+ worktreeBase: () => Promise<string>;
1783
1785
  /** Set the slot pool's user cap (Home tab), [0, 10]; 0 pauses new launches. Ignored if SM_SESSION_SLOTS is set. */
1784
1786
  setSessionSlots: (cap: number) => Promise<{
1785
1787
  total: number; inUse: number; holders: { owner: string; at: string }[];
@@ -231,6 +231,7 @@ contextBridge.exposeInMainWorld('api', {
231
231
  schedule: {
232
232
  state: () => ipcRenderer.invoke('schedule:state'),
233
233
  sessionSlots: () => ipcRenderer.invoke('schedule:session-slots'),
234
+ worktreeBase: () => ipcRenderer.invoke('schedule:worktree-base'),
234
235
  setSessionSlots: (cap) => ipcRenderer.invoke('schedule:set-session-slots', { cap }),
235
236
  setConfig: (partial) => ipcRenderer.invoke('schedule:set-config', partial),
236
237
  resetJob: (slug) => ipcRenderer.invoke('schedule:reset-job', { slug }),
@@ -1 +0,0 @@
1
- import{al as d,f as j,ab as $,U as g,aa as e,j as N,V as E,ad as S,W as b,an as T,b as p,I as D,g as v,E as R}from"./index-BcJqLDiY.js";import{P as V}from"./Panel-Yf3SxsRv.js";import"./monaco-editor-BW5C4Iv1.js";const C=[{key:"diagram",label:"Diagram"},{key:"tables",label:"Tables"}];function G(){const[s,t]=d.useState("diagram"),[a,i]=d.useState(null),[l,n]=d.useState(()=>new Set),c=d.useMemo(()=>j.filter(r=>!l.has(r.group)),[l]),m=d.useMemo(()=>$(c),[c]),o=r=>{n(x=>{const h=new Set(x);return h.has(r)?h.delete(r):h.add(r),h}),i(null)},u=a?g(a):null,y=r=>c.some(x=>x.id===r);return e.jsx(V,{toolbar:e.jsxs(e.Fragment,{children:[e.jsx(E,{options:C,active:s,onChange:t}),e.jsx(M,{hiddenGroups:l,onToggle:o}),e.jsxs("span",{className:"ml-auto text-fg-faint",children:[j.length," entities"]})]}),children:s==="diagram"?e.jsxs("div",{className:"h-full flex",children:[e.jsx("div",{className:"flex-1 min-w-0 overflow-auto",children:e.jsx(O,{layout:m,selectedId:a,onSelect:r=>i(x=>x===r?null:r)})}),e.jsx("div",{className:"w-96 shrink-0 border-l border-line overflow-y-auto",children:u?e.jsx(F,{entity:u,onSelectRelated:r=>y(r)?i(r):void 0}):e.jsx(N,{title:"select an entity",hint:"click a box in the diagram to see its storage, fields, and relations"})})]}):e.jsx(P,{})})}const L=d.memo(G);function M({hiddenGroups:s,onToggle:t}){return e.jsx("div",{className:"flex items-center gap-1.5 flex-wrap",children:v.map(a=>{const i=s.has(a.id);return e.jsxs("button",{onClick:()=>t(a.id),title:`${a.blurb} (${a.realm})`,className:`flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10.5px] ${i?"border-line text-fg-faint opacity-50":"border-line text-fg-dim hover:text-fg"}`,children:[e.jsx("span",{className:`w-1.5 h-1.5 rounded-full ${a.tone.dot}`}),a.label,e.jsx("span",{className:"text-fg-faint",children:a.realm})]},a.id)})})}function O({layout:s,selectedId:t,onSelect:a}){const i=d.useMemo(()=>t?new Set(S(t)):null,[t]),l=n=>!!t&&(n.relation.from===t||n.relation.to===t);return s.boxes.length===0?e.jsx(N,{title:"no entities in view",hint:"toggle a group in the legend to show its entities"}):e.jsxs("div",{className:"relative p-4",style:{width:s.width+32,height:s.height+32},children:[e.jsxs("svg",{width:s.width,height:s.height,className:"absolute left-4 top-4 overflow-visible",children:[s.edges.map(n=>{const c=l(n);return e.jsx("path",{"data-testid":`erd-edge-${n.relation.from}-${n.relation.to}`,d:n.d,fill:"none",className:c?"stroke-accent":"stroke-line",strokeWidth:c?2:1,opacity:t&&!c?.25:1},`${n.relation.from}-${n.relation.to}-${n.relation.label}`)}),s.edges.map(n=>l(n)?e.jsx("text",{x:n.mx,y:n.my,textAnchor:"middle",className:"fill-fg-dim text-[10px]",children:n.relation.label},`label-${n.relation.from}-${n.relation.to}-${n.relation.label}`):null)]}),s.boxes.map(n=>e.jsx(_,{box:n,selected:n.id===t,dimmed:!!t&&n.id!==t&&!(i!=null&&i.has(n.id)),onClick:()=>a(n.id)},n.id))]})}function _({box:s,selected:t,dimmed:a,onClick:i}){const l=g(s.id),n=b(s.group),c=l.fields.slice(0,R),m=l.fields.length-c.length;return e.jsxs("button",{type:"button","data-testid":`erd-box-${s.id}`,onClick:i,className:`absolute text-left rounded border overflow-hidden bg-bg-hi ${t?"border-accent ring-1 ring-accent":"border-line"} ${a?"opacity-30":""}`,style:{left:s.x,top:s.y,width:s.w,height:s.h},children:[e.jsxs("div",{className:"px-2 py-1 border-b border-line flex items-center gap-1.5",children:[e.jsx("span",{className:`w-1.5 h-1.5 rounded-full shrink-0 ${n.tone.dot}`}),e.jsx("span",{className:"text-[11px] font-semibold text-fg truncate",children:l.name})]}),l.aka&&e.jsx("div",{className:"px-2 pt-0.5 text-[9.5px] text-fg-faint truncate",children:l.aka}),e.jsxs("div",{className:"px-2 py-0.5",children:[c.map(o=>e.jsxs("div",{className:"text-[9.5px] font-mono text-fg-dim truncate flex items-center gap-1",children:[o.key&&e.jsx("span",{className:"text-fg-faint",children:o.key}),e.jsx("span",{className:"truncate",children:o.name})]},o.name)),m>0&&e.jsxs("div",{className:"text-[9.5px] text-fg-faint italic",children:["+",m," more"]})]})]})}function w({fields:s}){return e.jsxs("table",{className:"w-full text-[11px] font-mono",children:[e.jsx("thead",{className:"text-fg-faint uppercase tracking-wide text-[9.5px]",children:e.jsxs("tr",{className:"border-b border-line",children:[e.jsx("th",{className:"text-left font-normal px-2 py-1",children:"name"}),e.jsx("th",{className:"text-left font-normal px-2 py-1",children:"type"}),e.jsx("th",{className:"text-left font-normal px-2 py-1",children:"key"}),e.jsx("th",{className:"text-left font-normal px-2 py-1",children:"note"})]})}),e.jsx("tbody",{children:s.map(t=>e.jsxs("tr",{className:"border-b border-line/50",children:[e.jsxs("td",{className:"px-2 py-1 text-fg",children:[t.name,t.optional&&e.jsx("span",{className:"text-fg-faint",children:"?"})]}),e.jsx("td",{className:"px-2 py-1 text-fg-dim",children:t.type}),e.jsx("td",{className:"px-2 py-1",children:t.key&&e.jsxs(p,{tone:t.key==="pk"?"accent":"dim",children:[t.key,t.ref?` → ${t.ref}`:""]})}),e.jsx("td",{className:"px-2 py-1 text-fg-faint",children:t.note})]},t.name))})]})}function k({entity:s}){return e.jsxs("div",{className:"space-y-1 text-[11px]",children:[e.jsxs("div",{className:"flex items-center gap-1.5",children:[e.jsx("span",{className:"text-fg-faint w-20 shrink-0",children:"path"}),e.jsx("span",{className:"font-mono text-fg-dim break-all",children:s.store.path})]}),e.jsxs("div",{className:"flex items-center gap-1.5",children:[e.jsx("span",{className:"text-fg-faint w-20 shrink-0",children:"format"}),e.jsx("span",{className:"font-mono text-fg-dim",children:s.store.format})]}),s.store.writer&&e.jsxs("div",{className:"flex items-center gap-1.5",children:[e.jsx("span",{className:"text-fg-faint w-20 shrink-0",children:"writer"}),e.jsxs(p,{tone:"accent",children:["single-writer: ",s.store.writer]})]}),e.jsxs("div",{className:"flex items-center gap-1.5",children:[e.jsx("span",{className:"text-fg-faint w-20 shrink-0",children:"definedIn"}),e.jsx("span",{className:"font-mono text-fg-dim break-all",children:s.store.definedIn})]})]})}function F({entity:s,onSelectRelated:t}){const a=b(s.group),i=T(s.id);return e.jsxs("div",{className:"p-3 space-y-3",children:[e.jsxs("div",{children:[e.jsxs("div",{className:"flex items-center gap-1.5",children:[e.jsx("span",{className:`w-2 h-2 rounded-full ${a.tone.dot}`}),e.jsx("span",{className:"text-sm font-semibold text-fg",children:s.name})]}),s.aka&&e.jsx("div",{className:"text-[10.5px] text-fg-faint",children:s.aka}),e.jsx(p,{tone:"dim",className:"mt-1",children:a.label})]}),e.jsx("div",{className:"text-xs text-fg-dim leading-relaxed",children:s.summary}),e.jsxs("div",{children:[e.jsx(f,{children:"storage"}),e.jsx(k,{entity:s})]}),e.jsxs("div",{children:[e.jsx(f,{children:"fields"}),e.jsx(w,{fields:s.fields})]}),s.notes&&s.notes.length>0&&e.jsxs("div",{children:[e.jsx(f,{children:"notes"}),e.jsx("ul",{className:"list-disc pl-4 space-y-0.5 text-xs text-fg-dim",children:s.notes.map(l=>e.jsx("li",{children:l},l))})]}),e.jsxs("div",{children:[e.jsx(f,{children:"relations"}),i.length===0?e.jsx("div",{className:"text-xs text-fg-faint italic",children:"no relations"}):e.jsx("ul",{className:"space-y-1",children:i.map(l=>{const n=l.from===s.id?l.to:l.from,c=g(n);return e.jsxs("li",{className:"flex items-center gap-1.5 text-xs",children:[e.jsx("span",{className:"font-mono text-fg-faint w-10 shrink-0",children:D(l.cardinality)}),e.jsx("span",{className:"text-fg-dim",children:l.label}),l.via&&e.jsxs("span",{className:"font-mono text-[10px] text-fg-faint",children:["via ",l.via]}),e.jsx("button",{type:"button",onClick:()=>t(n),className:"ml-auto text-accent hover:underline font-mono",children:c.name})]},`${l.from}-${l.to}-${l.label}`)})})]})]})}function f({children:s}){return e.jsx("div",{className:"text-[10.5px] uppercase tracking-wide text-fg-faint mb-1 font-mono",children:s})}function P(){return e.jsx("div",{className:"p-4 space-y-6",children:v.map(s=>{const t=j.filter(a=>a.group===s.id);return t.length===0?null:e.jsxs("div",{children:[e.jsxs("div",{className:"flex items-center gap-1.5 mb-2",children:[e.jsx("span",{className:`w-2 h-2 rounded-full ${s.tone.dot}`}),e.jsx("span",{className:"text-xs font-semibold text-fg",children:s.label}),e.jsx("span",{className:"text-[10.5px] text-fg-faint",children:s.realm})]}),e.jsx("div",{className:"grid grid-cols-1 xl:grid-cols-2 gap-3",children:t.map(a=>e.jsxs("div",{className:"border border-line rounded bg-bg-hi p-3 space-y-2",children:[e.jsxs("div",{children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("span",{className:"text-sm font-semibold text-fg",children:a.name}),a.aka&&e.jsx("span",{className:"text-[10.5px] text-fg-faint",children:a.aka})]}),e.jsx(p,{tone:"dim",className:"mt-1",children:s.label})]}),e.jsx("div",{className:"text-xs text-fg-dim leading-relaxed",children:a.summary}),e.jsx(k,{entity:a}),e.jsx(w,{fields:a.fields}),a.notes&&a.notes.length>0&&e.jsx("ul",{className:"list-disc pl-4 space-y-0.5 text-xs text-fg-dim",children:a.notes.map(i=>e.jsx("li",{children:i},i))})]},a.id))})]},s.id)})})}export{L as DataModel};
@@ -1 +0,0 @@
1
- import{aa as i,F as r}from"./index-BcJqLDiY.js";function l({value:e,onChange:a,path:n,readOnly:o}){return i.jsx(r,{path:`file://${n}`,defaultLanguage:"markdown",value:e,onChange:t=>a(t??""),theme:"vs-dark",options:{minimap:{enabled:!1},fontSize:13,fontFamily:"JetBrains Mono, ui-monospace, Menlo, monospace",lineNumbers:"on",renderLineHighlight:"line",scrollBeyondLastLine:!1,tabSize:2,wordWrap:"on",readOnly:o,automaticLayout:!0}})}export{l as M};