claude-code-session-manager 0.39.4 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/assets/{TiptapBody-BSRtwtPP.js → TiptapBody-DWdlsmg9.js} +1 -1
  2. package/dist/assets/index-BbI9slj8.js +3198 -0
  3. package/dist/assets/{index-DyLm7GM_.css → index-BxVBtmjA.css} +1 -1
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +14 -13
  7. package/src/main/__tests__/prdCreate.test.cjs +54 -15
  8. package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +118 -0
  9. package/src/main/__tests__/transcriptsUsageFor.test.cjs +89 -0
  10. package/src/main/__tests__/uniquePrdNumbers.test.cjs +119 -0
  11. package/src/main/config.cjs +17 -0
  12. package/src/main/index.cjs +24 -6
  13. package/src/main/ipcSchemas.cjs +35 -0
  14. package/src/main/lib/__tests__/instanceLock.test.cjs +87 -0
  15. package/src/main/lib/__tests__/projectBriefCore.test.cjs +142 -0
  16. package/src/main/lib/epicMint.cjs +1 -1
  17. package/src/main/lib/instanceLock.cjs +100 -0
  18. package/src/main/lib/prdCreate.cjs +19 -6
  19. package/src/main/lib/prdLocations.cjs +28 -0
  20. package/src/main/lib/projectBriefCore.cjs +176 -0
  21. package/src/main/lib/runClaudeP.cjs +52 -0
  22. package/src/main/lib/schedulerBatch.cjs +36 -4
  23. package/src/main/memoryAggregate.cjs +2 -40
  24. package/src/main/projectBrief.cjs +313 -0
  25. package/src/main/pty.cjs +9 -0
  26. package/src/main/scheduler/prdParser.cjs +28 -4
  27. package/src/main/scheduler.cjs +94 -5
  28. package/src/main/templates/PRD_AUTHORING.md +9 -5
  29. package/src/main/transcripts.cjs +73 -0
  30. package/src/preload/api.d.ts +80 -3
  31. package/src/preload/index.cjs +8 -0
  32. package/dist/assets/index-XGsBHqYu.js +0 -3180
@@ -0,0 +1,89 @@
1
+ /**
2
+ * transcriptsUsageFor.test.cjs — unit tests for transcripts.cjs's usageFor(),
3
+ * the batched token-usage reader backing the `transcript:usageFor` IPC (Epics
4
+ * workspace tokens metric).
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/transcriptsUsageFor.test.cjs
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ import { test, expect, beforeEach, afterEach } from 'vitest';
12
+
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+
17
+ const { usageFor, transcriptPath } = require('../transcripts.cjs');
18
+
19
+ let cwd;
20
+
21
+ beforeEach(() => {
22
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-usage-test-'));
23
+ });
24
+
25
+ afterEach(() => {
26
+ fs.rmSync(cwd, { recursive: true, force: true });
27
+ });
28
+
29
+ function writeTranscript(cwdArg, sessionId, lines) {
30
+ const filePath = transcriptPath(cwdArg, sessionId);
31
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
32
+ fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
33
+ return filePath;
34
+ }
35
+
36
+ test('sums input/output tokens across usage events in a session transcript', async () => {
37
+ const sessionId = 'sess-aaaa-1111';
38
+ writeTranscript(cwd, sessionId, [
39
+ { type: 'assistant', usage: { input_tokens: 100, output_tokens: 20 } },
40
+ { type: 'assistant', usage: { input_tokens: 50, output_tokens: 10 } },
41
+ { type: 'user', message: { content: 'no usage here' } },
42
+ ]);
43
+
44
+ const result = await usageFor(cwd, [sessionId]);
45
+ expect(result[sessionId]).toEqual({ inputTokens: 150, outputTokens: 30 });
46
+ });
47
+
48
+ test('maps a session with no transcript file to null', async () => {
49
+ const result = await usageFor(cwd, ['no-such-session']);
50
+ expect(result['no-such-session']).toBeNull();
51
+ });
52
+
53
+ test('batches multiple sessions in one call', async () => {
54
+ writeTranscript(cwd, 'sess-a', [{ usage: { input_tokens: 5, output_tokens: 1 } }]);
55
+ writeTranscript(cwd, 'sess-b', [{ usage: { input_tokens: 7, output_tokens: 2 } }]);
56
+
57
+ const result = await usageFor(cwd, ['sess-a', 'sess-b', 'sess-missing']);
58
+ expect(result).toEqual({
59
+ 'sess-a': { inputTokens: 5, outputTokens: 1 },
60
+ 'sess-b': { inputTokens: 7, outputTokens: 2 },
61
+ 'sess-missing': null,
62
+ });
63
+ });
64
+
65
+ test('a repeat call against an unchanged file returns the same cached totals', async () => {
66
+ const sessionId = 'sess-cache';
67
+ writeTranscript(cwd, sessionId, [{ usage: { input_tokens: 10, output_tokens: 5 } }]);
68
+
69
+ const first = await usageFor(cwd, [sessionId]);
70
+ const second = await usageFor(cwd, [sessionId]);
71
+ expect(second[sessionId]).toEqual(first[sessionId]);
72
+ expect(second[sessionId]).toEqual({ inputTokens: 10, outputTokens: 5 });
73
+ });
74
+
75
+ test('a growing transcript is re-summed after the file mtime/size change', async () => {
76
+ const sessionId = 'sess-grow';
77
+ writeTranscript(cwd, sessionId, [{ usage: { input_tokens: 10, output_tokens: 5 } }]);
78
+ const before = await usageFor(cwd, [sessionId]);
79
+ expect(before[sessionId]).toEqual({ inputTokens: 10, outputTokens: 5 });
80
+
81
+ // Force a distinct mtime — some filesystems have coarse mtime resolution.
82
+ const filePath = transcriptPath(cwd, sessionId);
83
+ const future = new Date(Date.now() + 5000);
84
+ fs.appendFileSync(filePath, JSON.stringify({ usage: { input_tokens: 3, output_tokens: 1 } }) + '\n');
85
+ fs.utimesSync(filePath, future, future);
86
+
87
+ const after = await usageFor(cwd, [sessionId]);
88
+ expect(after[sessionId]).toEqual({ inputTokens: 13, outputTokens: 6 });
89
+ });
@@ -0,0 +1,119 @@
1
+ // PRD 832 — unique per-project PRD numbers + dependsOn eligibility.
2
+ // User decision 2026-07-31: duplicate NN must be impossible; ordering is
3
+ // expressed via dependsOn frontmatter, never via shared numbers.
4
+ import { describe, it, expect } from 'vitest';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ const prdParser = require('../scheduler/prdParser.cjs');
10
+ const { pickForProject } = require('../lib/schedulerBatch.cjs');
11
+
12
+ function tmpdir() {
13
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'sm-832-'));
14
+ }
15
+
16
+ const job = (slug, status, group, extra = {}) => ({
17
+ slug, status, parallelGroup: group, cwd: '/proj', ...extra,
18
+ });
19
+
20
+ describe('allocateParallelGroup extraFloor (PRD 832)', () => {
21
+ it('never allocates at or below the cross-dir max', async () => {
22
+ const dir = tmpdir();
23
+ // Bookkeeping dir itself is empty; another dir in the project already
24
+ // used numbers up to 830.
25
+ const nn = await prdParser.allocateParallelGroup(dir, { extraFloor: 830 });
26
+ expect(nn).toBe(831);
27
+ // And the high-water mark persists the raised floor for later calls.
28
+ const nn2 = await prdParser.allocateParallelGroup(dir);
29
+ expect(nn2).toBe(832);
30
+ });
31
+
32
+ it('local scan still wins when higher than extraFloor', async () => {
33
+ const dir = tmpdir();
34
+ fs.writeFileSync(path.join(dir, '900-existing.md'), '---\ntitle: x\n---\n# Goal\n');
35
+ const nn = await prdParser.allocateParallelGroup(dir, { extraFloor: 10 });
36
+ expect(nn).toBe(901);
37
+ });
38
+ });
39
+
40
+ describe('parsePrdRaw dependsOn (PRD 832)', () => {
41
+ it('parses bracket and comma forms, rejects junk', async () => {
42
+ const dir = tmpdir();
43
+ const fp = path.join(dir, '840-x.md');
44
+ fs.writeFileSync(fp, '---\ntitle: x\ncwd: /proj\ndependsOn: [833-epics-review-fixes, 834-guard]\n---\n# Goal\nbody\n');
45
+ const p = await prdParser.parsePrdRaw(fp);
46
+ expect(p.dependsOn).toEqual(['833-epics-review-fixes', '834-guard']);
47
+
48
+ const fp2 = path.join(dir, '841-y.md');
49
+ fs.writeFileSync(fp2, '---\ntitle: y\ncwd: /proj\ndependsOn: 833-a, ../evil/path, 834-b\n---\n# Goal\nbody\n');
50
+ const p2 = await prdParser.parsePrdRaw(fp2);
51
+ expect(p2.dependsOn).toEqual(['833-a', '834-b']);
52
+
53
+ const fp3 = path.join(dir, '842-z.md');
54
+ fs.writeFileSync(fp3, '---\ntitle: z\ncwd: /proj\n---\n# Goal\nbody\n');
55
+ const p3 = await prdParser.parsePrdRaw(fp3);
56
+ expect(p3.dependsOn).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe('pickForProject dependsOn eligibility (PRD 832)', () => {
61
+ it('holds a job while its dep row is pending/running; releases when completed', () => {
62
+ const dep = job('850-base', 'running', 850);
63
+ const dependent = job('851-next', 'pending', 851, { dependsOn: ['850-base'] });
64
+
65
+ // Dep running (tracked in queue rows, not runningSet) — not eligible.
66
+ let res = pickForProject([dep, dependent], new Set(), 3);
67
+ expect(res.batch.map((j) => j.slug)).not.toContain('851-next');
68
+
69
+ // Dep completed — eligible.
70
+ res = pickForProject([{ ...dep, status: 'completed' }, dependent], new Set(), 3);
71
+ expect(res.batch.map((j) => j.slug)).toContain('851-next');
72
+ });
73
+
74
+ it('a dep slug with NO queue row counts as done (retired to history)', () => {
75
+ const dependent = job('853-next', 'pending', 853, { dependsOn: ['852-archived-long-ago'] });
76
+ const res = pickForProject([dependent], new Set(), 3);
77
+ expect(res.batch.map((j) => j.slug)).toContain('853-next');
78
+ });
79
+
80
+ it('a FAILED dep holds the dependent with an explicit depends-gate reason', () => {
81
+ const dep = job('854-base', 'failed', 854);
82
+ const dependent = job('855-next', 'pending', 855, { dependsOn: ['854-base'] });
83
+ const res = pickForProject([dep, dependent], new Set(), 3);
84
+ expect(res.batch).toEqual([]);
85
+ expect(res.reason).toMatch(/depends-gate/);
86
+ expect(res.reason).toContain('855-next');
87
+ expect(res.reason).toContain('854-base');
88
+ });
89
+
90
+ it('legacy same-NN pending jobs keep their wave order untouched', () => {
91
+ const a = job('827-one', 'pending', 827);
92
+ const b = job('827-two', 'pending', 827);
93
+ const later = job('828-three', 'pending', 828);
94
+ const res = pickForProject([a, b, later], new Set(), 3);
95
+ expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two']);
96
+ });
97
+ });
98
+
99
+ describe('reconcile retires archived-twin pending rows (post-832 cleanup)', () => {
100
+ it('drops a pending row whose PRD lives in prds-archived, keeps a normal invisible pending row', async () => {
101
+ const scheduler = require('../scheduler.cjs');
102
+ const root = tmpdir();
103
+ const opsDir = path.join(root, 'session-manager-operations', 'scheduler');
104
+ fs.mkdirSync(path.join(opsDir, 'prds'), { recursive: true });
105
+ fs.mkdirSync(path.join(opsDir, 'prds-archived'), { recursive: true });
106
+ fs.writeFileSync(path.join(opsDir, 'prds-archived', '833-landed-inline.md'), '---\ntitle: x\n---\n# Goal\n');
107
+
108
+ const state = {
109
+ jobs: [
110
+ job('833-landed-inline', 'pending', 833, { cwd: root }),
111
+ job('900-still-hidden', 'pending', 900, { cwd: root }),
112
+ ],
113
+ };
114
+ await scheduler.reconcile(state);
115
+ const slugs = state.jobs.map((j) => j.slug);
116
+ expect(slugs).not.toContain('833-landed-inline');
117
+ expect(slugs).toContain('900-still-hidden');
118
+ });
119
+ });
@@ -152,6 +152,23 @@ function validateWrite(realAbs) {
152
152
  if (realAbs === promptSessionsSub || realAbs.startsWith(promptSessionsSub + path.sep)) {
153
153
  return;
154
154
  }
155
+ // Scheduler PRD sources (PRD 825 — per-project Epic PRD dirs under
156
+ // scheduler/epics/<id>/prds/, plus the legacy flat scheduler/prds/ and
157
+ // scheduler/state/ shards): narrowly scoped to
158
+ // session-manager-operations/scheduler/, this repo's existing
159
+ // per-project artifact-store convention (scheduler.cjs's remote.writePrd
160
+ // writes here via config.writeTextAtomic).
161
+ const schedulerSub = path.join(realRoot, 'session-manager-operations', 'scheduler');
162
+ if (realAbs === schedulerSub || realAbs.startsWith(schedulerSub + path.sep)) {
163
+ return;
164
+ }
165
+ // Project Brief persistence (PRD 837 — projectBrief.cjs's writeJson of
166
+ // brief.json): narrowly scoped to session-manager-operations/project-brief/,
167
+ // this repo's existing per-project artifact-store convention.
168
+ const briefSub = path.join(realRoot, 'session-manager-operations', 'project-brief');
169
+ if (realAbs === briefSub || realAbs.startsWith(briefSub + path.sep)) {
170
+ return;
171
+ }
155
172
  }
156
173
  }
157
174
  throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
@@ -7,6 +7,7 @@ const os = require('node:os');
7
7
  const { schemas, validated } = require('./ipcSchemas.cjs');
8
8
  const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
9
9
  const { terminateLosingInstance } = require('./lib/singleInstanceGuard.cjs');
10
+ const { acquireSchedulerOwnership, releaseSchedulerOwnership } = require('./lib/instanceLock.cjs');
10
11
  const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
11
12
  const browserView = require('./browserView.cjs');
12
13
  const browserCapture = require('./browserCapture.cjs');
@@ -53,6 +54,7 @@ const { tryAcquireLock, releaseLock, DEFAULT_LOCK_PATH } = require('../../script
53
54
  const schedulerConfig = require('./lib/schedulerConfig.cjs');
54
55
  const memoryTool = require('./memoryTool.cjs');
55
56
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
57
+ const { registerProjectBriefIpc } = require('./projectBrief.cjs');
56
58
  const agentMemory = require('./agentMemory.cjs');
57
59
  const git = require('./git.cjs');
58
60
  const filesIpc = require('./files.cjs');
@@ -769,6 +771,7 @@ registerHistoryDashboardHandlers();
769
771
  pluginInstall.registerPluginInstallHandlers();
770
772
  memoryTool.registerMemoryHandlers();
771
773
  registerMemoryAggregateIpc();
774
+ registerProjectBriefIpc();
772
775
  agentMemory.registerAgentMemoryHandlers();
773
776
  git.register(ipcMain);
774
777
  filesIpc.registerFilesHandlers();
@@ -1090,12 +1093,26 @@ app.whenReady().then(async () => {
1090
1093
  webRemote.attachWindow(mainWindow);
1091
1094
  chatRunner.attachWindow(mainWindow);
1092
1095
  attachDocEditWindow(mainWindow);
1093
- scheduler.init().catch((e) => {
1094
- logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
1095
- });
1096
- adminHttp.start().catch((e) => {
1097
- logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1098
- });
1096
+ // Scheduler ownership (PRD 834): exactly one instance machine-wide runs
1097
+ // scheduler mutation (boot reconciliation, ticking, sweep, supervisor) and
1098
+ // the admin API. Electron's own single-instance lock is keyed by userData
1099
+ // identity, which a dev/e2e/Playwright launch bypasses — live incident
1100
+ // 2026-07-31: a secondary instance's boot reconciliation SIGTERM'd the
1101
+ // owner's running job and overwrote admin-api.json. Losing instances run
1102
+ // scheduler-PASSIVE: read IPC still works (handlers registered above),
1103
+ // nothing mutates.
1104
+ const ownership = acquireSchedulerOwnership();
1105
+ if (ownership.owner) {
1106
+ scheduler.init().catch((e) => {
1107
+ logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
1108
+ });
1109
+ adminHttp.start().catch((e) => {
1110
+ logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1111
+ });
1112
+ } else {
1113
+ console.log(`[main] scheduler ownership held by pid ${ownership.holderPid} — running scheduler-passive (no reconciliation, no admin server)`);
1114
+ logs.writeLine({ scope: 'scheduler', level: 'info', message: 'scheduler-passive: ownership held by another instance', meta: { holderPid: ownership.holderPid } });
1115
+ }
1099
1116
  browserAgentServer.start().catch((e) => {
1100
1117
  logs.writeLine({ scope: 'browser-agent-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1101
1118
  });
@@ -1199,6 +1216,7 @@ app.on('activate', () => {
1199
1216
  });
1200
1217
 
1201
1218
  app.on('before-quit', () => {
1219
+ releaseSchedulerOwnership();
1202
1220
  supervisor.stopSupervisor();
1203
1221
  ptyManager.killAll();
1204
1222
  configMgr.closeAllWatchers();
@@ -20,6 +20,10 @@ const ptySpawn = z.object({
20
20
 
21
21
  const ptyTabId = z.object({ tabId: z.string().min(1).max(128) });
22
22
 
23
+ // PRD 833 C1: Epics-workspace reconcile — which of these session keys still
24
+ // have a live PTY? Bounded so the renderer can't ask about unbounded lists.
25
+ const ptyAlive = z.object({ tabIds: z.array(z.string().min(1).max(128)).max(500) });
26
+
23
27
  // v2 mobile: subscribe to a session's live state + summary. cwd is needed to
24
28
  // locate the transcript JSONL (transcriptPath); validated against home-dir boundary
25
29
  // in webRemote before any fs access.
@@ -168,6 +172,14 @@ const transcriptPath = z.object({
168
172
  sessionUuid: z.string().regex(SESSION_UUID_RE),
169
173
  });
170
174
 
175
+ // Batched token-usage lookup for the Epics workspace (one call per visible
176
+ // set of Epics rather than a per-row IPC round trip). Bounded array size —
177
+ // mirrors ptyAlive's cap for the same reason (renderer can't ask unbounded).
178
+ const transcriptUsageFor = z.object({
179
+ cwd: z.string().min(1).max(4096),
180
+ sessionIds: z.array(z.string().regex(SESSION_UUID_RE)).max(500),
181
+ });
182
+
171
183
  // ──────────────────────────────────────────── Config
172
184
  const configPath = z.object({ path: z.string().min(1).max(4096) });
173
185
 
@@ -266,7 +278,12 @@ const schedulerCreatePrd = z.object({
266
278
  implementationNotes: z.string().min(1).max(20000),
267
279
  outOfScope: z.array(z.string().min(1).max(2000)).max(100).optional(),
268
280
  slug: z.string().min(1).max(60).regex(PRD_CREATE_SLUG_RE).optional(),
281
+ // DEPRECATED (PRD 832): accepted for wire-compat but ignored — numbers are
282
+ // strictly unique per project; use dependsOn for ordering.
269
283
  parallelGroup: z.number().int().min(1).max(999999).optional(),
284
+ // Explicit ordering (PRD 832): slugs that must complete before this PRD
285
+ // becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
286
+ dependsOn: z.array(z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/)).max(20).optional(),
270
287
  // Originating PromptTicket.id (PRD 748) when this PRD was authored from a
271
288
  // ticket classified 'develop' (PRD 749) — traces the PRD back to the
272
289
  // prompt that spawned it. Same newline-injection guard as title/cwd since
@@ -395,6 +412,20 @@ const memoryAggregate = z.object({
395
412
  refresh: z.boolean().optional(),
396
413
  }).strict();
397
414
 
415
+ // ──────────────────────────────────────────── Project Brief (PRD 837)
416
+ // cwd is validated for real via config.cjs's validatePath (allowedRoots =
417
+ // home dir) at first fs access in projectBrief.cjs — this schema only bounds
418
+ // the wire shape.
419
+ const PROJECT_BRIEF_BLOCK = z.enum(['what', 'conventions']);
420
+ const projectBriefCwd = z.object({
421
+ cwd: z.string().min(1).max(4096),
422
+ }).strict();
423
+ const projectBriefSetPin = z.object({
424
+ cwd: z.string().min(1).max(4096),
425
+ block: PROJECT_BRIEF_BLOCK,
426
+ pinned: z.boolean(),
427
+ }).strict();
428
+
398
429
  // ──────────────────────────────────────────── Per-subagent memory
399
430
  // Distinct from the workspace-scoped Memory tool: agentMemory is keyed by
400
431
  // subagent name (the .md filename in ~/.claude/agents/, e.g. "code-reviewer"),
@@ -726,6 +757,7 @@ module.exports = {
726
757
  webRemoteAuditTail,
727
758
  ptySpawn,
728
759
  ptyTabId,
760
+ ptyAlive,
729
761
  ptyWrite,
730
762
  ptyResize,
731
763
  sessionSubscribe,
@@ -745,6 +777,7 @@ module.exports = {
745
777
  transcriptSubscribe,
746
778
  transcriptTabId,
747
779
  transcriptPath,
780
+ transcriptUsageFor,
748
781
  configPath,
749
782
  configWriteJson,
750
783
  configWriteText,
@@ -782,6 +815,8 @@ module.exports = {
782
815
  memoryCreate,
783
816
  memoryAggregate,
784
817
  memoryStale,
818
+ projectBriefCwd,
819
+ projectBriefSetPin,
785
820
  agentMemoryList,
786
821
  agentMemoryGet,
787
822
  agentMemorySet,
@@ -0,0 +1,87 @@
1
+ // PRD 834 — machine-wide scheduler-ownership lock. Secondary instances must
2
+ // go scheduler-passive instead of running boot reconciliation / admin server
3
+ // against the owner's queue state (live incident 2026-07-31).
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ const {
10
+ acquireSchedulerOwnership,
11
+ releaseSchedulerOwnership,
12
+ pidAlive,
13
+ lockPath,
14
+ } = require('../instanceLock.cjs');
15
+
16
+ let dir;
17
+
18
+ beforeEach(() => {
19
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-instance-lock-'));
20
+ process.env.SM_SCHEDULER_LOCK_PATH = path.join(dir, 'scheduler-owner.lock');
21
+ });
22
+
23
+ afterEach(() => {
24
+ delete process.env.SM_SCHEDULER_LOCK_PATH;
25
+ fs.rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('instanceLock (PRD 834)', () => {
29
+ it('first acquirer becomes owner and writes its pid', () => {
30
+ const res = acquireSchedulerOwnership();
31
+ expect(res.owner).toBe(true);
32
+ const onDisk = JSON.parse(fs.readFileSync(lockPath(), 'utf8'));
33
+ expect(onDisk.pid).toBe(process.pid);
34
+ });
35
+
36
+ it('a live foreign holder makes acquisition passive with the holder pid reported', () => {
37
+ // Use our own live pid as the "foreign" holder — pidAlive(process.pid) is
38
+ // true, and acquire treats pid !== process.pid as foreign, so fake one by
39
+ // writing pid 1 (init — always alive, never ours).
40
+ fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
41
+ fs.writeFileSync(lockPath(), JSON.stringify({ pid: 1, startedAt: 'x' }));
42
+ const res = acquireSchedulerOwnership();
43
+ expect(res.owner).toBe(false);
44
+ expect(res.holderPid).toBe(1);
45
+ // The holder's lock is untouched.
46
+ expect(JSON.parse(fs.readFileSync(lockPath(), 'utf8')).pid).toBe(1);
47
+ });
48
+
49
+ it('a stale lock (dead pid) is broken and ownership taken', () => {
50
+ // Find a pid that is certainly dead: spawn nothing — use a huge pid
51
+ // beyond pid_max defaults.
52
+ fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
53
+ fs.writeFileSync(lockPath(), JSON.stringify({ pid: 2 ** 30, startedAt: 'x' }));
54
+ const res = acquireSchedulerOwnership();
55
+ expect(res.owner).toBe(true);
56
+ expect(JSON.parse(fs.readFileSync(lockPath(), 'utf8')).pid).toBe(process.pid);
57
+ });
58
+
59
+ it('re-acquiring our own lock stays owner (idempotent across re-init)', () => {
60
+ expect(acquireSchedulerOwnership().owner).toBe(true);
61
+ expect(acquireSchedulerOwnership().owner).toBe(true);
62
+ });
63
+
64
+ it('release removes only our own lock', () => {
65
+ acquireSchedulerOwnership();
66
+ releaseSchedulerOwnership();
67
+ expect(fs.existsSync(lockPath())).toBe(false);
68
+ // A foreign lock is never removed by release.
69
+ fs.writeFileSync(lockPath(), JSON.stringify({ pid: 1, startedAt: 'x' }));
70
+ releaseSchedulerOwnership();
71
+ expect(fs.existsSync(lockPath())).toBe(true);
72
+ });
73
+
74
+ it('a corrupt lock file is treated as stale, not fatal', () => {
75
+ fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
76
+ fs.writeFileSync(lockPath(), 'not json {');
77
+ const res = acquireSchedulerOwnership();
78
+ expect(res.owner).toBe(true);
79
+ });
80
+
81
+ it('pidAlive: own pid alive, absurd pid dead', () => {
82
+ expect(pidAlive(process.pid)).toBe(true);
83
+ expect(pidAlive(2 ** 30)).toBe(false);
84
+ expect(pidAlive(-5)).toBe(false);
85
+ expect(pidAlive(0)).toBe(false);
86
+ });
87
+ });
@@ -0,0 +1,142 @@
1
+ // projectBriefCore.cjs — pure logic for the Brief backend (PRD 837).
2
+ // vitest globals (test/beforeEach/afterEach) — same convention as the other .cjs tests.
3
+ const assert = require('node:assert/strict');
4
+ const {
5
+ computeDrift,
6
+ buildSources,
7
+ buildSynthesisPrompt,
8
+ validateBriefShape,
9
+ applyPinEnforcement,
10
+ buildPersistedBrief,
11
+ computeSetPin,
12
+ } = require('../projectBriefCore.cjs');
13
+
14
+ test('computeDrift: true only when mtime strictly newer than synthesizedAt', () => {
15
+ assert.equal(computeDrift(200, 100), true);
16
+ assert.equal(computeDrift(100, 200), false);
17
+ assert.equal(computeDrift(100, 100), false);
18
+ assert.equal(computeDrift(null, 100), false);
19
+ assert.equal(computeDrift(200, null), false);
20
+ });
21
+
22
+ test('buildSources: omits null sources, computes drift per source', () => {
23
+ const synthesizedAt = new Date(1_000_000).toISOString();
24
+ const sources = buildSources({
25
+ synthesizedAt,
26
+ claudeMd: { detail: '40 lines', mtimeMs: 500_000 },
27
+ epics: { detail: '2 active · 1 archived', mtimeMs: 2_000_000 },
28
+ sessions: null,
29
+ git: { detail: '12 commits', mtimeMs: 900_000 },
30
+ });
31
+ assert.equal(sources.length, 3);
32
+ const byLabel = Object.fromEntries(sources.map((s) => [s.label, s]));
33
+ assert.equal(byLabel['CLAUDE.md'].drift, false);
34
+ assert.equal(byLabel['Epics'].drift, true);
35
+ assert.equal(byLabel['Git'].drift, false);
36
+ assert.equal(byLabel['Sessions'], undefined);
37
+ });
38
+
39
+ test('buildSources: no synthesizedAt yet means nothing drifts', () => {
40
+ const sources = buildSources({
41
+ synthesizedAt: null,
42
+ claudeMd: { detail: '1 line', mtimeMs: 999 },
43
+ epics: null,
44
+ sessions: null,
45
+ git: null,
46
+ });
47
+ assert.equal(sources[0].drift, false);
48
+ });
49
+
50
+ test('buildSynthesisPrompt includes CLAUDE.md, epics, git log, tree, and pinned block instruction', () => {
51
+ const prompt = buildSynthesisPrompt({
52
+ claudeMdText: '# Project X',
53
+ epics: [{ status: 'active', tag: 'feature', goalText: 'Ship the thing' }],
54
+ gitLogLines: ['abc123 fix bug'],
55
+ srcTree: 'src/\n main/\n renderer/',
56
+ pinnedBlocks: { conventions: ['Never use shell:true'] },
57
+ });
58
+ assert.match(prompt, /# Project X/);
59
+ assert.match(prompt, /Ship the thing/);
60
+ assert.match(prompt, /abc123 fix bug/);
61
+ assert.match(prompt, /src\/\n main\//);
62
+ assert.match(prompt, /PINNED BLOCK "conventions"/);
63
+ assert.match(prompt, /Never use shell:true/);
64
+ assert.doesNotMatch(prompt, /PINNED BLOCK "what"/);
65
+ });
66
+
67
+ test('validateBriefShape: accepts a well-formed object, rejects malformed ones', () => {
68
+ const good = { purpose: 'x', what: [], areas: [], scope: [], conventions: [] };
69
+ assert.deepEqual(validateBriefShape(good), { ok: true, error: null });
70
+ assert.equal(validateBriefShape(null).ok, false);
71
+ assert.equal(validateBriefShape({ ...good, purpose: '' }).ok, false);
72
+ assert.equal(validateBriefShape({ ...good, what: 'not an array' }).ok, false);
73
+ assert.equal(validateBriefShape({ ...good, areas: undefined }).ok, false);
74
+ });
75
+
76
+ test('applyPinEnforcement: overwrites a pinned block with the frozen copy, carries pins/pinned forward', () => {
77
+ const raw = { purpose: 'p', what: ['fresh what'], areas: [], scope: [], conventions: ['fresh conventions'] };
78
+ const priorPins = { what: false, conventions: true };
79
+ const priorPinned = { what: null, conventions: ['frozen conventions'] };
80
+ const merged = applyPinEnforcement(raw, priorPins, priorPinned);
81
+ assert.deepEqual(merged.what, ['fresh what']);
82
+ assert.deepEqual(merged.conventions, ['frozen conventions']);
83
+ assert.deepEqual(merged.pins, priorPins);
84
+ assert.deepEqual(merged.pinned, priorPinned);
85
+ });
86
+
87
+ test('applyPinEnforcement: unpinned brief passes model output through untouched', () => {
88
+ const raw = { purpose: 'p', what: ['fresh'], areas: [], scope: [], conventions: ['fresh'] };
89
+ const merged = applyPinEnforcement(raw, null, null);
90
+ assert.deepEqual(merged.what, ['fresh']);
91
+ assert.deepEqual(merged.conventions, ['fresh']);
92
+ assert.deepEqual(merged.pins, { what: false, conventions: false });
93
+ });
94
+
95
+ test('buildPersistedBrief stamps version/synthesizedAt/model and enforces pins', () => {
96
+ const raw = { purpose: 'p', what: ['fresh'], areas: [{ name: 'a', files: 1, note: '', epic: null, heat: 0.1 }], scope: [], conventions: ['fresh'] };
97
+ const persisted = buildPersistedBrief({
98
+ rawBrief: raw,
99
+ priorPins: { what: true, conventions: false },
100
+ priorPinned: { what: ['frozen what'], conventions: null },
101
+ model: 'claude-sonnet-5',
102
+ nowIso: '2026-07-31T00:00:00.000Z',
103
+ });
104
+ assert.equal(persisted.version, 1);
105
+ assert.equal(persisted.synthesizedAt, '2026-07-31T00:00:00.000Z');
106
+ assert.equal(persisted.model, 'claude-sonnet-5');
107
+ assert.deepEqual(persisted.what, ['frozen what']);
108
+ assert.deepEqual(persisted.conventions, ['fresh']);
109
+ assert.deepEqual(persisted.areas, raw.areas);
110
+ });
111
+
112
+ test('computeSetPin: errors when there is no brief yet', () => {
113
+ const res = computeSetPin(null, 'what', true);
114
+ assert.equal(res.ok, false);
115
+ });
116
+
117
+ test('computeSetPin: errors on an unpinnable block', () => {
118
+ const brief = { purpose: 'p', what: [], areas: [], scope: [], conventions: [], pins: {}, pinned: {} };
119
+ const res = computeSetPin(brief, 'areas', true);
120
+ assert.equal(res.ok, false);
121
+ });
122
+
123
+ test('computeSetPin: pinning freezes current block content, unpinning clears it', () => {
124
+ const brief = {
125
+ purpose: 'p',
126
+ what: ['a', 'b'],
127
+ areas: [],
128
+ scope: [],
129
+ conventions: ['c1'],
130
+ pins: { what: false, conventions: false },
131
+ pinned: { what: null, conventions: null },
132
+ };
133
+ const pinned = computeSetPin(brief, 'what', true);
134
+ assert.equal(pinned.ok, true);
135
+ assert.equal(pinned.brief.pins.what, true);
136
+ assert.deepEqual(pinned.brief.pinned.what, ['a', 'b']);
137
+
138
+ const unpinned = computeSetPin(pinned.brief, 'what', false);
139
+ assert.equal(unpinned.ok, true);
140
+ assert.equal(unpinned.brief.pins.what, false);
141
+ assert.equal(unpinned.brief.pinned.what, null);
142
+ });
@@ -141,4 +141,4 @@ function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
141
141
  return true;
142
142
  }
143
143
 
144
- module.exports = { ensureEpic, appendPrdCreatedEvent, activeIndexPath };
144
+ module.exports = { ensureEpic, appendPrdCreatedEvent, activeIndexPath, readActiveIndex };