claude-code-session-manager 0.95.0 → 0.96.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 (113) hide show
  1. package/dist/assets/{DataModel-DZK9QZpV.js → DataModel-T0D00BpK.js} +1 -1
  2. package/dist/assets/History-DuAovTr7.js +2 -0
  3. package/dist/assets/{Hooks-CmK6l0uf.js → Hooks-CgP9jsAN.js} +3 -3
  4. package/dist/assets/{HostBilko-D8TIx-Bx.js → HostBilko-DHfz3Mxa.js} +1 -1
  5. package/dist/assets/{Library-DTxpOjx8.js → Library--poVP-d1.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-BPJyrevq.js → MarkdownEditor-C56-Pc4c.js} +1 -1
  7. package/dist/assets/{McpServers-B_rvIYDs.js → McpServers-D-ykP4Vi.js} +2 -2
  8. package/dist/assets/Memory-BrkJjRL8.js +8 -0
  9. package/dist/assets/{Permissions-DhMMp27n.js → Permissions-BMpAaxJu.js} +3 -3
  10. package/dist/assets/Plugins-OuEe5S_T.js +2 -0
  11. package/dist/assets/{ProvenanceBadge-ptlItdCK.js → ProvenanceBadge-4eNbDYe8.js} +1 -1
  12. package/dist/assets/{SaveBar-CSbNSl2W.js → SaveBar-BQ8y9Lkv.js} +1 -1
  13. package/dist/assets/Scheduler-DcMT5sT_.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-BJof-GWH.js → ScopeSwitcher-XdxtVKP-.js} +1 -1
  15. package/dist/assets/Settings-FdaPUq2t.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-CGl4B89-.js → SkillReferenceGraph-CJzvvc0_.js} +1 -1
  17. package/dist/assets/Skills-Wvm9OE1e.js +3 -0
  18. package/dist/assets/{SystemPrompt-DKj-q39J.js → SystemPrompt--LYw_69j.js} +1 -1
  19. package/dist/assets/TagLibrary-Be6HbMMx.js +1 -0
  20. package/dist/assets/{TiptapBody-BbcJL9az.js → TiptapBody-DzFlWMgO.js} +1 -1
  21. package/dist/assets/{Toggle-DAslvnnv.js → Toggle-BxtAFE1i.js} +1 -1
  22. package/dist/assets/index-XkrLB67D.js +3079 -0
  23. package/dist/assets/{settingsSchema-pzp0TgvN.js → settingsSchema-BIvDZ1Cj.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +8 -2
  26. package/plugins/CLAUDE.md +3 -3
  27. package/plugins/session-manager-dev/skills/develop/SKILL.md +83 -67
  28. package/plugins/session-manager-dev/skills/develop/standards.md +5 -11
  29. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +5 -15
  30. package/scripts/README.md +1 -0
  31. package/scripts/hooks/guard-destructive-git.cjs +9 -374
  32. package/scripts/hooks/guard-inline-implementation.cjs +9 -128
  33. package/scripts/hooks/guard-prd-writes.cjs +9 -74
  34. package/scripts/hooks/guard-self-schedule.cjs +9 -61
  35. package/scripts/hooks/lib/guard-destructive-git-policy.cjs +410 -0
  36. package/scripts/hooks/lib/guard-inline-implementation-policy.cjs +159 -0
  37. package/scripts/hooks/lib/guard-prd-writes-policy.cjs +100 -0
  38. package/scripts/hooks/lib/guard-self-schedule-policy.cjs +89 -0
  39. package/scripts/scheduler-mcp-server.cjs +8 -1
  40. package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +59 -0
  41. package/src/main/__tests__/exchanges.test.cjs +41 -96
  42. package/src/main/__tests__/exchangesPromptId.test.cjs +16 -25
  43. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  44. package/src/main/__tests__/health-credentials.test.cjs +81 -0
  45. package/src/main/__tests__/memoryAggregate.test.cjs +40 -1
  46. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +1 -1
  47. package/src/main/__tests__/planValidator.test.cjs +71 -0
  48. package/src/main/__tests__/prdCreate.test.cjs +3 -1
  49. package/src/main/__tests__/prdSizing.test.cjs +106 -0
  50. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +46 -0
  51. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +10 -0
  52. package/src/main/__tests__/scheduler-looks-done.test.cjs +40 -0
  53. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +81 -0
  54. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +6 -1
  55. package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
  56. package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
  57. package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
  58. package/src/main/__tests__/telemetryClient.test.cjs +2 -2
  59. package/src/main/__tests__/telemetryContract.test.cjs +2 -2
  60. package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
  61. package/src/main/__tests__/validationSentinels.test.cjs +84 -0
  62. package/src/main/build-info.json +4 -4
  63. package/src/main/config.cjs +26 -14
  64. package/src/main/exchanges.cjs +26 -21
  65. package/src/main/health.cjs +66 -4
  66. package/src/main/index.cjs +1 -1
  67. package/src/main/ipcSchemas.cjs +5 -1
  68. package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
  69. package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
  70. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
  71. package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
  72. package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
  73. package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
  74. package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
  75. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
  76. package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
  77. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
  78. package/src/main/lib/agentModelResolve.cjs +1 -0
  79. package/src/main/lib/credentials.cjs +62 -0
  80. package/src/main/lib/cwdClassify.cjs +15 -0
  81. package/src/main/lib/definitionOfDone.cjs +9 -13
  82. package/src/main/lib/effectiveModelInfo.cjs +2 -9
  83. package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
  84. package/src/main/lib/loadGate.cjs +29 -5
  85. package/src/main/lib/opsOwnership.cjs +23 -0
  86. package/src/main/lib/planValidator.cjs +28 -0
  87. package/src/main/lib/prdCreate.cjs +8 -4
  88. package/src/main/lib/prdSizing.cjs +83 -0
  89. package/src/main/lib/runLogRetention.cjs +2 -8
  90. package/src/main/lib/telemetryBacklog.cjs +9 -3
  91. package/src/main/lib/telemetryClient.cjs +12 -3
  92. package/src/main/lib/telemetrySettings.cjs +8 -2
  93. package/src/main/lib/validationSentinels.cjs +29 -0
  94. package/src/main/memoryAggregate.cjs +36 -11
  95. package/src/main/otelSettings.cjs +8 -2
  96. package/src/main/scheduler.cjs +215 -31
  97. package/src/main/seedAgentPersonas.cjs +1 -1
  98. package/src/main/sessionsStore.cjs +8 -2
  99. package/src/main/supervisor.cjs +3 -3
  100. package/src/main/templates/PRD_AUTHORING.md +9 -9
  101. package/src/main/voiceSettings.cjs +8 -2
  102. package/src/preload/api.d.ts +3 -3
  103. package/src/preload/index.cjs +5 -5
  104. package/src/seed/agents/dev-lead.md +24 -15
  105. package/src/seed/agents/validator.md +33 -0
  106. package/dist/assets/History-BDyJUGtR.js +0 -2
  107. package/dist/assets/Memory-B-9GlHqe.js +0 -8
  108. package/dist/assets/Plugins-DKfAlmMY.js +0 -2
  109. package/dist/assets/Scheduler--rn1QBYc.js +0 -16
  110. package/dist/assets/Settings-Dsx6XZP5.js +0 -3
  111. package/dist/assets/Skills-D522Zfol.js +0 -3
  112. package/dist/assets/TagLibrary-BH7CG7-6.js +0 -1
  113. package/dist/assets/index-Yf8e7VCB.js +0 -3079
@@ -2,7 +2,11 @@
2
2
 
3
3
  import { test } from 'vitest';
4
4
  const assert = require('node:assert/strict');
5
- const { parseClusters } = require('../memoryAggregate.cjs');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { parseClusters, aggregate, clustersCachePath } = require('../memoryAggregate.cjs');
9
+ const { opsPath } = require('../lib/opsOwnership.cjs');
6
10
 
7
11
  test('parseClusters maps well-formed JSON to the result shape and routes unlisted slugs to orphans', () => {
8
12
  const slugs = ['scheduler_concurrency_memory', 'no_schedule_self_e2e', 'unrelated_note'];
@@ -68,3 +72,38 @@ test('parseClusters drops link endpoints and members that reference nonexistent
68
72
  assert.deepEqual(result.clusters[0].links, []);
69
73
  assert.deepEqual(result.orphans, []);
70
74
  });
75
+
76
+ test('clustersCachePath resolves under <cwd>/session-manager-operations/memory-clusters/clusters.json, one file per project', () => {
77
+ const cwd = '/home/u/Projects/demo';
78
+ assert.equal(clustersCachePath(cwd), opsPath(cwd, 'memory-clusters', 'clusters.json'));
79
+ assert.match(clustersCachePath(cwd), /\/session-manager-operations\/memory-clusters\/clusters\.json$/);
80
+ });
81
+
82
+ test('aggregate persists and reads back the per-project cache file at the new ops-owned location', async () => {
83
+ const cwd = fs.mkdtempSync(path.join(os.homedir(), '.sm-test-memory-clusters-'));
84
+ try {
85
+ const workspace = `test-ws-${Date.now()}`;
86
+
87
+ const written = await aggregate({ workspace, refresh: true, cwd });
88
+ assert.equal(written.cached, false);
89
+ assert.deepEqual(written.clusters, []);
90
+ assert.deepEqual(written.orphans, []);
91
+
92
+ const expectedPath = opsPath(cwd, 'memory-clusters', 'clusters.json');
93
+ assert.ok(fs.existsSync(expectedPath), `expected cache file at ${expectedPath}`);
94
+ assert.deepEqual(JSON.parse(fs.readFileSync(expectedPath, 'utf8')).workspace, workspace);
95
+
96
+ const read = await aggregate({ workspace, refresh: false, cwd });
97
+ assert.equal(read.cached, true);
98
+ assert.equal(read.workspace, workspace);
99
+ } finally {
100
+ fs.rmSync(cwd, { recursive: true, force: true });
101
+ }
102
+ });
103
+
104
+ test('aggregate without a cwd skips the cache instead of throwing', async () => {
105
+ const workspace = `test-ws-nocwd-${Date.now()}`;
106
+ const result = await aggregate({ workspace, refresh: false });
107
+ assert.equal(result.cached, false);
108
+ assert.deepEqual(result.clusters, []);
109
+ });
@@ -137,7 +137,7 @@ test('a realistic error with an absolute path and a prompt-like string never rea
137
137
  process.env.HOME = home;
138
138
  // Explicit opt-in: overrides the test-environment no-op guard so this
139
139
  // test's real telemetryClient call actually persists into an isolated dir.
140
- process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
140
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.claude', 'session-manager');
141
141
 
142
142
  for (const p of ['../lib/telemetryClient.cjs', '../config.cjs', '../lib/telemetrySettings.cjs', '../lib/machineProfile.cjs']) {
143
143
  const resolved = require.resolve(p);
@@ -0,0 +1,71 @@
1
+ /**
2
+ * planValidator.test.cjs — unit tests for hasDownstreamValidator (PRD 1408).
3
+ *
4
+ * Run: timeout 300 npx vitest run src/main/__tests__/planValidator.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ import { test, expect } from 'vitest';
10
+ const { hasDownstreamValidator } = require('../lib/planValidator.cjs');
11
+
12
+ test('true when a pending validator row depends on the job', () => {
13
+ const job = { slug: '111-alpha' };
14
+ const jobs = [
15
+ job,
16
+ { slug: '999-validate', agentType: 'validator', status: 'pending', dependsOn: ['111-alpha'] },
17
+ ];
18
+ expect(hasDownstreamValidator(job, jobs)).toBe(true);
19
+ });
20
+
21
+ test('true when the depending validator row is running', () => {
22
+ const job = { slug: '111-alpha' };
23
+ const jobs = [
24
+ job,
25
+ { slug: '999-validate', agentType: 'validator', status: 'running', dependsOn: ['111-alpha'] },
26
+ ];
27
+ expect(hasDownstreamValidator(job, jobs)).toBe(true);
28
+ });
29
+
30
+ test('false when the job itself is a validator', () => {
31
+ const job = { slug: '999-validate', agentType: 'validator' };
32
+ const jobs = [
33
+ job,
34
+ { slug: '998-other-validate', agentType: 'validator', status: 'pending', dependsOn: ['999-validate'] },
35
+ ];
36
+ expect(hasDownstreamValidator(job, jobs)).toBe(false);
37
+ });
38
+
39
+ test('false for null/undefined job or jobs', () => {
40
+ expect(hasDownstreamValidator(null, [{ slug: 'x' }])).toBe(false);
41
+ expect(hasDownstreamValidator(undefined, [{ slug: 'x' }])).toBe(false);
42
+ expect(hasDownstreamValidator({ slug: '111-alpha' }, null)).toBe(false);
43
+ expect(hasDownstreamValidator({ slug: '111-alpha' }, undefined)).toBe(false);
44
+ });
45
+
46
+ test('false when the validator row does not depend on this job', () => {
47
+ const job = { slug: '111-alpha' };
48
+ const jobs = [
49
+ job,
50
+ { slug: '999-validate', agentType: 'validator', status: 'pending', dependsOn: ['222-beta'] },
51
+ ];
52
+ expect(hasDownstreamValidator(job, jobs)).toBe(false);
53
+ });
54
+
55
+ test('false when the depending row is a completed validator', () => {
56
+ const job = { slug: '111-alpha' };
57
+ const jobs = [
58
+ job,
59
+ { slug: '999-validate', agentType: 'validator', status: 'completed', dependsOn: ['111-alpha'] },
60
+ ];
61
+ expect(hasDownstreamValidator(job, jobs)).toBe(false);
62
+ });
63
+
64
+ test('false when the depending row is not agentType validator', () => {
65
+ const job = { slug: '111-alpha' };
66
+ const jobs = [
67
+ job,
68
+ { slug: '999-other', agentType: 'dev-lead', status: 'pending', dependsOn: ['111-alpha'] },
69
+ ];
70
+ expect(hasDownstreamValidator(job, jobs)).toBe(false);
71
+ });
@@ -74,7 +74,7 @@ test('buildPrdBody emits required frontmatter keys and body sections in order',
74
74
  expect(body).toMatch(/- not this/);
75
75
  // must point at STANDARDS_PATH rather than inline the standards.md content
76
76
  expect(body.includes(STANDARDS_PATH)).toBeTruthy();
77
- expect(body).toMatch(/Before writing any code, read/);
77
+ expect(body).toMatch(/Your system prompt carries the ordered run contract/);
78
78
  // the pointer block mentions "Execution discipline" by name, but must not
79
79
  // inline the full standards.md prose (e.g. its Performance-section rules)
80
80
  expect(body.includes('Lay out hot data contiguously')).toBeFalsy();
@@ -417,6 +417,8 @@ test('createPrd with a valid payload allocates a group, writes through remote.wr
417
417
  expect(result.ok).toBe(true);
418
418
  expect(typeof result.nn).toBe('number');
419
419
  expect(result.filename).toMatch(/^\d+-add-widget-frobnication\.md$/);
420
+ // PRD 1403: sizing warnings ride along in the success response, advisory only.
421
+ expect(Array.isArray(result.warnings)).toBe(true);
420
422
 
421
423
  const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
422
424
  expect(written).toMatch(/^---\n/);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * prdSizing.test.cjs — unit tests for sizingWarnings (PRD 1403).
3
+ *
4
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdSizing.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ import { test, expect } from 'vitest';
10
+ const { SIZING_LIMITS, sizingWarnings } = require('../lib/prdSizing.cjs');
11
+
12
+ function compliantInput(overrides = {}) {
13
+ return {
14
+ estimateMinutes: 10,
15
+ acceptanceCriteria: ['widget frobnicates on click', 'unit test covers the frobnication path'],
16
+ goal: 'Add frobnication to the widget subsystem.',
17
+ implementationNotes: 'See src/widget.cjs:10.',
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ test('compliant PRD yields no warnings', () => {
23
+ expect(sizingWarnings(compliantInput())).toEqual([]);
24
+ });
25
+
26
+ test('warns when estimateMinutes exceeds the limit', () => {
27
+ const warnings = sizingWarnings(compliantInput({ estimateMinutes: SIZING_LIMITS.estimateMinutes + 1 }));
28
+ expect(warnings.some((w) => /estimateMinutes/.test(w))).toBe(true);
29
+ });
30
+
31
+ test('does not warn when estimateMinutes is exactly the limit', () => {
32
+ const warnings = sizingWarnings(compliantInput({ estimateMinutes: SIZING_LIMITS.estimateMinutes }));
33
+ expect(warnings.some((w) => /estimateMinutes/.test(w))).toBe(false);
34
+ });
35
+
36
+ test('warns when acceptanceCriteria has more than the limit of lines', () => {
37
+ const acceptanceCriteria = Array.from({ length: SIZING_LIMITS.acLines + 1 }, (_, i) => `criterion ${i}`);
38
+ const warnings = sizingWarnings(compliantInput({ acceptanceCriteria }));
39
+ expect(warnings.some((w) => /acceptanceCriteria has/.test(w))).toBe(true);
40
+ });
41
+
42
+ test('does not warn when acceptanceCriteria has exactly the limit of lines', () => {
43
+ const acceptanceCriteria = Array.from({ length: SIZING_LIMITS.acLines }, (_, i) => `criterion ${i}`);
44
+ const warnings = sizingWarnings(compliantInput({ acceptanceCriteria }));
45
+ expect(warnings.some((w) => /acceptanceCriteria has/.test(w))).toBe(false);
46
+ });
47
+
48
+ test('warns when an AC line exceeds the per-line character limit', () => {
49
+ const longLine = 'x'.repeat(SIZING_LIMITS.acLineChars + 1);
50
+ const warnings = sizingWarnings(compliantInput({ acceptanceCriteria: [longLine] }));
51
+ expect(warnings.some((w) => /exceed \d+ characters/.test(w))).toBe(true);
52
+ });
53
+
54
+ test('does not warn when an AC line is exactly at the per-line character limit', () => {
55
+ const line = 'x'.repeat(SIZING_LIMITS.acLineChars);
56
+ const warnings = sizingWarnings(compliantInput({ acceptanceCriteria: [line] }));
57
+ expect(warnings.some((w) => /exceed \d+ characters/.test(w))).toBe(false);
58
+ });
59
+
60
+ test('warns on an open-ended search-and-fix AC line', () => {
61
+ const warnings = sizingWarnings(compliantInput({
62
+ acceptanceCriteria: ['grep e2e specs and update expectations to match the new sort order'],
63
+ }));
64
+ expect(warnings.some((w) => /open-ended search-and-fix/.test(w))).toBe(true);
65
+ });
66
+
67
+ test('does not warn on an AC line that merely mentions grep or update in isolation', () => {
68
+ const warnings = sizingWarnings(compliantInput({
69
+ acceptanceCriteria: ['grep confirms no leftover references to the old flag'],
70
+ }));
71
+ expect(warnings.some((w) => /open-ended search-and-fix/.test(w))).toBe(false);
72
+ });
73
+
74
+ test('warns on an open-ended search-and-fix AC line whose "then"/"update" half is a second sentence', () => {
75
+ const warnings = sizingWarnings(compliantInput({
76
+ acceptanceCriteria: ['Grep the repo for every usage of the old flag. Then update each call site.'],
77
+ }));
78
+ expect(warnings.some((w) => /open-ended search-and-fix/.test(w))).toBe(true);
79
+ });
80
+
81
+ test('warns on an AC line with two or more timeout commands', () => {
82
+ const warnings = sizingWarnings(compliantInput({
83
+ acceptanceCriteria: ['timeout 120 npm run typecheck && timeout 300 npm run test:unit both pass'],
84
+ }));
85
+ expect(warnings.some((w) => /two or more `timeout/.test(w))).toBe(true);
86
+ });
87
+
88
+ test('does not warn on an AC line with a single timeout command', () => {
89
+ const warnings = sizingWarnings(compliantInput({
90
+ acceptanceCriteria: ['timeout 120 npm run typecheck passes'],
91
+ }));
92
+ expect(warnings.some((w) => /two or more `timeout/.test(w))).toBe(false);
93
+ });
94
+
95
+ test('warns when goal + implementationNotes + joined AC exceeds the body character limit', () => {
96
+ const warnings = sizingWarnings(compliantInput({
97
+ goal: 'g'.repeat(SIZING_LIMITS.bodyChars + 1),
98
+ }));
99
+ expect(warnings.some((w) => /exceeding the \d+-character sizing limit/.test(w))).toBe(true);
100
+ });
101
+
102
+ test('does not warn when the combined body is exactly at the character limit', () => {
103
+ const goal = 'g'.repeat(SIZING_LIMITS.bodyChars);
104
+ const warnings = sizingWarnings({ estimateMinutes: 10, acceptanceCriteria: [], goal, implementationNotes: '' });
105
+ expect(warnings.some((w) => /exceeding the \d+-character sizing limit/.test(w))).toBe(false);
106
+ });
@@ -216,6 +216,52 @@ test('rows held by a launch breaker / lease read blocked, not starved (per proje
216
216
  expect(byProject.map((v) => v.kind)).toEqual(['blocked']);
217
217
  });
218
218
 
219
+ test('a starved queue forces its tick with bypassLoadGate:true — the CPU load gate must not freeze the last-resort dispatch', async () => {
220
+ // Regression for the 2026-09-23 freeze: 3 dispatchable jobs, 0 running,
221
+ // lastTickReason 'load-deferred' unbroken 07:00→13:06. The watchdog is the
222
+ // safety net for exactly that state, but it used to force its tick with
223
+ // bypassLoadGate:false, so a box loaded by ANOTHER process held the net's
224
+ // own tick behind the load gate and nothing ever ran. Spy the exported
225
+ // tickQueue seam (the call is routed via module.exports precisely so this
226
+ // is assertable without manufacturing real machine load, which
227
+ // SM_LOAD_GATE_PER_CORE clamps at 0.25 and can't force deterministically).
228
+ const cwd = '/tmp/sm-starve-bypass-project';
229
+ const jobs = [{ slug: 'bypass-a', cwd, status: 'pending', dependsOn: [] }];
230
+ const state = { jobs, paused: null, lastRunAt: new Date(NOW - 40 * 60_000).toISOString() };
231
+ const opts = { now: NOW, bootedAtMs: NOW - 3 * 60 * 60_000 };
232
+ const tickSpy = vi.spyOn(scheduler, 'tickQueue').mockResolvedValue({ fired: false, reason: 'drained' });
233
+ try {
234
+ const verdict = await scheduler.runQueueStarvationWatchdog(state, opts);
235
+ expect(verdict.kind).toBe('starved');
236
+ expect(tickSpy).toHaveBeenCalledTimes(1);
237
+ expect(tickSpy).toHaveBeenCalledWith({ bypassLoadGate: true });
238
+ } finally {
239
+ tickSpy.mockRestore();
240
+ }
241
+ });
242
+
243
+ test('the bypass is not unconditional — a queue with something already running never force-ticks at all', async () => {
244
+ // Guard against the bypass becoming an escape hatch: the watchdog only
245
+ // reaches its forced tick on a genuine 'starved' verdict (0 running). When
246
+ // a job is already running, classifyQueueStarvation returns null and no
247
+ // forced tick fires — so bypassLoadGate never even comes into play.
248
+ const cwd = '/tmp/sm-running-nobypass-project';
249
+ const jobs = [
250
+ { slug: 'run-a', cwd, status: 'running', dependsOn: [] },
251
+ { slug: 'pend-b', cwd, status: 'pending', dependsOn: [] },
252
+ ];
253
+ const state = { jobs, paused: null, lastRunAt: new Date(NOW - 40 * 60_000).toISOString() };
254
+ const opts = { now: NOW, bootedAtMs: NOW - 3 * 60 * 60_000 };
255
+ const tickSpy = vi.spyOn(scheduler, 'tickQueue').mockResolvedValue({ fired: false, reason: 'drained' });
256
+ try {
257
+ const verdict = await scheduler.runQueueStarvationWatchdog(state, opts);
258
+ expect(verdict).toBeNull();
259
+ expect(tickSpy).not.toHaveBeenCalled();
260
+ } finally {
261
+ tickSpy.mockRestore();
262
+ }
263
+ });
264
+
219
265
  test('the watchdog latches: one forced tick / audit per starve episode, re-armed by threshold or running-count change', async () => {
220
266
  const cwd = '/tmp/sm-starve-latch-project';
221
267
  const jobs = [{ slug: 'latch-a', cwd, status: 'pending', dependsOn: [] }];
@@ -33,6 +33,7 @@ const path = require('node:path');
33
33
  let tmpHome;
34
34
  let originalHome;
35
35
  let scheduler;
36
+ let bustCwdCache;
36
37
 
37
38
  beforeAll(() => {
38
39
  originalHome = process.env.HOME;
@@ -40,6 +41,7 @@ beforeAll(() => {
40
41
  process.env.HOME = tmpHome;
41
42
 
42
43
  scheduler = require('../scheduler.cjs');
44
+ ({ bustCwdCache } = require('../lib/queueStore.cjs'));
43
45
 
44
46
  if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
45
47
  throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
@@ -56,6 +58,14 @@ function registerActiveProject(cwd) {
56
58
  const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
57
59
  fs.mkdirSync(slugDir, { recursive: true });
58
60
  fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
61
+ // Each test registers a NEW project mid-file; without busting queueStore's
62
+ // stateCwds cache (CACHE_MS 120s), a reconcile() call from an EARLIER test
63
+ // in this file can leave a stale cwd list cached that this test's freshly
64
+ // registered project is invisible to — reconcile() then never discovers
65
+ // its PRD at all (confirmed: this is what made
66
+ // 'reconcile() still onboards a PRD written to the canonical Epic-scoped
67
+ // dir' intermittently fail under full-suite load, PRD 1414).
68
+ bustCwdCache();
59
69
  }
60
70
 
61
71
  function makeFixtureProject(prefix) {
@@ -32,6 +32,23 @@ const { execFileSync } = require('node:child_process');
32
32
 
33
33
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'looks-done-test-'));
34
34
  process.env.HOME = tmpHome;
35
+ // reverifyNeedsReview's own auto-fix pass (PRD 1414 root cause): a
36
+ // needs_review row with autoFixAttempted:true is still RETRY-eligible
37
+ // (selectAutoFixTargets) whenever autoFixOutcome is unset/'no-plan'/'error'
38
+ // and autoFixRetries < 1 — exactly this file's PRD-1136 fixture. reverify
39
+ // then calls spawnInvestigation(job, ...) FIRE-AND-FORGET (no `await`, only
40
+ // `.catch()`), so it keeps running real disk/git I/O in the background after
41
+ // reverifyNeedsReview() (and the test awaiting it) returns. Under
42
+ // `npm run test:unit`'s full-suite load that background work sometimes had
43
+ // enough real time to run before this file's own assertions/next test read
44
+ // queue.json, and was observed re-discovering '30-example' as an unstamped
45
+ // PRD and quarantining it out from under this test (confirmed via a captured
46
+ // failure: 'quarantining unstamped PRD 30-example ... no createdVia
47
+ // provenance'). This file's own tests only assert reverifyNeedsReview's
48
+ // direct return value / queue mutations, never spawnInvestigation's, so
49
+ // disable auto-fix the same way sibling scheduler test files already do
50
+ // (e.g. scheduler-shard-quarantine.test.cjs).
51
+ process.env.SM_AUTOFIX_DISABLE = '1';
35
52
 
36
53
  const { reverifyNeedsReview, computeLooksDone, applyNeedsReviewAutoResolve, findSatisfyingCommitOnMain } = require('../scheduler.cjs');
37
54
  const { resolveEpicPrdWriteDir } = require('../lib/prdLocations.cjs');
@@ -122,6 +139,29 @@ async function wait(ms) {
122
139
  await new Promise((r) => setTimeout(r, ms));
123
140
  }
124
141
 
142
+ // Every test in this file shares ONE tmpHome (see the module-level mkdtempSync
143
+ // above) and most call reverifyNeedsReview(), which scans EVERY project
144
+ // registered under tmpHome/.claude/projects — not just the one this test just
145
+ // wrote (queueStore.cjs's stateCwds unions allProjectCwds()+activeProjectCwds()
146
+ // across the whole HOME). Without this cleanup, each later test's
147
+ // reverifyNeedsReview() call re-processes every earlier test's already-settled
148
+ // fixture row too (confirmed via full-suite stdout: the PRD-1136 test's own
149
+ // reverifyNeedsReview() call was observed re-running auto-fix bookkeeping for
150
+ // 171-example, a prior test's row), and each re-processed candidate costs 1+
151
+ // real `git` subprocess calls (fetchAllRefs, committedInWindow, ...). That
152
+ // backlog only grows as more tests run, so a later test's single
153
+ // reverifyNeedsReview() call does correspondingly more child-process I/O — on
154
+ // a loaded `npm run test:unit` worker pool this cumulative latency is what
155
+ // occasionally pushes a later test toward vitest's 15s per-test budget
156
+ // (vitest.config.ts documents the same class of load-timeout elsewhere),
157
+ // while the file passes in ~10s alone with nothing to accumulate. Wiping the
158
+ // registry after each test bounds every reverifyNeedsReview() call to just
159
+ // that test's own row(s), removing the cross-test amplification.
160
+ afterEach(() => {
161
+ fs.rmSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true, force: true });
162
+ bustCwdCache();
163
+ });
164
+
125
165
  test('failed + no result event (unverified-shaped) + later commit touching declared path → looksDone, transitions to needs_review', async () => {
126
166
  const projectCwd = path.join(tmpHome, 'proj-171');
127
167
  initRepo(projectCwd);
@@ -260,3 +260,84 @@ test('PRD 985: a PRD missing entirely still routes via the job row\'s own epicId
260
260
  );
261
261
  expect(appendTranscriptTurn).toHaveBeenCalledWith(cwd, 'psess-985-from-queue-row', expect.anything());
262
262
  });
263
+
264
+ // ─── PRD 1407: validator jobs stamp verdicts onto the authoring Epic ────────
265
+ // A completed validator job's result text carries one `VALIDATION: <slug>
266
+ // VERIFIED|REFUTED` line per PRD it checked. Each parsed verdict becomes its
267
+ // own response event BEFORE the validator's own (still-unvalidated)
268
+ // check-in, and the validator's check-in never enqueues a validation prompt
269
+ // for itself.
270
+ test('PRD 1407: a validator job with two sentinel lines appends two verdict events plus its own check-in, and never enqueues validation', async () => {
271
+ const sendPrompt = vi.fn();
272
+ const appendResponseEvent = vi.fn(async () => true);
273
+ const appendTranscriptTurn = vi.fn(async () => {});
274
+ const enqueueValidation = vi.fn();
275
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-validator-1' }));
276
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
277
+ const readResultFromLog = vi.fn(() => [
278
+ 'VALIDATION: 111-alpha VERIFIED',
279
+ 'VALIDATION: 222-beta REFUTED — missing coverage',
280
+ ].join('\n'));
281
+
282
+ await notifyOriginatingTab(
283
+ { slug: '1407-validator-run', status: 'completed', cwd: '/some/cwd', agentType: 'validator' },
284
+ {
285
+ parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent,
286
+ appendTranscriptTurn, readResultFromLog, enqueueValidation,
287
+ },
288
+ );
289
+
290
+ expect(appendResponseEvent).toHaveBeenCalledTimes(3);
291
+ expect(appendResponseEvent).toHaveBeenNthCalledWith(
292
+ 1, '/some/cwd', 'psess-validator-1', expect.stringContaining('111-alpha'),
293
+ { prdSlug: '111-alpha', outcome: 'completed', validation: 'verified' },
294
+ );
295
+ expect(appendResponseEvent).toHaveBeenNthCalledWith(
296
+ 2, '/some/cwd', 'psess-validator-1', expect.stringContaining('222-beta'),
297
+ { prdSlug: '222-beta', outcome: 'completed', validation: 'refuted' },
298
+ );
299
+ expect(appendResponseEvent).toHaveBeenNthCalledWith(
300
+ 3, '/some/cwd', 'psess-validator-1', expect.stringContaining('1407-validator-run'),
301
+ { prdSlug: '1407-validator-run', outcome: 'completed', validation: 'unvalidated' },
302
+ );
303
+ expect(enqueueValidation).not.toHaveBeenCalled();
304
+ });
305
+
306
+ // ─── PRD 1408: a downstream validator suppresses the per-PRD validation ask ─
307
+ test('skips enqueueValidation when loadJobs reports a pending validator row depending on this job', async () => {
308
+ const sendPrompt = vi.fn();
309
+ const appendResponseEvent = vi.fn(async () => true);
310
+ const enqueueValidation = vi.fn();
311
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-1' }));
312
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
313
+ const loadJobs = vi.fn(async () => ([
314
+ { slug: '1408-work-item' },
315
+ { slug: '1408-validate', agentType: 'validator', status: 'pending', dependsOn: ['1408-work-item'] },
316
+ ]));
317
+
318
+ await notifyOriginatingTab(
319
+ { slug: '1408-work-item', status: 'completed', cwd: '/some/cwd' },
320
+ { parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent, enqueueValidation, loadJobs },
321
+ );
322
+
323
+ expect(appendResponseEvent).toHaveBeenCalledTimes(1);
324
+ expect(loadJobs).toHaveBeenCalledTimes(1);
325
+ expect(enqueueValidation).not.toHaveBeenCalled();
326
+ });
327
+
328
+ test('still enqueues validation when loadJobs reports no downstream validator', async () => {
329
+ const sendPrompt = vi.fn();
330
+ const appendResponseEvent = vi.fn(async () => true);
331
+ const enqueueValidation = vi.fn();
332
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-1' }));
333
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
334
+ const loadJobs = vi.fn(async () => ([{ slug: '1408-work-item' }]));
335
+
336
+ await notifyOriginatingTab(
337
+ { slug: '1408-work-item', status: 'completed', cwd: '/some/cwd' },
338
+ { parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent, enqueueValidation, loadJobs },
339
+ );
340
+
341
+ expect(appendResponseEvent).toHaveBeenCalledTimes(1);
342
+ expect(enqueueValidation).toHaveBeenCalledTimes(1);
343
+ });
@@ -90,11 +90,16 @@ test('tickQueue dispatches the healthy project while the torn project is skipped
90
90
  const snaps = fs.readdirSync(path.dirname(torn.queueFile)).filter((f) => f.startsWith('queue.json.corrupt-'));
91
91
  expect(snaps).toHaveLength(1);
92
92
 
93
+ // 60s, not 20s: under npm run test:unit's full-suite parallel-worker load
94
+ // this occasionally needed more than 20s for the real child-process
95
+ // dispatch to land (PRD 1414 — confirmed flaky only inside the full suite,
96
+ // never in isolation or under synthetic single-file CPU load). The
97
+ // surrounding vitest test timeout below (120s) already budgets for this.
93
98
  await new Promise((resolve, reject) => {
94
99
  const t0 = Date.now();
95
100
  const poll = () => {
96
101
  if (fs.existsSync(path.join(good.cwd, 'ran-here.marker'))) return resolve();
97
- if (Date.now() - t0 > 20_000) return reject(new Error('healthy project never dispatched'));
102
+ if (Date.now() - t0 > 60_000) return reject(new Error('healthy project never dispatched'));
98
103
  setTimeout(poll, 100);
99
104
  };
100
105
  poll();
@@ -0,0 +1,110 @@
1
+ /**
2
+ * schedulerStateSidecarRestore.test.cjs — covers PRD 1392: scheduler-state.json
3
+ * previously left lastPollOk, lastFailureKind, backoffNextAt, cachedUtilization and
4
+ * cachedBindingWindowName memory-only, so a fresh boot showed misleading footer/KPI
5
+ * placeholders (a healthy poller reading "failed", a still-rate-limited state
6
+ * un-showable, utilization stuck at "—") until the next post-boot billing poll
7
+ * overwrote them. Proves loadSchedulerState() restores all five from disk instead of
8
+ * leaving the hardcoded defaults (false / null / null / null / null) in place.
9
+ *
10
+ * scheduler.cjs is a plain CJS require (like rateLimitPollerStreak.test.cjs's), so its
11
+ * module-level `let`s are a singleton for this test file's process — the first test
12
+ * below captures the pre-load snapshot itself (the hardcoded defaults) rather than
13
+ * assuming a fresh module per test, so the proof doesn't depend on test ordering.
14
+ *
15
+ * Run: timeout 120 npx vitest run src/main/__tests__/schedulerStateSidecarRestore.test.cjs
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ import { test, expect, beforeEach } from 'vitest';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ const { loadSchedulerState, persistSchedulerState, getSchedulerStateSnapshot } = require('../scheduler.cjs');
25
+ const schedulerPaths = require('../lib/schedulerPaths.cjs');
26
+
27
+ function writeSidecar(data) {
28
+ fs.mkdirSync(path.dirname(schedulerPaths.schedulerStatePath()), { recursive: true });
29
+ fs.writeFileSync(schedulerPaths.schedulerStatePath(), JSON.stringify(data));
30
+ }
31
+
32
+ beforeEach(() => {
33
+ try { fs.unlinkSync(schedulerPaths.schedulerStatePath()); } catch { /* no sidecar yet */ }
34
+ });
35
+
36
+ test('loadSchedulerState restores lastPollOk/lastFailureKind/backoffNextAt/cachedUtilization/cachedBindingWindowName from the sidecar, not the hardcoded defaults', () => {
37
+ // Before a restore, the module starts (or was left, by whatever ran before this test
38
+ // in-process) at whatever it was — capture it so the assertion below is a genuine
39
+ // "changed to match the file" proof, not an assumption about fresh-module defaults.
40
+ const before = getSchedulerStateSnapshot();
41
+
42
+ const written = {
43
+ version: 1,
44
+ lastPollOk: true,
45
+ lastFailureKind: 'meter_rate_limited',
46
+ backoffNextAt: 1_726_000_000_000,
47
+ cachedUtilization: 42,
48
+ cachedBindingWindowName: 'five_hour',
49
+ };
50
+ writeSidecar(written);
51
+
52
+ // Simulate a restart: a fresh process starts from the hardcoded module defaults
53
+ // (false / null / null / null / null) until loadSchedulerState() runs.
54
+ loadSchedulerState();
55
+
56
+ const after = getSchedulerStateSnapshot();
57
+ expect(after).toEqual({
58
+ lastPollOk: true,
59
+ lastFailureKind: 'meter_rate_limited',
60
+ backoffNextAt: 1_726_000_000_000,
61
+ cachedUtilization: 42,
62
+ cachedBindingWindowName: 'five_hour',
63
+ });
64
+ // The restore must have actually changed something relative to the pre-load
65
+ // snapshot — proves this run exercised the restore path, not a no-op.
66
+ expect(after).not.toEqual(before);
67
+ });
68
+
69
+ test('persistSchedulerState writes the same five fields back out, round-tripping through the sidecar', () => {
70
+ writeSidecar({
71
+ version: 1,
72
+ lastPollOk: false,
73
+ lastFailureKind: 'auth',
74
+ backoffNextAt: 1_726_100_000_000,
75
+ cachedUtilization: 87.5,
76
+ cachedBindingWindowName: 'weekly_all',
77
+ });
78
+ loadSchedulerState();
79
+
80
+ persistSchedulerState();
81
+
82
+ const onDisk = JSON.parse(fs.readFileSync(schedulerPaths.schedulerStatePath(), 'utf8'));
83
+ expect(onDisk.lastPollOk).toBe(false);
84
+ expect(onDisk.lastFailureKind).toBe('auth');
85
+ expect(onDisk.backoffNextAt).toBe(1_726_100_000_000);
86
+ expect(onDisk.cachedUtilization).toBe(87.5);
87
+ expect(onDisk.cachedBindingWindowName).toBe('weekly_all');
88
+ });
89
+
90
+ test('loadSchedulerState against a sidecar missing the five fields leaves the in-memory values unchanged (pre-PRD-1392 file)', () => {
91
+ // Establish a known non-default in-memory state first.
92
+ writeSidecar({
93
+ version: 1,
94
+ lastPollOk: true,
95
+ lastFailureKind: 'transient',
96
+ backoffNextAt: 5000,
97
+ cachedUtilization: 10,
98
+ cachedBindingWindowName: 'seven_day',
99
+ });
100
+ loadSchedulerState();
101
+ const established = getSchedulerStateSnapshot();
102
+
103
+ // A sidecar written before PRD 1392 (or first-boot with only the older fields) has
104
+ // none of the five new keys — loadSchedulerState's per-field `typeof` guards must
105
+ // leave whatever was already in memory alone, not stomp it back to a hardcoded default.
106
+ writeSidecar({ version: 1, lastPollAt: 123 });
107
+ loadSchedulerState();
108
+
109
+ expect(getSchedulerStateSnapshot()).toEqual(established);
110
+ });
@@ -54,7 +54,7 @@ function silentLogger() {
54
54
  return { log: () => {}, warn: () => {} };
55
55
  }
56
56
 
57
- const ALL_PERSONAS = ['architect', 'dev-lead', 'project-home-builder'];
57
+ const ALL_PERSONAS = ['architect', 'dev-lead', 'project-home-builder', 'validator'];
58
58
 
59
59
  test('fresh seed writes every persona file', async () => {
60
60
  await seedAgentPersonas({ logger: silentLogger() });
@@ -156,6 +156,24 @@ test('the bundled project-home-builder persona contains no session-manager-repo
156
156
  }
157
157
  });
158
158
 
159
+ test('seeded dev-lead persona pins an executor-tier model', () => {
160
+ const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
161
+ const raw = fs.readFileSync(path.join(__dirname, '..', '..', 'seed', 'agents', 'dev-lead.md'), 'utf8');
162
+ const { fm } = splitFrontmatter(raw);
163
+ expect(fm.model).toBe('sonnet');
164
+ });
165
+
166
+ test('seeded dev-lead body fits AGENT_BODY_CHAR_CAP and carries the run contract', () => {
167
+ const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
168
+ const { AGENT_BODY_CHAR_CAP } = require('../lib/agentModelResolve.cjs');
169
+ const raw = fs.readFileSync(path.join(__dirname, '..', '..', 'seed', 'agents', 'dev-lead.md'), 'utf8');
170
+ const { body } = splitFrontmatter(raw);
171
+ const trimmed = body.trim();
172
+ expect(trimmed.length).toBeLessThan(AGENT_BODY_CHAR_CAP);
173
+ expect(trimmed).toContain('## Run contract');
174
+ expect(trimmed).toContain('SCHEDULER_VERDICT: PASS');
175
+ });
176
+
159
177
  test('SM_SEED_AGENT_PERSONAS_DISABLE=1 short-circuits', async () => {
160
178
  process.env.SM_SEED_AGENT_PERSONAS_DISABLE = '1';
161
179