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,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
+ });
@@ -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,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * opsErrorLog.cjs — sole writer of the `logs` ops namespace
5
+ * (single-writer law, see opsOwnership.cjs). Appends one structured JSONL
6
+ * line per error to `<cwd>/session-manager-operations/logs/errors-<date>.jsonl`
7
+ * so errors from every tab/surface land in one common, per-project place
8
+ * instead of scattered across console.error calls and the machine-global
9
+ * <userData>/logs/ (which mixes every project together and isn't tagged for
10
+ * tracing). Every call site funnels through `appendError` — that is what
11
+ * makes this the sole writer in practice, not just in the OWNERS map.
12
+ */
13
+
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+ const { assertOpsWrite } = require('./opsOwnership.cjs');
17
+
18
+ // Same redaction policy as logs.cjs's sanitizeMeta — kept independent (not
19
+ // shared/required) so this module has no dependency on the Electron `app`
20
+ // module and can be unit-tested without a running app.
21
+ const REDACT_KEY = /^(transcript|interim|final|text|content|partial|userText|message|token|secret|password|authorization|cookie|api[_-]?key|access[_-]?token|refresh[_-]?token)$/i;
22
+
23
+ function sanitizeMeta(meta) {
24
+ if (!meta || typeof meta !== 'object') return meta;
25
+ if (Array.isArray(meta)) return meta;
26
+ const out = {};
27
+ for (const [k, v] of Object.entries(meta)) {
28
+ out[k] = REDACT_KEY.test(k) ? '[redacted]' : v;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function logsDir(cwd) {
34
+ return path.join(cwd, 'session-manager-operations', 'logs');
35
+ }
36
+
37
+ function todayFile(cwd) {
38
+ const d = new Date();
39
+ const yyyy = d.getFullYear();
40
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
41
+ const dd = String(d.getDate()).padStart(2, '0');
42
+ return path.join(logsDir(cwd), `errors-${yyyy}-${mm}-${dd}.jsonl`);
43
+ }
44
+
45
+ /**
46
+ * Append one structured error line, tagged for tracing/analysis.
47
+ *
48
+ * @param {{
49
+ * cwd: string, // required — which project's ops root owns this line
50
+ * scope: string, // subsystem name, e.g. 'pty', 'chatRunner', 'voice'
51
+ * level?: string, // default 'error'; 'warn' also accepted
52
+ * tabId?: string, // claudeSessionId of the tab this error belongs to, if any
53
+ * epicId?: string, // Epic id, if this error happened inside an Epic-backed run
54
+ * tags?: string[], // extra caller-supplied tags, merged with the auto-derived ones
55
+ * message: string,
56
+ * meta?: unknown,
57
+ * }} entry
58
+ */
59
+ function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta }) {
60
+ if (!cwd || typeof cwd !== 'string') return; // no project to attribute this line to — skip
61
+ const file = todayFile(cwd);
62
+ try {
63
+ assertOpsWrite(file, 'logs');
64
+ } catch {
65
+ return; // fail-closed, same as every other ops write path — never throw from a log call
66
+ }
67
+
68
+ const autoTags = [
69
+ `level:${level}`,
70
+ `scope:${scope || 'unknown'}`,
71
+ ...(tabId ? [`tab:${tabId}`] : []),
72
+ ...(epicId ? [`epic:${epicId}`] : []),
73
+ ];
74
+ const allTags = Array.from(new Set([...autoTags, ...tags]));
75
+
76
+ const line = {
77
+ ts: new Date().toISOString(),
78
+ level,
79
+ scope: scope || 'unknown',
80
+ tabId: tabId || null,
81
+ epicId: epicId || null,
82
+ tags: allTags,
83
+ message: String(message ?? ''),
84
+ ...(meta !== undefined ? { meta: sanitizeMeta(meta) } : {}),
85
+ };
86
+
87
+ try {
88
+ fs.mkdirSync(logsDir(cwd), { recursive: true });
89
+ // 0o600: same rationale as logs.cjs — error meta can carry local paths /
90
+ // device labels, keep it off other local users' reach.
91
+ const fd = fs.openSync(file, 'a', 0o600);
92
+ try { fs.writeSync(fd, JSON.stringify(line) + '\n'); } finally { fs.closeSync(fd); }
93
+ } catch { /* best-effort — a logging failure must never break the caller */ }
94
+ }
95
+
96
+ module.exports = { appendError, logsDir, todayFile };