claude-code-session-manager 0.62.0 → 0.62.1

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 (33) hide show
  1. package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-BwN4pNC0.js} +1 -1
  2. package/dist/assets/{index-DOXvph-Z.css → index-CVCCMw5o.css} +1 -1
  3. package/dist/assets/{index-BfiMRkln.js → index-ZjTDk5b7.js} +1083 -1111
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  7. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  8. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  9. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  10. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  11. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  12. package/src/main/config.cjs +1 -22
  13. package/src/main/files.cjs +20 -0
  14. package/src/main/index.cjs +1 -63
  15. package/src/main/ipcSchemas.cjs +11 -209
  16. package/src/main/lib/opsOwnership.cjs +0 -2
  17. package/src/main/lib/summarize.cjs +0 -1
  18. package/src/main/runVerify.cjs +55 -6
  19. package/src/main/scheduler.cjs +46 -7
  20. package/src/preload/api.d.ts +2 -197
  21. package/src/preload/index.cjs +1 -100
  22. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  23. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  24. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  25. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  26. package/src/main/browserAgentServer.cjs +0 -224
  27. package/src/main/browserCapture.cjs +0 -728
  28. package/src/main/browserView.cjs +0 -916
  29. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  30. package/src/main/lib/browserAgentActions.cjs +0 -114
  31. package/src/main/lib/e2eStateMachine.cjs +0 -39
  32. package/src/main/webRemote.cjs +0 -1367
  33. package/src/preload/browserViewPreload.cjs +0 -118
@@ -25,17 +25,6 @@ const ptyTabId = z.object({ tabId: z.string().min(1).max(128) });
25
25
  // have a live PTY? Bounded so the renderer can't ask about unbounded lists.
26
26
  const ptyAlive = z.object({ tabIds: z.array(z.string().min(1).max(128)).max(500) });
27
27
 
28
- // v2 mobile: subscribe to a session's live state + summary. cwd is needed to
29
- // locate the transcript JSONL (transcriptPath); validated against home-dir boundary
30
- // in webRemote before any fs access.
31
- const sessionSubscribe = z.object({
32
- // tabId becomes a transcript FILENAME (`<tabId>.jsonl`) — restrict to a
33
- // session-id charset (no '/', no '.', so it can't traverse out of the project
34
- // transcript dir). sessionId is a UUID, which satisfies this.
35
- tabId: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/),
36
- cwd: z.string().min(1).max(4096),
37
- });
38
-
39
28
  // 64 KiB cap per pty:write — typewriter input is bounded; a renderer firing
40
29
  // megabytes per call is either a bug or an attack. Block it at the boundary.
41
30
  const PTY_WRITE_MAX_BYTES = 64 * 1024;
@@ -50,115 +39,12 @@ const ptyResize = z.object({
50
39
  rows: z.number().int().min(3).max(1000),
51
40
  });
52
41
 
53
- // ──────────────────────────────────────────── Browser (WebContentsView embed)
54
- // viewId is a renderer-generated identifier; restrict to a safe charset (no
55
- // '/', no '.') since it keys the in-process Map<viewId, WebContentsView> —
56
- // not used as a filesystem path, but kept consistent with tabId conventions.
57
- const BROWSER_VIEW_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
58
- const browserViewId = z.object({
59
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
60
- });
61
-
62
- const browserCreate = z.object({
63
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
64
- // Non-persistent partition string (PRD 400 run-mode isolation). No leading
65
- // 'persist:' enforced here — callers choose persistence explicitly.
66
- partition: z.string().min(1).max(256),
67
- });
68
-
69
- const BOUNDS_INT = z.number().int().min(0).max(100000);
70
- const browserSetBounds = z.object({
71
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
72
- x: BOUNDS_INT,
73
- y: BOUNDS_INT,
74
- width: BOUNDS_INT,
75
- height: BOUNDS_INT,
76
- });
77
-
78
- const browserNavigate = z.object({
79
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
80
- url: z.string().min(1).max(8192),
81
- });
82
-
83
- // PRD 407: DOM/text capture from the active browser sub-tab.
84
- const browserCaptureDom = z.object({
85
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
86
- kind: z.enum(['text', 'html']),
87
- });
88
-
89
- // PRD 404: filter -> prune -> summarize -> chunk capture of a picked
90
- // selection (browser:capture). selectors comes from the PRD 403 picker.
91
- const browserCaptureSelection = z.object({
92
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
93
- selectors: z.array(z.string().min(1).max(2048)).min(1).max(50),
94
- mode: z.enum(['agent', 'html', 'a11y', 'selector']),
95
- });
96
-
97
- // PRD 407: clipboard image write (browser:copy-image). dataUrl is a PNG data
98
- // URL from webContents.capturePage() — capped well above any realistic
99
- // screenshot so a malformed/huge payload can't wedge the IPC channel.
100
- const browserCopyImage = z.object({
101
- dataUrl: z.string().min(1).max(50_000_000),
102
- });
103
-
104
42
  // Recorder export (PRD 412): write arbitrary recorded-flow text to the OS
105
- // clipboard, separate from the image-only browserCopyImage above.
43
+ // clipboard.
106
44
  const clipboardWriteText = z.object({
107
45
  text: z.string().max(1_000_000),
108
46
  });
109
47
 
110
- // Recorder export (PRD 412): native "Save As" dialog write, bypassing the
111
- // config.cjs write-boundary since the path is user-chosen via OS dialog.
112
- const browserSaveRecording = z.object({
113
- defaultName: z.string().min(1).max(255),
114
- text: z.string().max(1_000_000),
115
- });
116
-
117
- // PRD 407: binary-safe atomic write (browser:save-binary) for screenshot
118
- // captures — config:write-text is utf8-only.
119
- const browserSaveBinary = z.object({
120
- path: z.string().min(1).max(4096),
121
- base64: z.string().min(1).max(50_000_000),
122
- // Single-writer law (lib/opsOwnership.cjs) — required for ops-root paths.
123
- writer: z.string().min(1).max(64).optional(),
124
- });
125
-
126
- // PRD 410: replay a recorded step list against a live view. The renderer
127
- // owns the step list (main never persists recorded steps), so every call is
128
- // self-contained. `select` is accepted for forward-compat even though the
129
- // live recorder engine doesn't emit it yet.
130
- const browserReplayStep = z.object({
131
- n: z.number().int().min(1),
132
- verb: z.enum(['navigate', 'click', 'type', 'select', 'wait-for']),
133
- target: z.string().max(2000),
134
- value: z.string().max(2000).optional(),
135
- variable: z.string().max(64).nullable().optional(),
136
- kind: z.enum(['nav', 'assert']).optional(),
137
- masked: z.boolean().optional(),
138
- variableSuggestion: z.string().max(64).optional(),
139
- });
140
-
141
- const browserReplay = z.object({
142
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
143
- steps: z.array(browserReplayStep).max(500),
144
- values: z.record(z.string().max(64), z.string().max(2000)).optional(),
145
- continueOnError: z.boolean().optional(),
146
- });
147
-
148
- // PRD 402: address-bar zoom control. factor is clamped again in
149
- // browserView.cjs's setZoom — this just bounds the wire payload.
150
- const browserSetZoom = z.object({
151
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
152
- factor: z.number().min(0.1).max(10),
153
- });
154
-
155
- // PRD 402: Cmd/Ctrl+F find bar.
156
- const browserFind = z.object({
157
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
158
- text: z.string().max(2000),
159
- forward: z.boolean().optional(),
160
- });
161
-
162
48
  // ──────────────────────────────────────────── Transcripts
163
49
  const SESSION_UUID_RE = /^[a-zA-Z0-9-]{1,64}$/;
164
50
 
@@ -680,6 +566,15 @@ const exchangesList = z.object({
680
566
  // this one lives here per PRD 638 so it's reusable without importing files.cjs.
681
567
  const filesDuplicate = z.object({ path: z.string().min(1).max(4096) });
682
568
 
569
+ // files:save-binary — binary-safe atomic write (formerly browser:save-binary,
570
+ // re-homed off the Browser tab). Mirrors the retired browserSaveBinary shape.
571
+ const filesSaveBinary = z.object({
572
+ path: z.string().min(1).max(4096),
573
+ base64: z.string().min(1).max(50_000_000),
574
+ // Single-writer law (lib/opsOwnership.cjs) — required for ops-root paths.
575
+ writer: z.string().min(1).max(64).optional(),
576
+ });
577
+
683
578
  // ──────────────────────────────────────────── Doc Edit (PRD 638 rewrite runner)
684
579
  // docedit:run — consumed by docEdit.cjs's registerDocEditHandlers.
685
580
  const docEditRun = z.object({
@@ -744,23 +639,6 @@ const chatExternalSend = z.object({
744
639
  ),
745
640
  });
746
641
 
747
- // ──────────────────────────────────────────── Web Remote
748
- // OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
749
- const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
750
- const DEVICE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
751
-
752
- const webRemotePair = z.object({
753
- otp: z.string().regex(WEB_REMOTE_OTP_RE),
754
- }).strict();
755
-
756
- const webRemoteRevokeDevice = z.object({
757
- deviceId: z.string().regex(DEVICE_ID_RE),
758
- }).strict();
759
-
760
- const webRemoteAuditTail = z.object({
761
- lines: z.number().int().min(1).max(500).optional(),
762
- }).strict();
763
-
764
642
  // ──────────────────────────────────────────── History
765
643
  const DATE_YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
766
644
 
@@ -892,96 +770,19 @@ function validated(schema, handler) {
892
770
  };
893
771
  }
894
772
 
895
- // ──────────────────────────────────────────── Web Remote command allowlist
896
- // Commands are split into three tiers:
897
- // READ_COMMANDS — return data; allowed when remoteEnabled=true.
898
- // SAS_GATED_READS — return sensitive user data (sessions, PRDs, logs,
899
- // transcript summaries); additionally require
900
- // _e2eAuthenticated=true (SAS confirmed by user).
901
- // A compromised relay cannot exfiltrate this data from
902
- // a session that has not been SAS-confirmed.
903
- // MUTATE_COMMANDS — write files, spawn processes, or mutate persisted
904
- // state; gated behind remoteControlEnabled=true AND
905
- // _e2eAuthenticated=true.
906
- // ALLOWED_COMMANDS is the union, kept for existing import compatibility.
907
- //
908
- // Ungated READ_COMMANDS (justify each):
909
- // cmd:app:version — exposes only the app semver string; no user data.
910
- // cmd:session:unsubscribe — teardown lifecycle; returns nothing sensitive.
911
- const READ_COMMANDS = new Set([
912
- 'cmd:app:version',
913
- // v2 mobile: unsubscribe is a teardown lifecycle call with no data payload.
914
- 'cmd:session:unsubscribe',
915
- ]);
916
-
917
- // Sensitive reads — return user data; require SAS confirmation same as MUTATE.
918
- const SAS_GATED_READS = new Set([
919
- 'cmd:sessions:load',
920
- 'cmd:history:aggregate',
921
- // subscribe initiates a live stream of session state/summary — sensitive.
922
- 'cmd:session:subscribe',
923
- // NOTE: cmd:exchanges:list is intentionally NOT allowlisted — webRemote.cjs has
924
- // no dispatch handler for it, so an allowlist entry would only fail closed with
925
- // an opaque reject. Re-add here together with the handler when remote exchanges
926
- // are wired, so the allowlist always mirrors an actual capability.
927
- //
928
- // Scheduler/Epics commands (cmd:schedule:*) were removed here deliberately
929
- // (core scheduler/Epics redesign) — Remote no longer reaches into scheduler
930
- // internals at all; a future Remote rebuild will talk to Scheduler/Epics
931
- // through whatever higher-level surface that redesign lands on, not this
932
- // per-command allowlist.
933
- ]);
934
-
935
- const MUTATE_COMMANDS = new Set([
936
- 'cmd:sessions:save',
937
- 'cmd:pty:spawn',
938
- 'cmd:pty:write',
939
- // pty:kill terminates a live session; pty:resize drives the geometry of the
940
- // user's interactive PTY — both write live process state, so they are gated
941
- // behind remoteControlEnabled + SAS like every other mutation. A read-only
942
- // mobile mirror has no business killing or resizing the desktop's session.
943
- 'cmd:pty:kill',
944
- 'cmd:pty:resize',
945
- // Pushes a prompt into an open tab's chat queue — a real mutation of that
946
- // tab's session, same tier as pty:write.
947
- 'cmd:chat:send',
948
- ]);
949
-
950
- const ALLOWED_COMMANDS = new Set([...READ_COMMANDS, ...SAS_GATED_READS, ...MUTATE_COMMANDS]);
951
-
952
773
  module.exports = {
953
774
  // Centralized slug regex — used by scheduler.cjs and queueOps.cjs for
954
775
  // direct test()/match() containment checks alongside the zod parses.
955
776
  SCHEDULE_SLUG_RE,
956
777
  SCHEDULE_RUN_ID_RE,
957
778
  PRD_CREATE_SLUG_RE,
958
- READ_COMMANDS,
959
- SAS_GATED_READS,
960
- MUTATE_COMMANDS,
961
- ALLOWED_COMMANDS,
962
779
  schemas: {
963
- webRemotePair,
964
- webRemoteRevokeDevice,
965
- webRemoteAuditTail,
966
780
  ptySpawn,
967
781
  ptyTabId,
968
782
  ptyAlive,
969
783
  ptyWrite,
970
784
  ptyResize,
971
- sessionSubscribe,
972
- browserViewId,
973
- browserCreate,
974
- browserSetBounds,
975
- browserNavigate,
976
- browserCaptureDom,
977
- browserCaptureSelection,
978
- browserCopyImage,
979
- browserSaveBinary,
980
785
  clipboardWriteText,
981
- browserSaveRecording,
982
- browserReplay,
983
- browserSetZoom,
984
- browserFind,
985
786
  transcriptSubscribe,
986
787
  transcriptTabId,
987
788
  transcriptPath,
@@ -1049,6 +850,7 @@ module.exports = {
1049
850
  watchersRemove,
1050
851
  watchersKillTab,
1051
852
  filesDuplicate,
853
+ filesSaveBinary,
1052
854
  docEditRun,
1053
855
  docEditRunInSession,
1054
856
  chatRun,
@@ -48,8 +48,6 @@ const OWNERS = Object.freeze({
48
48
  'scheduler': 'scheduler',
49
49
  // Project Home owns the synthesized Brief (generate + hand-edit).
50
50
  'project-brief': 'project-home',
51
- // Browser tab scratch saves (DOM captures, screenshots, recorded flows).
52
- 'browser': 'browser',
53
51
  // Structured per-tab error log lines (JSONL), tagged for tracing/analysis.
54
52
  'logs': 'logs',
55
53
  // Host on Bilko.run tab's deterministic bundle prep (dist/index.html +
@@ -5,7 +5,6 @@
5
5
  *
6
6
  * Single source of truth for the Anthropic summarization call. Used by:
7
7
  * - exchanges.cjs (records durable per-exchange summaries)
8
- * - webRemote.cjs (mobile summary push — refactored to import from here)
9
8
  *
10
9
  * Degrade contracts (never throw — always return a record):
11
10
  * no API key → { summary: text.slice(0,600), model:'raw', degraded:'no_api_key' }
@@ -157,6 +157,7 @@ function parseLog(logPath) {
157
157
  const events = [];
158
158
  let resultEvent = null;
159
159
  let seq = 0;
160
+ let transcriptCommitLanded = false;
160
161
 
161
162
  for (const line of text.split('\n')) {
162
163
  const trimmed = line.trim();
@@ -166,6 +167,18 @@ function parseLog(logPath) {
166
167
  try { obj = JSON.parse(trimmed); } catch { continue; }
167
168
  if (!obj || typeof obj !== 'object') continue;
168
169
 
170
+ // Harness-emitted commit evidence: git itself produced these lines (a
171
+ // `vcs_state_changed` system event, or a tool_use_result carrying a
172
+ // gitOperation), so the model cannot fabricate them. Detected here as a
173
+ // plain boolean scan — never pushed into `events`, so the index-based
174
+ // final-20% math elsewhere is unaffected.
175
+ if (obj.type === 'system' && obj.subtype === 'vcs_state_changed' && obj.kind === 'commit') {
176
+ transcriptCommitLanded = true;
177
+ }
178
+ if (obj.tool_use_result?.gitOperation?.commit?.kind === 'committed') {
179
+ transcriptCommitLanded = true;
180
+ }
181
+
169
182
  // Final result event.
170
183
  if (obj.type === 'result') {
171
184
  const ev = {
@@ -215,7 +228,7 @@ function parseLog(logPath) {
215
228
  }
216
229
  }
217
230
 
218
- return { events, resultEvent, error: null };
231
+ return { events, resultEvent, error: null, transcriptCommitLanded };
219
232
  }
220
233
 
221
234
  // ─── self-recovery helpers ────────────────────────────────────────────────────
@@ -591,6 +604,27 @@ function isAncestorCommit({ cwd, sha, timeoutMs = GH_CHECK_TIMEOUT_MS, execImpl
591
604
  }
592
605
  }
593
606
 
607
+ /**
608
+ * True when `cwd` is inside a git repository. Used to gate the transcript
609
+ * commit-evidence fallback below: a job whose cwd is not a git repo can never
610
+ * produce committedDuringRun=true (gitHead()/committedInWindow() fail closed
611
+ * there), so the fallback must only apply in that narrow case — never when
612
+ * git ancestry is actually observable.
613
+ */
614
+ function isGitRepo(cwd) {
615
+ if (!cwd) return false;
616
+ try {
617
+ execFileSync('git', ['-C', cwd, 'rev-parse', '--git-dir'], {
618
+ timeout: 10_000,
619
+ encoding: 'utf8',
620
+ stdio: ['ignore', 'pipe', 'pipe'],
621
+ });
622
+ return true;
623
+ } catch {
624
+ return false;
625
+ }
626
+ }
627
+
594
628
  // ─── already-shipped postcondition exemption (original PRD re-runs) ─────────
595
629
 
596
630
  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;
@@ -743,12 +777,21 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
743
777
  }
744
778
 
745
779
  // ── 2. Parse log ───────────────────────────────────────────────────────
746
- const { events, resultEvent, error: parseError } = parseLog(logPath);
780
+ const { events, resultEvent, error: parseError, transcriptCommitLanded } = parseLog(logPath);
747
781
 
748
782
  if (parseError) {
749
783
  return conclude('verify_unavailable', `log unreadable: ${parseError}`, 'needs_review');
750
784
  }
751
785
 
786
+ // A job whose cwd is not a git repo (e.g. an investigation fix-plan that
787
+ // inherited cwd: /tmp) can never produce committedDuringRun=true —
788
+ // gitHead()/committedInWindow() both fail closed there. In that case
789
+ // ONLY, fall back to the harness-emitted commit evidence in the
790
+ // transcript, which git itself produced and the model cannot fabricate.
791
+ const cwdIsGitRepo = isGitRepo(queueEntry?.cwd);
792
+ const commitEvidence = committedDuringRun || (!cwdIsGitRepo && transcriptCommitLanded);
793
+ const commitEvidenceSource = (!committedDuringRun && commitEvidence) ? 'transcript' : null;
794
+
752
795
  // ── 3. HALT detection ─────────────────────────────────────────────────
753
796
  // Primary: check the final `{"type":"result"}` event's `result` text.
754
797
  if (resultEvent) {
@@ -865,7 +908,7 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
865
908
  // committed anything nor ever emitted the finish-protocol sentinel likely
866
909
  // ended before doing real work (e.g. stopped on a clarifying question).
867
910
  // Weaker evidence than a caught transcript error, but still not clean.
868
- if (sentinel === null && !committedDuringRun) {
911
+ if (sentinel === null && !commitEvidence) {
869
912
  issues.push({
870
913
  verdict: 'no_verdict_sentinel',
871
914
  reason: 'run made no commit and emitted no SCHEDULER_VERDICT sentinel — likely ended before the finish protocol (e.g. stopped on a clarifying question)',
@@ -894,7 +937,7 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
894
937
  // bare PASS with no commit from one is still a strong silent-failure
895
938
  // signal and stays covered by this check.
896
939
  const isFixPlanJob = /^\d+-fix-/.test(queueEntry?.slug || '');
897
- if (sentinel === 'pass' && !committedDuringRun && !isFixPlanJob) {
940
+ if (sentinel === 'pass' && !commitEvidence && !isFixPlanJob) {
898
941
  // EXEMPTION: `-merge-main` PRDs (see isMergeMainSlug) can genuinely and
899
942
  // correctly find nothing left to do when an out-of-band actor (a human,
900
943
  // another agent, a sibling scheduler job) already merged/updated the
@@ -1015,13 +1058,18 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
1015
1058
  // MUST NOT apply to halt or deps_unmet — those keep their existing semantics.
1016
1059
  if (
1017
1060
  sentinel === 'pass'
1018
- && committedDuringRun
1061
+ && commitEvidence
1019
1062
  && (top.verdict === 'transcript_errors' || top.verdict === 'verify_unavailable')
1020
1063
  ) {
1021
1064
  return conclude('clean',
1022
1065
  `SCHEDULER_VERDICT: PASS + commit landed overrides ${top.verdict}`,
1023
1066
  null,
1024
- { ...(annotations.length ? { annotations } : {}), sentinel, sentinelOverride: top.verdict },
1067
+ {
1068
+ ...(annotations.length ? { annotations } : {}),
1069
+ sentinel,
1070
+ sentinelOverride: top.verdict,
1071
+ ...(commitEvidenceSource ? { commitEvidenceSource } : {}),
1072
+ },
1025
1073
  );
1026
1074
  }
1027
1075
 
@@ -1073,4 +1121,5 @@ module.exports = {
1073
1121
  extractPrdDeliverablePaths,
1074
1122
  allDeliverablesAlreadyTracked,
1075
1123
  isAncestorCommit,
1124
+ isGitRepo,
1076
1125
  };
@@ -271,6 +271,28 @@ function gitHead(cwd) {
271
271
  });
272
272
  }
273
273
 
274
+ // True when cwd is inside a git repository. Used to keep a non-git cwd (e.g.
275
+ // a scratch dir like /tmp) from ever being handed to an investigation's
276
+ // fix-plan as its cwd — the commit guard, worktree isolation, and
277
+ // runVerify's PASS+commit sentinel override all read git state from job.cwd,
278
+ // so a fix plan rooted at a non-repo path gets parked in needs_review even
279
+ // when its commit really landed (Incident: 99-fix-e2e-needs-review-test,
280
+ // 2026-08-07 — green, committed 6ecbd40, PASS sentinel, still needs_review).
281
+ // Bounded and never throws.
282
+ function isGitRepoSync(cwd) {
283
+ if (!cwd) return false;
284
+ try {
285
+ execFileSync('git', ['-C', cwd, 'rev-parse', '--git-dir'], {
286
+ timeout: 10_000,
287
+ windowsHide: true,
288
+ stdio: ['ignore', 'pipe', 'pipe'],
289
+ });
290
+ return true;
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+
274
296
  // Best-effort `git fetch --all --prune` in cwd, bounded and never throwing.
275
297
  // A PRD that does its work in a separate `git worktree add` checkout (per
276
298
  // standards.md's own recommended pattern for shared repos) commits and pushes
@@ -2666,6 +2688,10 @@ ${logTail}
2666
2688
  estimateMinutes: <your time estimate>
2667
2689
  ---
2668
2690
  \`\`\`
2691
+ \`cwd\` must be the git repo root where the fix will actually land. If the failed job's cwd is
2692
+ not that repo (e.g. a scratch dir like \`/tmp\`), set \`cwd:\` to the correct repo root instead —
2693
+ the scheduler's commit guard and post-run verifier read git state from this path, and a
2694
+ non-repo cwd parks even a fully green, committed run in \`needs_review\`.
2669
2695
  6. The PRD body MUST be self-contained — \`claude -p\` runs it on a fresh Sonnet session with NO conversation context. Include:
2670
2696
  - Root-cause analysis (what went wrong and why)
2671
2697
  - Concrete fix steps (specific files / commands / edits)
@@ -2803,6 +2829,16 @@ async function spawnInvestigation(failedJob, runDir) {
2803
2829
  console.warn(`[scheduler] investigation cwd missing (${cwd}); falling back to ${DEFAULT_PROJECT_CWD}`);
2804
2830
  cwd = DEFAULT_PROJECT_CWD;
2805
2831
  }
2832
+ // A non-git cwd is unusable as a fix-plan cwd: the commit guard, worktree
2833
+ // isolation, and runVerify's PASS+commit sentinel override all read git
2834
+ // state from job.cwd, so a fix plan rooted at e.g. /tmp gets parked in
2835
+ // needs_review even when its commit really landed. (Incident:
2836
+ // 99-fix-e2e-needs-review-test, 2026-08-07 — green, committed 6ecbd40,
2837
+ // PASS sentinel, still needs_review.)
2838
+ if (!isGitRepoSync(cwd)) {
2839
+ console.warn(`[scheduler] investigation cwd is not a git repo (${cwd}); falling back to ${DEFAULT_PROJECT_CWD}`);
2840
+ cwd = DEFAULT_PROJECT_CWD;
2841
+ }
2806
2842
  const prompt = buildInvestigationPrompt({ failedJob, cwd, failedLogPath, originalBody, logTail, fixPath, group });
2807
2843
 
2808
2844
  // Phase 1: open log fd for pre-spawn diagnostics.
@@ -3024,11 +3060,12 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
3024
3060
  await setPaused('rate_limit', resetIso);
3025
3061
  }
3026
3062
 
3027
- // Stale queue entry: the PRD already shipped and was archived before this
3028
- // run fired (see archivedTwinExists in executeJob). Treat it as a plain
3029
- // completion — no verify pass, no commit guard, no RCA feedback — since
3030
- // there is no real transcript/commit to check.
3031
- if (res.skipped === 'prd-archived') {
3063
+ // Stale queue entry: the PRD was archived (already shipped) or is gone
3064
+ // from disk entirely (deleted before dispatch). Both are retire-not-fail
3065
+ // results from prdArchivedSkipResult with exitCode 0 and no transcript —
3066
+ // treat any truthy skip reason as a plain completion: no verify pass, no
3067
+ // commit guard, no RCA feedback item.
3068
+ if (res.skipped) {
3032
3069
  await mutate((s) => {
3033
3070
  const idx = s.jobs.findIndex((x) => x.slug === job.slug);
3034
3071
  if (idx >= 0) {
@@ -4765,7 +4802,9 @@ async function init() {
4765
4802
  }
4766
4803
  }
4767
4804
 
4768
- // remote — callable from webRemote.cjs without going through IPC.
4805
+ // remote — in-process (non-IPC) scheduler accessors, used by prdCreate.cjs
4806
+ // and other main-process callers. (Named for the retired web-remote relay,
4807
+ // its original consumer; kept because it still has in-process callers.)
4769
4808
  const remote = {
4770
4809
  // `cwd` is optional: prdCreate.cjs's create flow passes the target
4771
4810
  // project's cwd explicitly (the file may not exist yet, so there's
@@ -4956,4 +4995,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
4956
4995
  });
4957
4996
  }
4958
4997
 
4959
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, healRefusalReason, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, resolveVerifyPrdPath, resolveNotifyPrd, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
4998
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, healRefusalReason, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, isGitRepoSync, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, resolveVerifyPrdPath, resolveNotifyPrd, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult, spawnJob };