claude-code-session-manager 0.40.3 → 0.41.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 (50) hide show
  1. package/dist/assets/{TiptapBody-DweEtHks.js → TiptapBody-BnRle0iw.js} +1 -1
  2. package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
  3. package/dist/assets/{index-QRU8uTeq.js → index-H7rwsoKV.js} +631 -624
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -5
  7. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  8. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +2 -2
  9. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/project-status/SKILL.md +20 -34
  11. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +68 -0
  12. package/scripts/lib/watchdogHelpers.cjs +5 -5
  13. package/scripts/mint-epic.cjs +28 -0
  14. package/scripts/propose-epic.cjs +55 -0
  15. package/src/main/__tests__/epicMint.test.cjs +85 -0
  16. package/src/main/__tests__/prdCreate.test.cjs +54 -2
  17. package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
  18. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  19. package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
  20. package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
  21. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
  22. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
  23. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
  24. package/src/main/browserView.cjs +1 -1
  25. package/src/main/chatRunner.cjs +2 -46
  26. package/src/main/config.cjs +12 -6
  27. package/src/main/docEdit.cjs +3 -4
  28. package/src/main/index.cjs +12 -0
  29. package/src/main/ipcSchemas.cjs +51 -11
  30. package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
  31. package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
  32. package/src/main/lib/epicMint.cjs +133 -59
  33. package/src/main/lib/opsOwnership.cjs +166 -0
  34. package/src/main/lib/prdCreate.cjs +9 -1
  35. package/src/main/lib/prdLocations.cjs +21 -0
  36. package/src/main/lib/projectBriefCore.cjs +70 -4
  37. package/src/main/lib/queueStore.cjs +3 -0
  38. package/src/main/lib/rcaFeedbackHook.cjs +41 -55
  39. package/src/main/projectBrief.cjs +36 -2
  40. package/src/main/promptSessionEvents.cjs +24 -2
  41. package/src/main/promptSessionTranscript.cjs +0 -0
  42. package/src/main/queueOps.cjs +1 -1
  43. package/src/main/scheduler/prdParser.cjs +8 -0
  44. package/src/main/scheduler.cjs +158 -62
  45. package/src/main/templates/PRD_AUTHORING.md +1 -1
  46. package/src/preload/api.d.ts +76 -3
  47. package/src/preload/index.cjs +21 -3
  48. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
  49. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
  50. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +0 -265
@@ -18,13 +18,11 @@
18
18
  * Public surface:
19
19
  * run({ tabId, sessionId, prompt, cwd, resume, silent }): void — fire-and-forget enqueue.
20
20
  * Both silent (PRD 470) and manual runs go through the same CONCURRENCY_CAP FIFO lane.
21
- * Silent runs additionally suppress the six turn-affecting broadcasts + recordExchange
22
- * (see probeContextUsage below).
21
+ * Silent runs additionally suppress the six turn-affecting broadcasts + recordExchange.
23
22
  * cancel(tabId): Promise<void> — resolves once the cancelled run fully settles
24
23
  * parseStopSignal(finalText): { questions: string[] } | null — exported for reuse
25
24
  * parseContextUsageMarkdown(text): { usedTokens, totalTokens, usedPct, categories } | null
26
25
  * — pure parser for `/context`
27
- * probeContextUsage({ tabId, sessionId, cwd }): void — fire-and-forget silent `/context` probe
28
26
  * STOP_SENTINEL: string — exported for tests
29
27
  * __setExecutor(fn): void — test seam
30
28
  *
@@ -42,8 +40,6 @@
42
40
  * landed before verifying/retrying
43
41
  * chat:run:notice { tabId, sessionId, message } — informational, not terminal
44
42
  * (also fires at 80% of the kill ceiling as a wrap-up nudge)
45
- * chat:context-usage { tabId, sessionId, usedTokens, totalTokens, usedPct, categories }
46
- * — result of a silent `/context` probe
47
43
  */
48
44
 
49
45
  const { spawn } = require('node:child_process');
@@ -184,41 +180,6 @@ function parseContextUsageMarkdown(text) {
184
180
  return { usedTokens, totalTokens, usedPct, categories };
185
181
  }
186
182
 
187
- /**
188
- * Fire-and-forget silent probe of the resumed session's context usage. Reuses
189
- * the normal `run()` queue/lane (resume: true, silent: true) so it can never
190
- * race a real chat run against the same `--resume sessionId`. Broadcasts
191
- * `chat:context-usage` on success; logs (never throws/broadcasts) on a parse
192
- * failure.
193
- *
194
- * @param {{ tabId: string, sessionId: string, cwd: string }} params
195
- */
196
- function probeContextUsage({ tabId, sessionId, cwd }) {
197
- run({
198
- tabId,
199
- sessionId,
200
- prompt: '/context',
201
- cwd,
202
- resume: true,
203
- silent: true,
204
- onSilentResult: (text) => {
205
- const parsed = parseContextUsageMarkdown(text);
206
- if (!parsed) {
207
- console.error('[chatRunner] parseContextUsageMarkdown failed to parse /context probe output');
208
- return;
209
- }
210
- broadcast('chat:context-usage', {
211
- tabId,
212
- sessionId,
213
- usedTokens: parsed.usedTokens,
214
- totalTokens: parsed.totalTokens,
215
- usedPct: parsed.usedPct,
216
- categories: parsed.categories,
217
- });
218
- },
219
- });
220
- }
221
-
222
183
  // ─── MCP consent-denial detection ──────────────────────────────────────────
223
184
  // Best-effort substring heuristic over known CLI phrasing — NOT a documented
224
185
  // stream-json schema field. Confirmed by extracting strings from the compiled
@@ -686,7 +647,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
686
647
  if (event.subtype === 'success') {
687
648
  if (silent) {
688
649
  // Silent probe: no turn broadcast, no recordExchange — just hand
689
- // the final text to whoever enqueued the probe (e.g. probeContextUsage).
650
+ // the final text to whoever enqueued the probe.
690
651
  emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
691
652
  if (typeof onSilentResult === 'function') onSilentResult(text);
692
653
  } else {
@@ -888,10 +849,6 @@ function registerChatHandlers() {
888
849
  await cancel(tabId);
889
850
  });
890
851
 
891
- ipcMain.handle('chat:probe-context', validated(schemas.chatProbeContext, async ({ tabId, sessionId, cwd }) => {
892
- probeContextUsage({ tabId, sessionId, cwd });
893
- return { ok: true };
894
- }));
895
852
  }
896
853
 
897
854
  module.exports = {
@@ -903,7 +860,6 @@ module.exports = {
903
860
  parseStopSignal,
904
861
  splitStopSignal,
905
862
  parseContextUsageMarkdown,
906
- probeContextUsage,
907
863
  STOP_SENTINEL,
908
864
  CHAT_MODE_TRUTH_INSTRUCTION,
909
865
  KILL_CEILING_MS,
@@ -19,6 +19,7 @@ const fs = require('node:fs');
19
19
  const fsp = require('node:fs/promises');
20
20
  const path = require('node:path');
21
21
  const os = require('node:os');
22
+ const { assertOpsWrite } = require('./lib/opsOwnership.cjs');
22
23
  const chokidar = require('chokidar');
23
24
  const logs = require('./logs.cjs');
24
25
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
@@ -219,6 +220,7 @@ async function readText(abs) {
219
220
  async function writeTextAtomic(abs, text, opts = {}) {
220
221
  const real = validatePath(expandHome(abs));
221
222
  validateWrite(real);
223
+ assertOpsWrite(real, opts.writer);
222
224
  const dir = path.dirname(real);
223
225
  await fsp.mkdir(dir, { recursive: true });
224
226
  const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -242,9 +244,10 @@ async function writeTextAtomic(abs, text, opts = {}) {
242
244
  * Binary-safe sibling of writeTextAtomic — same validate + tmp+rename flow,
243
245
  * for callers (screenshot capture) whose payload is a Buffer, not utf8 text.
244
246
  */
245
- async function writeBinaryAtomic(abs, buffer) {
247
+ async function writeBinaryAtomic(abs, buffer, opts = {}) {
246
248
  const real = validatePath(expandHome(abs));
247
249
  validateWrite(real);
250
+ assertOpsWrite(real, opts.writer);
248
251
  const dir = path.dirname(real);
249
252
  await fsp.mkdir(dir, { recursive: true });
250
253
  const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -259,9 +262,9 @@ async function writeBinaryAtomic(abs, buffer) {
259
262
  return { ok: true, mtimeMs: stat.mtimeMs };
260
263
  }
261
264
 
262
- async function writeJson(abs, data) {
265
+ async function writeJson(abs, data, opts = {}) {
263
266
  const pretty = JSON.stringify(data, null, 2) + '\n';
264
- return writeTextAtomic(abs, pretty);
267
+ return writeTextAtomic(abs, pretty, opts);
265
268
  }
266
269
 
267
270
  /**
@@ -269,9 +272,10 @@ async function writeJson(abs, data) {
269
272
  * an event-loop yield would deadlock the caller. Use writeJson/writeTextAtomic
270
273
  * for everything else.
271
274
  */
272
- function writeJsonSync(abs, data) {
275
+ function writeJsonSync(abs, data, opts = {}) {
273
276
  const real = validatePath(expandHome(abs));
274
277
  validateWrite(real);
278
+ assertOpsWrite(real, opts.writer);
275
279
  const dir = path.dirname(real);
276
280
  fs.mkdirSync(dir, { recursive: true });
277
281
  const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -432,8 +436,10 @@ function registerConfigHandlers() {
432
436
  const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
433
437
  ipcMain.handle('config:read-json', v(s.configPath, ({ path: p }) => readJson(p)));
434
438
  ipcMain.handle('config:read-text', v(s.configPath, ({ path: p }) => readText(p)));
435
- ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data }) => writeJson(p, data)));
436
- ipcMain.handle('config:write-text', v(s.configWriteText, ({ path: p, text }) => writeTextAtomic(p, text)));
439
+ // `writer` is the renderer's declared owner id for the single-writer law
440
+ // (lib/opsOwnership.cjs). Ignored outside the ops root; required inside it.
441
+ ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) => writeJson(p, data, { writer })));
442
+ ipcMain.handle('config:write-text', v(s.configWriteText, ({ path: p, text, writer }) => writeTextAtomic(p, text, { writer })));
437
443
  ipcMain.handle('config:list-dir', v(s.configListDir, ({ path: p, opts }) => listDir(p, opts || {})));
438
444
  ipcMain.handle('config:exists', v(s.configPath, ({ path: p }) => exists(p)));
439
445
  ipcMain.handle('config:parse-imports', v(s.configParseImports, async ({ path: p }) => {
@@ -169,13 +169,12 @@ const SESSION_EDIT_TIMEOUT_MS = 120_000;
169
169
  /**
170
170
  * Route a doc edit into an already-open, currently-idle chat session (PRD 680)
171
171
  * instead of an isolated one-shot `claude -p`. Reuses chatRunner.cjs's silent/
172
- * resumed run path verbatim (the same mechanism `probeContextUsage` uses) so
173
- * the edit lands as one more turn of the session's own transcript.
172
+ * resumed run path verbatim so the edit lands as one more turn of the
173
+ * session's own transcript.
174
174
  *
175
175
  * Fire-and-forget: the result reaches the renderer via a `docedit:session-result`
176
176
  * broadcast (tagged with `requestId`) once chatRunner's onSilentResult fires, or
177
- * once SESSION_EDIT_TIMEOUT_MS elapses without one (see above) — the same
178
- * indirection probeContextUsage uses for `chat:context-usage`, plus the bound.
177
+ * once SESSION_EDIT_TIMEOUT_MS elapses without one (see above).
179
178
  *
180
179
  * @param {{ tabId: string, sessionId: string, cwd: string, before: string, instruction: string, documentText?: string, requestId: string }} params
181
180
  */
@@ -29,6 +29,7 @@ const scheduler = require('./scheduler.cjs');
29
29
  const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
30
30
  const prdCreate = require('./lib/prdCreate.cjs');
31
31
  const chatRunner = require('./chatRunner.cjs');
32
+ const promptSessionEvents = require('./promptSessionEvents.cjs');
32
33
  const adminHttp = createAdminHttp();
33
34
  scheduler.registerAdminRoutes(adminHttp);
34
35
  prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
@@ -55,6 +56,7 @@ const schedulerConfig = require('./lib/schedulerConfig.cjs');
55
56
  const memoryTool = require('./memoryTool.cjs');
56
57
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
57
58
  const { registerProjectBriefIpc } = require('./projectBrief.cjs');
59
+ const promptSessionTranscript = require('./promptSessionTranscript.cjs');
58
60
  const agentMemory = require('./agentMemory.cjs');
59
61
  const git = require('./git.cjs');
60
62
  const filesIpc = require('./files.cjs');
@@ -307,6 +309,7 @@ async function rebootApp() {
307
309
  watchers.attachWindow(mainWindow);
308
310
  pluginInstall.attachWindow(mainWindow);
309
311
  chatRunner.attachWindow(mainWindow);
312
+ promptSessionEvents.attachWindow(mainWindow);
310
313
  attachDocEditWindow(mainWindow);
311
314
  rebooting = false;
312
315
  return;
@@ -772,6 +775,14 @@ pluginInstall.registerPluginInstallHandlers();
772
775
  memoryTool.registerMemoryHandlers();
773
776
  registerMemoryAggregateIpc();
774
777
  registerProjectBriefIpc();
778
+ ipcMain.handle('promptSessionTranscript:append', validated(schemas.promptSessionTranscriptAppend, async ({ cwd, epicId, role, text, at, eventId }) => {
779
+ const ok = await promptSessionTranscript.appendTurn(cwd, epicId, { role, text, at, eventId });
780
+ return { ok };
781
+ }));
782
+ ipcMain.handle('promptSessionTranscript:read', validated(schemas.promptSessionTranscriptRead, async ({ cwd, epicId, limit }) => {
783
+ const turns = await promptSessionTranscript.readTurns(cwd, epicId, { limit });
784
+ return { turns };
785
+ }));
775
786
  agentMemory.registerAgentMemoryHandlers();
776
787
  git.register(ipcMain);
777
788
  filesIpc.registerFilesHandlers();
@@ -1092,6 +1103,7 @@ app.whenReady().then(async () => {
1092
1103
  pluginInstall.attachWindow(mainWindow);
1093
1104
  webRemote.attachWindow(mainWindow);
1094
1105
  chatRunner.attachWindow(mainWindow);
1106
+ promptSessionEvents.attachWindow(mainWindow);
1095
1107
  attachDocEditWindow(mainWindow);
1096
1108
  // Scheduler ownership (PRD 834): exactly one instance machine-wide runs
1097
1109
  // scheduler mutation (boot reconciliation, ticking, sweep, supervisor) and
@@ -118,6 +118,8 @@ const browserSaveRecording = z.object({
118
118
  const browserSaveBinary = z.object({
119
119
  path: z.string().min(1).max(4096),
120
120
  base64: z.string().min(1).max(50_000_000),
121
+ // Single-writer law (lib/opsOwnership.cjs) — required for ops-root paths.
122
+ writer: z.string().min(1).max(64).optional(),
121
123
  });
122
124
 
123
125
  // PRD 410: replay a recorded step list against a live view. The renderer
@@ -183,14 +185,20 @@ const transcriptUsageFor = z.object({
183
185
  // ──────────────────────────────────────────── Config
184
186
  const configPath = z.object({ path: z.string().min(1).max(4096) });
185
187
 
188
+ // `writer` carries the renderer's declared owner id for the single-writer law
189
+ // (lib/opsOwnership.cjs). Optional on the wire because most writes target
190
+ // ~/.claude, not a project ops root; writes INSIDE the ops root are refused
191
+ // when it is absent or wrong.
186
192
  const configWriteJson = z.object({
187
193
  path: z.string().min(1).max(4096),
188
194
  data: z.unknown(),
195
+ writer: z.string().min(1).max(64).optional(),
189
196
  });
190
197
 
191
198
  const configWriteText = z.object({
192
199
  path: z.string().min(1).max(4096),
193
200
  text: z.string(),
201
+ writer: z.string().min(1).max(64).optional(),
194
202
  });
195
203
 
196
204
  const configListDir = z.object({
@@ -284,10 +292,12 @@ const schedulerCreatePrd = z.object({
284
292
  // Explicit ordering (PRD 832): slugs that must complete before this PRD
285
293
  // becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
286
294
  dependsOn: z.array(z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/)).max(20).optional(),
287
- // Originating PromptTicket.id (PRD 748) when this PRD was authored from a
288
- // ticket classified 'develop' (PRD 749) — traces the PRD back to the
289
- // prompt that spawned it. Same newline-injection guard as title/cwd since
290
- // it also becomes a frontmatter value.
295
+ // An EXISTING Epic's promptSessionId (PRD 748) — NOT a PromptTicket.id.
296
+ // ensureEpic() (epicMint.cjs) joins the Epic whose active-index.json
297
+ // sessions key literally equals this value; the renderer only ever sends
298
+ // PromptTicket.promptSessionId here (see state/chat.ts), never
299
+ // PromptTicket.id — those are distinct fields. Same newline-injection
300
+ // guard as title/cwd since it also becomes a frontmatter value.
291
301
  sourcePromptId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
292
302
  // Originating tab id (PRD 761) — used at job completion to route a status
293
303
  // prompt back into the chat tab that queued this PRD, via
@@ -425,6 +435,40 @@ const projectBriefSetPin = z.object({
425
435
  block: PROJECT_BRIEF_BLOCK,
426
436
  pinned: z.boolean(),
427
437
  }).strict();
438
+ // Hand-edit patch. Only bounds the wire shape (sizes + which keys exist);
439
+ // per-field shape is enforced by projectBriefCore.validateUpdateField so the
440
+ // rule lives in one unit-tested place. At least one key is required.
441
+ const PROJECT_BRIEF_TEXT = z.string().max(20_000);
442
+ const projectBriefUpdate = z.object({
443
+ cwd: z.string().min(1).max(4096),
444
+ patch: z.object({
445
+ purpose: PROJECT_BRIEF_TEXT.optional(),
446
+ what: z.array(PROJECT_BRIEF_TEXT).max(50).optional(),
447
+ conventions: z.array(PROJECT_BRIEF_TEXT).max(100).optional(),
448
+ areas: z.array(z.record(z.unknown())).max(100).optional(),
449
+ scope: z.array(z.record(z.unknown())).max(200).optional(),
450
+ }).strict().refine((p) => Object.keys(p).length > 0, { message: 'patch is empty' }),
451
+ }).strict();
452
+
453
+ // ──────────────────────────────────────────── Prompt session transcript
454
+ // (durable full-text turn store, PRD 863). cwd/epicId are only bounded for
455
+ // wire shape here — real path safety is config.cjs's validatePath/
456
+ // validateWrite inside promptSessionTranscript.cjs.
457
+ const PROMPT_SESSION_EPIC_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
458
+ const PROMPT_SESSION_TRANSCRIPT_ROLE = z.enum(['user', 'assistant']);
459
+ const promptSessionTranscriptAppend = z.object({
460
+ cwd: z.string().min(1).max(4096),
461
+ epicId: z.string().regex(PROMPT_SESSION_EPIC_ID_RE),
462
+ role: PROMPT_SESSION_TRANSCRIPT_ROLE,
463
+ text: z.string().max(2_000_000),
464
+ at: z.string().max(64).optional(),
465
+ eventId: z.string().max(128).optional(),
466
+ }).strict();
467
+ const promptSessionTranscriptRead = z.object({
468
+ cwd: z.string().min(1).max(4096),
469
+ epicId: z.string().regex(PROMPT_SESSION_EPIC_ID_RE),
470
+ limit: z.number().int().positive().max(10_000).optional(),
471
+ }).strict();
428
472
 
429
473
  // ──────────────────────────────────────────── Per-subagent memory
430
474
  // Distinct from the workspace-scoped Memory tool: agentMemory is keyed by
@@ -516,12 +560,6 @@ const chatCancel = z.object({
516
560
  tabId: z.string().min(1).max(128),
517
561
  });
518
562
 
519
- const chatProbeContext = z.object({
520
- tabId: z.string().min(1).max(128),
521
- sessionId: z.string().min(1).max(128),
522
- cwd: z.string().min(1).max(4096),
523
- });
524
-
525
563
  const chatClassifyTicket = z.object({
526
564
  text: z.string().min(1).refine(
527
565
  (s) => Buffer.byteLength(s, 'utf8') <= CHAT_PROMPT_MAX_BYTES,
@@ -817,6 +855,9 @@ module.exports = {
817
855
  memoryStale,
818
856
  projectBriefCwd,
819
857
  projectBriefSetPin,
858
+ projectBriefUpdate,
859
+ promptSessionTranscriptAppend,
860
+ promptSessionTranscriptRead,
820
861
  agentMemoryList,
821
862
  agentMemoryGet,
822
863
  agentMemorySet,
@@ -830,7 +871,6 @@ module.exports = {
830
871
  docEditRunInSession,
831
872
  chatRun,
832
873
  chatCancel,
833
- chatProbeContext,
834
874
  chatClassifyTicket,
835
875
  chatExternalSend,
836
876
  exchangesList,
@@ -0,0 +1,92 @@
1
+ // opsOwnership.cjs — the single-writer law over a project's ops root.
2
+ // vitest globals (test/beforeEach) — same convention as the other .cjs tests.
3
+ const assert = require('node:assert/strict');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const fs = require('node:fs');
7
+ const { parseOpsPath, checkOpsWrite, assertOpsWrite, OWNERS } = require('../opsOwnership.cjs');
8
+
9
+ const P = '/home/u/Projects/demo/session-manager-operations';
10
+
11
+ test('parseOpsPath: splits namespace and remainder, ignores non-ops paths', () => {
12
+ assert.deepEqual(parseOpsPath(`${P}/prompt-sessions/active-index.json`), {
13
+ inOps: true, namespace: 'prompt-sessions', relative: 'active-index.json',
14
+ });
15
+ assert.deepEqual(parseOpsPath(`${P}/scheduler/epics/e1/prds/900-x.md`), {
16
+ inOps: true, namespace: 'scheduler', relative: 'epics/e1/prds/900-x.md',
17
+ });
18
+ assert.equal(parseOpsPath('/home/u/.claude/settings.json').inOps, false);
19
+ assert.equal(parseOpsPath(null).inOps, false);
20
+ });
21
+
22
+ test('paths outside any ops root are not governed by ownership', () => {
23
+ assert.deepEqual(checkOpsWrite('/home/u/.claude/settings.json', undefined), { ok: true, error: null });
24
+ });
25
+
26
+ test('the owner of a namespace may write it', () => {
27
+ assert.equal(checkOpsWrite(`${P}/prompt-sessions/active-index.json`, 'epics').ok, true);
28
+ assert.equal(checkOpsWrite(`${P}/scheduler/state/queue.json`, 'scheduler').ok, true);
29
+ assert.equal(checkOpsWrite(`${P}/project-brief/brief.json`, 'project-home').ok, true);
30
+ });
31
+
32
+ test('a non-owner is refused, and the error names the owner', () => {
33
+ const res = checkOpsWrite(`${P}/project-brief/brief.json`, 'scheduler');
34
+ assert.equal(res.ok, false);
35
+ assert.match(res.error, /owned by 'project-home'/);
36
+ // The exact cross-write this law exists to prevent: another surface
37
+ // rewriting the Epic store out from under Epics.
38
+ assert.equal(checkOpsWrite(`${P}/prompt-sessions/psess-1.json`, 'scheduler').ok, false);
39
+ });
40
+
41
+ test('an undeclared writer is refused (fail-closed)', () => {
42
+ const res = checkOpsWrite(`${P}/scheduler/state/queue.json`, undefined);
43
+ assert.equal(res.ok, false);
44
+ assert.match(res.error, /did not declare a writer/);
45
+ });
46
+
47
+ test('a namespace with no declared owner is refused, not silently allowed', () => {
48
+ const res = checkOpsWrite(`${P}/brand-new-thing/state.json`, 'scheduler');
49
+ assert.equal(res.ok, false);
50
+ assert.match(res.error, /no declared owner/);
51
+ });
52
+
53
+ test('the ops root itself is unownable', () => {
54
+ assert.equal(checkOpsWrite(`${P}/loose-file.json`, 'epics').ok, false);
55
+ });
56
+
57
+ test('the scheduler delegation covers active-index.json and nothing else', () => {
58
+ assert.equal(checkOpsWrite(`${P}/prompt-sessions/active-index.json`, 'scheduler').ok, true);
59
+ // Not the archives, and not a lookalike nested path.
60
+ assert.equal(checkOpsWrite(`${P}/prompt-sessions/psess-9.json`, 'scheduler').ok, false);
61
+ assert.equal(checkOpsWrite(`${P}/prompt-sessions/nested/active-index.json`, 'scheduler').ok, false);
62
+ });
63
+
64
+ test('assertOpsWrite throws for a refused write and is silent for an allowed one', () => {
65
+ assert.throws(() => assertOpsWrite(`${P}/project-brief/brief.json`, 'epics'), /single-writer law/);
66
+ assert.doesNotThrow(() => assertOpsWrite(`${P}/project-brief/brief.json`, 'project-home'));
67
+ });
68
+
69
+ test('every declared owner is a non-empty string (table sanity)', () => {
70
+ for (const [ns, owner] of Object.entries(OWNERS)) {
71
+ assert.equal(typeof owner, 'string', `${ns} owner must be a string`);
72
+ assert.ok(owner.length > 0, `${ns} owner must be non-empty`);
73
+ }
74
+ });
75
+
76
+ // Integration: config.cjs must enforce the law on its real write path, not
77
+ // just expose it — a write with the wrong writer has to actually fail.
78
+ test('config.writeJson refuses an ops write from a non-owner', async () => {
79
+ const config = require('../../config.cjs');
80
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-law-'));
81
+ config.addAllowedRoot(root);
82
+ const target = path.join(root, 'session-manager-operations', 'project-brief', 'brief.json');
83
+
84
+ await assert.rejects(() => config.writeJson(target, { a: 1 }, { writer: 'epics' }), /single-writer law/);
85
+ await assert.rejects(() => config.writeJson(target, { a: 1 }), /did not declare a writer/);
86
+
87
+ const res = await config.writeJson(target, { a: 1 }, { writer: 'project-home' });
88
+ assert.equal(res.ok, true);
89
+ assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).a, 1);
90
+
91
+ fs.rmSync(root, { recursive: true, force: true });
92
+ });
@@ -9,6 +9,7 @@ const {
9
9
  applyPinEnforcement,
10
10
  buildPersistedBrief,
11
11
  computeSetPin,
12
+ computeUpdate,
12
13
  } = require('../projectBriefCore.cjs');
13
14
 
14
15
  test('computeDrift: true only when mtime strictly newer than synthesizedAt', () => {
@@ -140,3 +141,76 @@ test('computeSetPin: pinning freezes current block content, unpinning clears it'
140
141
  assert.equal(unpinned.brief.pins.what, false);
141
142
  assert.equal(unpinned.brief.pinned.what, null);
142
143
  });
144
+
145
+ // ── computeUpdate: the hand-edit ("maintain") path ────────────────────────
146
+ function baseBrief() {
147
+ return {
148
+ version: 1,
149
+ synthesizedAt: '2026-07-31T00:00:00.000Z',
150
+ editedAt: null,
151
+ model: 'sonnet',
152
+ purpose: 'p',
153
+ what: ['a'],
154
+ areas: [{ name: 'x', files: 1, note: '', epic: null, heat: 0.1 }],
155
+ scope: [],
156
+ conventions: ['c1'],
157
+ pins: { what: false, conventions: false },
158
+ pinned: { what: null, conventions: null },
159
+ };
160
+ }
161
+
162
+ test('computeUpdate: errors when there is no brief yet', () => {
163
+ const res = computeUpdate(null, { what: ['x'] }, '2026-08-01T00:00:00.000Z');
164
+ assert.equal(res.ok, false);
165
+ assert.match(res.error, /generate one first/);
166
+ });
167
+
168
+ test('computeUpdate: rejects an empty patch and non-editable fields', () => {
169
+ assert.equal(computeUpdate(baseBrief(), {}, 'NOW').ok, false);
170
+ const res = computeUpdate(baseBrief(), { model: 'opus' }, 'NOW');
171
+ assert.equal(res.ok, false);
172
+ assert.match(res.error, /not editable: model/);
173
+ });
174
+
175
+ test('computeUpdate: rejects field shapes the renderer could not render', () => {
176
+ assert.equal(computeUpdate(baseBrief(), { purpose: ' ' }, 'NOW').ok, false);
177
+ assert.equal(computeUpdate(baseBrief(), { what: 'not an array' }, 'NOW').ok, false);
178
+ assert.equal(computeUpdate(baseBrief(), { conventions: [1, 2] }, 'NOW').ok, false);
179
+ assert.equal(computeUpdate(baseBrief(), { areas: ['nope'] }, 'NOW').ok, false);
180
+ });
181
+
182
+ test('computeUpdate: applies the patch, stamps editedAt, leaves other fields alone', () => {
183
+ const before = baseBrief();
184
+ const res = computeUpdate(before, { purpose: 'sharper' }, '2026-08-01T00:00:00.000Z');
185
+ assert.equal(res.ok, true);
186
+ assert.equal(res.brief.purpose, 'sharper');
187
+ assert.equal(res.brief.editedAt, '2026-08-01T00:00:00.000Z');
188
+ assert.deepEqual(res.brief.what, ['a']);
189
+ assert.equal(res.brief.synthesizedAt, before.synthesizedAt);
190
+ // purpose is not pinnable — a refresh legitimately re-derives it.
191
+ assert.equal(res.brief.pins.what, false);
192
+ });
193
+
194
+ test('computeUpdate: editing a pinnable block auto-pins it with the edited content', () => {
195
+ const res = computeUpdate(baseBrief(), { what: ['edited one', 'edited two'] }, 'NOW');
196
+ assert.equal(res.ok, true);
197
+ assert.equal(res.brief.pins.what, true);
198
+ assert.deepEqual(res.brief.pinned.what, ['edited one', 'edited two']);
199
+ assert.equal(res.brief.pins.conventions, false);
200
+ assert.equal(res.brief.pinned.conventions, null);
201
+ });
202
+
203
+ test('computeUpdate: an edited block survives the next synthesis via pin enforcement', () => {
204
+ const edited = computeUpdate(baseBrief(), { conventions: ['hand-written rule'] }, 'NOW').brief;
205
+ const persisted = buildPersistedBrief({
206
+ rawBrief: { purpose: 'new', what: ['fresh'], areas: [], scope: [], conventions: ['model rewrote it'] },
207
+ priorPins: edited.pins,
208
+ priorPinned: edited.pinned,
209
+ model: 'sonnet',
210
+ nowIso: '2026-08-02T00:00:00.000Z',
211
+ priorEditedAt: edited.editedAt,
212
+ });
213
+ assert.deepEqual(persisted.conventions, ['hand-written rule']);
214
+ assert.deepEqual(persisted.what, ['fresh']);
215
+ assert.equal(persisted.editedAt, 'NOW');
216
+ });