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,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[],
|
|
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));
|
|
@@ -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
|
|
204
|
-
* 1.
|
|
205
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
:
|
|
223
|
-
|
|
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
|
-
|
|
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] ${
|
|
376
|
-
return { filed: true,
|
|
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) =>
|
|
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
|
|