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.
- package/dist/assets/{TiptapBody-DweEtHks.js → TiptapBody-Cr-O75Yg.js} +1 -1
- package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
- package/dist/assets/{index-QRU8uTeq.js → index-CV9TNjxX.js} +631 -624
- 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__/opsErrorLog.test.cjs +87 -0
- package/src/main/__tests__/prdCreate.test.cjs +124 -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 +24 -47
- package/src/main/config.cjs +12 -6
- package/src/main/docEdit.cjs +3 -4
- package/src/main/index.cjs +12 -0
- package/src/main/ipcSchemas.cjs +59 -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/opsErrorLog.cjs +96 -0
- package/src/main/lib/opsOwnership.cjs +169 -0
- package/src/main/lib/prdCreate.cjs +39 -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/logs.cjs +19 -1
- package/src/main/projectBrief.cjs +36 -2
- package/src/main/promptSessionEvents.cjs +24 -2
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +15 -0
- package/src/main/queueOps.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +8 -0
- package/src/main/scheduler.cjs +158 -62
- package/src/main/templates/PRD_AUTHORING.md +1 -1
- package/src/preload/api.d.ts +83 -4
- package/src/preload/index.cjs +26 -5
- 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
|
@@ -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
|
-
|
|
99
|
+
return withPathLock(activeIndexPath(cwd), () => {
|
|
100
|
+
const index = readActiveIndex(cwd);
|
|
70
101
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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 };
|