claude-code-session-manager 0.40.2 → 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.
- package/dist/assets/{TiptapBody-CFCp4Mz9.js → TiptapBody-BnRle0iw.js} +1 -1
- package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
- package/dist/assets/{index-bdqOSyxG.js → index-H7rwsoKV.js} +650 -643
- package/dist/index.html +2 -2
- package/package.json +3 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -5
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +2 -2
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +20 -34
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +68 -0
- package/scripts/lib/watchdogHelpers.cjs +5 -5
- package/scripts/mint-epic.cjs +28 -0
- package/scripts/propose-epic.cjs +55 -0
- package/src/main/__tests__/epicMint.test.cjs +85 -0
- package/src/main/__tests__/prdCreate.test.cjs +54 -2
- package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
- package/src/main/browserView.cjs +1 -1
- package/src/main/chatRunner.cjs +68 -55
- package/src/main/config.cjs +12 -6
- package/src/main/docEdit.cjs +3 -4
- package/src/main/health.cjs +5 -0
- package/src/main/index.cjs +12 -0
- package/src/main/ipcSchemas.cjs +51 -11
- package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
- package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
- package/src/main/lib/epicMint.cjs +133 -59
- package/src/main/lib/opsOwnership.cjs +166 -0
- package/src/main/lib/prdCreate.cjs +9 -1
- package/src/main/lib/prdLocations.cjs +21 -0
- package/src/main/lib/projectBriefCore.cjs +70 -4
- package/src/main/lib/queueStore.cjs +3 -0
- package/src/main/lib/rcaFeedbackHook.cjs +41 -55
- package/src/main/projectBrief.cjs +36 -2
- package/src/main/promptSessionEvents.cjs +24 -2
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/queueOps.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +8 -0
- package/src/main/scheduler.cjs +281 -164
- package/src/main/templates/PRD_AUTHORING.md +1 -1
- package/src/preload/api.d.ts +76 -3
- package/src/preload/index.cjs +21 -3
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
- package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +0 -265
package/src/main/chatRunner.cjs
CHANGED
|
@@ -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
|
|
@@ -341,12 +302,30 @@ function getConcurrencyCap() {
|
|
|
341
302
|
// await full teardown instead of firing SIGTERM and returning immediately.
|
|
342
303
|
const inFlight = new Map();
|
|
343
304
|
const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult }]
|
|
305
|
+
// tabIds picked by pump() but not yet registered in `inFlight` by executeRun.
|
|
306
|
+
// Closes the microtask-wide window in which per-tab exclusivity would
|
|
307
|
+
// otherwise not hold. See pump().
|
|
308
|
+
const dispatching = new Set();
|
|
344
309
|
let activeCount = 0;
|
|
345
310
|
|
|
346
311
|
// Indirection so tests can stub the spawn without launching claude.
|
|
347
312
|
let executor = executeRun;
|
|
348
313
|
function __setExecutor(fn) { executor = fn || executeRun; }
|
|
349
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Test hook: drop all lane state. The module is a singleton shared across
|
|
317
|
+
* every test in a file, and a spec whose fake child never emits 'exit' leaves
|
|
318
|
+
* its run permanently in flight — holding a session slot and a lane seat that
|
|
319
|
+
* silently starve every later test. Also releases the shared slot pool.
|
|
320
|
+
*/
|
|
321
|
+
function __resetQueueForTests() {
|
|
322
|
+
inFlight.clear();
|
|
323
|
+
dispatching.clear();
|
|
324
|
+
waiting.length = 0;
|
|
325
|
+
activeCount = 0;
|
|
326
|
+
sessionSlots.__resetForTests();
|
|
327
|
+
}
|
|
328
|
+
|
|
350
329
|
// ─── Window reference (set by attachWindow) ────────────────────────────────
|
|
351
330
|
|
|
352
331
|
let mainWindow = null;
|
|
@@ -375,27 +354,56 @@ function broadcast(channel, payload) {
|
|
|
375
354
|
* @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void, promptId?: string }} opts
|
|
376
355
|
*/
|
|
377
356
|
function run(opts) {
|
|
378
|
-
// Per-tab exclusivity
|
|
379
|
-
//
|
|
380
|
-
// for
|
|
381
|
-
|
|
357
|
+
// Per-tab exclusivity still holds — two `claude -p --resume` against ONE
|
|
358
|
+
// sessionId must never overlap — but it is now enforced in pump() (which
|
|
359
|
+
// refuses to start a run for a tab that already has one live) rather than by
|
|
360
|
+
// discarding the submission here.
|
|
361
|
+
//
|
|
362
|
+
// Dropping was silent and unrecoverable: `chat:run` still resolved
|
|
363
|
+
// { ok: true }, so the renderer sat at running: true forever with no toast
|
|
364
|
+
// and no terminal event. A `silent` /context probe holding the tabId was
|
|
365
|
+
// enough to swallow a user's message. User-initiated sends are now QUEUED
|
|
366
|
+
// behind whatever holds the tab and dispatched in FIFO order.
|
|
367
|
+
// `dispatching` counts as busy too: between pump() picking a run and
|
|
368
|
+
// executeRun registering it in `inFlight`, the tab is committed but not yet
|
|
369
|
+
// visible in `inFlight` — the same window pump() guards against.
|
|
370
|
+
const tabBusy = inFlight.has(opts.tabId)
|
|
371
|
+
|| dispatching.has(opts.tabId)
|
|
372
|
+
|| waiting.some((w) => w.tabId === opts.tabId);
|
|
373
|
+
if (tabBusy && opts.silent) {
|
|
374
|
+
// Silent probes stay best-effort and invisible: a probe that collides is
|
|
375
|
+
// still dropped, since queueing one would delay real work to no benefit.
|
|
376
|
+
return { accepted: false, reason: 'tab-busy-silent' };
|
|
377
|
+
}
|
|
382
378
|
|
|
383
379
|
waiting.push(opts);
|
|
384
380
|
pump();
|
|
381
|
+
return { accepted: true, queued: tabBusy };
|
|
385
382
|
}
|
|
386
383
|
|
|
387
384
|
// Fill open lanes FIFO up to CONCURRENCY_CAP, then announce queue positions for
|
|
388
385
|
// the remainder. O(n) over the waiting list (bounded by open tabs).
|
|
389
386
|
function pump() {
|
|
390
387
|
while (activeCount < getConcurrencyCap() && waiting.length > 0) {
|
|
388
|
+
// Per-tab exclusivity: skip past any waiting run whose tab already has one
|
|
389
|
+
// live, rather than head-blocking the whole lane on it. `dispatching`
|
|
390
|
+
// covers the gap between picking a job here and executeRun() registering
|
|
391
|
+
// it in `inFlight` (which happens a microtask later) — without it, one
|
|
392
|
+
// synchronous sweep of this loop could start two runs for the same tab and
|
|
393
|
+
// race two --resume processes against a single sessionId.
|
|
394
|
+
const idx = waiting.findIndex((w) => !inFlight.has(w.tabId) && !dispatching.has(w.tabId));
|
|
395
|
+
// Everything queued is blocked behind its own tab's live run; the settle()
|
|
396
|
+
// of that run re-pumps.
|
|
397
|
+
if (idx === -1) break;
|
|
391
398
|
// The chat lane cap is a POLICY bound; actual capacity comes from the
|
|
392
399
|
// Session-Manager-wide slot pool shared with the scheduler
|
|
393
400
|
// (lib/sessionSlots.cjs). No slot → everyone keeps waiting; the next
|
|
394
401
|
// settle() in either subsystem re-pumps.
|
|
395
|
-
const slotToken = sessionSlots.acquire(`chat:${waiting[
|
|
402
|
+
const slotToken = sessionSlots.acquire(`chat:${waiting[idx].tabId}`);
|
|
396
403
|
if (!slotToken) break;
|
|
397
|
-
const job = waiting.
|
|
404
|
+
const [job] = waiting.splice(idx, 1);
|
|
398
405
|
activeCount += 1;
|
|
406
|
+
dispatching.add(job.tabId);
|
|
399
407
|
Promise.resolve()
|
|
400
408
|
.then(() => {
|
|
401
409
|
const donePromise = executor(job);
|
|
@@ -404,7 +412,12 @@ function pump() {
|
|
|
404
412
|
return donePromise;
|
|
405
413
|
})
|
|
406
414
|
.catch(() => { /* executeRun never rejects; defensive */ })
|
|
407
|
-
.finally(() => {
|
|
415
|
+
.finally(() => {
|
|
416
|
+
dispatching.delete(job.tabId);
|
|
417
|
+
sessionSlots.release(slotToken);
|
|
418
|
+
activeCount -= 1;
|
|
419
|
+
pump();
|
|
420
|
+
});
|
|
408
421
|
}
|
|
409
422
|
// Anyone still waiting gets a 1-based position update. Silent (automated
|
|
410
423
|
// probe) runs must stay invisible to the renderer, same as every other
|
|
@@ -634,7 +647,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
634
647
|
if (event.subtype === 'success') {
|
|
635
648
|
if (silent) {
|
|
636
649
|
// Silent probe: no turn broadcast, no recordExchange — just hand
|
|
637
|
-
// the final text to whoever enqueued the probe
|
|
650
|
+
// the final text to whoever enqueued the probe.
|
|
638
651
|
emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
|
|
639
652
|
if (typeof onSilentResult === 'function') onSilentResult(text);
|
|
640
653
|
} else {
|
|
@@ -815,8 +828,12 @@ function registerChatHandlers() {
|
|
|
815
828
|
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
816
829
|
|
|
817
830
|
ipcMain.handle('chat:run', validated(schemas.chatRun, async ({ tabId, sessionId, prompt, cwd, resume, promptId }) => {
|
|
818
|
-
|
|
819
|
-
|
|
831
|
+
// Report what actually happened instead of an unconditional { ok: true }.
|
|
832
|
+
// A user send is always accepted now (queued behind the tab's live run if
|
|
833
|
+
// one holds it), but the renderer gets the truth either way rather than a
|
|
834
|
+
// success it can't verify.
|
|
835
|
+
const outcome = run({ tabId, sessionId, prompt, cwd, resume: !!resume, promptId });
|
|
836
|
+
return { ok: outcome.accepted !== false, queued: !!outcome.queued, reason: outcome.reason };
|
|
820
837
|
}));
|
|
821
838
|
|
|
822
839
|
// Classification for a queued PromptTicket reaching the front of its
|
|
@@ -832,21 +849,17 @@ function registerChatHandlers() {
|
|
|
832
849
|
await cancel(tabId);
|
|
833
850
|
});
|
|
834
851
|
|
|
835
|
-
ipcMain.handle('chat:probe-context', validated(schemas.chatProbeContext, async ({ tabId, sessionId, cwd }) => {
|
|
836
|
-
probeContextUsage({ tabId, sessionId, cwd });
|
|
837
|
-
return { ok: true };
|
|
838
|
-
}));
|
|
839
852
|
}
|
|
840
853
|
|
|
841
854
|
module.exports = {
|
|
842
855
|
run,
|
|
843
856
|
cancel,
|
|
857
|
+
__resetQueueForTests,
|
|
844
858
|
attachWindow,
|
|
845
859
|
registerChatHandlers,
|
|
846
860
|
parseStopSignal,
|
|
847
861
|
splitStopSignal,
|
|
848
862
|
parseContextUsageMarkdown,
|
|
849
|
-
probeContextUsage,
|
|
850
863
|
STOP_SENTINEL,
|
|
851
864
|
CHAT_MODE_TRUTH_INSTRUCTION,
|
|
852
865
|
KILL_CEILING_MS,
|
package/src/main/config.cjs
CHANGED
|
@@ -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
|
-
|
|
436
|
-
|
|
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 }) => {
|
package/src/main/docEdit.cjs
CHANGED
|
@@ -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
|
|
173
|
-
*
|
|
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)
|
|
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
|
*/
|
package/src/main/health.cjs
CHANGED
|
@@ -84,6 +84,11 @@ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
|
|
|
84
84
|
if (queueState.paused) return { stalled: false, reason: 'paused' };
|
|
85
85
|
if (config.enabled === false) return { stalled: false, reason: 'disabled' };
|
|
86
86
|
if (running >= concurrencyCap) return { stalled: false, reason: 'at-capacity' };
|
|
87
|
+
// 'manual' means the operator fires batches by hand — the scheduler is not
|
|
88
|
+
// supposed to pick these up on its own, so pending work sitting with free
|
|
89
|
+
// capacity is the configured behaviour, not a stall. Without this, any queue
|
|
90
|
+
// under a manual policy reported RED forever and drowned out real stalls.
|
|
91
|
+
if (config.firePolicy === 'manual') return { stalled: false, reason: 'manual-fire-policy' };
|
|
87
92
|
|
|
88
93
|
const lastRunAt = queueState.lastRunAt ? Date.parse(queueState.lastRunAt) : null;
|
|
89
94
|
// No lastRunAt at all (fresh install, never ticked) — nothing to measure
|
package/src/main/index.cjs
CHANGED
|
@@ -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
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
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
|
+
});
|