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.
Files changed (51) hide show
  1. package/dist/assets/{TiptapBody-CFCp4Mz9.js → TiptapBody-BnRle0iw.js} +1 -1
  2. package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
  3. package/dist/assets/{index-bdqOSyxG.js → index-H7rwsoKV.js} +650 -643
  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 +68 -55
  26. package/src/main/config.cjs +12 -6
  27. package/src/main/docEdit.cjs +3 -4
  28. package/src/main/health.cjs +5 -0
  29. package/src/main/index.cjs +12 -0
  30. package/src/main/ipcSchemas.cjs +51 -11
  31. package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
  32. package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
  33. package/src/main/lib/epicMint.cjs +133 -59
  34. package/src/main/lib/opsOwnership.cjs +166 -0
  35. package/src/main/lib/prdCreate.cjs +9 -1
  36. package/src/main/lib/prdLocations.cjs +21 -0
  37. package/src/main/lib/projectBriefCore.cjs +70 -4
  38. package/src/main/lib/queueStore.cjs +3 -0
  39. package/src/main/lib/rcaFeedbackHook.cjs +41 -55
  40. package/src/main/projectBrief.cjs +36 -2
  41. package/src/main/promptSessionEvents.cjs +24 -2
  42. package/src/main/promptSessionTranscript.cjs +0 -0
  43. package/src/main/queueOps.cjs +1 -1
  44. package/src/main/scheduler/prdParser.cjs +8 -0
  45. package/src/main/scheduler.cjs +281 -164
  46. package/src/main/templates/PRD_AUTHORING.md +1 -1
  47. package/src/preload/api.d.ts +76 -3
  48. package/src/preload/index.cjs +21 -3
  49. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
  50. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
  51. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +0 -265
@@ -21,6 +21,7 @@ const fs = require('node:fs');
21
21
  const path = require('node:path');
22
22
  const crypto = require('node:crypto');
23
23
  const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
24
+ const { assertOpsWrite } = require('./opsOwnership.cjs');
24
25
 
25
26
  function activeIndexPath(cwd) {
26
27
  return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
@@ -39,6 +40,9 @@ function readActiveIndex(cwd) {
39
40
  }
40
41
 
41
42
  function writeActiveIndex(cwd, index) {
43
+ // Single-writer law: prompt-sessions/ is owned by 'epics'; the scheduler
44
+ // holds a narrow delegation for active-index.json only (opsOwnership.cjs).
45
+ assertOpsWrite(activeIndexPath(cwd), 'scheduler');
42
46
  const file = activeIndexPath(cwd);
43
47
  fs.mkdirSync(path.dirname(file), { recursive: true });
44
48
  const tmp = `${file}.tmp-${process.pid}`;
@@ -54,8 +58,34 @@ function slugify(text) {
54
58
  .slice(0, 48) || 'epic';
55
59
  }
56
60
 
61
+ // Serializes read-modify-write cycles per active-index.json path, mirroring
62
+ // promptSessionEvents.cjs's own pendingWritesByPath/withPathLock. The current
63
+ // read/write pair below is synchronous (fs.readFileSync/writeFileSync), so
64
+ // there is no await between them today and no real interleaving is possible
65
+ // — but wrapping every read-modify-write in the same lock the sibling module
66
+ // uses means the two files no longer diverge on this pattern, and the lock
67
+ // is already in place if either function's I/O ever becomes async.
68
+ const pendingWritesByPath = new Map();
69
+
70
+ function withPathLock(lockPath, task) {
71
+ const prior = pendingWritesByPath.get(lockPath) || Promise.resolve();
72
+ const settle = () => task();
73
+ const run = prior.then(settle, settle);
74
+ pendingWritesByPath.set(
75
+ lockPath,
76
+ run.then(
77
+ () => undefined,
78
+ () => undefined,
79
+ ),
80
+ );
81
+ return run;
82
+ }
83
+
57
84
  /**
58
- * ensureEpic(cwd, { goalText, tag?, reuseByGoal? }) → { epicId, prdDir, created }
85
+ * ensureEpic(cwd, { goalText, tag?, reuseByGoal?, status? }) → Promise<{ epicId, prdDir, created }>
86
+ *
87
+ * `status` defaults to 'active'. Pass 'proposed' to file an Epic that waits
88
+ * for human approval before anything runs.
59
89
  *
60
90
  * Mints a new Epic — or, with `reuseByGoal`, joins the existing ACTIVE Epic
61
91
  * whose goalText matches (used by the recurring feedback sweep so successive
@@ -64,81 +94,125 @@ function slugify(text) {
64
94
  * The Epic's id doubles as its directory name under scheduler/epics/, so the
65
95
  * PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
66
96
  */
67
- function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId } = {}) {
97
+ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId, status = 'active', openingPrompt = null } = {}) {
68
98
  if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
69
- const index = readActiveIndex(cwd);
99
+ return withPathLock(activeIndexPath(cwd), () => {
100
+ const index = readActiveIndex(cwd);
70
101
 
71
- // A dispatch that already knows its Epic (sourcePromptId frontmatter from
72
- // an Epic-conversation dispatch) joins it rather than minting a sibling.
73
- if (explicitEpicId && index.sessions[explicitEpicId]) {
74
- const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
75
- fs.mkdirSync(prdDir, { recursive: true });
76
- return { epicId: explicitEpicId, prdDir, created: false };
77
- }
102
+ // A dispatch that already knows its Epic (sourcePromptId frontmatter from
103
+ // an Epic-conversation dispatch) joins it rather than minting a sibling.
104
+ if (explicitEpicId && index.sessions[explicitEpicId]) {
105
+ const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
106
+ fs.mkdirSync(prdDir, { recursive: true });
107
+ return { epicId: explicitEpicId, prdDir, created: false };
108
+ }
78
109
 
79
- if (reuseByGoal) {
80
- for (const s of Object.values(index.sessions)) {
81
- if (s && s.status === 'active' && s.goalText === goalText) {
82
- const prdDir = resolveEpicPrdWriteDir(cwd, s.id);
83
- fs.mkdirSync(prdDir, { recursive: true });
84
- return { epicId: s.id, prdDir, created: false };
110
+ if (reuseByGoal) {
111
+ for (const s of Object.values(index.sessions)) {
112
+ // Match the status being requested so repeat proposals chain into
113
+ // one proposal instead of spawning a duplicate per trigger.
114
+ if (s && s.status === status && s.goalText === goalText) {
115
+ // A PROPOSED Epic has not started, so its opening prompt is still
116
+ // mutable: a re-trigger carrying richer detail (e.g. the RCA hook's
117
+ // later investigation pass) enriches the pending proposal in place
118
+ // rather than filing a duplicate. Never done for an active Epic —
119
+ // its first turn is already history.
120
+ if (s.status === 'proposed' && openingPrompt && openingPrompt !== s.openingPrompt) {
121
+ s.openingPrompt = String(openingPrompt);
122
+ const chain = index.events[s.id];
123
+ if (Array.isArray(chain) && chain[0] && chain[0].kind === 'prompt') {
124
+ chain[0].text = String(openingPrompt);
125
+ }
126
+ writeActiveIndex(cwd, index);
127
+ }
128
+ const prdDir = resolveEpicPrdWriteDir(cwd, s.id);
129
+ fs.mkdirSync(prdDir, { recursive: true });
130
+ return { epicId: s.id, prdDir, created: false };
131
+ }
85
132
  }
86
133
  }
87
- }
88
134
 
89
- const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
90
- const now = new Date().toISOString();
91
- const session = {
92
- id: epicId,
93
- cwd,
94
- goalText: String(goalText || ''),
95
- // Independently minted, never shared with a SessionTab — same invariant
96
- // as renderer-created PromptSessions (state/promptSessions.ts).
97
- claudeSessionId: crypto.randomUUID(),
98
- status: 'active',
99
- createdAt: now,
100
- completedAt: null,
101
- ...(tag ? { tag } : {}),
102
- };
103
- const firstEvent = {
104
- id: crypto.randomUUID(),
105
- promptSessionId: epicId,
106
- kind: 'prompt',
107
- causedByEventId: null,
108
- at: now,
109
- text: String(goalText || ''),
110
- };
111
- index.sessions[epicId] = session;
112
- index.events[epicId] = [firstEvent];
113
- writeActiveIndex(cwd, index);
135
+ const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
136
+ const now = new Date().toISOString();
137
+ const session = {
138
+ id: epicId,
139
+ cwd,
140
+ goalText: String(goalText || ''),
141
+ // Independently minted, never shared with a SessionTab — same invariant
142
+ // as renderer-created PromptSessions (state/promptSessions.ts).
143
+ claudeSessionId: crypto.randomUUID(),
144
+ // 'proposed' files the Epic WITHOUT starting it — nothing runs until a
145
+ // human approves it in the Epics workspace. This is the sink that
146
+ // replaced the feedback-folder intake (see lib/rcaFeedbackHook.cjs).
147
+ status,
148
+ createdAt: now,
149
+ completedAt: null,
150
+ ...(tag ? { tag } : {}),
151
+ // Full body for a proposal whose goalText is only a one-line title;
152
+ // sent verbatim as the first prompt when a human approves it.
153
+ ...(openingPrompt ? { openingPrompt: String(openingPrompt) } : {}),
154
+ };
155
+ const firstEvent = {
156
+ id: crypto.randomUUID(),
157
+ promptSessionId: epicId,
158
+ kind: 'prompt',
159
+ causedByEventId: null,
160
+ at: now,
161
+ text: String(openingPrompt || goalText || ''),
162
+ };
163
+ index.sessions[epicId] = session;
164
+ index.events[epicId] = [firstEvent];
165
+ writeActiveIndex(cwd, index);
114
166
 
115
- const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
116
- fs.mkdirSync(prdDir, { recursive: true });
117
- return { epicId, prdDir, created: true };
167
+ const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
168
+ fs.mkdirSync(prdDir, { recursive: true });
169
+ return { epicId, prdDir, created: true };
170
+ });
118
171
  }
119
172
 
120
173
  /**
121
174
  * appendPrdCreatedEvent(cwd, epicId, prdSlug) — record a PRD dispatch on the
122
175
  * Epic's event chain, FK-linked to the current tail (chain, not tree — the
123
- * referential-integrity requirement from promptSessions.ts).
176
+ * referential-integrity requirement from promptSessions.ts). Returns a
177
+ * Promise<boolean>.
124
178
  */
125
179
  function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
180
+ return withPathLock(activeIndexPath(cwd), () => {
181
+ const index = readActiveIndex(cwd);
182
+ if (!index.sessions[epicId]) return false;
183
+ const chain = Array.isArray(index.events[epicId]) ? index.events[epicId] : [];
184
+ const tail = chain.length ? chain[chain.length - 1] : null;
185
+ chain.push({
186
+ id: crypto.randomUUID(),
187
+ promptSessionId: epicId,
188
+ kind: 'prd_created',
189
+ causedByEventId: tail ? tail.id : null,
190
+ at: new Date().toISOString(),
191
+ prdSlug,
192
+ ...(text ? { text } : {}),
193
+ });
194
+ index.events[epicId] = chain;
195
+ writeActiveIndex(cwd, index);
196
+ return true;
197
+ });
198
+ }
199
+
200
+ /**
201
+ * removeEpic(cwd, epicId) — roll back a freshly-minted Epic when the caller
202
+ * that minted it (ensureEpic's `created: true`) failed to complete its work
203
+ * (e.g. the PRD write that motivated the mint never landed). Deletes the
204
+ * Epic's entry from both active-index.json maps. Never call this for an
205
+ * Epic ensureEpic reported as joined (`created: false`) — that Epic predates
206
+ * this call and may carry unrelated history.
207
+ */
208
+ function removeEpic(cwd, epicId) {
209
+ if (!cwd || !epicId) return false;
126
210
  const index = readActiveIndex(cwd);
127
211
  if (!index.sessions[epicId]) return false;
128
- const chain = Array.isArray(index.events[epicId]) ? index.events[epicId] : [];
129
- const tail = chain.length ? chain[chain.length - 1] : null;
130
- chain.push({
131
- id: crypto.randomUUID(),
132
- promptSessionId: epicId,
133
- kind: 'prd_created',
134
- causedByEventId: tail ? tail.id : null,
135
- at: new Date().toISOString(),
136
- prdSlug,
137
- ...(text ? { text } : {}),
138
- });
139
- index.events[epicId] = chain;
212
+ delete index.sessions[epicId];
213
+ delete index.events[epicId];
140
214
  writeActiveIndex(cwd, index);
141
215
  return true;
142
216
  }
143
217
 
144
- module.exports = { ensureEpic, appendPrdCreatedEvent, activeIndexPath, readActiveIndex };
218
+ module.exports = { ensureEpic, appendPrdCreatedEvent, removeEpic, activeIndexPath, readActiveIndex };
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * opsOwnership.cjs — THE SINGLE-WRITER LAW for a project's operations root.
5
+ *
6
+ * Every per-project operational namespace under
7
+ * `<projectCwd>/session-manager-operations/<namespace>/` has exactly ONE
8
+ * owning surface that may write to it. Everyone else reads. Reads are never
9
+ * restricted — consistency here is about who may *mutate* state, not who may
10
+ * see it.
11
+ *
12
+ * Why: these folders ARE the app's source of truth (Epics, PRDs, queue state,
13
+ * the Brief). When two surfaces write the same folder, they race through
14
+ * separate read-modify-write cycles and the later rename silently wins — the
15
+ * same class of bug that already produced live queue.json rows whose
16
+ * sourcePromptId and sourceTabId disagree. One writer per namespace removes
17
+ * the race by construction instead of by convention.
18
+ *
19
+ * The law is FAIL-CLOSED: a write into the ops root by an undeclared writer,
20
+ * or into a namespace with no declared owner, is refused. Adding a new
21
+ * namespace or a new writer is therefore a deliberate edit to this file — the
22
+ * ownership map is reviewable in one place rather than implied by whichever
23
+ * modules happen to call fs.writeFile.
24
+ *
25
+ * Enforcement points (every write path into the ops root calls assertOpsWrite):
26
+ * - config.cjs writeTextAtomic / writeBinaryAtomic / writeJson / writeJsonSync
27
+ * - lib/epicMint.cjs (raw fs tmp+rename)
28
+ * - lib/queueStore.cjs (raw fs tmp+rename)
29
+ * Renderer callers declare their writer id through the IPC payload.
30
+ */
31
+
32
+ const path = require('node:path');
33
+
34
+ const OPS_ROOT_DIR = 'session-manager-operations';
35
+
36
+ /**
37
+ * namespace → the one writer id allowed to mutate it.
38
+ *
39
+ * Writer ids name a SURFACE (the tab/feature that owns the data), not a
40
+ * module — several modules may implement one surface, but they all write as
41
+ * that surface, and a surface owns its folder outright.
42
+ */
43
+ const OWNERS = Object.freeze({
44
+ // Epics own the PromptSession store: active-index.json + per-Epic archives.
45
+ 'prompt-sessions': 'epics',
46
+ // The Scheduler owns PRD sources, per-Epic PRD dirs, and queue/history shards.
47
+ 'scheduler': 'scheduler',
48
+ // Project Home owns the synthesized Brief (generate + hand-edit).
49
+ 'project-brief': 'project-home',
50
+ // The feedback intake folder — written by the RCA hook and feedback filing.
51
+ 'feedback': 'feedback',
52
+ // Browser tab scratch saves (DOM captures, screenshots, recorded flows).
53
+ 'browser': 'browser',
54
+ });
55
+
56
+ /**
57
+ * Narrow, explicit exceptions to single-writer. Each entry names a second
58
+ * writer, the exact file it may touch, and why the owner can't do it itself.
59
+ * Anything not listed here is refused — a delegation must be argued for in
60
+ * code review, not discovered later in a race.
61
+ */
62
+ const DELEGATIONS = Object.freeze({
63
+ 'prompt-sessions': Object.freeze([
64
+ Object.freeze({
65
+ writer: 'scheduler',
66
+ // Only this one file, and only its top level.
67
+ file: 'active-index.json',
68
+ reason:
69
+ 'The scheduler appends prd_created/response events to the Epic that '
70
+ + 'spawned a job (promptSessionEvents.cjs, PRD 814) and mints/joins an '
71
+ + 'Epic when a PRD is created (epicMint.cjs). Both happen in the main '
72
+ + 'process with no renderer attached, so the Epics surface cannot '
73
+ + 'perform them. Serialized per-path by each module\'s own write lock.',
74
+ }),
75
+ ]),
76
+ });
77
+
78
+ /**
79
+ * Split an absolute path into { inOps, namespace, relative }.
80
+ * `namespace` is the segment directly under session-manager-operations/;
81
+ * `relative` is the remainder (posix-joined), '' when the path IS the
82
+ * namespace dir. Returns inOps:false for any path outside an ops root.
83
+ *
84
+ * Complexity: O(n) in path segments.
85
+ */
86
+ function parseOpsPath(absPath) {
87
+ if (!absPath || typeof absPath !== 'string') return { inOps: false, namespace: null, relative: null };
88
+ const segs = absPath.split(path.sep).filter(Boolean);
89
+ const idx = segs.lastIndexOf(OPS_ROOT_DIR);
90
+ if (idx === -1) return { inOps: false, namespace: null, relative: null };
91
+ const rest = segs.slice(idx + 1);
92
+ // A write to the ops root itself carries no namespace — nobody owns it.
93
+ if (rest.length === 0) return { inOps: true, namespace: null, relative: '' };
94
+ return { inOps: true, namespace: rest[0], relative: rest.slice(1).join('/') };
95
+ }
96
+
97
+ /** True when `writer` may write `relative` inside `namespace` by delegation. */
98
+ function isDelegated(namespace, relative, writer) {
99
+ const entries = DELEGATIONS[namespace];
100
+ if (!entries) return false;
101
+ return entries.some((d) => d.writer === writer && d.file === relative);
102
+ }
103
+
104
+ /**
105
+ * Decide whether `writer` may write `absPath`. Pure — returns a verdict
106
+ * rather than throwing, so it can be unit-tested and so callers choose their
107
+ * own failure mode.
108
+ *
109
+ * Paths outside any ops root are allowed here unconditionally: they are
110
+ * governed by config.cjs's validateWrite boundary, which is a separate
111
+ * question (may this process write here at all) from ownership (which surface
112
+ * owns this state).
113
+ */
114
+ function checkOpsWrite(absPath, writer) {
115
+ const { inOps, namespace, relative } = parseOpsPath(absPath);
116
+ if (!inOps) return { ok: true, error: null };
117
+
118
+ if (!namespace) {
119
+ return { ok: false, error: `${OPS_ROOT_DIR}/ itself has no owner — write inside a namespace folder` };
120
+ }
121
+
122
+ const owner = OWNERS[namespace];
123
+ if (!owner) {
124
+ return {
125
+ ok: false,
126
+ error:
127
+ `no declared owner for ${OPS_ROOT_DIR}/${namespace}/ — `
128
+ + `add it to OWNERS in lib/opsOwnership.cjs before writing there`,
129
+ };
130
+ }
131
+
132
+ if (!writer) {
133
+ return {
134
+ ok: false,
135
+ error:
136
+ `write to ${OPS_ROOT_DIR}/${namespace}/ did not declare a writer `
137
+ + `(owner is '${owner}')`,
138
+ };
139
+ }
140
+
141
+ if (writer === owner) return { ok: true, error: null };
142
+ if (isDelegated(namespace, relative, writer)) return { ok: true, error: null };
143
+
144
+ return {
145
+ ok: false,
146
+ error:
147
+ `'${writer}' may not write ${OPS_ROOT_DIR}/${namespace}/${relative} — `
148
+ + `that namespace is owned by '${owner}' (single-writer law, `
149
+ + `lib/opsOwnership.cjs). Read it freely; route writes through the owner.`,
150
+ };
151
+ }
152
+
153
+ /** Throwing wrapper used at each enforcement point. */
154
+ function assertOpsWrite(absPath, writer) {
155
+ const verdict = checkOpsWrite(absPath, writer);
156
+ if (!verdict.ok) throw new Error(verdict.error);
157
+ }
158
+
159
+ module.exports = {
160
+ OPS_ROOT_DIR,
161
+ OWNERS,
162
+ DELEGATIONS,
163
+ parseOpsPath,
164
+ checkOpsWrite,
165
+ assertOpsWrite,
166
+ };
@@ -115,11 +115,19 @@ async function createPrd(input, remote) {
115
115
  // joins that treat it as a literal path segment, so an unexpanded `~/...`
116
116
  // fails downstream as a bare "invalid slug" instead of a clear cwd error.
117
117
  const cwd = expandHome(input.cwd);
118
+ let realCwd;
118
119
  try {
119
- config.validatePath(cwd);
120
+ realCwd = config.validatePath(cwd);
120
121
  } catch (e) {
121
122
  return { ok: false, status: 400, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` };
122
123
  }
124
+ // Register this project's cwd as a write-allowed root (config.cjs's
125
+ // validateWrite gate). Chat-only Epics (headless claude -p, no Terminal
126
+ // PTY ever spawned for this cwd) reach this code path without pty.cjs's
127
+ // addAllowedRoot call ever having run — without this, every PRD write for
128
+ // such a project fails with "Write outside allowed write boundaries" even
129
+ // though validatePath (the read boundary) just passed above.
130
+ config.addAllowedRoot(realCwd);
123
131
  input = { ...input, cwd };
124
132
 
125
133
  const slug = input.slug || deriveSlugFromTitle(input.title);
@@ -62,6 +62,26 @@ function listEpicPrdDirs(cwd) {
62
62
  return dirs;
63
63
  }
64
64
 
65
+ /**
66
+ * Every `prds-archived/` dir for one project cwd: the retired flat layout's
67
+ * sibling archive plus each Epic's own sibling archive. Consumed by the
68
+ * scheduler's archived-twin stale-queue-row guard, so a PRD archived under
69
+ * its Epic (the layout every new PRD uses) is still found.
70
+ */
71
+ function listArchivedPrdDirs(cwd) {
72
+ const dirs = [path.join(cwd, ...PRD_SUBPATH, '..', 'prds-archived')];
73
+ let root;
74
+ try { root = resolveEpicsRoot(cwd); } catch { return dirs; }
75
+ let entries;
76
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return dirs; }
77
+ for (const ent of entries) {
78
+ if (!ent.isDirectory()) continue;
79
+ const archiveDir = path.join(root, ent.name, 'prds-archived');
80
+ if (fs.existsSync(archiveDir)) dirs.push(archiveDir);
81
+ }
82
+ return dirs;
83
+ }
84
+
65
85
  /**
66
86
  * resolvePrdWriteDir(cwd) → `<cwd>/session-manager-operations/scheduler/prds`
67
87
  * Pure path join, no I/O. Throws on a missing/non-string cwd so a caller
@@ -158,6 +178,7 @@ module.exports = {
158
178
  resolveEpicsRoot,
159
179
  resolveEpicPrdWriteDir,
160
180
  listEpicPrdDirs,
181
+ listArchivedPrdDirs,
161
182
  deriveEpicIdFromPrdPath,
162
183
  PRD_SUBPATH,
163
184
  EPICS_SUBPATH,
@@ -8,12 +8,21 @@
8
8
  * with plain-object inputs.
9
9
  *
10
10
  * brief.json shape (see design-mocks/home/DESIGN_SPEC.md "Persistence"):
11
- * { version, synthesizedAt, model, purpose, what[], areas[], scope[],
12
- * conventions[], pins{what,conventions}, pinned{what,conventions} }
11
+ * { version, synthesizedAt, editedAt, model, purpose, what[], areas[],
12
+ * scope[], conventions[], pins{what,conventions}, pinned{what,conventions} }
13
+ *
14
+ * The file is not write-once: `computeUpdate` is the hand-edit path (the
15
+ * "maintain" half of generate-and-maintain), so the brief can be corrected
16
+ * without paying for a full re-synthesis.
13
17
  */
14
18
 
15
19
  const BRIEF_VERSION = 1;
16
20
  const PINNABLE_BLOCKS = ['what', 'conventions'];
21
+ /** Fields a caller may hand-edit through `computeUpdate`. Editing a PINNABLE
22
+ * block also auto-pins it, so the next refresh cannot silently undo the edit;
23
+ * the derived blocks (areas/scope) are re-synthesized on every refresh by
24
+ * design, so edits to them are a stopgap, not a source of truth. */
25
+ const EDITABLE_FIELDS = ['purpose', 'what', 'areas', 'scope', 'conventions'];
17
26
 
18
27
  /** true when a source's mtime is strictly newer than the brief's synthesizedAt. */
19
28
  function computeDrift(mtimeMs, synthesizedAtMs) {
@@ -131,12 +140,15 @@ function applyPinEnforcement(rawBrief, priorPins, priorPinned) {
131
140
  }
132
141
 
133
142
  /** Full persisted-shape builder: stamps version/synthesizedAt/model on top of
134
- * the pin-enforced content. `nowIso` is passed in — never computed here. */
135
- function buildPersistedBrief({ rawBrief, priorPins, priorPinned, model, nowIso }) {
143
+ * the pin-enforced content. `nowIso` is passed in — never computed here.
144
+ * `priorEditedAt` carries forward so a refresh doesn't erase the record that
145
+ * the (pinned, therefore preserved) blocks were hand-edited. */
146
+ function buildPersistedBrief({ rawBrief, priorPins, priorPinned, model, nowIso, priorEditedAt = null }) {
136
147
  const enforced = applyPinEnforcement(rawBrief, priorPins, priorPinned);
137
148
  return {
138
149
  version: BRIEF_VERSION,
139
150
  synthesizedAt: nowIso,
151
+ editedAt: priorEditedAt || null,
140
152
  model,
141
153
  purpose: enforced.purpose,
142
154
  what: enforced.what,
@@ -163,9 +175,61 @@ function computeSetPin(currentBrief, block, pinned) {
163
175
  return { ok: true, brief: { ...currentBrief, pins: nextPins, pinned: nextPinned } };
164
176
  }
165
177
 
178
+ /** Per-field shape guard for a hand-edit patch. Mirrors validateBriefShape's
179
+ * strictness so an edit can never write a brief the renderer can't render. */
180
+ function validateUpdateField(field, value) {
181
+ if (field === 'purpose') {
182
+ if (typeof value !== 'string' || !value.trim()) return 'purpose must be a non-empty string';
183
+ return null;
184
+ }
185
+ if (!Array.isArray(value)) return `${field} must be an array`;
186
+ if (field === 'what' || field === 'conventions') {
187
+ if (value.some((v) => typeof v !== 'string')) return `${field} must be an array of strings`;
188
+ return null;
189
+ }
190
+ if (value.some((v) => !v || typeof v !== 'object' || Array.isArray(v))) return `${field} must be an array of objects`;
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Pure hand-edit transform. Given the current brief (or null), a patch of
196
+ * editable fields, and `nowIso`, returns the next brief with the patch applied
197
+ * and `editedAt` stamped. Any edited PINNABLE block is auto-pinned and its
198
+ * frozen copy set to the new content, so the next refresh preserves the edit
199
+ * instead of overwriting it. Returns {ok:false, error} on a missing brief,
200
+ * an unknown/empty patch, or a field whose shape would break the renderer.
201
+ *
202
+ * Complexity: O(n) in the patched arrays' lengths.
203
+ */
204
+ function computeUpdate(currentBrief, patch, nowIso) {
205
+ if (!currentBrief) return { ok: false, error: 'no brief to edit yet — generate one first' };
206
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, error: 'patch must be an object' };
207
+
208
+ const fields = Object.keys(patch);
209
+ if (fields.length === 0) return { ok: false, error: 'patch is empty' };
210
+ const unknown = fields.filter((f) => !EDITABLE_FIELDS.includes(f));
211
+ if (unknown.length) return { ok: false, error: `not editable: ${unknown.join(', ')}` };
212
+ for (const f of fields) {
213
+ const err = validateUpdateField(f, patch[f]);
214
+ if (err) return { ok: false, error: err };
215
+ }
216
+
217
+ const next = { ...currentBrief, ...patch, editedAt: nowIso };
218
+ next.pins = { what: false, conventions: false, ...(currentBrief.pins || {}) };
219
+ next.pinned = { what: null, conventions: null, ...(currentBrief.pinned || {}) };
220
+ for (const block of PINNABLE_BLOCKS) {
221
+ if (fields.includes(block)) {
222
+ next.pins[block] = true;
223
+ next.pinned[block] = patch[block];
224
+ }
225
+ }
226
+ return { ok: true, brief: next };
227
+ }
228
+
166
229
  module.exports = {
167
230
  BRIEF_VERSION,
168
231
  PINNABLE_BLOCKS,
232
+ EDITABLE_FIELDS,
169
233
  computeDrift,
170
234
  buildSources,
171
235
  buildSynthesisPrompt,
@@ -173,4 +237,6 @@ module.exports = {
173
237
  applyPinEnforcement,
174
238
  buildPersistedBrief,
175
239
  computeSetPin,
240
+ validateUpdateField,
241
+ computeUpdate,
176
242
  };
@@ -33,6 +33,7 @@ const fsp = require('node:fs/promises');
33
33
  const path = require('node:path');
34
34
  const os = require('node:os');
35
35
  const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
36
+ const { assertOpsWrite } = require('./opsOwnership.cjs');
36
37
 
37
38
  const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
38
39
  const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
@@ -52,6 +53,7 @@ function projectHistoryPath(cwd) {
52
53
  }
53
54
 
54
55
  function writeJsonAtomicSync(file, value) {
56
+ assertOpsWrite(file, 'scheduler');
55
57
  fs.mkdirSync(path.dirname(file), { recursive: true });
56
58
  const tmp = `${file}.tmp-${process.pid}`;
57
59
  fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
@@ -59,6 +61,7 @@ function writeJsonAtomicSync(file, value) {
59
61
  }
60
62
 
61
63
  async function writeJsonAtomic(file, value) {
64
+ assertOpsWrite(file, 'scheduler');
62
65
  await fsp.mkdir(path.dirname(file), { recursive: true });
63
66
  const tmp = `${file}.tmp-${process.pid}`;
64
67
  await fsp.writeFile(tmp, JSON.stringify(value, null, 2));