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.
Files changed (65) hide show
  1. package/dist/assets/{AgentLibrary-CkS69HnN.js → AgentLibrary-CEI6vsmu.js} +2 -2
  2. package/dist/assets/{Button-BQxkfIbW.js → Button-BuyzGzO8.js} +1 -1
  3. package/dist/assets/{DataModel-YVgdJTZF.js → DataModel-CbXI_rEf.js} +1 -1
  4. package/dist/assets/{History-DneHh4jI.js → History-axy4nJyY.js} +2 -2
  5. package/dist/assets/{Hooks-Bt4xIjfo.js → Hooks-D5ghOppL.js} +3 -3
  6. package/dist/assets/{Library-DoP5FdXq.js → Library-wBI4Fwh3.js} +7 -7
  7. package/dist/assets/{ListDetail-BYxyl13e.js → ListDetail-CY5hhwn9.js} +1 -1
  8. package/dist/assets/{McpServers-CKm_iZsf.js → McpServers-CwEVZqfu.js} +2 -2
  9. package/dist/assets/Memory-Beh2r4Xk.js +25 -0
  10. package/dist/assets/{Panel-Cv8aYElQ.js → Panel-yOYf80ax.js} +1 -1
  11. package/dist/assets/{Permissions-CFmWwqyV.js → Permissions-DA-SMXO3.js} +3 -3
  12. package/dist/assets/{Plugins-CuYNgf_d.js → Plugins-qfKt6BxH.js} +2 -2
  13. package/dist/assets/ProvenanceBadge-DFyBe7Po.js +1 -0
  14. package/dist/assets/RemoteMachines-AG0K7kqS.js +3 -0
  15. package/dist/assets/{SaveBar-Cb4Q8TBK.js → SaveBar-35Nw2YsZ.js} +1 -1
  16. package/dist/assets/{Scheduler-ysJHQvW4.js → Scheduler-BPBAU0TO.js} +8 -8
  17. package/dist/assets/{ScopeSwitcher-BTOWfifI.js → ScopeSwitcher-BCXNrrrs.js} +1 -1
  18. package/dist/assets/{Settings-C6fH8diZ.js → Settings-C4yhzfZI.js} +4 -4
  19. package/dist/assets/{SkillReferenceGraph-esAcniTQ.js → SkillReferenceGraph-Ca5z2avC.js} +1 -1
  20. package/dist/assets/{Skills-4_vqzrSF.js → Skills-5WWusmKG.js} +1 -1
  21. package/dist/assets/SystemPrompt-ofVCdHAW.js +1 -0
  22. package/dist/assets/TagLibrary-rSrpD-Bz.js +1 -0
  23. package/dist/assets/{TiptapBody-EkZlmD9k.js → TiptapBody-DAYcdcVt.js} +1 -1
  24. package/dist/assets/{Toggle-CpR97XGM.js → Toggle-BzHdpXZI.js} +1 -1
  25. package/dist/assets/{ViewTabs-ekJfFek8.js → ViewTabs-D82jJ4VS.js} +1 -1
  26. package/dist/assets/{historyTotals-Bg0GVcsF.js → historyTotals-DMGFqK5h.js} +1 -1
  27. package/dist/assets/{index-CZsDDyLy.css → index-DAMBm3UN.css} +1 -1
  28. package/dist/assets/{index-DQSEUdx0.js → index-DZ5TJKNI.js} +501 -500
  29. package/dist/assets/{settingsSchema-BG5jZbhn.js → settingsSchema-BfnNE6ne.js} +1 -1
  30. package/dist/assets/{skillFrontmatter-DF6xI6Wb.js → skillFrontmatter-ED1DjPE1.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-Dox-xCx9.js → useAgentsDirWatch-CbpQIu3P.js} +1 -1
  32. package/dist/assets/{useRemotePeers-DHo62LbA.js → useRemotePeers-Dy_o0WlY.js} +1 -1
  33. package/dist/index.html +2 -2
  34. package/package.json +1 -1
  35. package/plugins/session-manager-dev/skills/develop/SKILL.md +23 -10
  36. package/scripts/scheduler-mcp-server.cjs +16 -1
  37. package/src/main/build-info.json +4 -4
  38. package/src/main/lib/epic/epicSettle.cjs +105 -0
  39. package/src/main/lib/epic/epicSweepValidator.cjs +74 -0
  40. package/src/main/lib/epic/epicValidationHook.cjs +171 -141
  41. package/src/main/lib/feedback-remote/macroAdminRoutes.cjs +2 -1
  42. package/src/main/lib/git/gitWorktree.cjs +7 -0
  43. package/src/main/lib/git/worktreeDeps.cjs +77 -0
  44. package/src/main/lib/mcpToolCatalog.cjs +6 -2
  45. package/src/main/lib/memoryMdNetwork.cjs +121 -0
  46. package/src/main/lib/memoryRefResolve.cjs +64 -0
  47. package/src/main/lib/memoryStale.cjs +27 -11
  48. package/src/main/lib/persona-macro/macroLibrary.cjs +49 -2
  49. package/src/main/lib/scheduler/baselineGate.cjs +128 -0
  50. package/src/main/lib/scheduler/definitionOfDone.cjs +9 -3
  51. package/src/main/lib/scheduler/needsReviewLedger.cjs +1 -1
  52. package/src/main/lib/scheduler/reviewNotice.cjs +53 -0
  53. package/src/main/memoryAssist.cjs +71 -31
  54. package/src/main/memoryTool.cjs +18 -6
  55. package/src/main/promptSessionEvents.cjs +6 -2
  56. package/src/main/runVerify.cjs +39 -3
  57. package/src/main/scheduler/reviewLadder.cjs +25 -6
  58. package/src/main/scheduler.cjs +121 -62
  59. package/src/main/templates/PRD_AUTHORING.md +2 -0
  60. package/src/preload/api.d.ts +24 -2
  61. package/dist/assets/Memory-BWMQuAoC.js +0 -18
  62. package/dist/assets/ProvenanceBadge-BMLVPyHr.js +0 -1
  63. package/dist/assets/RemoteMachines-D04Mby_W.js +0 -3
  64. package/dist/assets/SystemPrompt-CdUq4KXx.js +0 -1
  65. package/dist/assets/TagLibrary-CVFIphBv.js +0 -1
@@ -1,58 +1,29 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * epicValidationHook.cjs — a PRD check-in triggers validation in the
5
- * authoring Epic; it never asserts the PRD is done (PRD 986).
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, and the queue recorded
8
- * `completed`. Three layers of scheduler-side automation failed to notice.
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
- * Shape mirrors lib/scheduler/dodDrainHook.cjs: fire-and-forget (never throws to the
19
- * caller — errors are logged), kill-switched, idempotent.
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
- * Kill-switch: SM_EPIC_VALIDATION_DISABLE=1 (mirrors the SM_DOD_DISABLE
22
- * precedent) — turns the whole hook off without a code change.
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, log and do nothing.
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
- * LOOP GUARD + once-per-pair bookkeeping.
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
- * buildValidationPrompt — pure prompt builder.
75
- *
76
- * The prompt must carry: the PRD slug, the absolute path to its .md, the
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 buildValidationPrompt({ prdSlug, prdPath, outcome }) {
84
- const pathLine = prdPath
85
- ? `PRD file (absolute path): ${prdPath}`
86
- : 'PRD file: path could not be resolved — locate it under session-manager-operations/scheduler/epics/*/prds-archived/ by slug.';
87
- return [
88
- `VALIDATION REQUEST for PRD ${prdSlug} — this is a request to validate, NOT a completion notice.`,
89
- pathLine,
90
- `The scheduler job self-reported outcome "${outcome}". Treat that strictly as an UNVERIFIED CLAIM — it carries no authority about whether the work actually landed.`,
91
- '',
92
- 'Do the following, independently:',
93
- `1. Read the PRD's own "Acceptance criteria" section from the file above.`,
94
- '2. Check EACH criterion against the actual working tree (read the real files, run the real commands).',
95
- '3. Run `git diff --stat` over the run window (and `git log --stat` for commits landed during the run). An empty diff on an implementation PRD means the work did not land — treat that as REFUTED.',
96
- '',
97
- 'WARNING — the failure mode this validation exists to catch: an exit code of 0, a green queue row, or a confident completion report are NOT evidence that anything shipped. Only the working tree is evidence. (A prior PRD reported "completed" having made zero edits.)',
98
- '',
99
- 'Reply with exactly one verdict word, VERIFIED or REFUTED, followed by per-criterion evidence: for each acceptance criterion cite file:line or paste the command output that proves or disproves it.',
100
- ].join('\n');
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
- // Gate 2: LOOP GUARD (see doc comment above for how the field value
144
- // distinguishes a check-in from a validation result).
145
- if (eventValidation !== 'unvalidated') return { enqueued: false, reason: 'not-a-checkin' };
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
- if (!cwd || !epicId || !prdSlug || typeof sendPrompt !== 'function') {
148
- return { enqueued: false, reason: 'missing-args' };
149
- }
91
+ const _rowInEpic = (r, epicId) => r && (r.epicId === epicId || r.sourcePromptId === epicId);
150
92
 
151
- // Gate 4a: in-memory once-per-pair (checked before the disk read — cheap first).
152
- const key = pairKey(epicId, prdSlug);
153
- if (_fired.has(key)) return { enqueued: false, reason: 'already-fired' };
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 priorCheckins = events.filter(
167
- (e) => e && e.kind === 'response' && e.prdSlug === prdSlug && e.validation !== undefined,
168
- );
169
- if (priorCheckins.length >= 2) {
170
- _fired.add(key); // remember so later re-notifies skip the disk read too
171
- return { enqueued: false, reason: 'already-fired-durable' };
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
- const prompt = buildValidationPrompt({ prdSlug, prdPath, outcome });
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
- _fired.add(key);
177
- return { enqueued: true };
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] enqueue error', prdSlug, e); } catch { /* noop */ }
180
- return { enqueued: false, reason: 'error' };
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
- /** Test hook: clear the once-per-pair memory. */
185
- function __resetForTests() {
186
- _fired.clear();
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
- buildValidationPrompt,
191
- maybeEnqueueValidationPrompt,
192
- __resetForTests,
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 };