claude-code-session-manager 0.116.1 → 0.118.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-CkS69HnN.js → AgentLibrary-CEI6vsmu.js} +2 -2
- package/dist/assets/{Button-BQxkfIbW.js → Button-BuyzGzO8.js} +1 -1
- package/dist/assets/{DataModel-YVgdJTZF.js → DataModel-CbXI_rEf.js} +1 -1
- package/dist/assets/{History-DneHh4jI.js → History-axy4nJyY.js} +2 -2
- package/dist/assets/{Hooks-Bt4xIjfo.js → Hooks-D5ghOppL.js} +3 -3
- package/dist/assets/{Library-DoP5FdXq.js → Library-wBI4Fwh3.js} +7 -7
- package/dist/assets/{ListDetail-BYxyl13e.js → ListDetail-CY5hhwn9.js} +1 -1
- package/dist/assets/{McpServers-CKm_iZsf.js → McpServers-CwEVZqfu.js} +2 -2
- package/dist/assets/Memory-Beh2r4Xk.js +25 -0
- package/dist/assets/{Panel-Cv8aYElQ.js → Panel-yOYf80ax.js} +1 -1
- package/dist/assets/{Permissions-CFmWwqyV.js → Permissions-DA-SMXO3.js} +3 -3
- package/dist/assets/{Plugins-CuYNgf_d.js → Plugins-qfKt6BxH.js} +2 -2
- package/dist/assets/ProvenanceBadge-DFyBe7Po.js +1 -0
- package/dist/assets/RemoteMachines-AG0K7kqS.js +3 -0
- package/dist/assets/{SaveBar-Cb4Q8TBK.js → SaveBar-35Nw2YsZ.js} +1 -1
- package/dist/assets/{Scheduler-ysJHQvW4.js → Scheduler-BPBAU0TO.js} +8 -8
- package/dist/assets/{ScopeSwitcher-BTOWfifI.js → ScopeSwitcher-BCXNrrrs.js} +1 -1
- package/dist/assets/{Settings-C6fH8diZ.js → Settings-C4yhzfZI.js} +4 -4
- package/dist/assets/{SkillReferenceGraph-esAcniTQ.js → SkillReferenceGraph-Ca5z2avC.js} +1 -1
- package/dist/assets/{Skills-4_vqzrSF.js → Skills-5WWusmKG.js} +1 -1
- package/dist/assets/SystemPrompt-ofVCdHAW.js +1 -0
- package/dist/assets/TagLibrary-rSrpD-Bz.js +1 -0
- package/dist/assets/{TiptapBody-EkZlmD9k.js → TiptapBody-DAYcdcVt.js} +1 -1
- package/dist/assets/{Toggle-CpR97XGM.js → Toggle-BzHdpXZI.js} +1 -1
- package/dist/assets/{ViewTabs-ekJfFek8.js → ViewTabs-D82jJ4VS.js} +1 -1
- package/dist/assets/{historyTotals-Bg0GVcsF.js → historyTotals-DMGFqK5h.js} +1 -1
- package/dist/assets/{index-CZsDDyLy.css → index-DAMBm3UN.css} +1 -1
- package/dist/assets/{index-DQSEUdx0.js → index-DZ5TJKNI.js} +501 -500
- package/dist/assets/{settingsSchema-BG5jZbhn.js → settingsSchema-BfnNE6ne.js} +1 -1
- package/dist/assets/{skillFrontmatter-DF6xI6Wb.js → skillFrontmatter-ED1DjPE1.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-Dox-xCx9.js → useAgentsDirWatch-CbpQIu3P.js} +1 -1
- package/dist/assets/{useRemotePeers-DHo62LbA.js → useRemotePeers-Dy_o0WlY.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +23 -10
- package/scripts/scheduler-mcp-server.cjs +16 -1
- package/src/main/build-info.json +4 -4
- package/src/main/lib/epic/epicSettle.cjs +105 -0
- package/src/main/lib/epic/epicSweepValidator.cjs +74 -0
- package/src/main/lib/epic/epicValidationHook.cjs +171 -141
- package/src/main/lib/feedback-remote/macroAdminRoutes.cjs +2 -1
- package/src/main/lib/git/gitWorktree.cjs +7 -0
- package/src/main/lib/git/worktreeDeps.cjs +77 -0
- package/src/main/lib/mcpToolCatalog.cjs +6 -2
- package/src/main/lib/memoryMdNetwork.cjs +121 -0
- package/src/main/lib/memoryRefResolve.cjs +64 -0
- package/src/main/lib/memoryStale.cjs +27 -11
- package/src/main/lib/persona-macro/macroLibrary.cjs +49 -2
- package/src/main/lib/scheduler/baselineGate.cjs +128 -0
- package/src/main/lib/scheduler/definitionOfDone.cjs +9 -3
- package/src/main/lib/scheduler/needsReviewLedger.cjs +1 -1
- package/src/main/lib/scheduler/reviewNotice.cjs +53 -0
- package/src/main/memoryAssist.cjs +71 -31
- package/src/main/memoryTool.cjs +18 -6
- package/src/main/promptSessionEvents.cjs +6 -2
- package/src/main/runVerify.cjs +39 -3
- package/src/main/scheduler/reviewLadder.cjs +25 -6
- package/src/main/scheduler.cjs +121 -62
- package/src/main/templates/PRD_AUTHORING.md +2 -0
- package/src/preload/api.d.ts +24 -2
- package/dist/assets/Memory-BWMQuAoC.js +0 -18
- package/dist/assets/ProvenanceBadge-BMLVPyHr.js +0 -1
- package/dist/assets/RemoteMachines-D04Mby_W.js +0 -3
- package/dist/assets/SystemPrompt-CdUq4KXx.js +0 -1
- package/dist/assets/TagLibrary-CVFIphBv.js +0 -1
|
@@ -1,58 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* epicValidationHook.cjs —
|
|
5
|
-
*
|
|
4
|
+
* epicValidationHook.cjs — the Epic settle runner: once an Epic has no
|
|
5
|
+
* pending/running PRD it either queues ONE headless sweep validator for
|
|
6
|
+
* unchecked completed PRDs, or sends ONE plan digest into the Epic's chat.
|
|
6
7
|
*
|
|
7
|
-
* WHY: PRD 972 ran 34 s, made zero edits, exited 0,
|
|
8
|
-
* `completed
|
|
9
|
-
* The party with the context to judge whether the work is right is the Epic
|
|
10
|
-
* that WROTE the PRD — so a check-in is inverted from "assertion of done"
|
|
11
|
-
* into a REQUEST TO VALIDATE: when the scheduler appends a check-in response
|
|
12
|
-
* event to the authoring Epic's chain, this hook enqueues ONE validation
|
|
13
|
-
* prompt into that Epic's own chat session instructing it to independently
|
|
14
|
-
* verify each acceptance criterion against the real working tree and answer
|
|
15
|
-
* VERIFIED or REFUTED with evidence. The job's self-reported status is an
|
|
16
|
-
* input to that check, never a substitute for it.
|
|
8
|
+
* WHY validation exists (PRD 986): PRD 972 ran 34 s, made zero edits, exited 0,
|
|
9
|
+
* and the queue recorded `completed` — a job's self-report is never proof.
|
|
17
10
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
11
|
+
* Kill-switch: SM_EPIC_VALIDATION_DISABLE=1 turns the whole runner off.
|
|
12
|
+
* SM_EPIC_SETTLE_SECONDS sets the debounce before a settle run (default 120).
|
|
20
13
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* SESSION SLOT POOL: this module spawns NOTHING. The prompt is enqueued via
|
|
25
|
-
* chatRunner.cjs's enqueueExternalPrompt → `chat:external-send` → the
|
|
26
|
-
* renderer's chat queue → chatRunner's pump, which acquires a slot from the
|
|
27
|
-
* machine-wide lib/scheduler/sessionSlots.cjs pool (chatRunner.cjs pump()) before any
|
|
28
|
-
* `claude -p` process starts. So the validation session cannot start outside
|
|
29
|
-
* the pool — if the pool is exhausted the prompt simply waits in the chat
|
|
30
|
-
* lane's FIFO, it never fans out into an extra parallel process (the
|
|
31
|
-
* 2026-06-10 OOM shape this AC exists to prevent).
|
|
32
|
-
*
|
|
33
|
-
* Cost note: this spends tokens per PRD check-in — intended trade. The
|
|
34
|
-
* once-per-(epicId, prdSlug) guard and the kill-switch keep it bounded.
|
|
14
|
+
* This module spawns NO process itself: the sweep validator is a PRD queued
|
|
15
|
+
* through the scheduler (which acquires a lib/scheduler/sessionSlots.cjs slot),
|
|
16
|
+
* and the digest goes through the chat queue's pump, which does the same.
|
|
35
17
|
*
|
|
36
18
|
* Join-only: nothing here can create an Epic (epicMint.cjs's SINGLE-CREATOR
|
|
37
|
-
* LAW). If no active authoring Epic exists,
|
|
19
|
+
* LAW). If no active authoring Epic exists, do nothing.
|
|
38
20
|
*/
|
|
39
21
|
|
|
40
22
|
const fs = require('node:fs');
|
|
41
23
|
const path = require('node:path');
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
* `_fired` records every (epicId, prdSlug) pair this process has already
|
|
47
|
-
* enqueued a validation prompt for — the fast in-memory half of the
|
|
48
|
-
* once-per-pair guard (the durable half re-reads the Epic's own event chain,
|
|
49
|
-
* see maybeEnqueueValidationPrompt below).
|
|
50
|
-
*/
|
|
51
|
-
const _fired = new Set();
|
|
52
|
-
|
|
53
|
-
function pairKey(epicId, prdSlug) {
|
|
54
|
-
return `${epicId}::${prdSlug}`;
|
|
55
|
-
}
|
|
24
|
+
const { evaluateEpicSettle } = require('./epicSettle.cjs');
|
|
25
|
+
const { buildSweepValidatorInput, isSweepValidatorSlug } = require('./epicSweepValidator.cjs');
|
|
26
|
+
const { bareSlug } = require('../prd/depSlugResolve.cjs');
|
|
56
27
|
|
|
57
28
|
/**
|
|
58
29
|
* Default active-index reader: the same on-disk file
|
|
@@ -71,123 +42,182 @@ function defaultReadActiveIndex(cwd) {
|
|
|
71
42
|
}
|
|
72
43
|
|
|
73
44
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* job's self-reported outcome explicitly labelled as an UNVERIFIED CLAIM,
|
|
78
|
-
* the instruction to check every Acceptance Criterion against the actual
|
|
79
|
-
* working tree, and the VERIFIED/REFUTED reply contract with per-criterion
|
|
80
|
-
* evidence. It also warns against the exact failure mode that produced
|
|
81
|
-
* PRD 986: exit 0 / a green queue row / a confident report are not evidence.
|
|
45
|
+
* buildSettledDigest — pure builder for the ONE plan-digest prompt.
|
|
46
|
+
* Empty sections are omitted. `notValidatedReason` explains why the
|
|
47
|
+
* settle.uncovered slugs were not sent to a sweep validator.
|
|
82
48
|
*/
|
|
83
|
-
function
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
'',
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
'
|
|
100
|
-
|
|
49
|
+
function buildSettledDigest(settle, { epicId, notValidatedReason } = {}) {
|
|
50
|
+
const s = settle || {};
|
|
51
|
+
const verified = s.verified || [];
|
|
52
|
+
const refuted = s.refuted || [];
|
|
53
|
+
const failed = s.failed || [];
|
|
54
|
+
const parked = s.parked || [];
|
|
55
|
+
const notValidated = (s.uncovered || []).map((u) => (typeof u === 'string' ? u : u.slug));
|
|
56
|
+
const lines = [
|
|
57
|
+
`PLAN SETTLED — no PRD of this session is pending or running. This is ONE digest, not a per-PRD request.`,
|
|
58
|
+
`Validation record folder: session-manager-operations/reviews/validation/${epicId}/`,
|
|
59
|
+
'Do NOT re-verify VERIFIED PRDs — a validator already checked them.',
|
|
60
|
+
];
|
|
61
|
+
if (verified.length) {
|
|
62
|
+
lines.push('', `Verified (${verified.length}):`, ...verified.map((v) => `- ${v}`));
|
|
63
|
+
}
|
|
64
|
+
if (refuted.length) {
|
|
65
|
+
lines.push('', `REFUTED (${refuted.length}):`, ...refuted.map((r) => `- ${r.slug}${r.reason ? ` — ${r.reason}` : ''}`));
|
|
66
|
+
}
|
|
67
|
+
if (failed.length) {
|
|
68
|
+
lines.push('', `Failed (${failed.length}):`, ...failed.map((f) => `- ${f}`));
|
|
69
|
+
}
|
|
70
|
+
if (parked.length) {
|
|
71
|
+
lines.push('', `Parked (needs_review) (${parked.length}):`, ...parked.map((p) => `- ${p}`));
|
|
72
|
+
}
|
|
73
|
+
if (notValidated.length) {
|
|
74
|
+
lines.push(
|
|
75
|
+
'', `Not validated (${notValidated.length}) — ${notValidatedReason || 'no validator checked them'}:`,
|
|
76
|
+
...notValidated.map((n) => `- ${n}`),
|
|
77
|
+
`Validate these here: read each PRD file under session-manager-operations/scheduler/epics/${epicId}/prds-archived/ by slug, check every acceptance criterion against the working tree, and reply VERIFIED or REFUTED per slug.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return lines.join('\n');
|
|
101
81
|
}
|
|
102
82
|
|
|
103
|
-
|
|
104
|
-
* maybeEnqueueValidationPrompt(args, deps) → { enqueued: boolean, reason?: string }
|
|
105
|
-
*
|
|
106
|
-
* Called by scheduler.cjs's notifyOriginatingTab immediately after a
|
|
107
|
-
* SUCCESSFUL appendResponseEventIfKnown for a terminal (completed/failed)
|
|
108
|
-
* PRD outcome. Fire-and-forget: never throws; every refusal returns a
|
|
109
|
-
* reason so tests (and log lines) can tell the gates apart.
|
|
110
|
-
*
|
|
111
|
-
* Guards, in order (all four AC gates):
|
|
112
|
-
* 1. SM_EPIC_VALIDATION_DISABLE=1 kill-switch → skip.
|
|
113
|
-
* 2. LOOP GUARD — how the guard distinguishes a check-in from a
|
|
114
|
-
* validation result: a scheduler check-in event is born with
|
|
115
|
-
* `validation: 'unvalidated'` (stamped by appendResponseEventIfKnown's
|
|
116
|
-
* meta), while a validation RESULT event carries 'validating' /
|
|
117
|
-
* 'verified' / 'refuted' (and a plain chat response carries no
|
|
118
|
-
* validation field at all). Only `eventValidation === 'unvalidated'`
|
|
119
|
-
* may trigger a prompt, so an appended validation result can never
|
|
120
|
-
* enqueue a further prompt — no loop.
|
|
121
|
-
* 3. Epic must exist AND have status 'active' in the on-disk
|
|
122
|
-
* active-index.json (re-checked here even though the append already
|
|
123
|
-
* enforced it, so the gate holds for any future call site too).
|
|
124
|
-
* 4. Once per (epicId, prdSlug): in-memory `_fired` Set for the common
|
|
125
|
-
* path, plus a durable re-check of the Epic's own event chain — the
|
|
126
|
-
* check-in event just appended for this pair accounts for ONE
|
|
127
|
-
* validation-stamped response event with this prdSlug; two or more
|
|
128
|
-
* means an earlier check-in already requested validation (e.g. a
|
|
129
|
-
* re-notify after an app restart emptied `_fired`), so skip.
|
|
130
|
-
*
|
|
131
|
-
* (Gate: slot pool — see the module doc comment; no spawn happens here.)
|
|
132
|
-
*
|
|
133
|
-
* Complexity: O(n) over the Epic's event chain for the durable dedup scan.
|
|
134
|
-
*/
|
|
135
|
-
function maybeEnqueueValidationPrompt(
|
|
136
|
-
{ cwd, epicId, prdSlug, prdPath = null, outcome, eventValidation },
|
|
137
|
-
{ sendPrompt, readActiveIndex = defaultReadActiveIndex, log = console } = {},
|
|
138
|
-
) {
|
|
139
|
-
try {
|
|
140
|
-
// Gate 1: kill-switch (SM_EPIC_VALIDATION_DISABLE, per SM_DOD_DISABLE precedent).
|
|
141
|
-
if (process.env.SM_EPIC_VALIDATION_DISABLE === '1') return { enqueued: false, reason: 'disabled' };
|
|
83
|
+
const DEFAULT_SETTLE_SECONDS = 120;
|
|
142
84
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
85
|
+
function settleDelayMs() {
|
|
86
|
+
const raw = process.env.SM_EPIC_SETTLE_SECONDS;
|
|
87
|
+
const n = /^\d+$/.test(String(raw || '').trim()) ? parseInt(String(raw).trim(), 10) : 0;
|
|
88
|
+
return (n > 0 ? n : DEFAULT_SETTLE_SECONDS) * 1000;
|
|
89
|
+
}
|
|
146
90
|
|
|
147
|
-
|
|
148
|
-
return { enqueued: false, reason: 'missing-args' };
|
|
149
|
-
}
|
|
91
|
+
const _rowInEpic = (r, epicId) => r && (r.epicId === epicId || r.sourcePromptId === epicId);
|
|
150
92
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
93
|
+
/**
|
|
94
|
+
* runEpicSettle({ cwd, epicId }, deps) → { action, reason?, slug? }
|
|
95
|
+
* Never throws. Actions: 'none' | 'sweep-queued' | 'digest-sent'.
|
|
96
|
+
*/
|
|
97
|
+
async function runEpicSettle({ cwd, epicId } = {}, deps = {}) {
|
|
98
|
+
const {
|
|
99
|
+
readActiveIndex = defaultReadActiveIndex,
|
|
100
|
+
loadJobs = async () => [],
|
|
101
|
+
loadHistory = async () => [],
|
|
102
|
+
createPrd,
|
|
103
|
+
resolveBaseSha = async () => null,
|
|
104
|
+
sendPrompt,
|
|
105
|
+
appendResponseEvent,
|
|
106
|
+
now = Date.now,
|
|
107
|
+
log = console,
|
|
108
|
+
} = deps;
|
|
109
|
+
try {
|
|
110
|
+
if (process.env.SM_EPIC_VALIDATION_DISABLE === '1') return { action: 'none', reason: 'disabled' };
|
|
111
|
+
if (!cwd || !epicId) return { action: 'none', reason: 'missing-args' };
|
|
154
112
|
|
|
155
|
-
// Gate 3: authoring Epic must be a known, still-active session.
|
|
156
113
|
const index = readActiveIndex(cwd);
|
|
157
114
|
const session = index && index.sessions && index.sessions[epicId];
|
|
158
|
-
if (!session || session.status !== 'active') {
|
|
159
|
-
return { enqueued: false, reason: 'epic-not-active' };
|
|
160
|
-
}
|
|
115
|
+
if (!session || session.status !== 'active') return { action: 'none', reason: 'epic-not-active' };
|
|
161
116
|
|
|
162
|
-
// Gate 4b: durable once-per-pair — the just-appended check-in accounts
|
|
163
|
-
// for one validation-stamped response event for this prdSlug; a second
|
|
164
|
-
// one means a previous check-in already asked.
|
|
165
117
|
const events = (index.events && index.events[epicId]) || [];
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
);
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
118
|
+
const jobs = (await loadJobs()) || [];
|
|
119
|
+
const history = (await loadHistory()) || [];
|
|
120
|
+
const settle = evaluateEpicSettle({ epicId, cwd, events, jobs, now: now() });
|
|
121
|
+
if (!settle.settled) return { action: 'none', reason: 'not-settled' };
|
|
122
|
+
if (!settle.hasContent) return { action: 'none', reason: 'no-content' };
|
|
123
|
+
|
|
124
|
+
let newestDigest = null;
|
|
125
|
+
for (const e of events) if (e && e.outcome === 'digest') newestDigest = e;
|
|
126
|
+
if (newestDigest && newestDigest.digestKey === settle.digestKey) {
|
|
127
|
+
return { action: 'none', reason: 'already-digested' };
|
|
172
128
|
}
|
|
173
129
|
|
|
174
|
-
|
|
130
|
+
let notValidatedReason = null;
|
|
131
|
+
let digestSettle = settle;
|
|
132
|
+
if (settle.uncovered.length) {
|
|
133
|
+
const swept = new Set();
|
|
134
|
+
for (const r of [...jobs, ...history]) {
|
|
135
|
+
if (!_rowInEpic(r, epicId) || !isSweepValidatorSlug(r.slug) || !Array.isArray(r.dependsOn)) continue;
|
|
136
|
+
for (const d of r.dependsOn) swept.add(bareSlug(d));
|
|
137
|
+
}
|
|
138
|
+
const candidates = settle.uncovered.filter((u) => !swept.has(u.bareSlug || bareSlug(u.slug)));
|
|
139
|
+
if (candidates.length) {
|
|
140
|
+
const byBare = new Map();
|
|
141
|
+
for (const h of history) {
|
|
142
|
+
if (!h || !h.landedCommit || (h.cwd != null && h.cwd !== cwd)) continue;
|
|
143
|
+
const b = bareSlug(h.slug);
|
|
144
|
+
const prev = byBare.get(b);
|
|
145
|
+
if (!prev || String(h.finishedAt) < String(prev.finishedAt)) byBare.set(b, h);
|
|
146
|
+
}
|
|
147
|
+
let earliest = null;
|
|
148
|
+
for (const c of candidates) {
|
|
149
|
+
const h = byBare.get(c.bareSlug || bareSlug(c.slug));
|
|
150
|
+
if (h && (!earliest || String(h.finishedAt) < String(earliest.finishedAt))) earliest = h;
|
|
151
|
+
}
|
|
152
|
+
let baseSha = null;
|
|
153
|
+
try { baseSha = earliest ? await resolveBaseSha(cwd, earliest.landedCommit) : null; } catch { baseSha = null; }
|
|
154
|
+
const { input } = buildSweepValidatorInput({ epicId, cwd, uncovered: candidates, baseSha, now: now() });
|
|
155
|
+
let res;
|
|
156
|
+
try {
|
|
157
|
+
res = await createPrd(input);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
res = { ok: false, error: e && e.message ? e.message : String(e) };
|
|
160
|
+
}
|
|
161
|
+
if (res && res.ok) return { action: 'sweep-queued', slug: input.slug };
|
|
162
|
+
notValidatedReason = `the validator persona could not be queued: ${(res && res.error) || 'unknown error'}`;
|
|
163
|
+
} else {
|
|
164
|
+
notValidatedReason = 'a sweep validator already ran and did not check them';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const prompt = buildSettledDigest(digestSettle, { epicId, notValidatedReason });
|
|
175
169
|
sendPrompt(epicId, prompt);
|
|
176
|
-
|
|
177
|
-
|
|
170
|
+
await appendResponseEvent(
|
|
171
|
+
cwd, epicId,
|
|
172
|
+
`Plan digest sent: ${settle.verified.length} verified, ${settle.refuted.length} refuted, ${settle.uncovered.length} not validated`,
|
|
173
|
+
{ outcome: 'digest', digestKey: settle.digestKey },
|
|
174
|
+
);
|
|
175
|
+
return { action: 'digest-sent' };
|
|
178
176
|
} catch (e) {
|
|
179
|
-
try { (log || console).error('[epicValidationHook]
|
|
180
|
-
return {
|
|
177
|
+
try { (log || console).error('[epicValidationHook] settle error', epicId, e); } catch { /* noop */ }
|
|
178
|
+
return { action: 'none', reason: 'error' };
|
|
181
179
|
}
|
|
182
180
|
}
|
|
183
181
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
const _settle = new Map(); // `${cwd}::${epicId}` -> { timer, running }
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* scheduleEpicSettle({ cwd, epicId }, run, { delayMs }) — debounce per
|
|
186
|
+
* (cwd, epicId): a new call resets the timer; timers are unref'd; a run in
|
|
187
|
+
* flight is never started twice.
|
|
188
|
+
*/
|
|
189
|
+
function scheduleEpicSettle({ cwd, epicId } = {}, run, { delayMs } = {}) {
|
|
190
|
+
if (!cwd || !epicId || typeof run !== 'function') return;
|
|
191
|
+
const key = `${cwd}::${epicId}`;
|
|
192
|
+
const entry = _settle.get(key) || { timer: null, running: false };
|
|
193
|
+
_settle.set(key, entry);
|
|
194
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
195
|
+
const delay = Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : settleDelayMs();
|
|
196
|
+
entry.timer = setTimeout(async () => {
|
|
197
|
+
entry.timer = null;
|
|
198
|
+
if (entry.running) return;
|
|
199
|
+
entry.running = true;
|
|
200
|
+
try {
|
|
201
|
+
await run({ cwd, epicId });
|
|
202
|
+
} catch (e) {
|
|
203
|
+
try { console.error('[epicValidationHook] settle run error', epicId, e); } catch { /* noop */ }
|
|
204
|
+
} finally {
|
|
205
|
+
entry.running = false;
|
|
206
|
+
if (!entry.timer) _settle.delete(key);
|
|
207
|
+
}
|
|
208
|
+
}, delay);
|
|
209
|
+
if (entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Test hook: clear pending settle timers. */
|
|
213
|
+
function __resetSettleForTests() {
|
|
214
|
+
for (const e of _settle.values()) if (e.timer) clearTimeout(e.timer);
|
|
215
|
+
_settle.clear();
|
|
187
216
|
}
|
|
188
217
|
|
|
189
218
|
module.exports = {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
219
|
+
buildSettledDigest,
|
|
220
|
+
runEpicSettle,
|
|
221
|
+
scheduleEpicSettle,
|
|
222
|
+
__resetSettleForTests,
|
|
193
223
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the scheduler MCP, next PRD) can list and create/update a HOT KEYS macro
|
|
4
4
|
* for its own project, through the existing macroLibrary.cjs store:
|
|
5
5
|
* GET /admin/macros?cwd=… -> { ok, macros }
|
|
6
|
-
* POST /admin/macros/save {cwd, id?, label, agentName, tag, prompt}
|
|
6
|
+
* POST /admin/macros/save {cwd, id?, label, agentName, tag, prompt, inputs?}
|
|
7
7
|
*
|
|
8
8
|
* cwd resolution matches projectHomeAdminRoutes.cjs's resolveCwd exactly
|
|
9
9
|
* (reused, not copied). A create always stores `projects: [resolvedCwd]`,
|
|
@@ -69,6 +69,7 @@ function saveHandler(onChanged, libOpts) {
|
|
|
69
69
|
agentName: body.agentName,
|
|
70
70
|
tag: body.tag,
|
|
71
71
|
prompt: body.prompt,
|
|
72
|
+
inputs: body.inputs,
|
|
72
73
|
});
|
|
73
74
|
} catch (e) {
|
|
74
75
|
sendJson(res, 400, { ok: false, error: e?.issues?.[0]?.message ?? e?.message ?? 'invalid payload' });
|
|
@@ -894,6 +894,13 @@ async function createWorktree({ kind, cwd, key, fromRef }) {
|
|
|
894
894
|
carriedPaths = carry.paths;
|
|
895
895
|
}
|
|
896
896
|
|
|
897
|
+
if (process.env.SM_WORKTREE_DEP_LINK_DISABLE !== '1') {
|
|
898
|
+
try {
|
|
899
|
+
const { linked } = await require('./worktreeDeps.cjs').linkDependencyDirs({ mainCwd: cwd, worktreeCwd: dir });
|
|
900
|
+
if (linked.length) console.log(`[gitWorktree] linked deps: ${linked.join(', ')} -> ${dir}`);
|
|
901
|
+
} catch { /* best-effort — never fails worktree creation */ }
|
|
902
|
+
}
|
|
903
|
+
|
|
897
904
|
registeredCheckouts.set(dir, { kind, branch });
|
|
898
905
|
const result = { ok: true, dir, branch, baseCwd: cwd, carriedPaths, baseBranch };
|
|
899
906
|
if (resolvedFromRefSha) result.forkedFrom = fromRef;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktreeDeps.cjs — symlink the main checkout's gitignored top-level
|
|
3
|
+
* dependency dirs (node_modules, .venv, venv) into a fresh worktree.
|
|
4
|
+
*
|
|
5
|
+
* `git worktree add` checks out tracked files only, so a job worktree has no
|
|
6
|
+
* dependencies and executors park needs_review. A gitignore pattern like
|
|
7
|
+
* `node_modules/` (trailing slash) matches directories only and a symlink is
|
|
8
|
+
* not a directory, so each linked name is also written to the repo's shared
|
|
9
|
+
* `info/exclude` as `/<name>` — otherwise the link shows as untracked and the
|
|
10
|
+
* job parks as `uncommitted_changes`.
|
|
11
|
+
*
|
|
12
|
+
* Never throws; failures land in `skipped`.
|
|
13
|
+
*/
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const { execGit } = require('./gitExec.cjs');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_DEP_DIRS = ['node_modules', '.venv', 'venv'];
|
|
21
|
+
|
|
22
|
+
function isDir(p) {
|
|
23
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function exists(p) {
|
|
27
|
+
try { fs.lstatSync(p); return true; } catch { return false; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function isIgnored(cwd, name) {
|
|
31
|
+
try {
|
|
32
|
+
await execGit(cwd, ['check-ignore', '-q', name], { timeout: 10_000 });
|
|
33
|
+
return true;
|
|
34
|
+
} catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function addExcludes(mainCwd, names) {
|
|
38
|
+
const out = (await execGit(mainCwd, ['rev-parse', '--git-common-dir'], { timeout: 10_000 })).trim();
|
|
39
|
+
const commonDir = path.resolve(mainCwd, out);
|
|
40
|
+
const file = path.join(commonDir, 'info', 'exclude');
|
|
41
|
+
let existing = '';
|
|
42
|
+
try { existing = fs.readFileSync(file, 'utf8'); } catch { /* absent */ }
|
|
43
|
+
const have = new Set(existing.split('\n').map((l) => l.trim()));
|
|
44
|
+
const missing = names.map((n) => `/${n}`).filter((l) => !have.has(l));
|
|
45
|
+
if (!missing.length) return;
|
|
46
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
|
+
const sep = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
48
|
+
fs.appendFileSync(file, `${sep}${missing.join('\n')}\n`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {{ mainCwd: string, worktreeCwd: string, names?: string[] }} opts
|
|
53
|
+
* @returns {Promise<{ linked: string[], skipped: string[] }>}
|
|
54
|
+
*/
|
|
55
|
+
async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DIRS }) {
|
|
56
|
+
const linked = [];
|
|
57
|
+
const skipped = [];
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
try {
|
|
60
|
+
const src = path.join(mainCwd, name);
|
|
61
|
+
const dest = path.join(worktreeCwd, name);
|
|
62
|
+
if (!isDir(src) || exists(dest) || !(await isIgnored(mainCwd, name))) {
|
|
63
|
+
skipped.push(name);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Exclude first: a link without its exclude line would dirty status.
|
|
67
|
+
await addExcludes(mainCwd, [name]);
|
|
68
|
+
fs.symlinkSync(src, dest, 'dir');
|
|
69
|
+
linked.push(name);
|
|
70
|
+
} catch {
|
|
71
|
+
skipped.push(name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { linked, skipped };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { linkDependencyDirs, DEFAULT_DEP_DIRS };
|
|
@@ -365,10 +365,14 @@ const MCP_TOOL_CATALOG = [
|
|
|
365
365
|
label: 'Publish git + npm',
|
|
366
366
|
agentName: 'builder',
|
|
367
367
|
tag: 'feature',
|
|
368
|
-
prompt: 'Run the full publish flow: commit, bump version, gate, npm publish, push.',
|
|
368
|
+
prompt: 'Run the full publish flow for the {{stream}} stream: commit, bump version, gate, npm publish, push.',
|
|
369
|
+
inputs: [{ name: 'stream', label: 'Stream', default: 'education-veggies' }],
|
|
369
370
|
},
|
|
370
371
|
notes: "cwd is optional — defaults to the calling session's own project root (SM_PROJECT_ROOT or "
|
|
371
|
-
+ 'process.cwd()) when omitted. label, agentName, tag and prompt are all required strings.'
|
|
372
|
+
+ 'process.cwd()) when omitted. label, agentName, tag and prompt are all required strings. '
|
|
373
|
+
+ '`inputs` is an optional array of { name, label, default, required, options } fields the human fills in '
|
|
374
|
+
+ 'when pressing the macro; reference each in the prompt as {{name}}. Omitting `inputs` on update keeps '
|
|
375
|
+
+ 'the existing inputs; `[]` clears them.',
|
|
372
376
|
},
|
|
373
377
|
{
|
|
374
378
|
name: 'session_manager_help',
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gathers the project's markdown network (root CLAUDE.md, the docs it links to,
|
|
5
|
+
* every tracked scoped CLAUDE.md) and reports which docs already mention the
|
|
6
|
+
* backticked terms a memory uses. Top-level in lib/ (sibling of memoryStale):
|
|
7
|
+
* no domain subfolder fits. Pure apart from the injected `readFile`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
const ROOT_MAX_BYTES = 16 * 1024;
|
|
14
|
+
const MAX_DOCS = 60;
|
|
15
|
+
const MAX_SEARCH_BYTES = 200 * 1024;
|
|
16
|
+
const MAX_TERMS = 25;
|
|
17
|
+
const HITS_PER_DOC = 3;
|
|
18
|
+
const HITS_TOTAL = 8;
|
|
19
|
+
const MAX_LINE_CHARS = 200;
|
|
20
|
+
|
|
21
|
+
const MD_LINK_RE = /\]\(([^)\s]+\.md)(#[^)]*)?\)/g;
|
|
22
|
+
const TERM_RE = /`([^`\n]{4,80})`/g;
|
|
23
|
+
const ROOT_DOC = 'CLAUDE.md';
|
|
24
|
+
|
|
25
|
+
function defaultReadFile(p) {
|
|
26
|
+
return fs.readFileSync(p, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeRead(readFile, abs) {
|
|
30
|
+
try {
|
|
31
|
+
const out = readFile(abs);
|
|
32
|
+
return typeof out === 'string' ? out : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractLinkedDocs(rootText) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let m;
|
|
41
|
+
MD_LINK_RE.lastIndex = 0;
|
|
42
|
+
while ((m = MD_LINK_RE.exec(rootText)) !== null) {
|
|
43
|
+
const target = m[1];
|
|
44
|
+
if (/^(?:https?|file):\/\//i.test(target)) continue;
|
|
45
|
+
const norm = path.posix.normalize(target);
|
|
46
|
+
if (norm.startsWith('..') || norm.startsWith('/')) continue;
|
|
47
|
+
out.push(norm);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractTerms(body) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const out = [];
|
|
55
|
+
let m;
|
|
56
|
+
TERM_RE.lastIndex = 0;
|
|
57
|
+
while ((m = TERM_RE.exec(body || '')) !== null) {
|
|
58
|
+
if (out.length >= MAX_TERMS) break;
|
|
59
|
+
const term = m[1].trim();
|
|
60
|
+
if (term.length < 4 || seen.has(term)) continue;
|
|
61
|
+
seen.add(term);
|
|
62
|
+
out.push(term);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function titleOf(text, fallback) {
|
|
68
|
+
const m = /^# (.+?)\s*$/m.exec(text);
|
|
69
|
+
return m ? m[1] : fallback;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {{ cwd: string, memoryBody: string, trackedFiles: string[], readFile?: (p: string) => string }} args
|
|
74
|
+
*/
|
|
75
|
+
function collectMdNetwork({ cwd, memoryBody, trackedFiles, readFile = defaultReadFile }) {
|
|
76
|
+
const tracked = new Set((trackedFiles || []).map((f) => path.posix.normalize(f)));
|
|
77
|
+
const abs = (rel) => path.join(cwd, rel);
|
|
78
|
+
|
|
79
|
+
const rootFull = safeRead(readFile, abs(ROOT_DOC)) ?? '';
|
|
80
|
+
const rootClaudeMd = Buffer.byteLength(rootFull) > ROOT_MAX_BYTES
|
|
81
|
+
? Buffer.from(rootFull).subarray(0, ROOT_MAX_BYTES).toString('utf8')
|
|
82
|
+
: rootFull;
|
|
83
|
+
|
|
84
|
+
const paths = [];
|
|
85
|
+
const add = (p) => {
|
|
86
|
+
if (p !== ROOT_DOC && !paths.includes(p)) paths.push(p);
|
|
87
|
+
};
|
|
88
|
+
for (const p of extractLinkedDocs(rootFull)) if (tracked.has(p)) add(p);
|
|
89
|
+
for (const p of tracked) if (path.posix.basename(p) === ROOT_DOC) add(p);
|
|
90
|
+
|
|
91
|
+
const searchable = [{ path: ROOT_DOC, text: rootFull }];
|
|
92
|
+
const docs = [];
|
|
93
|
+
for (const rel of paths) {
|
|
94
|
+
if (docs.length >= MAX_DOCS) break;
|
|
95
|
+
const text = safeRead(readFile, abs(rel));
|
|
96
|
+
if (text === null) continue;
|
|
97
|
+
const bytes = Buffer.byteLength(text);
|
|
98
|
+
docs.push({ path: rel, title: titleOf(text, rel), bytes });
|
|
99
|
+
if (bytes <= MAX_SEARCH_BYTES) searchable.push({ path: rel, text });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const coverage = extractTerms(memoryBody).map((term) => {
|
|
103
|
+
const hits = [];
|
|
104
|
+
for (const doc of searchable) {
|
|
105
|
+
if (hits.length >= HITS_TOTAL) break;
|
|
106
|
+
let perDoc = 0;
|
|
107
|
+
const lines = doc.text.split(/\r?\n/);
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
if (perDoc >= HITS_PER_DOC || hits.length >= HITS_TOTAL) break;
|
|
110
|
+
if (!lines[i].includes(term)) continue;
|
|
111
|
+
hits.push({ path: doc.path, line: i + 1, text: lines[i].trim().slice(0, MAX_LINE_CHARS) });
|
|
112
|
+
perDoc++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { term, hits };
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return { rootClaudeMd, docs, coverage };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { collectMdNetwork };
|