claude-code-session-manager 0.39.1 → 0.39.2

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 (39) hide show
  1. package/dist/assets/{TiptapBody-CtuLATFR.js → TiptapBody-MCzz_6Zm.js} +1 -1
  2. package/dist/assets/{index-CefVSjmm.js → index-BPbLVT6E.js} +490 -488
  3. package/dist/assets/{index-B6JNrbpD.css → index-DVlD8N1X.css} +2 -2
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -3
  6. package/scripts/lib/activeSessions.cjs +26 -3
  7. package/scripts/lib/watchdogHelpers.cjs +13 -6
  8. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +84 -0
  9. package/src/main/__tests__/prdCreate.test.cjs +6 -0
  10. package/src/main/__tests__/prdLocations.test.cjs +41 -0
  11. package/src/main/__tests__/prdMigration.test.cjs +41 -0
  12. package/src/main/__tests__/promptSessionEvents.test.cjs +106 -0
  13. package/src/main/__tests__/queueHistory.test.cjs +2 -2
  14. package/src/main/__tests__/rcaFeedbackHook.test.cjs +108 -0
  15. package/src/main/__tests__/runVerify.test.cjs +459 -4
  16. package/src/main/__tests__/scheduler-admin-routes.test.cjs +52 -3
  17. package/src/main/__tests__/scheduler-archive-completed-prd.test.cjs +102 -0
  18. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +126 -0
  19. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +58 -3
  20. package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +42 -0
  21. package/src/main/__tests__/scheduler-investigation-clean-skip.test.cjs +63 -0
  22. package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +23 -0
  23. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +68 -0
  24. package/src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs +77 -0
  25. package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +62 -0
  26. package/src/main/browserView.cjs +5 -4
  27. package/src/main/chatRunner.cjs +90 -7
  28. package/src/main/config.cjs +9 -0
  29. package/src/main/ipcSchemas.cjs +1 -1
  30. package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +118 -0
  31. package/src/main/lib/prdLocations.cjs +43 -6
  32. package/src/main/lib/prdMigration.cjs +5 -3
  33. package/src/main/lib/queueHistory.cjs +1 -1
  34. package/src/main/lib/rcaFeedbackHook.cjs +55 -5
  35. package/src/main/lib/terminalRunOutcome.cjs +100 -0
  36. package/src/main/promptSessionEvents.cjs +87 -0
  37. package/src/main/runVerify.cjs +195 -3
  38. package/src/main/scheduler.cjs +436 -80
  39. package/src/preload/api.d.ts +5 -1
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Main-process counterpart to promptSessions.ts's event chain, used ONLY by
5
+ * the scheduler's PRD-finished notification path (notifyOriginatingTab,
6
+ * scheduler.cjs). The renderer owns the live in-memory PromptSession store
7
+ * and its own persistActiveIndex; this module never touches that store — it
8
+ * reads/writes the SAME on-disk active-index.json directly, read-modify-
9
+ * write, so a scheduler job (which runs in the main process, with no
10
+ * renderer store to append to) can chain a 'response' event onto a known
11
+ * session's tail without routing a synthetic prompt into an unrelated tab
12
+ * (PRD 814).
13
+ */
14
+
15
+ const config = require('./config.cjs');
16
+
17
+ function promptSessionActiveIndexPath(cwd) {
18
+ return `${String(cwd).replace(/\/+$/, '')}/session-manager-operations/prompt-sessions/active-index.json`;
19
+ }
20
+
21
+ let seq = 0;
22
+ function mintEventId() {
23
+ seq += 1;
24
+ return `pevt-${Date.now().toString(36)}-${seq}`;
25
+ }
26
+
27
+ // Serializes read-modify-write cycles per active-index.json path so two
28
+ // PRDs finishing back-to-back for sessions in the same cwd chain onto the
29
+ // tail in issue order instead of racing two independent read-then-write
30
+ // round trips (mirrors promptSessions.ts's own pendingWritesByPath).
31
+ const pendingWritesByPath = new Map();
32
+
33
+ function withPathLock(path, task) {
34
+ const prior = pendingWritesByPath.get(path) || Promise.resolve();
35
+ const settle = () => task();
36
+ const run = prior.then(settle, settle);
37
+ pendingWritesByPath.set(
38
+ path,
39
+ run.then(
40
+ () => undefined,
41
+ () => undefined,
42
+ ),
43
+ );
44
+ return run;
45
+ }
46
+
47
+ /**
48
+ * If `sourcePromptId` resolves to a known, still-active PromptSession under
49
+ * `cwd`'s active-index.json, appends a 'response' event chained to that
50
+ * session's current tail and returns true. Returns false (never throws) for
51
+ * every case the caller must fall back on: missing cwd/id, no index file,
52
+ * unknown id, a completed session, an empty event chain, or any I/O error —
53
+ * so notifyOriginatingTab's existing tab-external-ticket path stays the
54
+ * safety net, not a special case.
55
+ */
56
+ async function appendResponseEventIfKnown(cwd, sourcePromptId, text) {
57
+ if (!cwd || !sourcePromptId) return false;
58
+ const path = promptSessionActiveIndexPath(cwd);
59
+ try {
60
+ return await withPathLock(path, async () => {
61
+ const result = await config.readJson(path);
62
+ if (!result.exists || !result.data) return false;
63
+ const data = result.data;
64
+ const session = data.sessions && data.sessions[sourcePromptId];
65
+ if (!session || session.status !== 'active') return false;
66
+ const events = (data.events && data.events[sourcePromptId]) || [];
67
+ const tail = events.length > 0 ? events[events.length - 1] : null;
68
+ if (!tail) return false;
69
+ const event = {
70
+ id: mintEventId(),
71
+ promptSessionId: sourcePromptId,
72
+ kind: 'response',
73
+ causedByEventId: tail.id,
74
+ at: new Date().toISOString(),
75
+ text,
76
+ };
77
+ data.events[sourcePromptId] = [...events, event];
78
+ await config.writeJson(path, data);
79
+ return true;
80
+ });
81
+ } catch (e) {
82
+ console.error('[promptSessionEvents] appendResponseEventIfKnown error', cwd, sourcePromptId, e);
83
+ return false;
84
+ }
85
+ }
86
+
87
+ module.exports = { appendResponseEventIfKnown, promptSessionActiveIndexPath };
@@ -260,16 +260,34 @@ function hasInstallRecovery(events, fromSeq) {
260
260
  return false;
261
261
  }
262
262
 
263
+ // Matches a leading `sleep <N> &&` or `sleep <N> ;` wrapper (the shell idiom
264
+ // used to poll a still-pending remote job) so a retry that only drops/changes
265
+ // its own timing wrapper still counts as the same underlying command. Narrow
266
+ // on purpose: it strips nothing else, so two genuinely different commands
267
+ // never collapse into a false pairing (incident: RCA 745-pr188-ci-lint-docs-integrity).
268
+ const SLEEP_PREFIX_RE = /^\s*sleep\s+\d+\s*(&&|;)\s*/;
269
+
270
+ /**
271
+ * Strip a leading `sleep <N> (&&|;)` wrapper from a tool_use description so
272
+ * self-recovery comparisons ignore only that timing-poll idiom.
273
+ */
274
+ function normalizeDescForRecovery(desc) {
275
+ return (desc ?? '').replace(SLEEP_PREFIX_RE, '');
276
+ }
277
+
263
278
  /**
264
279
  * Check whether the error at `errorSeq` is self-recovered within the next 30
265
280
  * events.
266
281
  *
267
282
  * Recovery heuristic: another tool_use with the same non-empty `description`
283
+ * (after stripping a leading `sleep N &&`/`sleep N;` wrapper from both sides)
268
284
  * appears within 30 events of the error, AND its corresponding tool_result is
269
285
  * free of error patterns AND is not is_error=true.
270
286
  */
271
287
  function isSelfRecovered(events, errorSeq, desc) {
272
288
  if (!desc) return false;
289
+ const normalizedDesc = normalizeDescForRecovery(desc);
290
+ if (!normalizedDesc) return false;
273
291
 
274
292
  // Build a quick lookup from tool_use_id → tool_result event.
275
293
  const resultByUseId = new Map();
@@ -281,7 +299,7 @@ function isSelfRecovered(events, errorSeq, desc) {
281
299
  for (const ev of events) {
282
300
  if (ev.seq <= errorSeq) continue;
283
301
  if (++seen > 30) break;
284
- if (ev.kind !== 'tool_use' || ev.description !== desc) continue;
302
+ if (ev.kind !== 'tool_use' || normalizeDescForRecovery(ev.description) !== normalizedDesc) continue;
285
303
  const result = resultByUseId.get(ev.toolUseId);
286
304
  if (result && !result.isError && !detectPattern(result.content)) return true;
287
305
  }
@@ -512,6 +530,114 @@ function checkMergeablePr({ cwd, prNumber, timeoutMs = GH_CHECK_TIMEOUT_MS, exec
512
530
  }
513
531
  }
514
532
 
533
+ // ─── prior-run-landed-commit postcondition exemption (re-fired same slug) ───
534
+
535
+ /**
536
+ * Independently re-check, via real git ancestry, whether a commit a PREVIOUS
537
+ * run of this same slug landed is reachable from the current HEAD. Used to
538
+ * distinguish a truthful no-op re-run (the slug's work already shipped in an
539
+ * earlier run, so this run correctly made no new commit) from a lying PASS
540
+ * with no substantiating work at all.
541
+ *
542
+ * Bounded (15s) and fully fail-safe: ANY error (bad SHA, non-git cwd, timeout,
543
+ * `sha` not an ancestor) resolves `false` rather than throwing, so a failure
544
+ * here only ever falls back to today's pass_no_commit behavior — it can never
545
+ * turn a real failure into a false "verified".
546
+ *
547
+ * `execImpl` is injectable (defaults to `child_process.execFileSync`) so unit
548
+ * tests can stub the subprocess call without shelling out to real git.
549
+ */
550
+ function isAncestorCommit({ cwd, sha, timeoutMs = GH_CHECK_TIMEOUT_MS, execImpl = execFileSync }) {
551
+ if (!sha) return false;
552
+ try {
553
+ execImpl('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], {
554
+ cwd: cwd || process.cwd(),
555
+ timeout: timeoutMs,
556
+ encoding: 'utf8',
557
+ stdio: ['ignore', 'pipe', 'pipe'],
558
+ });
559
+ return true;
560
+ } catch {
561
+ return false;
562
+ }
563
+ }
564
+
565
+ // ─── already-shipped postcondition exemption (original PRD re-runs) ─────────
566
+
567
+ const PRD_DELIVERABLE_PATH_RE = /(?:^|[`\s(])((?:src|scripts|session-manager-operations|test|tests|docs|bin)\/[A-Za-z0-9_./-]*[A-Za-z0-9_-]\.[A-Za-z0-9]+)\b/g;
568
+
569
+ // Repo-root config files a PRD can legitimately name as its only deliverable.
570
+ // Deliberately an exact-name allowlist, not a general root-file pattern:
571
+ // a loose rule would match prose mentions (README.md, queue.json) and grant
572
+ // false exemptions. Incident: PRD 816 named vitest.config.ts as its primary
573
+ // deliverable and it was silently dropped from extraction.
574
+ const PRD_ROOT_CONFIG_DELIVERABLES = [
575
+ 'package.json',
576
+ 'tsconfig.json',
577
+ 'vitest.config.ts',
578
+ 'vite.config.ts',
579
+ 'playwright.config.ts',
580
+ 'eslint.config.js',
581
+ 'tailwind.config.js',
582
+ 'postcss.config.js',
583
+ ];
584
+
585
+ /**
586
+ * Extract the deliverable file paths a PRD body names — backticked or bare
587
+ * paths under a source dir with a file extension (e.g. `src/main/lib/foo.cjs`,
588
+ * `src/renderer/components/Bar.tsx`, `scripts/baz.cjs`), plus any allowlisted
589
+ * repo-root config filename named as a whole word (e.g. `vitest.config.ts`).
590
+ * Deliberately narrow: only repo-relative paths under a known source root or
591
+ * an exact-name root-config allowlist, no globs, no node_modules, no URLs.
592
+ * O(n) over the PRD body length.
593
+ */
594
+ function extractPrdDeliverablePaths(prdBody) {
595
+ if (typeof prdBody !== 'string' || !prdBody) return [];
596
+ const seen = new Set();
597
+ const out = [];
598
+ let m;
599
+ PRD_DELIVERABLE_PATH_RE.lastIndex = 0;
600
+ while ((m = PRD_DELIVERABLE_PATH_RE.exec(prdBody)) !== null) {
601
+ const p = m[1];
602
+ if (p.includes('node_modules') || p.includes('..') || seen.has(p)) continue;
603
+ seen.add(p);
604
+ out.push(p);
605
+ }
606
+ for (const name of PRD_ROOT_CONFIG_DELIVERABLES) {
607
+ if (seen.has(name)) continue;
608
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
609
+ const re = new RegExp(`(?:^|[\`\\s(])${escaped}(?:[\`\\s)]|$)`);
610
+ if (re.test(prdBody)) {
611
+ seen.add(name);
612
+ out.push(name);
613
+ }
614
+ }
615
+ return out;
616
+ }
617
+
618
+ /**
619
+ * Materially check whether every deliverable path a PRD names is already
620
+ * tracked in git — i.e. the PRD's work landed in an EARLIER run/commit and
621
+ * this run correctly had nothing to do. Uses `git ls-files --error-unmatch`
622
+ * (bounded, execImpl injectable for tests). Fails safe in every direction:
623
+ * any error, a non-git cwd, or zero paths returns false, which falls straight
624
+ * through to today's pass_no_commit behavior.
625
+ */
626
+ function allDeliverablesAlreadyTracked({ cwd, paths, execImpl = execFileSync, timeoutMs = 15_000 }) {
627
+ if (!Array.isArray(paths) || paths.length === 0) return false;
628
+ try {
629
+ execImpl('git', ['ls-files', '--error-unmatch', '--', ...paths], {
630
+ cwd: cwd || process.cwd(),
631
+ timeout: timeoutMs,
632
+ encoding: 'utf8',
633
+ stdio: ['ignore', 'pipe', 'pipe'],
634
+ });
635
+ return true;
636
+ } catch {
637
+ return false;
638
+ }
639
+ }
640
+
515
641
  // ─── main verifier ────────────────────────────────────────────────────────────
516
642
 
517
643
  /**
@@ -538,9 +664,13 @@ function checkMergeablePr({ cwd, prNumber, timeoutMs = GH_CHECK_TIMEOUT_MS, exec
538
664
  * subprocess call used by the -merge-main
539
665
  * postcondition exemption. Defaults to
540
666
  * child_process.execFileSync.
667
+ * @param {string|null} [params.priorLandedCommit] The commit SHA a PREVIOUS run of this
668
+ * same slug landed, if any — supplied by the
669
+ * caller (scheduler.cjs). Used by the
670
+ * pass_no_commit_prior_run_verified exemption.
541
671
  * @returns {Promise<{verdict:string, reason:string, downgradeTo:string|null}>}
542
672
  */
543
- async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedDuringRun = false, allowPreSentinelHeal = false, ghExecImpl }) {
673
+ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedDuringRun = false, allowPreSentinelHeal = false, ghExecImpl, priorLandedCommit = null }) {
544
674
  const { slug } = queueEntry;
545
675
  const logPath = path.join(runDir, `${slug}.log`);
546
676
  const verdictsPath = path.join(runDir, `${slug}.verdicts.json`);
@@ -758,7 +888,64 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
758
888
  }
759
889
  }
760
890
  }
761
- if (!mergeMainVerified) {
891
+ // EXEMPTION: the SAME slug was already executed by a PRIOR run that
892
+ // landed a commit, and that commit is still reachable from HEAD. "I
893
+ // re-checked every AC item, found it all already implemented by an
894
+ // earlier run of this exact PRD, declined to make a no-op commit, and
895
+ // printed a truthful PASS" is correct, idempotent behavior — the
896
+ // systemic false-failure class this exemption exists to catch (distinct
897
+ // from the already-shipped exemption below, which looks at PRD-named
898
+ // file paths rather than a specific prior run's SHA).
899
+ // (Incident: 812-workbench-review-nits-cleanup, 2026-07-31 — re-fired
900
+ // ~24 min after its own prior run landed 00d891c; re-verified,
901
+ // re-ran typecheck + 17 green tests, made no new commit, printed a
902
+ // truthful PASS, and was parked in needs_review anyway.)
903
+ let priorRunVerified = false;
904
+ if (!mergeMainVerified && priorLandedCommit
905
+ && isAncestorCommit({ cwd: queueEntry?.cwd, sha: priorLandedCommit })) {
906
+ priorRunVerified = true;
907
+ return conclude(
908
+ 'pass_no_commit_prior_run_verified',
909
+ `SCHEDULER_VERDICT: PASS with no commit, but a prior run of this slug landed ${priorLandedCommit}, `
910
+ + 'which is an ancestor of HEAD — the PRD\'s work is already in the tree, so this re-run correctly '
911
+ + 'made no change',
912
+ null,
913
+ {
914
+ ...(annotations.length ? { annotations } : {}),
915
+ sentinel,
916
+ priorLandedCommit,
917
+ },
918
+ );
919
+ }
920
+ // EXEMPTION: an ORIGINAL PRD re-queued after its deliverables already
921
+ // landed. "I checked, the work is already committed and green, nothing
922
+ // to change" is a truthful PASS, not a silent no-op — the same
923
+ // reasoning that exempts fix-plan slugs above. Materially checked
924
+ // against real git state, never inferred from the transcript.
925
+ // (Incident: 655-needs-review-rca-feedback-hook, 2026-07-31 — verified
926
+ // rcaFeedbackHook.cjs shipped in e13168d, ran typecheck + 23 green
927
+ // tests, made no commit, printed a truthful PASS, and was parked
928
+ // anyway.)
929
+ let alreadyShipped = false;
930
+ if (!mergeMainVerified && !priorRunVerified) {
931
+ const deliverablePaths = extractPrdDeliverablePaths(prdBody);
932
+ if (deliverablePaths.length > 0
933
+ && allDeliverablesAlreadyTracked({ cwd: queueEntry?.cwd, paths: deliverablePaths })) {
934
+ alreadyShipped = true;
935
+ return conclude(
936
+ 'pass_no_commit_already_shipped',
937
+ `SCHEDULER_VERDICT: PASS with no commit, but every PRD-named deliverable path is already `
938
+ + `tracked in git (${deliverablePaths.join(', ')}) — this PRD's work landed in an earlier run`,
939
+ null,
940
+ {
941
+ ...(annotations.length ? { annotations } : {}),
942
+ sentinel,
943
+ alreadyTrackedPaths: deliverablePaths,
944
+ },
945
+ );
946
+ }
947
+ }
948
+ if (!mergeMainVerified && !priorRunVerified && !alreadyShipped) {
762
949
  issues.push({
763
950
  verdict: 'pass_no_commit',
764
951
  reason: 'SCHEDULER_VERDICT: PASS but no commit landed during the run window — the run claims success but produced no code change',
@@ -830,6 +1017,8 @@ module.exports = {
830
1017
  // Exposed for unit tests.
831
1018
  detectPattern,
832
1019
  isHarnessToolError,
1020
+ isSelfRecovered,
1021
+ normalizeDescForRecovery,
833
1022
  toolUseName,
834
1023
  extractSoakFromBody,
835
1024
  parsePrdBodyDepFragments,
@@ -839,4 +1028,7 @@ module.exports = {
839
1028
  isMergeMainSlug,
840
1029
  extractMergeMainPrNumber,
841
1030
  checkMergeablePr,
1031
+ extractPrdDeliverablePaths,
1032
+ allDeliverablesAlreadyTracked,
1033
+ isAncestorCommit,
842
1034
  };