claude-code-session-manager 0.40.3 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/assets/{TiptapBody-DweEtHks.js → TiptapBody-Cr-O75Yg.js} +1 -1
  2. package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
  3. package/dist/assets/{index-QRU8uTeq.js → index-CV9TNjxX.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__/opsErrorLog.test.cjs +87 -0
  17. package/src/main/__tests__/prdCreate.test.cjs +124 -2
  18. package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
  19. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  20. package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
  21. package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
  22. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
  23. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
  24. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
  25. package/src/main/browserView.cjs +1 -1
  26. package/src/main/chatRunner.cjs +24 -47
  27. package/src/main/config.cjs +12 -6
  28. package/src/main/docEdit.cjs +3 -4
  29. package/src/main/index.cjs +12 -0
  30. package/src/main/ipcSchemas.cjs +59 -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/opsErrorLog.cjs +96 -0
  35. package/src/main/lib/opsOwnership.cjs +169 -0
  36. package/src/main/lib/prdCreate.cjs +39 -1
  37. package/src/main/lib/prdLocations.cjs +21 -0
  38. package/src/main/lib/projectBriefCore.cjs +70 -4
  39. package/src/main/lib/queueStore.cjs +3 -0
  40. package/src/main/lib/rcaFeedbackHook.cjs +41 -55
  41. package/src/main/logs.cjs +19 -1
  42. package/src/main/projectBrief.cjs +36 -2
  43. package/src/main/promptSessionEvents.cjs +24 -2
  44. package/src/main/promptSessionTranscript.cjs +0 -0
  45. package/src/main/pty.cjs +15 -0
  46. package/src/main/queueOps.cjs +1 -1
  47. package/src/main/scheduler/prdParser.cjs +8 -0
  48. package/src/main/scheduler.cjs +158 -62
  49. package/src/main/templates/PRD_AUTHORING.md +1 -1
  50. package/src/preload/api.d.ts +83 -4
  51. package/src/preload/index.cjs +26 -5
  52. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
  53. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
  54. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +0 -265
@@ -0,0 +1,169 @@
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
+ * - lib/opsErrorLog.cjs (raw fs append, JSONL error log)
30
+ * Renderer callers declare their writer id through the IPC payload.
31
+ */
32
+
33
+ const path = require('node:path');
34
+
35
+ const OPS_ROOT_DIR = 'session-manager-operations';
36
+
37
+ /**
38
+ * namespace → the one writer id allowed to mutate it.
39
+ *
40
+ * Writer ids name a SURFACE (the tab/feature that owns the data), not a
41
+ * module — several modules may implement one surface, but they all write as
42
+ * that surface, and a surface owns its folder outright.
43
+ */
44
+ const OWNERS = Object.freeze({
45
+ // Epics own the PromptSession store: active-index.json + per-Epic archives.
46
+ 'prompt-sessions': 'epics',
47
+ // The Scheduler owns PRD sources, per-Epic PRD dirs, and queue/history shards.
48
+ 'scheduler': 'scheduler',
49
+ // Project Home owns the synthesized Brief (generate + hand-edit).
50
+ 'project-brief': 'project-home',
51
+ // The feedback intake folder — written by the RCA hook and feedback filing.
52
+ 'feedback': 'feedback',
53
+ // Browser tab scratch saves (DOM captures, screenshots, recorded flows).
54
+ 'browser': 'browser',
55
+ // Structured per-tab error log lines (JSONL), tagged for tracing/analysis.
56
+ 'logs': 'logs',
57
+ });
58
+
59
+ /**
60
+ * Narrow, explicit exceptions to single-writer. Each entry names a second
61
+ * writer, the exact file it may touch, and why the owner can't do it itself.
62
+ * Anything not listed here is refused — a delegation must be argued for in
63
+ * code review, not discovered later in a race.
64
+ */
65
+ const DELEGATIONS = Object.freeze({
66
+ 'prompt-sessions': Object.freeze([
67
+ Object.freeze({
68
+ writer: 'scheduler',
69
+ // Only this one file, and only its top level.
70
+ file: 'active-index.json',
71
+ reason:
72
+ 'The scheduler appends prd_created/response events to the Epic that '
73
+ + 'spawned a job (promptSessionEvents.cjs, PRD 814) and mints/joins an '
74
+ + 'Epic when a PRD is created (epicMint.cjs). Both happen in the main '
75
+ + 'process with no renderer attached, so the Epics surface cannot '
76
+ + 'perform them. Serialized per-path by each module\'s own write lock.',
77
+ }),
78
+ ]),
79
+ });
80
+
81
+ /**
82
+ * Split an absolute path into { inOps, namespace, relative }.
83
+ * `namespace` is the segment directly under session-manager-operations/;
84
+ * `relative` is the remainder (posix-joined), '' when the path IS the
85
+ * namespace dir. Returns inOps:false for any path outside an ops root.
86
+ *
87
+ * Complexity: O(n) in path segments.
88
+ */
89
+ function parseOpsPath(absPath) {
90
+ if (!absPath || typeof absPath !== 'string') return { inOps: false, namespace: null, relative: null };
91
+ const segs = absPath.split(path.sep).filter(Boolean);
92
+ const idx = segs.lastIndexOf(OPS_ROOT_DIR);
93
+ if (idx === -1) return { inOps: false, namespace: null, relative: null };
94
+ const rest = segs.slice(idx + 1);
95
+ // A write to the ops root itself carries no namespace — nobody owns it.
96
+ if (rest.length === 0) return { inOps: true, namespace: null, relative: '' };
97
+ return { inOps: true, namespace: rest[0], relative: rest.slice(1).join('/') };
98
+ }
99
+
100
+ /** True when `writer` may write `relative` inside `namespace` by delegation. */
101
+ function isDelegated(namespace, relative, writer) {
102
+ const entries = DELEGATIONS[namespace];
103
+ if (!entries) return false;
104
+ return entries.some((d) => d.writer === writer && d.file === relative);
105
+ }
106
+
107
+ /**
108
+ * Decide whether `writer` may write `absPath`. Pure — returns a verdict
109
+ * rather than throwing, so it can be unit-tested and so callers choose their
110
+ * own failure mode.
111
+ *
112
+ * Paths outside any ops root are allowed here unconditionally: they are
113
+ * governed by config.cjs's validateWrite boundary, which is a separate
114
+ * question (may this process write here at all) from ownership (which surface
115
+ * owns this state).
116
+ */
117
+ function checkOpsWrite(absPath, writer) {
118
+ const { inOps, namespace, relative } = parseOpsPath(absPath);
119
+ if (!inOps) return { ok: true, error: null };
120
+
121
+ if (!namespace) {
122
+ return { ok: false, error: `${OPS_ROOT_DIR}/ itself has no owner — write inside a namespace folder` };
123
+ }
124
+
125
+ const owner = OWNERS[namespace];
126
+ if (!owner) {
127
+ return {
128
+ ok: false,
129
+ error:
130
+ `no declared owner for ${OPS_ROOT_DIR}/${namespace}/ — `
131
+ + `add it to OWNERS in lib/opsOwnership.cjs before writing there`,
132
+ };
133
+ }
134
+
135
+ if (!writer) {
136
+ return {
137
+ ok: false,
138
+ error:
139
+ `write to ${OPS_ROOT_DIR}/${namespace}/ did not declare a writer `
140
+ + `(owner is '${owner}')`,
141
+ };
142
+ }
143
+
144
+ if (writer === owner) return { ok: true, error: null };
145
+ if (isDelegated(namespace, relative, writer)) return { ok: true, error: null };
146
+
147
+ return {
148
+ ok: false,
149
+ error:
150
+ `'${writer}' may not write ${OPS_ROOT_DIR}/${namespace}/${relative} — `
151
+ + `that namespace is owned by '${owner}' (single-writer law, `
152
+ + `lib/opsOwnership.cjs). Read it freely; route writes through the owner.`,
153
+ };
154
+ }
155
+
156
+ /** Throwing wrapper used at each enforcement point. */
157
+ function assertOpsWrite(absPath, writer) {
158
+ const verdict = checkOpsWrite(absPath, writer);
159
+ if (!verdict.ok) throw new Error(verdict.error);
160
+ }
161
+
162
+ module.exports = {
163
+ OPS_ROOT_DIR,
164
+ OWNERS,
165
+ DELEGATIONS,
166
+ parseOpsPath,
167
+ checkOpsWrite,
168
+ assertOpsWrite,
169
+ };
@@ -23,6 +23,7 @@ const { kebabCase } = require('./kebabCase.cjs');
23
23
  const config = require('../config.cjs');
24
24
  const { expandHome } = require('./expandHome.cjs');
25
25
  const { readBody, sendJson } = require('./localAdminHttp.cjs');
26
+ const { readActiveIndex } = require('./epicMint.cjs');
26
27
 
27
28
  const STANDARDS_PATH = path.join(
28
29
  __dirname, '..', '..', '..',
@@ -90,6 +91,29 @@ function buildPrdBody(input) {
90
91
  return `${fmLines.join('\n')}${bodyLines.join('\n')}`;
91
92
  }
92
93
 
94
+ /**
95
+ * Best-effort fallback for a PRD queued via the scheduler_create_prd MCP tool
96
+ * from inside an Epic's own headless chat session, when the model's tool
97
+ * call omitted `sourcePromptId` — the exact gap that let PRDs 866/867 mint
98
+ * two unrelated sibling Epics instead of joining the Epic that was actually
99
+ * driving the conversation (2026-08-01). `claudeSessionId` is this process's
100
+ * `originClaudeSessionId` input (chatRunner.cjs's SM_CHAT_SESSION_ID env var,
101
+ * inherited down to the MCP server child); looked up against `cwd`'s
102
+ * active-index.json for the Epic whose own claudeSessionId matches (domain
103
+ * model: Epic:claude-session is 1:1). Returns null for a non-Epic tab (plain
104
+ * SessionTab chat) or when the id is stale/unknown — never throws, since this
105
+ * is strictly a convenience default and a miss must fall back to minting a
106
+ * normal Epic exactly like today.
107
+ */
108
+ function resolveSourcePromptIdFromClaudeSession(cwd, claudeSessionId) {
109
+ if (!claudeSessionId) return null;
110
+ const { sessions } = readActiveIndex(cwd);
111
+ for (const session of Object.values(sessions)) {
112
+ if (session && session.claudeSessionId === claudeSessionId) return session.id;
113
+ }
114
+ return null;
115
+ }
116
+
93
117
  /**
94
118
  * Core create-PRD orchestration: cwd validation, slug derivation, NN
95
119
  * allocation, existing-file collision check, body build, write. Shared by
@@ -115,13 +139,26 @@ async function createPrd(input, remote) {
115
139
  // joins that treat it as a literal path segment, so an unexpanded `~/...`
116
140
  // fails downstream as a bare "invalid slug" instead of a clear cwd error.
117
141
  const cwd = expandHome(input.cwd);
142
+ let realCwd;
118
143
  try {
119
- config.validatePath(cwd);
144
+ realCwd = config.validatePath(cwd);
120
145
  } catch (e) {
121
146
  return { ok: false, status: 400, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` };
122
147
  }
148
+ // Register this project's cwd as a write-allowed root (config.cjs's
149
+ // validateWrite gate). Chat-only Epics (headless claude -p, no Terminal
150
+ // PTY ever spawned for this cwd) reach this code path without pty.cjs's
151
+ // addAllowedRoot call ever having run — without this, every PRD write for
152
+ // such a project fails with "Write outside allowed write boundaries" even
153
+ // though validatePath (the read boundary) just passed above.
154
+ config.addAllowedRoot(realCwd);
123
155
  input = { ...input, cwd };
124
156
 
157
+ if (!input.sourcePromptId && input.originClaudeSessionId) {
158
+ const resolved = resolveSourcePromptIdFromClaudeSession(cwd, input.originClaudeSessionId);
159
+ if (resolved) input = { ...input, sourcePromptId: resolved };
160
+ }
161
+
125
162
  const slug = input.slug || deriveSlugFromTitle(input.title);
126
163
  if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
127
164
  return { ok: false, status: 400, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' };
@@ -201,6 +238,7 @@ module.exports = {
201
238
  readStandards,
202
239
  deriveSlugFromTitle,
203
240
  buildPrdBody,
241
+ resolveSourcePromptIdFromClaudeSession,
204
242
  createPrd,
205
243
  registerAdminRoute,
206
244
  };
@@ -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));
@@ -28,6 +28,7 @@ const fs = require('node:fs');
28
28
  const os = require('node:os');
29
29
  const path = require('node:path');
30
30
  const config = require('../config.cjs');
31
+ const { ensureEpic } = require('./epicMint.cjs');
31
32
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
32
33
  const { readTail } = require('./fileTail.cjs');
33
34
  const { resolvePrdWriteDir } = require('./prdLocations.cjs');
@@ -35,7 +36,6 @@ const { resolvePrdWriteDir } = require('./prdLocations.cjs');
35
36
  // The one project this hook always knows how to reach, used as the fallback
36
37
  // destination when a job's own cwd has no feedback inbox.
37
38
  const SM_REPO_ROOT = path.join(os.homedir(), 'Projects', 'session-manager');
38
- const SM_REPO_FEEDBACK_DIR = path.join(SM_REPO_ROOT, 'session-manager-operations', 'feedback');
39
39
 
40
40
  // NEVER write here: ~/.claude/session-manager/feedback/ is the scheduler's
41
41
  // auto-PRD intake (src/main/scheduler.cjs's periodic feedback sweep) — it
@@ -200,27 +200,31 @@ function extractAcceptanceCriteria(prdBody) {
200
200
  // ─── Destination resolution ──────────────────────────────────────────────────
201
201
 
202
202
  /**
203
- * Resolve where an RCA for `job` should be filed.
204
- * 1. <job.cwd>/session-manager-operations/feedback/ if it exists.
205
- * 2. Else session-manager's own repo inbox, naming the target project.
203
+ * Resolve which PROJECT the proposal is filed into.
204
+ * 1. the job's own cwd — Epics live in every project, so this is almost
205
+ * always the answer.
206
+ * 2. else session-manager's own repo, naming the target project in the body.
207
+ *
208
+ * The old version probed for a `session-manager-operations/feedback/`
209
+ * directory and fell back when it was missing; with proposals there is
210
+ * nothing to probe for — the Epic store is created on demand.
206
211
  */
207
212
  function resolveDestination(job) {
208
213
  const jobCwd = job?.cwd;
209
214
  if (jobCwd) {
210
- const candidate = path.join(jobCwd, 'session-manager-operations', 'feedback');
211
- try {
212
- if (fs.statSync(candidate).isDirectory()) {
213
- return { dir: candidate, allowlistRoot: jobCwd, targetProjectNote: null };
214
- }
215
- } catch {
216
- // missing/inaccessible — fall through to the session-manager fallback
217
- }
215
+ return { cwd: jobCwd, allowlistRoot: jobCwd, targetProjectNote: null };
218
216
  }
219
- const targetProjectNote = jobCwd
220
- ? `Filed into session-manager's own feedback inbox because \`${jobCwd}\` has no ` +
221
- `\`session-manager-operations/feedback/\` directory. Target project: \`${jobCwd}\`.`
222
- : 'Filed into session-manager\'s own feedback inbox (job had no cwd on record).';
223
- return { dir: SM_REPO_FEEDBACK_DIR, allowlistRoot: SM_REPO_ROOT, targetProjectNote };
217
+ return {
218
+ cwd: SM_REPO_ROOT,
219
+ allowlistRoot: SM_REPO_ROOT,
220
+ targetProjectNote: "Filed into session-manager's own project (job had no cwd on record).",
221
+ };
222
+ }
223
+
224
+ /** One-line title for the proposal — goalText is what the Epics list shows,
225
+ * so it must stay short; the full RCA markdown rides in openingPrompt. */
226
+ function rcaProposalTitle(job, verdict) {
227
+ return `PRD ${job?.slug ?? 'unknown'} needs review: ${humanVerdict(verdict)}`;
224
228
  }
225
229
 
226
230
  function shortRunId(runId) {
@@ -228,10 +232,6 @@ function shortRunId(runId) {
228
232
  return cleaned.slice(0, 12) || 'norun';
229
233
  }
230
234
 
231
- function rcaFileName(dateStr, slug, runId) {
232
- return `${dateStr}-rca-${slug}-${shortRunId(runId)}.md`;
233
- }
234
-
235
235
  function lastNLines(text, n) {
236
236
  const lines = (text || '').split('\n');
237
237
  return lines.slice(-n).join('\n');
@@ -328,35 +328,6 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
328
328
  }
329
329
 
330
330
  const dest = resolveDestination(job);
331
- const dateStr = (now instanceof Date ? now : new Date()).toISOString().slice(0, 10);
332
- const fileName = rcaFileName(dateStr, job.slug, job.runId);
333
- const destPath = path.resolve(path.join(dest.dir, fileName));
334
- if (!destPath.startsWith(path.resolve(dest.dir) + path.sep)) {
335
- console.log(`[rca] skip: destPath escaped dest dir (${destPath})`);
336
- return { filed: false, reason: 'unsafe-path' };
337
- }
338
- // /process-feedback archives dispositioned items to <dest.dir>/processed/
339
- // immediately at disposition time (its own README convention) — long
340
- // before this hook's own run-verify/self-heal passes might touch the same
341
- // (slug, runId) again. Once archived, the live-dir check alone goes false
342
- // and a re-trigger would refile a duplicate straight into the live inbox.
343
- const processedPath = path.resolve(path.join(dest.dir, 'processed', fileName));
344
- if (!processedPath.startsWith(path.resolve(path.join(dest.dir, 'processed')) + path.sep)) {
345
- console.log(`[rca] skip: processedPath escaped processed dir (${processedPath})`);
346
- return { filed: false, reason: 'unsafe-path' };
347
- }
348
-
349
- const liveExists = fs.existsSync(destPath);
350
- const processedExists = !liveExists && fs.existsSync(processedPath);
351
- const alreadyFiled = liveExists || processedExists;
352
- if (alreadyFiled && !investigationText) {
353
- const existingPath = liveExists ? destPath : processedPath;
354
- console.log(`[rca] skip: already filed for ${job.slug}@${job.runId}`);
355
- return { filed: false, reason: 'duplicate', path: existingPath };
356
- }
357
- // An investigationText update must target wherever the file actually
358
- // lives (live dir or processed/), not assume the live dir.
359
- const targetPath = liveExists || !processedExists ? destPath : processedPath;
360
331
 
361
332
  const meta = readRunMeta(runDir, job.slug);
362
333
  const logPath = runDir ? path.join(runDir, `${job.slug}.log`) : null;
@@ -369,11 +340,27 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
369
340
  targetProjectNote: dest.targetProjectNote, annotations,
370
341
  });
371
342
 
343
+ // File it as a PROPOSED Epic rather than a markdown file in a feedback
344
+ // inbox. The proposal lands directly in the Epics workspace behind an
345
+ // Approve & start gate: nothing runs and nothing is spent until a human
346
+ // approves it. This replaced the feedback-folder intake (and the
347
+ // process-feedback triage pass that existed only to read it back out) —
348
+ // the proposal IS the work item, already carrying its own session and
349
+ // PRD directory. reuseByGoal makes a re-trigger for the same failure
350
+ // join the existing proposal instead of duplicating it, which is what
351
+ // the old live/processed existence checks were for.
372
352
  config.addAllowedRoot(dest.allowlistRoot);
373
- await config.writeTextAtomic(targetPath, markdown);
353
+ const title = rcaProposalTitle(job, verdict);
354
+ const { epicId, created } = await ensureEpic(dest.cwd, {
355
+ goalText: title,
356
+ tag: 'bug',
357
+ status: 'proposed',
358
+ reuseByGoal: true,
359
+ openingPrompt: markdown,
360
+ });
374
361
 
375
- console.log(`[rca] ${alreadyFiled ? 'updated (investigation)' : 'filed'} ${targetPath}`);
376
- return { filed: true, path: targetPath, updated: alreadyFiled };
362
+ console.log(`[rca] ${created ? 'proposed' : 'joined'} epic ${epicId} for ${job.slug}`);
363
+ return { filed: true, epicId, created, updated: !created };
377
364
  } catch (e) {
378
365
  console.error('[rca] error filing RCA feedback', e?.message ?? String(e));
379
366
  return { filed: false, reason: 'error', error: e?.message ?? String(e) };
@@ -382,15 +369,14 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
382
369
 
383
370
  module.exports = {
384
371
  fileRcaFeedback,
372
+ rcaProposalTitle,
385
373
  // Pure helpers, exported for unit tests.
386
374
  humanVerdict,
387
375
  classifyFailure,
388
376
  extractAcceptanceCriteria,
389
377
  extractRcaBlock,
390
378
  resolveDestination,
391
- rcaFileName,
392
379
  buildRcaMarkdown,
393
380
  FAILURE_CLASSES,
394
381
  VERDICT_LABELS,
395
- SM_REPO_FEEDBACK_DIR,
396
382
  };
package/src/main/logs.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  const { app, ipcMain } = require('electron');
2
2
  const fs = require('node:fs');
3
3
  const path = require('node:path');
4
+ const opsErrorLog = require('./lib/opsErrorLog.cjs');
4
5
 
5
6
  // Rolling daily files under <userData>/logs/. Anything mtime'd older than
6
7
  // RETENTION_MS is pruned at boot; we never compact mid-session, since the
@@ -75,7 +76,24 @@ function writeLine(payload) {
75
76
  }
76
77
 
77
78
  function registerLogHandlers() {
78
- ipcMain.on('log:write', (_e, payload) => writeLine(payload));
79
+ ipcMain.on('log:write', (_e, payload) => {
80
+ writeLine(payload);
81
+ // Renderer errors that name which project/tab they belong to also land
82
+ // in that project's own session-manager-operations/logs/ — the
83
+ // machine-global file above mixes every project together and isn't
84
+ // taggable, so it's not a substitute for the per-project trace.
85
+ if (payload && payload.level === 'error' && payload.cwd) {
86
+ opsErrorLog.appendError({
87
+ cwd: payload.cwd,
88
+ scope: payload.scope,
89
+ tabId: payload.tabId,
90
+ epicId: payload.epicId,
91
+ tags: Array.isArray(payload.tags) ? payload.tags : [],
92
+ message: payload.message,
93
+ meta: payload.meta,
94
+ });
95
+ }
96
+ });
79
97
  ipcMain.handle('log:dir', () => logDir());
80
98
  }
81
99