claude-code-session-manager 0.79.0 → 0.81.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 (82) hide show
  1. package/dist/assets/{AgentLibrary-COtVRqBR.js → AgentLibrary-psZYVM2w.js} +1 -1
  2. package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-BMied5pg.js} +1 -1
  3. package/dist/assets/{History-CHHovrAO.js → History-BFC0oaKc.js} +1 -1
  4. package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-CTLfO9G8.js} +1 -1
  5. package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-D4I0Cpwn.js} +1 -1
  6. package/dist/assets/{Library-BtxdyTLz.js → Library-BTzS8KsS.js} +1 -1
  7. package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CuRmT008.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-BgQGtvWo.js} +1 -1
  9. package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-DiVQUK57.js} +1 -1
  10. package/dist/assets/{Memory-CR72KoyP.js → Memory-DSu15JmL.js} +1 -1
  11. package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CD5wxGSR.js} +1 -1
  12. package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-C_tAE6yJ.js} +1 -1
  13. package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-kwI7W-eK.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-CQceOgsH.js} +1 -1
  15. package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-BDk5e3Pp.js} +1 -1
  16. package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-DRnvEYzv.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-CeifaOlq.js} +1 -1
  18. package/dist/assets/{Settings-BXuyf4lJ.js → Settings-BWQ1Utop.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
  20. package/dist/assets/{Skills-CHqcpiyt.js → Skills-BLwFB0E4.js} +1 -1
  21. package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DC4ZTArJ.js} +1 -1
  22. package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-pMeGfjUb.js} +1 -1
  23. package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-BHFid2pZ.js} +1 -1
  24. package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-D9eYoZh4.js} +1 -1
  25. package/dist/assets/{index-DPYa6jbM.js → index-CuyM9vAP.js} +5 -5
  26. package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-DrxC67uZ.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  31. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
  32. package/src/main/__tests__/prdCreate.test.cjs +133 -8
  33. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
  34. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
  35. package/src/main/__tests__/queueHistory.test.cjs +33 -0
  36. package/src/main/__tests__/rcaReport.test.cjs +24 -0
  37. package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
  38. package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
  39. package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
  40. package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
  41. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
  42. package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
  43. package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
  44. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
  45. package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
  46. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
  47. package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
  48. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
  49. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
  50. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
  51. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
  52. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
  53. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
  54. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
  55. package/src/main/ipcSchemas.cjs +15 -1
  56. package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
  57. package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
  58. package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
  59. package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
  60. package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
  61. package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
  62. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
  63. package/src/main/lib/branchSweep.cjs +127 -0
  64. package/src/main/lib/depSlugResolve.cjs +72 -0
  65. package/src/main/lib/epicWorktreeMerge.cjs +3 -3
  66. package/src/main/lib/epicWorktreeMint.cjs +17 -5
  67. package/src/main/lib/fixPlanSlug.cjs +62 -0
  68. package/src/main/lib/gitWorktree.cjs +117 -12
  69. package/src/main/lib/landedSinceRun.cjs +41 -1
  70. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  71. package/src/main/lib/prdCreate.cjs +84 -5
  72. package/src/main/lib/prdFrontmatter.cjs +56 -8
  73. package/src/main/lib/queueHistory.cjs +50 -5
  74. package/src/main/lib/rateLimitWindow.cjs +62 -0
  75. package/src/main/lib/rcaReport.cjs +18 -3
  76. package/src/main/lib/reaperHelpers.cjs +169 -3
  77. package/src/main/lib/scheduleJobTransitions.cjs +28 -5
  78. package/src/main/lib/schedulerBatch.cjs +181 -23
  79. package/src/main/runVerify.cjs +71 -3
  80. package/src/main/scheduler/prdParser.cjs +7 -0
  81. package/src/main/scheduler.cjs +1554 -71
  82. package/src/preload/api.d.ts +8 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * prdFrontmatterDependsOn.test.cjs — PRD 1124: `dependsOn` is a recognized,
3
+ * patchable PRD frontmatter key. Before this PRD, RECOGNIZED_KEYS omitted
4
+ * `dependsOn`, so it only round-tripped verbatim via `extras` and
5
+ * scheduler_update_prd could not patch it — the only repair path for a
6
+ * wrong dependsOn was archive-and-recreate, which is unsafe (archiving
7
+ * marks the PRD completed, freeing its dependents with zero work done).
8
+ *
9
+ * Covers the pure parse/serialize contract in prdFrontmatter.cjs. The
10
+ * write-time FK validation + clearing semantics wired into
11
+ * scheduler.remote.updatePrd are covered by
12
+ * prdUpdateDependsOn.test.cjs.
13
+ *
14
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdFrontmatterDependsOn.test.cjs
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ import { test, expect } from 'vitest';
20
+
21
+ const { parsePrdFile, serializePrdFile, RECOGNIZED_KEYS } = require('../lib/prdFrontmatter.cjs');
22
+
23
+ test('dependsOn is a recognized key', () => {
24
+ expect(RECOGNIZED_KEYS.has('dependsOn')).toBe(true);
25
+ });
26
+
27
+ test('parsePrdFile parses an inline dependsOn list into an array, and round-trips it byte-identically when unedited', () => {
28
+ const raw = [
29
+ '---',
30
+ 'title: A follow-up PRD',
31
+ 'cwd: ~/Projects/session-manager',
32
+ 'estimateMinutes: 20',
33
+ 'agentType: dev-lead',
34
+ 'dependsOn: [widget-base, widget-shared]',
35
+ '---',
36
+ '# Goal',
37
+ '',
38
+ 'Build on the base.',
39
+ '',
40
+ ].join('\n');
41
+
42
+ const { frontmatter, body } = parsePrdFile(raw);
43
+ expect(frontmatter.dependsOn).toEqual(['widget-base', 'widget-shared']);
44
+ expect(serializePrdFile(frontmatter, body)).toBe(raw);
45
+ });
46
+
47
+ test('parsePrdFile leaves dependsOn undefined when omitted — a PRD with no dependsOn round-trips unaffected', () => {
48
+ const raw = [
49
+ '---',
50
+ 'title: An older PRD authored before dependsOn was patchable',
51
+ 'cwd: ~/Projects/session-manager',
52
+ 'estimateMinutes: 10',
53
+ '---',
54
+ '# Goal',
55
+ '',
56
+ 'Do the thing.',
57
+ '',
58
+ ].join('\n');
59
+
60
+ const { frontmatter, body } = parsePrdFile(raw);
61
+ expect(frontmatter.dependsOn).toBeUndefined();
62
+ expect(serializePrdFile(frontmatter, body)).toBe(raw);
63
+
64
+ // An unrelated patch (title) must not introduce a dependsOn line.
65
+ const updated = serializePrdFile({ ...frontmatter, title: 'Renamed' }, body);
66
+ expect(updated).not.toContain('dependsOn');
67
+ });
68
+
69
+ test('serializePrdFile emits a newly-set dependsOn onto a frontmatter object that lacked one', () => {
70
+ const raw = [
71
+ '---',
72
+ 'title: Legacy PRD',
73
+ 'cwd: ~/Projects/session-manager',
74
+ 'estimateMinutes: 5',
75
+ '---',
76
+ 'body',
77
+ '',
78
+ ].join('\n');
79
+
80
+ const { frontmatter, body } = parsePrdFile(raw);
81
+ const updated = serializePrdFile({ ...frontmatter, dependsOn: ['some-other-prd'] }, body);
82
+ expect(updated).toContain('dependsOn: [some-other-prd]');
83
+ });
84
+
85
+ test('serializePrdFile patches an existing dependsOn to a new list', () => {
86
+ const raw = [
87
+ '---',
88
+ 'title: A follow-up PRD',
89
+ 'cwd: ~/Projects/session-manager',
90
+ 'estimateMinutes: 20',
91
+ 'dependsOn: [widget-base]',
92
+ '---',
93
+ 'body',
94
+ '',
95
+ ].join('\n');
96
+
97
+ const { frontmatter, body } = parsePrdFile(raw);
98
+ const updated = serializePrdFile({ ...frontmatter, dependsOn: ['widget-shared', 'widget-extra'] }, body);
99
+ expect(updated).toContain('dependsOn: [widget-shared, widget-extra]');
100
+ expect(updated).not.toContain('widget-base');
101
+ });
102
+
103
+ test('serializePrdFile patching dependsOn to an explicit empty array CLEARS it', () => {
104
+ const raw = [
105
+ '---',
106
+ 'title: A follow-up PRD',
107
+ 'cwd: ~/Projects/session-manager',
108
+ 'estimateMinutes: 20',
109
+ 'dependsOn: [widget-base]',
110
+ '---',
111
+ 'body',
112
+ '',
113
+ ].join('\n');
114
+
115
+ const { frontmatter, body } = parsePrdFile(raw);
116
+ const updated = serializePrdFile({ ...frontmatter, dependsOn: [] }, body);
117
+ expect(updated).not.toContain('dependsOn');
118
+ });
119
+
120
+ test('unrelated recognized-key patch leaves an existing dependsOn line untouched', () => {
121
+ const raw = [
122
+ '---',
123
+ 'title: A follow-up PRD',
124
+ 'cwd: ~/Projects/session-manager',
125
+ 'estimateMinutes: 20',
126
+ 'dependsOn: [widget-base]',
127
+ '---',
128
+ 'body',
129
+ '',
130
+ ].join('\n');
131
+
132
+ const { frontmatter, body } = parsePrdFile(raw);
133
+ const updated = serializePrdFile({ ...frontmatter, estimateMinutes: 30 }, body);
134
+ expect(updated).toContain('dependsOn: [widget-base]');
135
+ expect(updated).toContain('estimateMinutes: 30');
136
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * prdUpdateDependsOn.test.cjs — PRD 1124: scheduler.remote.updatePrd accepts
3
+ * a `dependsOn` array patch, validates it write-time against the SAME
4
+ * resolver (depSlugResolve.cjs) scheduler_create_prd's prdCreate.cjs uses,
5
+ * and accepts an explicit empty array to CLEAR the dependency.
6
+ *
7
+ * Mirrors scheduler-reconcile-quarantine.test.cjs's HOME-isolation fixture.
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdUpdateDependsOn.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { test, expect, beforeAll, afterAll } from 'vitest';
15
+ const fs = require('node:fs');
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+
19
+ let tmpHome;
20
+ let originalHome;
21
+ let scheduler;
22
+ let queueStore;
23
+ let config;
24
+
25
+ beforeAll(() => {
26
+ originalHome = process.env.HOME;
27
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-update-prd-dependson-home-'));
28
+ process.env.HOME = tmpHome;
29
+
30
+ scheduler = require('../scheduler.cjs');
31
+ queueStore = require('../lib/queueStore.cjs');
32
+ config = require('../config.cjs');
33
+
34
+ if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
35
+ throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
36
+ }
37
+ });
38
+
39
+ afterAll(() => {
40
+ process.env.HOME = originalHome;
41
+ fs.rmSync(tmpHome, { recursive: true, force: true });
42
+ });
43
+
44
+ function registerActiveProject(cwd) {
45
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
46
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
47
+ fs.mkdirSync(slugDir, { recursive: true });
48
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
49
+ }
50
+
51
+ function makeFixtureProject(prefix) {
52
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
53
+ registerActiveProject(cwd);
54
+ // updatePrd writes through config.writeTextAtomic, which enforces
55
+ // validatePath's allowed-write-root boundary (os.homedir() by default).
56
+ // The live app widens this via pty.cjs's spawn / activeIndexMerge.cjs's
57
+ // boot registration as a side effect of opening the project; a test that
58
+ // never spawns a PTY has to register it explicitly, same as
59
+ // prdCreate.cjs's own createPrd does for a chat-only Epic.
60
+ config.addAllowedRoot(cwd);
61
+ const opsRoot = path.join(cwd, 'session-manager-operations');
62
+ const prdsDir = path.join(opsRoot, 'scheduler', 'epics', 'test-epic-1', 'prds');
63
+ const stateDir = path.join(opsRoot, 'scheduler', 'state');
64
+ fs.mkdirSync(prdsDir, { recursive: true });
65
+ fs.mkdirSync(stateDir, { recursive: true });
66
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
67
+ queueStore.bustCwdCache();
68
+ return { cwd, prdsDir, stateDir };
69
+ }
70
+
71
+ function writePrd(prdsDir, filename, cwd, extraFrontmatter = '') {
72
+ fs.writeFileSync(
73
+ path.join(prdsDir, filename),
74
+ `---\ntitle: ${filename}\ncwd: ${cwd}\nestimateMinutes: 10\ncreatedVia: scheduler-api\n${extraFrontmatter}---\n\n# Goal\nDo the thing.\n`,
75
+ 'utf8',
76
+ );
77
+ }
78
+
79
+ test('updatePrd patches dependsOn to a new valid list naming an existing bare slug', async () => {
80
+ const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-valid-');
81
+ try {
82
+ writePrd(prdsDir, '3-widget-base.md', cwd);
83
+ writePrd(prdsDir, '4-widget-follow-up.md', cwd);
84
+
85
+ const result = await scheduler.remote.updatePrd({
86
+ slug: '4-widget-follow-up',
87
+ cwd,
88
+ frontmatter: { dependsOn: ['widget-base'] },
89
+ });
90
+
91
+ expect(result.ok).toBe(true);
92
+ const written = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
93
+ expect(written).toMatch(/^dependsOn: \[widget-base\]$/m);
94
+ } finally {
95
+ fs.rmSync(cwd, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ test('updatePrd refuses a dependsOn entry that resolves to no existing PRD, with a near-match suggestion, and writes nothing', async () => {
100
+ const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-invalid-');
101
+ try {
102
+ writePrd(prdsDir, '3-widget-base.md', cwd);
103
+ writePrd(prdsDir, '4-widget-follow-up.md', cwd);
104
+ const before = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
105
+
106
+ const result = await scheduler.remote.updatePrd({
107
+ slug: '4-widget-follow-up',
108
+ cwd,
109
+ frontmatter: { dependsOn: ['wigdet-bsae'] },
110
+ });
111
+
112
+ expect(result.ok).toBe(false);
113
+ expect(result.error).toMatch(/does not resolve to any existing PRD/);
114
+ expect(result.error).toMatch(/widget-base/);
115
+ const after = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
116
+ expect(after).toBe(before);
117
+ } finally {
118
+ fs.rmSync(cwd, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test('updatePrd patches dependsOn to an explicit empty array, clearing a previously-set dependency without validation', async () => {
123
+ const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-clear-');
124
+ try {
125
+ writePrd(prdsDir, '3-widget-base.md', cwd);
126
+ writePrd(prdsDir, '4-widget-follow-up.md', cwd, 'dependsOn: [widget-base]\n');
127
+
128
+ const result = await scheduler.remote.updatePrd({
129
+ slug: '4-widget-follow-up',
130
+ cwd,
131
+ frontmatter: { dependsOn: [] },
132
+ });
133
+
134
+ expect(result.ok).toBe(true);
135
+ const written = fs.readFileSync(path.join(prdsDir, '4-widget-follow-up.md'), 'utf8');
136
+ expect(written).not.toContain('dependsOn');
137
+ } finally {
138
+ fs.rmSync(cwd, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ test('updatePrd patching an unrelated field leaves a PRD with no dependsOn unaffected', async () => {
143
+ const { cwd, prdsDir } = makeFixtureProject('sm-update-dependson-unrelated-');
144
+ try {
145
+ writePrd(prdsDir, '3-standalone.md', cwd);
146
+
147
+ const result = await scheduler.remote.updatePrd({
148
+ slug: '3-standalone',
149
+ cwd,
150
+ frontmatter: { estimateMinutes: 25 },
151
+ });
152
+
153
+ expect(result.ok).toBe(true);
154
+ const written = fs.readFileSync(path.join(prdsDir, '3-standalone.md'), 'utf8');
155
+ expect(written).not.toContain('dependsOn');
156
+ expect(written).toMatch(/^estimateMinutes: 25$/m);
157
+ } finally {
158
+ fs.rmSync(cwd, { recursive: true, force: true });
159
+ }
160
+ });
@@ -257,3 +257,36 @@ test('historyTerminalBySlug: cache invalidates after a new appendHistory call (m
257
257
  expect(second.has('cache-1')).toBe(true);
258
258
  expect(second.has('cache-2')).toBe(true);
259
259
  });
260
+
261
+ // ---------- completedSlugsForCwd (PRD 1122) ----------
262
+ //
263
+ // Feeds scheduler.cjs's computeDepHistorySatisfaction: a dependsOn slug with
264
+ // no live queue row is only satisfied when it shows up here (or in
265
+ // prds-archived/) — scoped to ONE project's own history shard so a
266
+ // same-named PRD in an unrelated project can never satisfy a dep.
267
+
268
+ test('completedSlugsForCwd: returns slugs completed in this project\'s own shard only', async () => {
269
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-projA-'));
270
+ const otherCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-projB-'));
271
+ await queueHistory.appendHistory([
272
+ old({ slug: '900-base', status: 'completed', runId: 'ra', cwd: projectCwd }),
273
+ old({ slug: '901-failed', status: 'failed', runId: 'rb', cwd: projectCwd }),
274
+ old({ slug: '900-base', status: 'completed', runId: 'rc', cwd: otherCwd }),
275
+ ]);
276
+
277
+ const slugs = await queueHistory.completedSlugsForCwd(projectCwd);
278
+ expect(slugs.has('900-base')).toBe(true);
279
+ expect(slugs.has('901-failed')).toBe(false);
280
+
281
+ // Same slug completed in a DIFFERENT project must not leak across.
282
+ const otherSlugs = await queueHistory.completedSlugsForCwd(otherCwd);
283
+ expect(otherSlugs.has('900-base')).toBe(true);
284
+ const unrelatedCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-projC-'));
285
+ expect((await queueHistory.completedSlugsForCwd(unrelatedCwd)).size).toBe(0);
286
+ });
287
+
288
+ test('completedSlugsForCwd: returns an empty Set when the project has no history shard yet', async () => {
289
+ const freshCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-1122-fresh-'));
290
+ const slugs = await queueHistory.completedSlugsForCwd(freshCwd);
291
+ expect(slugs.size).toBe(0);
292
+ });
@@ -240,3 +240,27 @@ test('writeRcaReport: SM_RCA_DISABLE=1 skips without writing', async () => {
240
240
  expect(res.reason).toBe('disabled');
241
241
  expect(fs.readdirSync(runDir).filter((f) => f.startsWith('root-cause-'))).toHaveLength(0);
242
242
  });
243
+
244
+ test('classifyFailure: blocked_by_foreign_wip_streak verdict classifies as BLOCKED_BY_FOREIGN_WIP, never UNKNOWN', () => {
245
+ const failureClass = rcaReport.classifyFailure({ verdict: 'blocked_by_foreign_wip_streak', logTail: 'anything' });
246
+ expect(failureClass).toBe(rcaReport.FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP);
247
+ });
248
+
249
+ test('writeRcaReport: a blocked_by_foreign_wip_streak verdict points at the sibling job, not "re-run the gate"', async () => {
250
+ const cwd = makeProject();
251
+ const runDir = path.join(tmpHome, 'run-blocked');
252
+ writeRun(runDir, 'blockedslug');
253
+ writePrd(cwd, 'blockedslug');
254
+
255
+ const res = await rcaReport.writeRcaReport({
256
+ job: baseJob({ cwd, slug: 'blockedslug' }),
257
+ runDir,
258
+ verdict: 'blocked_by_foreign_wip_streak',
259
+ });
260
+
261
+ expect(res.filed).toBe(true);
262
+ expect(res.failureClass).toBe(rcaReport.FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP);
263
+ const body = fs.readFileSync(res.path, 'utf8');
264
+ expect(body).toMatch(/sibling job/i);
265
+ expect(body).not.toMatch(/Re-run the acceptance criteria gate/);
266
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * runVerify-blocked-by-foreign-wip.test.cjs — scanSentinel's new
3
+ * BLOCKED_BY_FOREIGN_WIP token and the scanForeignWipPathsClaim evidence-line
4
+ * scanner (the executor-facing half of "give the executor a first-class
5
+ * verdict for a sibling job's in-flight file, but validate the claim").
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect } from 'vitest';
13
+ const { scanSentinel, scanForeignWipPathsClaim } = require('../runVerify.cjs');
14
+
15
+ function resultEventFor(text) {
16
+ return { kind: 'result', seq: 0, subtype: 'success', resultText: text };
17
+ }
18
+
19
+ test('scanSentinel: recognizes BLOCKED_BY_FOREIGN_WIP alongside PASS/FAIL', () => {
20
+ expect(scanSentinel(resultEventFor('SCHEDULER_VERDICT: PASS'), [])).toBe('pass');
21
+ expect(scanSentinel(resultEventFor('SCHEDULER_VERDICT: FAIL bad thing'), [])).toBe('fail');
22
+ expect(scanSentinel(resultEventFor('SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP'), [])).toBe('blocked_by_foreign_wip');
23
+ });
24
+
25
+ test('scanSentinel: BLOCKED_BY_FOREIGN_WIP only matches at line start, like PASS/FAIL', () => {
26
+ expect(scanSentinel(resultEventFor('I saw a SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP mention mid-sentence'), [])).toBe(null);
27
+ });
28
+
29
+ test('scanSentinel: falls back to the last tool_result content when no resultEvent matches', () => {
30
+ const events = [
31
+ { kind: 'tool_result', seq: 1, toolUseId: 'a', content: 'nothing here' },
32
+ { kind: 'tool_result', seq: 2, toolUseId: 'b', content: 'SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP\nFOREIGN_WIP_PATHS: src/foo.ts' },
33
+ ];
34
+ expect(scanSentinel(null, events)).toBe('blocked_by_foreign_wip');
35
+ });
36
+
37
+ test('scanForeignWipPathsClaim: extracts a comma-separated FOREIGN_WIP_PATHS line from resultEvent', () => {
38
+ const text = 'SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP\nFOREIGN_WIP_PATHS: src/foo.ts, src/bar.ts';
39
+ expect(scanForeignWipPathsClaim(resultEventFor(text), [])).toEqual(['src/foo.ts', 'src/bar.ts']);
40
+ });
41
+
42
+ test('scanForeignWipPathsClaim: dedupes and trims whitespace', () => {
43
+ const text = 'FOREIGN_WIP_PATHS: src/foo.ts ,src/foo.ts, src/bar.ts ';
44
+ expect(scanForeignWipPathsClaim(resultEventFor(text), [])).toEqual(['src/foo.ts', 'src/bar.ts']);
45
+ });
46
+
47
+ test('scanForeignWipPathsClaim: returns [] when no FOREIGN_WIP_PATHS line is present', () => {
48
+ expect(scanForeignWipPathsClaim(resultEventFor('SCHEDULER_VERDICT: FAIL nope'), [])).toEqual([]);
49
+ expect(scanForeignWipPathsClaim(null, [])).toEqual([]);
50
+ });
51
+
52
+ test('scanForeignWipPathsClaim: falls back to the last tool_result content', () => {
53
+ const events = [
54
+ { kind: 'tool_result', seq: 1, toolUseId: 'a', content: 'FOREIGN_WIP_PATHS: src/stale.ts' },
55
+ { kind: 'tool_result', seq: 2, toolUseId: 'b', content: 'FOREIGN_WIP_PATHS: src/latest.ts' },
56
+ ];
57
+ expect(scanForeignWipPathsClaim(null, events)).toEqual(['src/latest.ts']);
58
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * runVerify-policy-denial.test.cjs — a PreToolUse hook denial (`Blocked: ...`
3
+ * tool_result) must not downgrade an otherwise-clean run, but a genuine
4
+ * late-run failure must still downgrade normally.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/runVerify-policy-denial.test.cjs
7
+ */
8
+
9
+ import { test } from 'vitest';
10
+ const assert = require('node:assert/strict');
11
+ const os = require('node:os');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const { verifyRun } = require('../runVerify.cjs');
15
+
16
+ function makeTmpDir() {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-policy-denial-test-'));
18
+ }
19
+
20
+ function rmdir(dir) {
21
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
22
+ }
23
+
24
+ function writeLog(dir, slug, events) {
25
+ const lines = ['[scheduler] starting ' + slug + ' at 2026-09-02T00:00:00.000Z'];
26
+ for (const ev of events) lines.push(JSON.stringify(ev));
27
+ lines.push('[scheduler] exit code=0 (raw code=0 signal=null) duration=47s');
28
+ fs.writeFileSync(path.join(dir, `${slug}.log`), lines.join('\n') + '\n');
29
+ }
30
+
31
+ function writePrd(dir, slug, body) {
32
+ const text = `---\ntitle: Test PRD\ncwd: /tmp\nestimateMinutes: 30\n---\n${body}\n`;
33
+ fs.writeFileSync(path.join(dir, `${slug}.md`), text);
34
+ return path.join(dir, `${slug}.md`);
35
+ }
36
+
37
+ test('PreToolUse policy denial (Blocked: ...) in final 20% → clean, surfaced as annotation', async () => {
38
+ const tmp = makeTmpDir();
39
+ try {
40
+ const slug = '1106-policy-denial-exempt';
41
+ const events = [];
42
+ for (let k = 0; k < 8; k++) {
43
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
44
+ { type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
45
+ events.push({ type: 'user', message: { role: 'user', content: [
46
+ { type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
47
+ }
48
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
49
+ { type: 'tool_use', id: 'tgit', name: 'Bash', input: { command: 'git stash', description: 'stash before checkout' } }] } });
50
+ events.push({ type: 'user', message: { role: 'user', content: [
51
+ { type: 'tool_result', tool_use_id: 'tgit',
52
+ content: 'Blocked: `stash` in what this hook believes is a SHARED working tree (/home/bilko/Projects/session-manager).',
53
+ is_error: true }] } });
54
+ events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' });
55
+
56
+ writeLog(tmp, slug, events);
57
+ const prdPath = writePrd(tmp, slug, '# Policy denial exempt');
58
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
59
+ assert.equal(verdict.verdict, 'clean', `policy denial must not flag, got ${verdict.verdict}: ${verdict.reason}`);
60
+ assert.ok(
61
+ Array.isArray(verdict.annotations) && verdict.annotations.some((a) => a.verdict === 'policy_denial'),
62
+ `expected a policy_denial annotation, got ${JSON.stringify(verdict.annotations)}`,
63
+ );
64
+ } finally { rmdir(tmp); }
65
+ });
66
+
67
+ test('genuine late-run failure (not a policy denial) still downgrades to transcript_errors', async () => {
68
+ const tmp = makeTmpDir();
69
+ try {
70
+ const slug = '1106-genuine-failure-still-flags';
71
+ const events = [];
72
+ for (let k = 0; k < 8; k++) {
73
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
74
+ { type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
75
+ events.push({ type: 'user', message: { role: 'user', content: [
76
+ { type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
77
+ }
78
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
79
+ { type: 'tool_use', id: 'ttest', name: 'Bash', input: { command: 'npm test', description: 'run tests' } }] } });
80
+ events.push({ type: 'user', message: { role: 'user', content: [
81
+ { type: 'tool_result', tool_use_id: 'ttest', content: 'FAILED: 1 test failed', is_error: true }] } });
82
+ events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.' });
83
+
84
+ writeLog(tmp, slug, events);
85
+ const prdPath = writePrd(tmp, slug, '# Genuine failure still flags');
86
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
87
+ assert.equal(verdict.verdict, 'transcript_errors', `genuine failure must still flag, got ${verdict.verdict}: ${verdict.reason}`);
88
+ } finally { rmdir(tmp); }
89
+ });
@@ -120,6 +120,7 @@ test('LEGAL_TRANSITIONS covers every real scheduler.cjs edge', () => {
120
120
  ['needs_review', 'investigating'],
121
121
  ['needs_review', 'pending'],
122
122
  ['needs_review', 'completed'],
123
+ ['needs_review', 'running'],
123
124
  ['completed', 'pending'],
124
125
  ['skipped', 'pending'],
125
126
  ];
@@ -0,0 +1,105 @@
1
+ /**
2
+ * scheduler-already-satisfied-on-main.test.cjs — PRD 1136.
3
+ *
4
+ * Two live rows (1133-reaper-must-verify-integration-before-completed,
5
+ * 1134-land-stranded-sm-job-branches) were false-negatived on 2026-09-06:
6
+ * their work had already landed on main before their runs dispatched, so
7
+ * each run exited 0, made no commit, and left a clean tree — the exact
8
+ * 'silent_no_op' shape commitGuardVerdict parks as needs_review ("finish
9
+ * protocol incomplete"). Both then auto-minted a redundant `-fix-` child
10
+ * queued to re-do already-shipped work.
11
+ *
12
+ * This exercises the full finalize decision chain — commitGuardVerdict
13
+ * (unchanged) feeding resolveCommitGuardOutcome (new) — entirely at the
14
+ * pure-function level, per standards.md's TDD guidance and the PRD's own
15
+ * implementation note to prefer a pure isAlreadySatisfiedOnMain-style helper
16
+ * over driving the whole spawnJob pipeline.
17
+ *
18
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ import { test, expect } from 'vitest';
24
+ const { commitGuardVerdict, selectAutoFixTargets } = require('../scheduler.cjs');
25
+ const { resolveCommitGuardOutcome, isAlreadySatisfiedOnMain } = require('../lib/reaperHelpers.cjs');
26
+
27
+ const noSiblingOnDisk = () => false;
28
+
29
+ // AC 1: exit 0 / no commit / clean tree, with a commit on main newer than
30
+ // queuedAt touching the PRD's declared paths → completed, naming the sha.
31
+ test('silent_no_op + a satisfying commit on main → completed-shaped verdict naming the sha, not needs_review', () => {
32
+ const guardVerdict = commitGuardVerdict({
33
+ newlyDirty: [],
34
+ siblingRunning: false,
35
+ jobSelfCommitted: false,
36
+ legitimateNoOp: false,
37
+ isFixPlanJob: false,
38
+ verifyResult: null,
39
+ });
40
+ expect(guardVerdict.verdict).toBe('silent_no_op'); // sanity: this is the shape 1133/1134 hit
41
+
42
+ const outcome = resolveCommitGuardOutcome(guardVerdict, ['5de4134abc123']);
43
+ expect(outcome.verdict).toBe('already_satisfied_on_main');
44
+ expect(outcome.verdict).not.toBe('needs_review');
45
+ expect(outcome.satisfyingSha).toBe('5de4134abc123');
46
+ expect(outcome.reason).toMatch(/5de4134abc123/);
47
+ });
48
+
49
+ // AC 2: the inverse — same clean-tree/no-commit shape, but NO commit on main
50
+ // satisfies it → still parks as the existing 'finish protocol incomplete'
51
+ // verdict. The gate must not be weakened into always-passing.
52
+ test('silent_no_op + NO satisfying commit on main → unchanged silent_no_op verdict, still parks needs_review', () => {
53
+ const guardVerdict = commitGuardVerdict({
54
+ newlyDirty: [],
55
+ siblingRunning: false,
56
+ jobSelfCommitted: false,
57
+ legitimateNoOp: false,
58
+ isFixPlanJob: false,
59
+ verifyResult: null,
60
+ });
61
+
62
+ const outcome = resolveCommitGuardOutcome(guardVerdict, []);
63
+ expect(outcome).toBe(guardVerdict); // untouched — same object, not just same shape
64
+ expect(outcome.verdict).toBe('silent_no_op');
65
+ expect(outcome.downgradeTo).toBe('needs_review');
66
+ expect(outcome.reason).toMatch(/finish protocol incomplete/);
67
+ });
68
+
69
+ test('resolveCommitGuardOutcome never touches the uncommitted_changes shape, satisfied or not', () => {
70
+ const guardVerdict = commitGuardVerdict({
71
+ newlyDirty: ['src/main/someFeature.cjs'],
72
+ siblingRunning: false,
73
+ jobSelfCommitted: false,
74
+ legitimateNoOp: false,
75
+ verifyResult: null,
76
+ });
77
+ expect(guardVerdict.verdict).toBe('uncommitted_changes');
78
+
79
+ const outcome = resolveCommitGuardOutcome(guardVerdict, ['5de4134abc123']);
80
+ expect(outcome).toBe(guardVerdict);
81
+ expect(outcome.verdict).toBe('uncommitted_changes');
82
+ });
83
+
84
+ test('resolveCommitGuardOutcome: null guardVerdict (no violation) passes through as null', () => {
85
+ expect(resolveCommitGuardOutcome(null, ['5de4134'])).toBeNull();
86
+ });
87
+
88
+ // AC 3: a job terminalised via the already-satisfied path never becomes a
89
+ // fix-plan target — selectAutoFixTargets only ever considers needs_review
90
+ // rows, and the already-satisfied path never produces one.
91
+ test('a job whose status is completed (the already-satisfied outcome) is never selected for an auto-fix child', () => {
92
+ const jobs = [{
93
+ slug: '1133-reaper-must-verify-integration-before-completed',
94
+ status: 'completed',
95
+ runId: '2026-09-06T13-03-00-000Z',
96
+ verifierVerdict: 'already_satisfied_on_main',
97
+ }];
98
+ const targets = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
99
+ expect(targets).toEqual([]);
100
+ });
101
+
102
+ test('isAlreadySatisfiedOnMain picks the newest (first) satisfying commit when several land', () => {
103
+ const verdict = isAlreadySatisfiedOnMain(['newest111', 'older222']);
104
+ expect(verdict.sha).toBe('newest111');
105
+ });