claude-code-session-manager 0.40.2 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-CFCp4Mz9.js → TiptapBody-BnRle0iw.js} +1 -1
- package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
- package/dist/assets/{index-bdqOSyxG.js → index-H7rwsoKV.js} +650 -643
- package/dist/index.html +2 -2
- package/package.json +3 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -5
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +2 -2
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +20 -34
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +68 -0
- package/scripts/lib/watchdogHelpers.cjs +5 -5
- package/scripts/mint-epic.cjs +28 -0
- package/scripts/propose-epic.cjs +55 -0
- package/src/main/__tests__/epicMint.test.cjs +85 -0
- package/src/main/__tests__/prdCreate.test.cjs +54 -2
- package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
- package/src/main/browserView.cjs +1 -1
- package/src/main/chatRunner.cjs +68 -55
- package/src/main/config.cjs +12 -6
- package/src/main/docEdit.cjs +3 -4
- package/src/main/health.cjs +5 -0
- package/src/main/index.cjs +12 -0
- package/src/main/ipcSchemas.cjs +51 -11
- package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
- package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
- package/src/main/lib/epicMint.cjs +133 -59
- package/src/main/lib/opsOwnership.cjs +166 -0
- package/src/main/lib/prdCreate.cjs +9 -1
- package/src/main/lib/prdLocations.cjs +21 -0
- package/src/main/lib/projectBriefCore.cjs +70 -4
- package/src/main/lib/queueStore.cjs +3 -0
- package/src/main/lib/rcaFeedbackHook.cjs +41 -55
- package/src/main/projectBrief.cjs +36 -2
- package/src/main/promptSessionEvents.cjs +24 -2
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/queueOps.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +8 -0
- package/src/main/scheduler.cjs +281 -164
- package/src/main/templates/PRD_AUTHORING.md +1 -1
- package/src/preload/api.d.ts +76 -3
- package/src/preload/index.cjs +21 -3
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
- package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +0 -265
|
@@ -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,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[],
|
|
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
|
-
|
|
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));
|