claude-code-session-manager 0.68.0 → 0.69.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/{AgentLibrary-D1SHQWCQ.js → AgentLibrary-DngWZmn2.js} +1 -1
- package/dist/assets/{History-BySQczDf.js → History-CiqbZNCW.js} +1 -1
- package/dist/assets/{Hooks-CG8jWh3-.js → Hooks-1LHEEKhK.js} +1 -1
- package/dist/assets/{HostBilko-BxjPY1PW.js → HostBilko-DruBVVx_.js} +1 -1
- package/dist/assets/{Library-BA8cOckT.js → Library-BXt58QDN.js} +1 -1
- package/dist/assets/{ListDetail-CVIhWPWb.js → ListDetail-V5JV-SVN.js} +1 -1
- package/dist/assets/{MarkdownEditor-DUoKh1Qr.js → MarkdownEditor-BYUdiq8p.js} +1 -1
- package/dist/assets/{McpServers-CkowlPR7.js → McpServers-C3b18FY5.js} +1 -1
- package/dist/assets/{Memory-C5Vv5Wim.js → Memory-MyToD3ri.js} +1 -1
- package/dist/assets/{Panel-CVgA2-Y3.js → Panel-D1z0ZvX_.js} +1 -1
- package/dist/assets/{Permissions-u43I9j0-.js → Permissions-BDRMk8Uu.js} +1 -1
- package/dist/assets/{Plugins-DComjLBQ.js → Plugins-Bd1wIgVW.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DNTnbu6f.js → ProvenanceBadge-BIX0JW7m.js} +1 -1
- package/dist/assets/{SaveBar-R1ZZ9WH5.js → SaveBar-DOJnzj3n.js} +1 -1
- package/dist/assets/{Scheduler-D4Gzz6iD.js → Scheduler-LdawPR1e.js} +1 -1
- package/dist/assets/{ScopeSwitcher-DBcYhNZF.js → ScopeSwitcher-Cl3g7YOM.js} +1 -1
- package/dist/assets/{Settings-CXcfro2y.js → Settings-7jHzkgfK.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DTfBzZ7O.js → SkillReferenceGraph-B_KqYC6R.js} +1 -1
- package/dist/assets/{Skills-CVlcQjVO.js → Skills-H8pCR_6D.js} +1 -1
- package/dist/assets/{SystemPrompt-COLmPm10.js → SystemPrompt-fCD2rD2R.js} +1 -1
- package/dist/assets/{TagLibrary-BN6bvsjU.js → TagLibrary-erEiP4bj.js} +1 -1
- package/dist/assets/{TiptapBody-DxzA0zb5.js → TiptapBody-A93HQtTH.js} +1 -1
- package/dist/assets/{Toggle-CPlUEMUg.js → Toggle-CoFtvmoX.js} +1 -1
- package/dist/assets/{index-CEnMgeQU.css → index-B77KHr7n.css} +1 -1
- package/dist/assets/{index-Ceid3k5X.js → index-C-iwyX_s.js} +356 -355
- package/dist/assets/{settingsSchema-CgmSslES.js → settingsSchema-DHDH8NJR.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +149 -0
- package/src/main/__tests__/crossProjectFeedback.test.cjs +327 -0
- package/src/main/__tests__/crossProjectFeedbackRoutes.test.cjs +154 -0
- package/src/main/index.cjs +5 -0
- package/src/main/lib/buildTarget.cjs +15 -0
- package/src/main/lib/crossProjectFeedback.cjs +545 -0
- package/src/main/lib/epicMint.cjs +38 -7
- package/src/main/lib/promptSessionSchema.cjs +7 -1
- package/plugins/session-manager-dev/skills/builder/session-manager-operations/scheduler/state/queue.json +0 -3
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* crossProjectFeedback.cjs — THE PROJECT-TO-PROJECT CONDUIT.
|
|
5
|
+
*
|
|
6
|
+
* Problem this solves. `/process-feedback`, `/my-feedback` and the
|
|
7
|
+
* `session-manager-operations/feedback/` intake folder were retired
|
|
8
|
+
* (2026-08-02), and the scheduler was correctly locked down so a PRD can only
|
|
9
|
+
* ever JOIN an Epic that a human already approved. Both changes were right on
|
|
10
|
+
* their own, and together they removed the last channel an agent working in
|
|
11
|
+
* Project-A had for handing a finding to Project-B. The finding either died in
|
|
12
|
+
* a transcript or the human relayed it by hand.
|
|
13
|
+
*
|
|
14
|
+
* What this restores, and what it deliberately does NOT restore. Session
|
|
15
|
+
* Manager's main process is the ONE process that legitimately holds several
|
|
16
|
+
* projects' operations roots open at once, so it — and only it — can perform
|
|
17
|
+
* the cross-folder write. A session in Project-A calls `feedback_open_session`
|
|
18
|
+
* (MCP) → this module mints a `proposed` Epic in **Project-B's own**
|
|
19
|
+
* `session-manager-operations/prompt-sessions/active-index.json`, carrying the
|
|
20
|
+
* report as its opening prompt and stamped with where it came from. It appears
|
|
21
|
+
* in Project-B's Sessions queue exactly like a hand-filed proposal.
|
|
22
|
+
*
|
|
23
|
+
* It does NOT re-open the agent-facing proposal channel that was removed. That
|
|
24
|
+
* channel let an agent file work into the project it was ALREADY running in,
|
|
25
|
+
* which is what "run /develop inside the Epic you're already in" replaced and
|
|
26
|
+
* which stays refused — `sameProject` below rejects toCwd === fromCwd outright
|
|
27
|
+
* and names /develop in the error. The only new power is depositing a proposal
|
|
28
|
+
* into a DIFFERENT project's inbox.
|
|
29
|
+
*
|
|
30
|
+
* Why that is safe. The part of the SINGLE-CREATOR LAW that actually guards
|
|
31
|
+
* spend is not "a human typed it" — it's "every Epic is born 'proposed' and
|
|
32
|
+
* nothing runs until a human presses Approve & start". A cross-project
|
|
33
|
+
* feedback Epic is born 'proposed' like every other Epic (epicMint.cjs's
|
|
34
|
+
* BORN-PROPOSED check applies to this mint authority verbatim), so the human
|
|
35
|
+
* in the RECEIVING project is still the one who decides whether a single token
|
|
36
|
+
* gets spent. An agent can ask; it cannot start.
|
|
37
|
+
*
|
|
38
|
+
* Guards, all fail-closed:
|
|
39
|
+
* - both cwds go through config.cjs's validatePath (allowedRoots = home)
|
|
40
|
+
* - both must be real, existing directories
|
|
41
|
+
* - toCwd !== fromCwd (see above)
|
|
42
|
+
* - toCwd must ALREADY be a Session Manager project — its
|
|
43
|
+
* `session-manager-operations/` must exist. This module never conjures an
|
|
44
|
+
* operations root in an arbitrary directory, so it can't be used to
|
|
45
|
+
* scatter app state across the filesystem.
|
|
46
|
+
* - title/body length caps, so one call can't write an unbounded blob into
|
|
47
|
+
* another project's active-index.json.
|
|
48
|
+
*
|
|
49
|
+
* Single-writer law: the actual write is `ensureEpic`'s, which already
|
|
50
|
+
* declares writer 'scheduler' against prompt-sessions/'s narrow
|
|
51
|
+
* active-index.json delegation (opsOwnership.cjs). No new namespace, no new
|
|
52
|
+
* writer, no new delegation.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const fs = require('node:fs');
|
|
56
|
+
const fsp = require('node:fs/promises');
|
|
57
|
+
const os = require('node:os');
|
|
58
|
+
const path = require('node:path');
|
|
59
|
+
|
|
60
|
+
const config = require('../config.cjs');
|
|
61
|
+
const { readBody, sendJson } = require('./localAdminHttp.cjs');
|
|
62
|
+
const {
|
|
63
|
+
ensureEpic,
|
|
64
|
+
readActiveIndex,
|
|
65
|
+
MINT_AUTHORITY_CROSS_PROJECT_FEEDBACK,
|
|
66
|
+
} = require('./epicMint.cjs');
|
|
67
|
+
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
68
|
+
const { OPS_ROOT_DIR } = require('./opsOwnership.cjs');
|
|
69
|
+
|
|
70
|
+
/** Only these three Epic tags make sense for an inbound report — they are
|
|
71
|
+
* exactly the tags that route through /develop (tagLibrary.ts). 'build',
|
|
72
|
+
* 'project-home-builder' and 'bilko-host-publisher' name dedicated local
|
|
73
|
+
* pipelines and are meaningless as a request from another project. */
|
|
74
|
+
const FEEDBACK_TAGS = Object.freeze(['bug', 'feature', 'discussion']);
|
|
75
|
+
const DEFAULT_FEEDBACK_TAG = 'discussion';
|
|
76
|
+
|
|
77
|
+
/** Default persona for the receiving session. 'architect' owns scope
|
|
78
|
+
* clarification and decomposition, which is precisely what a receiving human
|
|
79
|
+
* wants pointed at an incoming report. */
|
|
80
|
+
const DEFAULT_FEEDBACK_AGENT = 'architect';
|
|
81
|
+
|
|
82
|
+
const MAX_TITLE_CHARS = 200;
|
|
83
|
+
const MAX_BODY_CHARS = 20000;
|
|
84
|
+
const MAX_REFERENCES = 20;
|
|
85
|
+
|
|
86
|
+
/** Bound on how many `~/.claude/projects/` folders listFeedbackTargets will
|
|
87
|
+
* resolve. That directory accumulates one folder per path the CLI was ever
|
|
88
|
+
* launched from (thousands here — see CLAUDE.md's knownProjectAggregate
|
|
89
|
+
* note), so the scan is capped at the most recently active folders and
|
|
90
|
+
* reports the truncation rather than silently covering a subset. */
|
|
91
|
+
const TARGET_SCAN_LIMIT = 300;
|
|
92
|
+
|
|
93
|
+
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where listFeedbackTargets scans for transcript folders. Injectable rather
|
|
97
|
+
* than hardcoded to `os.homedir()` for a reason that bit immediately: the
|
|
98
|
+
* transcripts tests seed and then delete folders under the REAL
|
|
99
|
+
* `~/.claude/projects/` in their own afterEach, so a test that scanned the
|
|
100
|
+
* live directory raced their teardown and failed intermittently under the
|
|
101
|
+
* full suite while passing in isolation. A module whose only I/O root is
|
|
102
|
+
* baked into a constant can't be tested without touching the developer's
|
|
103
|
+
* actual home directory — so the root is a parameter, defaulted.
|
|
104
|
+
*/
|
|
105
|
+
function resolveProjectsDir(override) {
|
|
106
|
+
return override || process.env.SM_PROJECTS_DIR || PROJECTS_DIR;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function singleLine(s) {
|
|
110
|
+
return String(s ?? '').replace(/\r?\n/g, ' ').trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isDirectory(p) {
|
|
114
|
+
try {
|
|
115
|
+
return fs.statSync(p).isDirectory();
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** True when `cwd` is a Session Manager project — i.e. it already has an
|
|
122
|
+
* operations root. Deliberately the ONLY definition of "can receive
|
|
123
|
+
* feedback": it needs no registry, can't go stale, and refuses to create the
|
|
124
|
+
* folder it is checking for. */
|
|
125
|
+
function isSessionManagerProject(cwd) {
|
|
126
|
+
return isDirectory(path.join(cwd, OPS_ROOT_DIR));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* composeFeedbackIntake({ fromCwd, fromEpicId, title, body, referencePaths })
|
|
131
|
+
* → { goalText, openingPrompt, sections }
|
|
132
|
+
*
|
|
133
|
+
* Pure. Mirrors the renderer's `composeEpicIntake` (lib/epicIntake.ts) —
|
|
134
|
+
* same `EpicIntakeSection` shape, same emission order, same '\n\n' joins — so
|
|
135
|
+
* the receiving Epic's first turn renders the identical AIM briefing card as a
|
|
136
|
+
* hand-created one. It cannot import that module (TS, renderer-side), so it
|
|
137
|
+
* deliberately emits only the section kinds whose text it OWNS:
|
|
138
|
+
*
|
|
139
|
+
* actor — the persona line, same sentence template as composeEpicIntake
|
|
140
|
+
* input — AIM's Input axis: what grounds this session. For an inbound
|
|
141
|
+
* report that is literally "an external report from Project-A",
|
|
142
|
+
* which is this module's own fact to state.
|
|
143
|
+
* goal — the report itself.
|
|
144
|
+
* reference — attached paths.
|
|
145
|
+
*
|
|
146
|
+
* There is NO `mission` section, on purpose. A mission section's text is the
|
|
147
|
+
* tag's `initialPromptTemplate`, which lives in agentTagDefs.ts; re-typing it
|
|
148
|
+
* here would be a second, drifting copy of a tag's meaning — the exact thing
|
|
149
|
+
* CLAUDE.md forbids. The Epic still carries its `tag` field, so every surface
|
|
150
|
+
* that renders a tag's meaning keeps reading it through `agentTagDef(tag)`.
|
|
151
|
+
*
|
|
152
|
+
* Complexity: O(n) in the number of references.
|
|
153
|
+
*/
|
|
154
|
+
function composeFeedbackIntake({
|
|
155
|
+
fromCwd,
|
|
156
|
+
fromEpicId = null,
|
|
157
|
+
title,
|
|
158
|
+
body,
|
|
159
|
+
agentName = DEFAULT_FEEDBACK_AGENT,
|
|
160
|
+
agentDescription = null,
|
|
161
|
+
referencePaths = [],
|
|
162
|
+
} = {}) {
|
|
163
|
+
const trimmedTitle = singleLine(title);
|
|
164
|
+
const trimmedBody = String(body ?? '').trim();
|
|
165
|
+
const sections = [];
|
|
166
|
+
|
|
167
|
+
if (agentName && agentDescription) {
|
|
168
|
+
sections.push({
|
|
169
|
+
kind: 'actor',
|
|
170
|
+
label: 'Actor',
|
|
171
|
+
text: `You are acting as the "${singleLine(agentName)}" agent: ${singleLine(agentDescription)}`,
|
|
172
|
+
source: agentName,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const origin = fromEpicId
|
|
177
|
+
? `${singleLine(fromCwd)} (session ${singleLine(fromEpicId)})`
|
|
178
|
+
: singleLine(fromCwd);
|
|
179
|
+
sections.push({
|
|
180
|
+
kind: 'input',
|
|
181
|
+
label: 'Input',
|
|
182
|
+
text:
|
|
183
|
+
`This session is INBOUND FEEDBACK from another project: ${origin}. `
|
|
184
|
+
+ 'Nobody in this project wrote the report below — an agent working in that project did, and it '
|
|
185
|
+
+ 'may be wrong about this codebase. Verify the claim against the code here before acting on it, '
|
|
186
|
+
+ 'and if it does not hold, say so and close the session rather than building against it.',
|
|
187
|
+
source: fromCwd,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const goalBody = trimmedTitle ? `Goal: ${trimmedTitle}\n\n${trimmedBody}` : trimmedBody;
|
|
191
|
+
sections.push({ kind: 'goal', label: 'Goal', text: goalBody });
|
|
192
|
+
|
|
193
|
+
for (const p of referencePaths) {
|
|
194
|
+
sections.push({ kind: 'reference', label: 'Reference', text: `Reference: ${singleLine(p)}`, source: p });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Same join rule as epicIntake.ts's joinSections: '\n\n' everywhere except
|
|
198
|
+
// between two consecutive reference lines, which stay one visual block.
|
|
199
|
+
const openingPrompt = sections.reduce((out, s, i) => {
|
|
200
|
+
if (i === 0) return s.text;
|
|
201
|
+
const sep = s.kind === 'reference' && sections[i - 1].kind === 'reference' ? '\n' : '\n\n';
|
|
202
|
+
return out + sep + s.text;
|
|
203
|
+
}, '');
|
|
204
|
+
|
|
205
|
+
const bodyText = trimmedTitle ? `${trimmedTitle}\n\n${trimmedBody}` : trimmedBody;
|
|
206
|
+
const refLines = referencePaths.map((p) => `Reference: ${singleLine(p)}`);
|
|
207
|
+
const goalText = refLines.length ? `${bodyText}\n\n${refLines.join('\n')}` : bodyText;
|
|
208
|
+
|
|
209
|
+
return { goalText, openingPrompt, sections };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* validateFeedbackInput(input) → { ok: true, value } | { ok: false, status, error }
|
|
214
|
+
*
|
|
215
|
+
* Pure apart from filesystem existence checks. Split out from
|
|
216
|
+
* openFeedbackSession so every refusal is unit-testable without minting
|
|
217
|
+
* anything.
|
|
218
|
+
*/
|
|
219
|
+
function validateFeedbackInput(input = {}) {
|
|
220
|
+
const bad = (status, error) => ({ ok: false, status, error });
|
|
221
|
+
|
|
222
|
+
const title = singleLine(input.title);
|
|
223
|
+
if (!title) return bad(400, 'title is required');
|
|
224
|
+
if (title.length > MAX_TITLE_CHARS) {
|
|
225
|
+
return bad(400, `title is ${title.length} chars; cap is ${MAX_TITLE_CHARS}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const body = String(input.body ?? '').trim();
|
|
229
|
+
if (!body) return bad(400, 'body is required — the feedback report itself');
|
|
230
|
+
if (body.length > MAX_BODY_CHARS) {
|
|
231
|
+
return bad(400, `body is ${body.length} chars; cap is ${MAX_BODY_CHARS}. Summarize, and attach detail as a reference path.`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const tag = input.tag ?? DEFAULT_FEEDBACK_TAG;
|
|
235
|
+
if (!FEEDBACK_TAGS.includes(tag)) {
|
|
236
|
+
return bad(400, `tag must be one of ${FEEDBACK_TAGS.join(' | ')} (got ${JSON.stringify(tag)})`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const referencePaths = Array.isArray(input.referencePaths) ? input.referencePaths : [];
|
|
240
|
+
if (referencePaths.length > MAX_REFERENCES) {
|
|
241
|
+
return bad(400, `too many referencePaths (${referencePaths.length}); cap is ${MAX_REFERENCES}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!input.toCwd || typeof input.toCwd !== 'string') return bad(400, 'toCwd is required');
|
|
245
|
+
if (!input.fromCwd || typeof input.fromCwd !== 'string') return bad(400, 'fromCwd is required');
|
|
246
|
+
|
|
247
|
+
let toCwd;
|
|
248
|
+
let fromCwd;
|
|
249
|
+
try {
|
|
250
|
+
toCwd = config.validatePath(path.resolve(input.toCwd));
|
|
251
|
+
} catch (e) {
|
|
252
|
+
return bad(400, `toCwd rejected: ${e.message}`);
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
fromCwd = config.validatePath(path.resolve(input.fromCwd));
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return bad(400, `fromCwd rejected: ${e.message}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!isDirectory(toCwd)) return bad(400, `toCwd is not an existing directory: ${toCwd}`);
|
|
261
|
+
if (!isDirectory(fromCwd)) return bad(400, `fromCwd is not an existing directory: ${fromCwd}`);
|
|
262
|
+
|
|
263
|
+
if (toCwd === fromCwd) {
|
|
264
|
+
return bad(
|
|
265
|
+
400,
|
|
266
|
+
'toCwd and fromCwd are the same project — this tool is only for handing a finding to a DIFFERENT '
|
|
267
|
+
+ 'project. Work you want done in the project you are already in goes through /develop inside the '
|
|
268
|
+
+ 'Epic you are already in, which is what replaced the retired feedback folder.',
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!isSessionManagerProject(toCwd)) {
|
|
273
|
+
return bad(
|
|
274
|
+
400,
|
|
275
|
+
`${toCwd} is not a Session Manager project — it has no ${OPS_ROOT_DIR}/ directory, so it has no `
|
|
276
|
+
+ 'Sessions queue to deliver a proposal into. Open it as a project in Session Manager once (which '
|
|
277
|
+
+ 'creates that directory), then send the feedback again.',
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
ok: true,
|
|
283
|
+
value: {
|
|
284
|
+
toCwd,
|
|
285
|
+
fromCwd,
|
|
286
|
+
title,
|
|
287
|
+
body,
|
|
288
|
+
tag,
|
|
289
|
+
referencePaths: referencePaths.map((p) => String(p)),
|
|
290
|
+
fromEpicId: input.fromEpicId ? String(input.fromEpicId) : null,
|
|
291
|
+
agentType: input.agentType ? String(input.agentType) : DEFAULT_FEEDBACK_AGENT,
|
|
292
|
+
agentDescription: input.agentDescription ? String(input.agentDescription) : null,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* openFeedbackSession(input, deps?) → { ok, status?, error?, epicId, toCwd, ... }
|
|
299
|
+
*
|
|
300
|
+
* Mints the `proposed` Epic in the RECEIVING project and, when the sending
|
|
301
|
+
* Epic is known and still active, chains a receipt onto its event chain so the
|
|
302
|
+
* sender's own transcript records where the finding went. The receipt is
|
|
303
|
+
* best-effort: a failure there never invalidates a delivered proposal, it just
|
|
304
|
+
* reports `receiptOnOriginEpic: false`.
|
|
305
|
+
*
|
|
306
|
+
* `deps` exists so tests can inject the two side-effecting collaborators
|
|
307
|
+
* without an Electron/main-process boot.
|
|
308
|
+
*/
|
|
309
|
+
async function openFeedbackSession(input, deps = {}) {
|
|
310
|
+
const mint = deps.ensureEpic || ensureEpic;
|
|
311
|
+
const appendReceipt =
|
|
312
|
+
deps.appendResponseEventIfKnown
|
|
313
|
+
// Required lazily: promptSessionEvents.cjs pulls in config.cjs's watcher
|
|
314
|
+
// machinery, and this module is also required from unit tests that never
|
|
315
|
+
// boot the main process.
|
|
316
|
+
|| ((...args) => require('../promptSessionEvents.cjs').appendResponseEventIfKnown(...args));
|
|
317
|
+
|
|
318
|
+
const checked = validateFeedbackInput(input);
|
|
319
|
+
if (!checked.ok) return checked;
|
|
320
|
+
const v = checked.value;
|
|
321
|
+
|
|
322
|
+
// The MCP client can't know its own Epic id — it only inherits the claude
|
|
323
|
+
// session id chatRunner.cjs stamps on the `claude -p` child. Resolve it
|
|
324
|
+
// through prdCreate.cjs's existing resolver rather than a second copy of
|
|
325
|
+
// the same lookup (required lazily: prdCreate pulls in ipcSchemas/config).
|
|
326
|
+
if (!v.fromEpicId && input.originClaudeSessionId) {
|
|
327
|
+
try {
|
|
328
|
+
const { resolveSourcePromptIdFromClaudeSession } = require('./prdCreate.cjs');
|
|
329
|
+
v.fromEpicId = resolveSourcePromptIdFromClaudeSession(v.fromCwd, String(input.originClaudeSessionId)) || null;
|
|
330
|
+
} catch { /* unresolvable — the proposal still delivers, just without a receipt */ }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const { goalText, openingPrompt, sections } = composeFeedbackIntake({
|
|
334
|
+
fromCwd: v.fromCwd,
|
|
335
|
+
fromEpicId: v.fromEpicId,
|
|
336
|
+
title: v.title,
|
|
337
|
+
body: v.body,
|
|
338
|
+
agentName: v.agentType,
|
|
339
|
+
agentDescription: v.agentDescription,
|
|
340
|
+
referencePaths: v.referencePaths,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// The Epic's stored identity is the one-line title, not the whole report —
|
|
344
|
+
// that's what the receiving queue row shows. The report travels in
|
|
345
|
+
// openingPrompt, which is what Approve & start sends.
|
|
346
|
+
let minted;
|
|
347
|
+
try {
|
|
348
|
+
minted = await mint(v.toCwd, {
|
|
349
|
+
goalText: v.title,
|
|
350
|
+
tag: v.tag,
|
|
351
|
+
agentType: v.agentType,
|
|
352
|
+
status: 'proposed',
|
|
353
|
+
openingPrompt,
|
|
354
|
+
sections,
|
|
355
|
+
source: {
|
|
356
|
+
producer: 'cross-project-feedback',
|
|
357
|
+
fromCwd: v.fromCwd,
|
|
358
|
+
...(v.fromEpicId ? { fromEpicId: v.fromEpicId } : {}),
|
|
359
|
+
},
|
|
360
|
+
mintAuthority: MINT_AUTHORITY_CROSS_PROJECT_FEEDBACK,
|
|
361
|
+
});
|
|
362
|
+
} catch (e) {
|
|
363
|
+
return { ok: false, status: 500, error: `could not open the feedback session: ${e.message}` };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
appendAuditEvent('epic_cross_project_feedback', {
|
|
367
|
+
cwd: v.toCwd,
|
|
368
|
+
epicId: minted.epicId,
|
|
369
|
+
fromCwd: v.fromCwd,
|
|
370
|
+
fromEpicId: v.fromEpicId,
|
|
371
|
+
tag: v.tag,
|
|
372
|
+
title: v.title,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
let receiptOnOriginEpic = false;
|
|
376
|
+
if (v.fromEpicId) {
|
|
377
|
+
try {
|
|
378
|
+
receiptOnOriginEpic = await appendReceipt(
|
|
379
|
+
v.fromCwd,
|
|
380
|
+
v.fromEpicId,
|
|
381
|
+
`Feedback delivered to ${v.toCwd} as a proposed session (${minted.epicId}): "${v.title}". `
|
|
382
|
+
+ 'It will not run until a human in that project approves it.',
|
|
383
|
+
);
|
|
384
|
+
} catch {
|
|
385
|
+
receiptOnOriginEpic = false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
ok: true,
|
|
391
|
+
epicId: minted.epicId,
|
|
392
|
+
toCwd: v.toCwd,
|
|
393
|
+
fromCwd: v.fromCwd,
|
|
394
|
+
tag: v.tag,
|
|
395
|
+
status: 'proposed',
|
|
396
|
+
receiptOnOriginEpic,
|
|
397
|
+
// Say plainly what did NOT happen, same posture as create-prd's
|
|
398
|
+
// `enqueued: false` — never let a caller report this as "work started".
|
|
399
|
+
note:
|
|
400
|
+
'Delivered as a PROPOSED session in the receiving project\'s Sessions queue. Nothing runs and no '
|
|
401
|
+
+ 'tokens are spent until a human in that project presses Approve & start. There is no callback: '
|
|
402
|
+
+ 'if you need an answer, ask the human to follow up.',
|
|
403
|
+
goalText,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* listFeedbackTargets() → { projects: [{ cwd, lastActivity }], scanned, truncated }
|
|
409
|
+
*
|
|
410
|
+
* Which projects can receive feedback. Resolved the same way CLAUDE.md
|
|
411
|
+
* requires everywhere else: a PROJECT IS A CWD, resolved from transcript
|
|
412
|
+
* CONTENT, never by naively decoding a `~/.claude/projects/` folder name back
|
|
413
|
+
* into a path. A folder whose cwd cannot be resolved is DROPPED, not guessed.
|
|
414
|
+
* Results are then filtered to directories that still exist AND already carry
|
|
415
|
+
* an operations root — exactly `openFeedbackSession`'s own precondition, so
|
|
416
|
+
* this list can't offer a target that would then be refused.
|
|
417
|
+
*/
|
|
418
|
+
async function listFeedbackTargets({ limit = TARGET_SCAN_LIMIT, projectsDir } = {}) {
|
|
419
|
+
const root = resolveProjectsDir(projectsDir);
|
|
420
|
+
let entries;
|
|
421
|
+
try {
|
|
422
|
+
entries = await fsp.readdir(root, { withFileTypes: true });
|
|
423
|
+
} catch {
|
|
424
|
+
return { projects: [], scanned: 0, truncated: 0 };
|
|
425
|
+
}
|
|
426
|
+
const dirs = entries.filter((e) => e.isDirectory());
|
|
427
|
+
|
|
428
|
+
// Newest-first by folder mtime, so the cap keeps the projects actually in
|
|
429
|
+
// use rather than an arbitrary alphabetical slice.
|
|
430
|
+
const stamped = [];
|
|
431
|
+
for (const d of dirs) {
|
|
432
|
+
const full = path.join(root, d.name);
|
|
433
|
+
try {
|
|
434
|
+
stamped.push({ full, mtimeMs: (await fsp.stat(full)).mtimeMs });
|
|
435
|
+
} catch { /* raced away */ }
|
|
436
|
+
}
|
|
437
|
+
stamped.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
438
|
+
const scanned = stamped.slice(0, limit);
|
|
439
|
+
|
|
440
|
+
const byCwd = new Map();
|
|
441
|
+
for (const { full, mtimeMs } of scanned) {
|
|
442
|
+
const cwd = await resolveCwdFromTranscriptDir(full);
|
|
443
|
+
if (!cwd) continue;
|
|
444
|
+
const prior = byCwd.get(cwd);
|
|
445
|
+
if (!prior || prior.lastActivity < mtimeMs) byCwd.set(cwd, { cwd, lastActivity: mtimeMs });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const projects = [...byCwd.values()]
|
|
449
|
+
.filter((p) => isDirectory(p.cwd) && isSessionManagerProject(p.cwd))
|
|
450
|
+
.sort((a, b) => b.lastActivity - a.lastActivity)
|
|
451
|
+
.map((p) => ({ cwd: p.cwd, lastActivity: new Date(p.lastActivity).toISOString() }));
|
|
452
|
+
|
|
453
|
+
return { projects, scanned: scanned.length, truncated: Math.max(0, stamped.length - scanned.length) };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Read the `cwd` field off the first JSON line of the newest .jsonl in a
|
|
457
|
+
* `~/.claude/projects/<encoded>/` folder. Returns null when the folder holds
|
|
458
|
+
* no transcript or no line carries a cwd — the caller drops it rather than
|
|
459
|
+
* falling back to decoding the folder name. */
|
|
460
|
+
async function resolveCwdFromTranscriptDir(dir) {
|
|
461
|
+
let files;
|
|
462
|
+
try {
|
|
463
|
+
files = (await fsp.readdir(dir)).filter((f) => f.endsWith('.jsonl'));
|
|
464
|
+
} catch {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
if (!files.length) return null;
|
|
468
|
+
const stamped = [];
|
|
469
|
+
for (const f of files) {
|
|
470
|
+
try {
|
|
471
|
+
stamped.push({ f, mtimeMs: (await fsp.stat(path.join(dir, f))).mtimeMs });
|
|
472
|
+
} catch { /* raced away */ }
|
|
473
|
+
}
|
|
474
|
+
stamped.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
475
|
+
for (const { f } of stamped.slice(0, 3)) {
|
|
476
|
+
try {
|
|
477
|
+
// Transcripts can be large; only the head is needed — the cwd is
|
|
478
|
+
// stamped on the very first records.
|
|
479
|
+
const handle = await fsp.open(path.join(dir, f), 'r');
|
|
480
|
+
try {
|
|
481
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
482
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
|
|
483
|
+
const head = buf.subarray(0, bytesRead).toString('utf8');
|
|
484
|
+
for (const line of head.split('\n')) {
|
|
485
|
+
if (!line.trim()) continue;
|
|
486
|
+
try {
|
|
487
|
+
const parsed = JSON.parse(line);
|
|
488
|
+
if (parsed && typeof parsed.cwd === 'string' && parsed.cwd) return path.resolve(parsed.cwd);
|
|
489
|
+
} catch { /* partial trailing line */ }
|
|
490
|
+
}
|
|
491
|
+
} finally {
|
|
492
|
+
await handle.close();
|
|
493
|
+
}
|
|
494
|
+
} catch { /* unreadable */ }
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Registers both routes on the injected loopback admin transport. Mirrors
|
|
500
|
+
* prdCreate.cjs's registerAdminRoute — auth is the transport's job. */
|
|
501
|
+
function registerAdminRoute(adminHttp) {
|
|
502
|
+
adminHttp.registerRoute('POST', '/admin/feedback/open-session', async (req, res) => {
|
|
503
|
+
const raw = await readBody(req);
|
|
504
|
+
let parsed;
|
|
505
|
+
try {
|
|
506
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
507
|
+
} catch {
|
|
508
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const result = await openFeedbackSession(parsed);
|
|
512
|
+
if (!result.ok) {
|
|
513
|
+
sendJson(res, result.status ?? 400, { ok: false, error: result.error });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
sendJson(res, 200, result);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
adminHttp.registerRoute('GET', '/admin/feedback/targets', async (req, res, query) => {
|
|
520
|
+
// `projectsDir` is a test seam only — production callers never pass it,
|
|
521
|
+
// and it can only narrow WHICH transcript folders are scanned, never
|
|
522
|
+
// where a feedback session may be written (that stays governed by
|
|
523
|
+
// openFeedbackSession's own validatePath + ops-root checks).
|
|
524
|
+
const projectsDir = query?.get('projectsDir') || undefined;
|
|
525
|
+
const result = await listFeedbackTargets({ projectsDir });
|
|
526
|
+
sendJson(res, 200, { ok: true, ...result });
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
module.exports = {
|
|
531
|
+
FEEDBACK_TAGS,
|
|
532
|
+
DEFAULT_FEEDBACK_TAG,
|
|
533
|
+
DEFAULT_FEEDBACK_AGENT,
|
|
534
|
+
MAX_TITLE_CHARS,
|
|
535
|
+
MAX_BODY_CHARS,
|
|
536
|
+
MAX_REFERENCES,
|
|
537
|
+
PROJECTS_DIR,
|
|
538
|
+
resolveProjectsDir,
|
|
539
|
+
isSessionManagerProject,
|
|
540
|
+
composeFeedbackIntake,
|
|
541
|
+
validateFeedbackInput,
|
|
542
|
+
openFeedbackSession,
|
|
543
|
+
listFeedbackTargets,
|
|
544
|
+
registerAdminRoute,
|
|
545
|
+
};
|
|
@@ -45,6 +45,33 @@ const promptSessionSchema = require('./promptSessionSchema.cjs');
|
|
|
45
45
|
// deliberate domain-model change, not a convenience.
|
|
46
46
|
const MINT_AUTHORITY_NEW_EPIC_UI = 'new-epic-ui';
|
|
47
47
|
|
|
48
|
+
// The SECOND (and only other) mint authority — a deliberate domain-model
|
|
49
|
+
// change, argued for here rather than discovered later.
|
|
50
|
+
//
|
|
51
|
+
// PROJECT-TO-PROJECT FEEDBACK. With `/process-feedback`, `/my-feedback` and
|
|
52
|
+
// the `feedback/` folder retired (2026-08-02) and the scheduler correctly
|
|
53
|
+
// locked down to join-only, an agent working in Project-A had NO way to hand
|
|
54
|
+
// a finding to Project-B — the finding either died in a transcript or the
|
|
55
|
+
// human had to relay it by hand. `lib/crossProjectFeedback.cjs` restores that
|
|
56
|
+
// conduit, and it is the sole holder of this token.
|
|
57
|
+
//
|
|
58
|
+
// What makes it safe is the part of the SINGLE-CREATOR LAW that actually
|
|
59
|
+
// guards spend: every Epic is born 'proposed', and NOTHING runs until a human
|
|
60
|
+
// in the RECEIVING project presses "Approve & start". This authority cannot
|
|
61
|
+
// change that — the BORN-PROPOSED check below still applies to it verbatim,
|
|
62
|
+
// and `crossProjectFeedback.cjs` additionally pins tag/source before minting.
|
|
63
|
+
// So this is not a re-opening of the agent-facing proposal channel that was
|
|
64
|
+
// removed: an agent may deposit a proposal into another project's inbox, and
|
|
65
|
+
// that is all it may do. The removed channel let an agent file work into the
|
|
66
|
+
// project it was already running in, which is what /develop-inside-your-own-
|
|
67
|
+
// Epic replaced and which stays refused.
|
|
68
|
+
const MINT_AUTHORITY_CROSS_PROJECT_FEEDBACK = 'cross-project-feedback';
|
|
69
|
+
|
|
70
|
+
const MINT_AUTHORITIES = Object.freeze([
|
|
71
|
+
MINT_AUTHORITY_NEW_EPIC_UI,
|
|
72
|
+
MINT_AUTHORITY_CROSS_PROJECT_FEEDBACK,
|
|
73
|
+
]);
|
|
74
|
+
|
|
48
75
|
function activeIndexPath(cwd) {
|
|
49
76
|
return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
|
|
50
77
|
}
|
|
@@ -175,13 +202,15 @@ function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'prop
|
|
|
175
202
|
});
|
|
176
203
|
}
|
|
177
204
|
|
|
178
|
-
// SINGLE-CREATOR LAW (see this file's header). Only the New Epic UI
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
205
|
+
// SINGLE-CREATOR LAW (see this file's header). Only the New Epic UI —
|
|
206
|
+
// or, for an inbound cross-project feedback proposal, lib/
|
|
207
|
+
// crossProjectFeedback.cjs — may mint; every other caller reaching this
|
|
208
|
+
// line was trying to JOIN an Epic that isn't there, which is a bug in the
|
|
209
|
+
// caller, not a cue to create one.
|
|
210
|
+
if (!MINT_AUTHORITIES.includes(mintAuthority)) {
|
|
211
|
+
const reason = 'no open Epic to join — an Epic is created in exactly two places, the New Epic UI and an '
|
|
212
|
+
+ 'inbound cross-project feedback proposal (mintAuthority). Agents that are sure the work is needed run '
|
|
213
|
+
+ '/develop inside the Epic they are already in; a PRD-authoring path may never conjure one';
|
|
185
214
|
appendAuditEvent('epic_mint_refused', { cwd, epicId: explicitEpicId ?? null, status, reason });
|
|
186
215
|
throw new Error(`ensureEpic: ${reason} (epicId=${explicitEpicId ?? 'none'})`);
|
|
187
216
|
}
|
|
@@ -323,6 +352,8 @@ function removeEpic(cwd, epicId) {
|
|
|
323
352
|
module.exports = {
|
|
324
353
|
ensureEpic,
|
|
325
354
|
MINT_AUTHORITY_NEW_EPIC_UI,
|
|
355
|
+
MINT_AUTHORITY_CROSS_PROJECT_FEEDBACK,
|
|
356
|
+
MINT_AUTHORITIES,
|
|
326
357
|
appendPrdCreatedEvent,
|
|
327
358
|
removeEpic,
|
|
328
359
|
activeIndexPath,
|
|
@@ -32,10 +32,16 @@ const EpicTagSchema = z.enum([
|
|
|
32
32
|
|
|
33
33
|
// Mirrors EpicSource (src/renderer/state/promptSessions.ts:22-27).
|
|
34
34
|
const EpicSourceSchema = z.object({
|
|
35
|
-
producer: z.enum(['new-epic-ui', 'scheduler-dispatch']),
|
|
35
|
+
producer: z.enum(['new-epic-ui', 'scheduler-dispatch', 'cross-project-feedback']),
|
|
36
36
|
prdSlug: z.string().optional(),
|
|
37
37
|
runId: z.string().optional(),
|
|
38
38
|
sourceTabId: z.string().optional(),
|
|
39
|
+
// Cross-project provenance ('cross-project-feedback' only): which project,
|
|
40
|
+
// and which Epic inside it, sent this proposal. Kept on the Epic itself so
|
|
41
|
+
// the receiving human can see who is asking without a second lookup table —
|
|
42
|
+
// there is no cross-project registry anywhere else.
|
|
43
|
+
fromCwd: z.string().optional(),
|
|
44
|
+
fromEpicId: z.string().optional(),
|
|
39
45
|
});
|
|
40
46
|
|
|
41
47
|
// Mirrors EpicIntakeSection (src/renderer/lib/epicIntake.ts) — the labeled
|