claude-code-session-manager 0.35.7 → 0.35.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-DgFpE4dn.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-CCqF0xvC.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
13
  <link rel="stylesheet" crossorigin href="./assets/index-C7NyYuXu.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.35.7",
3
+ "version": "0.35.8",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -58,7 +58,9 @@ Data-driven from 400+ scheduler runs: long hangs (not bad code) are the dominant
58
58
  - **Verify before done.** Run the acceptance test command once before declaring success. If it's red, fix it or `exit 1` with the failure — never end the run on a failing test (that trips the verifier's `transcript_errors` downgrade).
59
59
  - **Fail loud, fail fast.** On any step failure, print one diagnostic line and `exit 1`; don't swallow with `|| true` or spin in a silent retry. A `rateLimited` exit-1 is the scheduler's benign auto-pause (auto-resumes next window) — not a failure to engineer around.
60
60
  - **Stay in the AC.** Do not add work past the acceptance checklist ("while we're here" generators/fixtures are the post-AC-overrun incident). Body must be clean UTF-8 — no NUL/control bytes.
61
- - **You ARE the executor — never re-queue or self-schedule.** A headless PRD run must perform its own acceptance criteria directly. Do NOT invoke `/develop`, `/process-feedback`, or any queue-authoring skill from inside a run — those are interactive main-loop skills that author a *new* PRD and return, so the run exits 0 having done nothing (no commit, no sentinel → `needs_review` with `no_verdict_sentinel`). Do NOT call `ScheduleWakeup`/set a tracking loop either — the process exits when the run ends and nothing re-invokes it. If the PRD's work looks large, decompose and execute it inline within this run; never delegate it back to the queue. (Incident: PRD 460 invoked `/develop`, spawned a duplicate PRD 461, and exited 0 with no work.)
61
+ - **You ARE the executor — never re-queue or self-schedule.** A headless PRD run must perform its own acceptance criteria directly. Do NOT invoke `/develop`, `/process-feedback`, or any queue-authoring skill from inside a run — those are interactive main-loop skills that author a *new* PRD and return, so the run exits 0 having done nothing (no commit, no sentinel → `needs_review` with `no_verdict_sentinel`). Do NOT call `ScheduleWakeup`/set a tracking loop either — the process exits when the run ends and nothing re-invokes it. This applies just as much to spawning your own review agents and waiting on them: do NOT invoke `/code-review`, `/security-review`, `requesting-code-review`, or any other skill/subagent as a background/async step and then end your turn with something like "I'll wait for the review agents to complete" — a headless run has no next turn, so that line is the run's last output, no verdict sentinel prints, and the job parks in `needs_review` even though the actual work already landed. If a PRD's acceptance criteria call for a second review pass, run it **synchronously, inline, before the finish protocol** — call the reviewer and read its result in the same turn, don't fire-and-wait. If the PRD's work looks large, decompose and execute it inline within this run; never delegate it back to the queue. (Incidents: PRD 460 invoked `/develop`, spawned a duplicate PRD 461, and exited 0 with no work. PRD 479 landed its commit correctly but then backgrounded `/code-review --fix` + `/security-review` and called `ScheduleWakeup` to "wait" for them — same class of failure, different entry point.)
62
+ - **A shared-repo `cwd` can be occupied by a concurrent job — check before you touch shared state.** When a PRD's `cwd` is a repo other headless runs may also target (a shared team repo like sigma, not a private single-purpose project), a `git checkout`/`gh pr checkout` can land you in another job's live worktree with its own uncommitted WIP. Before running `git stash`, `git reset`, or any command that discards or hides working-tree state, check `git stash list` and `git status` first, and if you must set aside pre-existing uncommitted changes that aren't yours, **stash with a descriptive message** (`git stash push -m "pre-existing WIP found by PRD <NN>, not mine"`) and **restore it before your run ends** (or, if you can't safely restore because your own commit depends on that worktree state, leave it stashed with the message and say so explicitly in your finish output — never let the run end silently dropping someone else's stash). Never `git stash drop`/`git clean -fd` on state you didn't create. (Incident: PRD 477 stashed a concurrent job's rAF-throttle-revert WIP to get its own checkout, finished, and exited without restoring it — orphaning the other job's uncommitted work in `stash@{0}` with no record of whose it was.)
63
+ - **`gh pr edit --body` can fail on repos with legacy GitHub Projects (classic) boards** — the underlying GraphQL query fetches `repository.pullRequest.projectCards`, a field GitHub is sunsetting, and errors with `GraphQL: Projects (classic) is being deprecated ... (repository.pullRequest.projectCards)` even though the edit itself would otherwise succeed. This is a known `gh` CLI quirk, not a defect in your work. Prefer `gh api -X PATCH repos/<owner>/<repo>/pulls/<n> -f body="$(cat body.md)"` for updating a PR description headlessly — it doesn't touch the deprecated field. If you do use `gh pr edit` and it fails this way, don't leave the bare GraphQL error as the last thing in that step (it reads as an unrecovered error in the final-20%-of-transcript verifier heuristic): immediately retry with the `gh api` form and print one line noting the known-bug fallback, so the recovery is adjacent to the error.
62
64
  - **Negative-assertion checks must exit 0 when clean.** A check that verifies the *absence* of something (a `grep` that should find nothing, "no leftover X", `diff` expecting no change) must return exit 0 on the clean case. A bare `grep` exits **1 on no-match** — so the *success* path surfaces as `is_error=true` and the verifier downgrades a perfect run to `needs_review`. Always invert: `if <detector>; then echo "HALT: <what was found>"; exit 1; fi; echo clean`. Never let the no-match/empty path carry the non-zero exit.
63
65
  - **Recover or annotate every error — don't strand a Traceback in the transcript.** The verifier downgrades an otherwise-perfect run to `needs_review` when a `Traceback`/`Error` appears with *no visible recovery within ~10 lines* (the `transcript_errors` heuristic — the single most common false-positive on green deliverables). Two executor habits cause it: (1) **throwaway probes that error** — an inline `python -c` with a quoting/f-string slip, a wrong kwarg, a bad path. When a probe errors, immediately re-run the corrected version *or* print one line `# expected/handled: <why>` right after, so recovery is adjacent. Don't move on leaving a bare error as the last thing in that step. Prefer a small temp `.py` file over a fragile multi-quote `python -c` one-liner (inline f-string errors are the top source of stranded tracebacks). (2) See the timeout rule below.
64
66
  - **An *expected* bounded-timeout (exit 124) must be annotated, not bare.** `timeout`-capping a genuinely long task you expect to hit the cap (a full-universe ingest, a long scan) is correct — but a bare `Exit code 124` reads as a failure to the verifier. Wrap it so the cap is a success-with-note: `timeout 120 <cmd> || { rc=$?; [ $rc -eq 124 ] && echo "hit time cap — idempotent/partial, rows persist incrementally; OK" || { echo "HALT: <cmd> failed rc=$rc"; exit 1; }; }`. (Distinguish 124 = expected cap from a real non-zero.) For work that legitimately needs longer than a safe cap, run it in the background and poll a bounded number of times rather than capping the foreground command.
@@ -19,6 +19,7 @@ const os = require('node:os');
19
19
  const fs = require('node:fs');
20
20
  const path = require('node:path');
21
21
  const { runDefinitionOfDoneOnDrain } = require('../lib/dodDrainHook.cjs');
22
+ const { readWatermark } = require('../lib/definitionOfDone.cjs');
22
23
 
23
24
  // ─── helpers ──────────────────────────────────────────────────────────────────
24
25
 
@@ -172,6 +173,110 @@ test('paused state → no report written', async () => {
172
173
  }
173
174
  });
174
175
 
176
+ test('first-ever drain (no watermark file) processes all completed jobs', async () => {
177
+ const tmpDir = makeTmpDir();
178
+ const prdsDir = path.join(tmpDir, 'prds');
179
+ const runsDir = path.join(tmpDir, 'runs');
180
+ const cwd = path.join(tmpDir, 'project');
181
+ fs.mkdirSync(prdsDir);
182
+ fs.mkdirSync(cwd);
183
+
184
+ const jobA = writePrd(prdsDir, '106-old', cwd);
185
+ jobA.finishedAt = '2020-01-01T00:00:00.000Z';
186
+ const jobB = writePrd(prdsDir, '107-new', cwd);
187
+ jobB.finishedAt = '2026-07-11T00:00:00.000Z';
188
+ const state = { paused: null, jobs: [jobA, jobB] };
189
+ const cancelToken = { cancelled: false };
190
+ const savedDisable = process.env.SM_DOD_DISABLE;
191
+ delete process.env.SM_DOD_DISABLE;
192
+
193
+ try {
194
+ assert.strictEqual(readWatermark(runsDir), null, 'no watermark should exist yet');
195
+ await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
196
+ assert.strictEqual(countReports(runsDir), 1);
197
+
198
+ const reportsRoot = fs.readdirSync(runsDir, { withFileTypes: true }).filter(e => e.isDirectory());
199
+ const reportFile = reportsRoot
200
+ .map(e => path.join(runsDir, e.name))
201
+ .flatMap(dir => fs.readdirSync(dir).filter(f => f.startsWith('definition-of-done-')).map(f => path.join(dir, f)))[0];
202
+ const contents = fs.readFileSync(reportFile, 'utf8');
203
+ assert.match(contents, /106-old/, 'first-ever drain must include old job too');
204
+ assert.match(contents, /107-new/);
205
+ } finally {
206
+ if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
207
+ rmdir(tmpDir);
208
+ }
209
+ });
210
+
211
+ test('subsequent drain with a persisted watermark only reverifies newer jobs', async () => {
212
+ const tmpDir = makeTmpDir();
213
+ const prdsDir = path.join(tmpDir, 'prds');
214
+ const runsDir = path.join(tmpDir, 'runs');
215
+ const cwd = path.join(tmpDir, 'project');
216
+ fs.mkdirSync(prdsDir);
217
+ fs.mkdirSync(cwd);
218
+ fs.mkdirSync(runsDir, { recursive: true });
219
+ fs.writeFileSync(
220
+ path.join(runsDir, '.dod-watermark.json'),
221
+ JSON.stringify({ lastFinishedAt: '2026-01-01T00:00:00.000Z' })
222
+ );
223
+
224
+ const jobOld = writePrd(prdsDir, '108-old', cwd);
225
+ jobOld.finishedAt = '2025-01-01T00:00:00.000Z';
226
+ const jobNew = writePrd(prdsDir, '109-new', cwd);
227
+ jobNew.finishedAt = '2026-07-11T00:00:00.000Z';
228
+ const state = { paused: null, jobs: [jobOld, jobNew] };
229
+ const cancelToken = { cancelled: false };
230
+ const savedDisable = process.env.SM_DOD_DISABLE;
231
+ delete process.env.SM_DOD_DISABLE;
232
+
233
+ try {
234
+ await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
235
+ assert.strictEqual(countReports(runsDir), 1);
236
+
237
+ const reportsRoot = fs.readdirSync(runsDir, { withFileTypes: true }).filter(e => e.isDirectory());
238
+ const reportFile = reportsRoot
239
+ .map(e => path.join(runsDir, e.name))
240
+ .flatMap(dir => fs.readdirSync(dir).filter(f => f.startsWith('definition-of-done-')).map(f => path.join(dir, f)))[0];
241
+ const contents = fs.readFileSync(reportFile, 'utf8');
242
+ assert.doesNotMatch(contents, /108-old/, 'jobs finished before the watermark must be excluded');
243
+ assert.match(contents, /109-new/, 'jobs finished after the watermark must be included');
244
+ } finally {
245
+ if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
246
+ rmdir(tmpDir);
247
+ }
248
+ });
249
+
250
+ test('watermark advances after a report so a repeat drain over the same jobs is a no-op', async () => {
251
+ const tmpDir = makeTmpDir();
252
+ const prdsDir = path.join(tmpDir, 'prds');
253
+ const runsDir = path.join(tmpDir, 'runs');
254
+ const cwd = path.join(tmpDir, 'project');
255
+ fs.mkdirSync(prdsDir);
256
+ fs.mkdirSync(cwd);
257
+
258
+ const job = writePrd(prdsDir, '110-test', cwd);
259
+ job.finishedAt = '2026-07-11T00:00:00.000Z';
260
+ const state = { paused: null, jobs: [job] };
261
+ const cancelToken = { cancelled: false };
262
+ const savedDisable = process.env.SM_DOD_DISABLE;
263
+ delete process.env.SM_DOD_DISABLE;
264
+
265
+ try {
266
+ await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
267
+ assert.strictEqual(countReports(runsDir), 1, 'first drain writes one report');
268
+ assert.strictEqual(readWatermark(runsDir), job.finishedAt, 'watermark must advance to the covered job\'s finishedAt');
269
+
270
+ // Second drain with a fresh job list containing the same already-covered job.
271
+ const state2 = { paused: null, jobs: [{ ...job }] };
272
+ await runDefinitionOfDoneOnDrain(state2, { cancelToken, prdsDir, runsDir });
273
+ assert.strictEqual(countReports(runsDir), 1, 'repeat drain over the same (now-old) job must be a no-op');
274
+ } finally {
275
+ if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
276
+ rmdir(tmpDir);
277
+ }
278
+ });
279
+
175
280
  test('cancelToken.cancelled=true → no report written', async () => {
176
281
  const tmpDir = makeTmpDir();
177
282
  const prdsDir = path.join(tmpDir, 'prds');
@@ -297,6 +297,7 @@ test('Truly clean run (no error markers) → clean/null', async () => {
297
297
  prdPath,
298
298
  queueEntry: { slug, status: 'running' },
299
299
  allJobs: [],
300
+ committedDuringRun: true,
300
301
  });
301
302
 
302
303
  assert.equal(verdict.verdict, 'clean', `expected clean, got ${verdict.verdict}: ${verdict.reason}`);
@@ -379,6 +380,7 @@ test('FAIL recovered within 30 events → clean', async () => {
379
380
  prdPath,
380
381
  queueEntry: { slug, status: 'running' },
381
382
  allJobs: [],
383
+ committedDuringRun: true,
382
384
  });
383
385
 
384
386
  assert.equal(verdict.verdict, 'clean', `self-recovery should yield clean, got ${verdict.verdict}: ${verdict.reason}`);
@@ -430,7 +432,7 @@ test('feedback 01: reviewer prose mentioning ImportError mid-sentence → clean'
430
432
  ].join('\n');
431
433
  writeLog(tmp, slug, bashRunEvents(prose));
432
434
  const prdPath = writePrd(tmp, slug, '# Hardening');
433
- const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
435
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
434
436
  assert.equal(verdict.verdict, 'clean', `prose mention must not flag, got ${verdict.verdict}: ${verdict.reason}`);
435
437
  } finally { rmdir(tmp); }
436
438
  });
@@ -477,7 +479,7 @@ test('addendum: Traceback→ModuleNotFoundError in a SUCCEEDED run → clean (an
477
479
  ].join('\n');
478
480
  writeLog(tmp, slug, bashRunEvents(probe)); // resultSubtype defaults to success
479
481
  const prdPath = writePrd(tmp, slug, '# Shared lib');
480
- const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
482
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
481
483
  assert.equal(verdict.verdict, 'clean', `recovered env probe must not downgrade, got ${verdict.verdict}: ${verdict.reason}`);
482
484
  assert.equal(verdict.downgradeTo, null);
483
485
  assert.ok(Array.isArray(verdict.annotations) && verdict.annotations.length === 1, 'should record one annotation');
@@ -491,7 +493,7 @@ test('addendum: bare Import/ModuleNotFound probe (no traceback) in SUCCEEDED run
491
493
  const slug = '26-self-parser-tests';
492
494
  writeLog(tmp, slug, bashRunEvents("ModuleNotFoundError: No module named 'conftest'"));
493
495
  const prdPath = writePrd(tmp, slug, '# Parser tests');
494
- const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
496
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
495
497
  assert.equal(verdict.verdict, 'clean', `got ${verdict.verdict}: ${verdict.reason}`);
496
498
  assert.ok(Array.isArray(verdict.annotations) && verdict.annotations.length === 1);
497
499
  } finally { rmdir(tmp); }
@@ -527,7 +529,7 @@ test('feedback 01: error-shaped text inside a Task (subagent) result → clean',
527
529
  ].join('\n');
528
530
  writeLog(tmp, slug, bashRunEvents(reviewFinding, { toolName: 'Task' }));
529
531
  const prdPath = writePrd(tmp, slug, '# Review-heavy PRD');
530
- const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
532
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
531
533
  assert.equal(verdict.verdict, 'clean', `Task results must be exempt from pattern scan, got ${verdict.verdict}: ${verdict.reason}`);
532
534
  } finally { rmdir(tmp); }
533
535
  });
@@ -543,7 +545,7 @@ test('feedback 01: quoted "Traceback..." line (leading quote) → clean', async
543
545
  ].join('\n');
544
546
  writeLog(tmp, slug, bashRunEvents(prose));
545
547
  const prdPath = writePrd(tmp, slug, '# Docs PRD');
546
- const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
548
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
547
549
  assert.equal(verdict.verdict, 'clean', `quoted traceback prose must not flag, got ${verdict.verdict}: ${verdict.reason}`);
548
550
  } finally { rmdir(tmp); }
549
551
  });
@@ -572,7 +574,7 @@ test('harness tool error (<tool_use_error>) in final 20% → clean', async () =>
572
574
 
573
575
  writeLog(tmp, slug, events);
574
576
  const prdPath = writePrd(tmp, slug, '# Correctness batch');
575
- const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [] });
577
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
576
578
  assert.equal(verdict.verdict, 'clean', `harness tool error must not flag, got ${verdict.verdict}: ${verdict.reason}`);
577
579
  } finally { rmdir(tmp); }
578
580
  });
@@ -859,7 +861,7 @@ test('no_verdict_sentinel guard: same run but committedDuringRun:true → stays
859
861
  }
860
862
  });
861
863
 
862
- test('no_verdict_sentinel guard: same run but with SCHEDULER_VERDICT: PASS sentinel → stays clean', async () => {
864
+ test('no_verdict_sentinel guard: same run but with SCHEDULER_VERDICT: PASS sentinel + a commit → stays clean', async () => {
863
865
  const tmp = makeTmpDir();
864
866
  try {
865
867
  const slug = '406-browser-capture-panel-ui-sentinel';
@@ -870,15 +872,37 @@ test('no_verdict_sentinel guard: same run but with SCHEDULER_VERDICT: PASS senti
870
872
  prdPath,
871
873
  queueEntry: { slug, status: 'running' },
872
874
  allJobs: [],
873
- committedDuringRun: false,
875
+ committedDuringRun: true,
874
876
  });
875
- assert.equal(verdict.verdict, 'clean', `PASS sentinel should stay clean, got ${verdict.verdict}: ${verdict.reason}`);
877
+ assert.equal(verdict.verdict, 'clean', `PASS sentinel + commit should stay clean, got ${verdict.verdict}: ${verdict.reason}`);
876
878
  assert.equal(verdict.downgradeTo, null);
877
879
  } finally {
878
880
  rmdir(tmp);
879
881
  }
880
882
  });
881
883
 
884
+ // ─── pass_no_commit: PASS sentinel with no commit is not "clean" ───────────
885
+
886
+ test('pass_no_commit: SCHEDULER_VERDICT: PASS but no commit landed → needs_review', async () => {
887
+ const tmp = makeTmpDir();
888
+ try {
889
+ const slug = '511-recorder-mouse-drag-panel-export';
890
+ writeLog(tmp, slug, noOpRunEvents('All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS'));
891
+ const prdPath = writePrd(tmp, slug, '# Recorder mouse drag panel export');
892
+ const verdict = await verifyRun({
893
+ runDir: tmp,
894
+ prdPath,
895
+ queueEntry: { slug, status: 'running' },
896
+ allJobs: [],
897
+ committedDuringRun: false,
898
+ });
899
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
900
+ assert.equal(verdict.downgradeTo, 'needs_review');
901
+ } finally {
902
+ rmdir(tmp);
903
+ }
904
+ });
905
+
882
906
  // (e) halt + sentinel PASS → still halt (override must not apply to halt)
883
907
  test('halt result + sentinel PASS + committedDuringRun:true → still halt', async () => {
884
908
  const tmp = makeTmpDir();
@@ -0,0 +1,62 @@
1
+ /**
2
+ * scheduler-committed-in-window.test.cjs — unit tests for committedInWindow.
3
+ *
4
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-committed-in-window.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { execFileSync } = require('node:child_process');
13
+ const { committedInWindow } = require('../scheduler.cjs');
14
+
15
+ function git(cwd, args) {
16
+ return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
17
+ }
18
+
19
+ function makeRepo() {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-committed-in-window-'));
21
+ git(dir, ['init', '-q']);
22
+ git(dir, ['config', 'user.email', 'test@example.com']);
23
+ git(dir, ['config', 'user.name', 'Test']);
24
+ fs.writeFileSync(path.join(dir, 'base.txt'), 'base\n');
25
+ git(dir, ['add', '.']);
26
+ git(dir, ['commit', '-q', '-m', 'base']);
27
+ return dir;
28
+ }
29
+
30
+ test('committedInWindow returns true for a commit landed on a non-checked-out branch', async () => {
31
+ const dir = makeRepo();
32
+ try {
33
+ git(dir, ['checkout', '-q', '-b', 'work']);
34
+
35
+ const startedAt = new Date().toISOString();
36
+ fs.writeFileSync(path.join(dir, 'feature.txt'), 'feature\n');
37
+ git(dir, ['add', '.']);
38
+ git(dir, ['commit', '-q', '-m', 'feature commit']);
39
+ const finishedAt = new Date(Date.now() + 1000).toISOString();
40
+
41
+ // Simulate the run leaving a different branch checked out at exit.
42
+ git(dir, ['checkout', '-q', '-b', 'other-branch-at-exit']);
43
+
44
+ const result = await committedInWindow(dir, startedAt, finishedAt);
45
+ expect(result).toBe(true);
46
+ } finally {
47
+ fs.rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ test('committedInWindow returns false when the window covers no commit', async () => {
52
+ const dir = makeRepo();
53
+ try {
54
+ const farFutureStart = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
55
+ const farFutureEnd = new Date(Date.now() + 366 * 24 * 60 * 60 * 1000).toISOString();
56
+
57
+ const result = await committedInWindow(dir, farFutureStart, farFutureEnd);
58
+ expect(result).toBe(false);
59
+ } finally {
60
+ fs.rmSync(dir, { recursive: true, force: true });
61
+ }
62
+ });
@@ -344,7 +344,7 @@ function handleRecordEvent(event, payload) {
344
344
  const viewId = contentsIdToViewId.get(event.sender.id);
345
345
  if (!viewId || !recordingViewIds.has(viewId)) return;
346
346
  const verb = payload && payload.verb;
347
- if (verb !== 'click' && verb !== 'type') return;
347
+ if (verb !== 'click' && verb !== 'type' && verb !== 'drag') return;
348
348
  const target = typeof payload.target === 'string' ? payload.target.slice(0, 300) : '';
349
349
  const step = { verb, target };
350
350
  if (verb === 'type') {
@@ -353,6 +353,19 @@ function handleRecordEvent(event, payload) {
353
353
  step.variableSuggestion = payload.variableSuggestion.slice(0, 64);
354
354
  }
355
355
  }
356
+ if (verb === 'click') {
357
+ const x = Number(payload.x);
358
+ const y = Number(payload.y);
359
+ if (Number.isFinite(x)) step.x = x;
360
+ if (Number.isFinite(y)) step.y = y;
361
+ }
362
+ if (verb === 'drag') {
363
+ if (typeof payload.endTarget === 'string') step.endTarget = payload.endTarget.slice(0, 300);
364
+ for (const key of ['x', 'y', 'endX', 'endY']) {
365
+ const n = Number(payload[key]);
366
+ if (Number.isFinite(n)) step[key] = n;
367
+ }
368
+ }
356
369
  emitRecordStep(viewId, step);
357
370
  }
358
371
 
@@ -371,7 +384,16 @@ function withTimeout(promise, ms, label) {
371
384
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
372
385
  }
373
386
 
387
+ async function replayPositionedClick(wc, x, y) {
388
+ wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
389
+ wc.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
390
+ }
391
+
374
392
  async function replayClickOrType(wc, step, values) {
393
+ if (step.verb === 'click' && Number.isFinite(step.x) && Number.isFinite(step.y)) {
394
+ await replayPositionedClick(wc, step.x, step.y);
395
+ return;
396
+ }
375
397
  const script =
376
398
  step.verb === 'type'
377
399
  ? `(() => {
@@ -429,6 +451,25 @@ async function replayWaitFor(wc, step) {
429
451
  }
430
452
  }
431
453
 
454
+ const REPLAY_DRAG_STEPS = 3;
455
+
456
+ async function replayDrag(wc, step) {
457
+ const { x, y, endX, endY } = step;
458
+ if (![x, y, endX, endY].every((n) => Number.isFinite(n))) {
459
+ throw new Error('drag step missing coordinates');
460
+ }
461
+ wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
462
+ for (let i = 1; i <= REPLAY_DRAG_STEPS; i += 1) {
463
+ const t = i / REPLAY_DRAG_STEPS;
464
+ wc.sendInputEvent({
465
+ type: 'mouseMove',
466
+ x: Math.round(x + (endX - x) * t),
467
+ y: Math.round(y + (endY - y) * t),
468
+ });
469
+ }
470
+ wc.sendInputEvent({ type: 'mouseUp', x: endX, y: endY, button: 'left', clickCount: 1 });
471
+ }
472
+
432
473
  async function replayStep(view, step, values) {
433
474
  const wc = view.webContents;
434
475
  if (step.verb === 'navigate') {
@@ -445,6 +486,10 @@ async function replayStep(view, step, values) {
445
486
  await replayWaitFor(wc, step);
446
487
  return;
447
488
  }
489
+ if (step.verb === 'drag') {
490
+ await withTimeout(replayDrag(wc, step), REPLAY_STEP_TIMEOUT_MS, 'drag');
491
+ return;
492
+ }
448
493
  throw new Error(`unsupported step verb: ${step.verb}`);
449
494
  }
450
495
 
@@ -533,6 +533,42 @@ function writeReport(key, { acResults = [], riskFlags = [], runsDir } = {}) {
533
533
  return reportPath;
534
534
  }
535
535
 
536
+ const WATERMARK_FILENAME = '.dod-watermark.json';
537
+
538
+ /**
539
+ * Read the persisted "last finished" watermark used to bound which completed
540
+ * jobs get reverified on each drain. Never throws — a missing or unparseable
541
+ * sidecar means "beginning of time" (process everything), matching the
542
+ * pre-watermark unbounded behavior on first-ever drain.
543
+ *
544
+ * @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
545
+ * @returns {string|null} ISO8601 timestamp, or null if unset/unreadable.
546
+ */
547
+ function readWatermark(runsDir = RUNS_DIR) {
548
+ try {
549
+ const raw = fs.readFileSync(path.join(runsDir, WATERMARK_FILENAME), 'utf8');
550
+ const parsed = JSON.parse(raw);
551
+ return typeof parsed.lastFinishedAt === 'string' ? parsed.lastFinishedAt : null;
552
+ } catch {
553
+ return null;
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Persist the "last finished" watermark so the next drain only reverifies
559
+ * jobs that completed after it.
560
+ *
561
+ * @param {string} lastFinishedAt ISO8601 timestamp.
562
+ * @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
563
+ */
564
+ function writeWatermark(lastFinishedAt, runsDir = RUNS_DIR) {
565
+ fs.mkdirSync(runsDir, { recursive: true });
566
+ _writeFileAtomic(
567
+ path.join(runsDir, WATERMARK_FILENAME),
568
+ JSON.stringify({ lastFinishedAt }, null, 2)
569
+ );
570
+ }
571
+
536
572
  module.exports = {
537
573
  batchKey,
538
574
  reportPathFor,
@@ -542,4 +578,6 @@ module.exports = {
542
578
  reverifyBatch,
543
579
  flagRiskySurfaces,
544
580
  writeReport,
581
+ readWatermark,
582
+ writeWatermark,
545
583
  };
@@ -21,6 +21,8 @@ const {
21
21
  reverifyBatch,
22
22
  flagRiskySurfaces,
23
23
  writeReport,
24
+ readWatermark,
25
+ writeWatermark,
24
26
  } = require('./definitionOfDone.cjs');
25
27
 
26
28
  // Track keys whose DoD pass is currently in-flight.
@@ -58,7 +60,19 @@ async function runDefinitionOfDoneOnDrain(state, opts = {}) {
58
60
  if (state.paused) return;
59
61
  if (cancelToken.cancelled) return;
60
62
 
61
- const completedJobs = (state.jobs || []).filter(j => j.status === 'completed');
63
+ const allCompleted = (state.jobs || []).filter(j => j.status === 'completed');
64
+ if (allCompleted.length === 0) return;
65
+
66
+ // Watermark: only reverify jobs that finished after the last DoD report.
67
+ // Jobs missing finishedAt are treated as older than any real watermark
68
+ // (excluded once a watermark exists; included on the first-ever drain when
69
+ // watermark is null, preserving back-compat with the unbounded behavior).
70
+ const wmIso = readWatermark(runsDir); // null on first-ever drain
71
+ const wm = wmIso ? new Date(wmIso).getTime() : 0; // 0 = beginning of time
72
+ const completedJobs = allCompleted.filter(j => {
73
+ const t = j.finishedAt ? new Date(j.finishedAt).getTime() : NaN;
74
+ return wm === 0 ? true : (Number.isFinite(t) && t > wm);
75
+ });
62
76
  if (completedJobs.length === 0) return;
63
77
 
64
78
  const key = batchKey(completedJobs);
@@ -73,6 +87,14 @@ async function runDefinitionOfDoneOnDrain(state, opts = {}) {
73
87
  if (cancelToken.cancelled) return;
74
88
  const riskFlags = flagRiskySurfaces(completedJobs, { prdsDir });
75
89
  writeReport(key, { acResults, riskFlags, runsDir });
90
+
91
+ // Advance watermark to the newest finishedAt we just covered, so the next
92
+ // drain excludes this batch. Guard against jobs with no/invalid finishedAt.
93
+ const maxFinished = completedJobs
94
+ .map(j => (j.finishedAt ? new Date(j.finishedAt).getTime() : NaN))
95
+ .filter(Number.isFinite)
96
+ .reduce((a, b) => Math.max(a, b), wm);
97
+ if (maxFinished > wm) writeWatermark(new Date(maxFinished).toISOString(), runsDir);
76
98
  } finally {
77
99
  _inFlight.delete(key);
78
100
  }
@@ -628,6 +628,22 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
628
628
  });
629
629
  }
630
630
 
631
+ // A truthful-looking PASS sentinel with no commit is still not "clean":
632
+ // the finish protocol requires the commit to land before printing PASS
633
+ // (see the module's finish-protocol docs), so a PASS with no commit means
634
+ // the run's own claim of success is unsubstantiated — route it to
635
+ // needs_review so the auto-fix pipeline can investigate rather than
636
+ // silently accepting a bare sentinel as proof of work done. Mutually
637
+ // exclusive with the no_verdict_sentinel case above (sentinel === null
638
+ // vs sentinel === 'pass'), kept as a separate sibling check for clarity.
639
+ if (sentinel === 'pass' && !committedDuringRun) {
640
+ issues.push({
641
+ verdict: 'pass_no_commit',
642
+ reason: 'SCHEDULER_VERDICT: PASS but no commit landed during the run window — the run claims success but produced no code change',
643
+ priority: 1,
644
+ });
645
+ }
646
+
631
647
  if (issues.length === 0) {
632
648
  const reason = annotations.length
633
649
  ? `no blocking issues (${annotations.length} annotation(s): ${annotations.map((a) => a.reason).join('; ')})`
@@ -193,10 +193,12 @@ function gitHead(cwd) {
193
193
  });
194
194
  }
195
195
 
196
- // Returns true if ≥1 commit landed in cwd between startedAt and finishedAt
197
- // (with 60s slack). Used by the self-heal pass to derive committedDuringRun
198
- // from the recorded run window the live commit-guard uses gitHead() instead.
199
- // Never throws; git-unavailable false (no override, job stays as-is).
196
+ // Returns true if ≥1 commit landed on any ref (branch, remote-tracking branch,
197
+ // or tag) in cwd between startedAt and finishedAt (with 60s slack) not just
198
+ // the currently checked-out branch. Used by the self-heal pass to derive
199
+ // committedDuringRun from the recorded run window the live commit-guard uses
200
+ // gitHead() instead. Never throws; git-unavailable → false (no override, job
201
+ // stays as-is).
200
202
  function committedInWindow(cwd, startedAt, finishedAt) {
201
203
  return new Promise((resolve) => {
202
204
  if (!cwd || !startedAt) { resolve(false); return; }
@@ -205,7 +207,7 @@ function committedInWindow(cwd, startedAt, finishedAt) {
205
207
  : new Date().toISOString();
206
208
  execFile(
207
209
  'git',
208
- ['-C', cwd, 'log', '--format=%H', `--since=${startedAt}`, `--until=${until}`],
210
+ ['-C', cwd, 'log', '--all', '--format=%H', `--since=${startedAt}`, `--until=${until}`],
209
211
  { timeout: 10_000, windowsHide: true },
210
212
  (err, stdout) => { resolve(!err && String(stdout || '').trim().length > 0); },
211
213
  );
@@ -2658,4 +2660,4 @@ const remote = {
2658
2660
  },
2659
2661
  };
2660
2662
 
2661
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt };
2663
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow };
@@ -29,7 +29,7 @@ export interface RecordStep {
29
29
  n: number;
30
30
  /** `select` is accepted by replay/export (PRD 410) for forward-compat;
31
31
  * the live engine does not emit it yet. */
32
- verb: 'navigate' | 'click' | 'type' | 'select' | 'wait-for';
32
+ verb: 'navigate' | 'click' | 'type' | 'select' | 'wait-for' | 'drag';
33
33
  target: string;
34
34
  kind?: 'nav' | 'assert';
35
35
  /** True for `type` steps — the actual typed value is never captured. */
@@ -40,6 +40,16 @@ export interface RecordStep {
40
40
  variable?: string | null;
41
41
  /** `select` steps only — the option value to choose on replay/export. */
42
42
  value?: string;
43
+ /** Click position, or drag-start position for `drag` steps. */
44
+ x?: number;
45
+ /** Click position, or drag-start position for `drag` steps. */
46
+ y?: number;
47
+ /** `drag` steps only — drag-end target selector. */
48
+ endTarget?: string;
49
+ /** `drag` steps only — drag-end x position. */
50
+ endX?: number;
51
+ /** `drag` steps only — drag-end y position. */
52
+ endY?: number;
43
53
  }
44
54
 
45
55
  /** Per-step replay outcome (PRD 410), streamed as `browser:replay-step:<viewId>`. */