claude-code-session-manager 0.117.0 → 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 (40) hide show
  1. package/dist/assets/{AgentLibrary-BLRc_YvR.js → AgentLibrary-CEI6vsmu.js} +1 -1
  2. package/dist/assets/{Button-DHEnLfDi.js → Button-BuyzGzO8.js} +1 -1
  3. package/dist/assets/{DataModel-C2ytUxJm.js → DataModel-CbXI_rEf.js} +1 -1
  4. package/dist/assets/{History-eoVSG_DX.js → History-axy4nJyY.js} +1 -1
  5. package/dist/assets/{Hooks-rS9UvMcI.js → Hooks-D5ghOppL.js} +1 -1
  6. package/dist/assets/{Library-CdUqaZIj.js → Library-wBI4Fwh3.js} +1 -1
  7. package/dist/assets/{ListDetail-DhtJPQvm.js → ListDetail-CY5hhwn9.js} +1 -1
  8. package/dist/assets/{McpServers-DoOcEGFd.js → McpServers-CwEVZqfu.js} +1 -1
  9. package/dist/assets/{Memory-BbYwtTBZ.js → Memory-Beh2r4Xk.js} +1 -1
  10. package/dist/assets/{Panel-M4ouFOKI.js → Panel-yOYf80ax.js} +1 -1
  11. package/dist/assets/{Permissions-DB0DjkgF.js → Permissions-DA-SMXO3.js} +1 -1
  12. package/dist/assets/{Plugins-DnBwH9PN.js → Plugins-qfKt6BxH.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-1MQmqxxO.js → ProvenanceBadge-DFyBe7Po.js} +1 -1
  14. package/dist/assets/{RemoteMachines-DnT08vg8.js → RemoteMachines-AG0K7kqS.js} +1 -1
  15. package/dist/assets/{SaveBar-DGBVV6ro.js → SaveBar-35Nw2YsZ.js} +1 -1
  16. package/dist/assets/{Scheduler-CGDLYpdc.js → Scheduler-BPBAU0TO.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-jgpoKdvH.js → ScopeSwitcher-BCXNrrrs.js} +1 -1
  18. package/dist/assets/{Settings-Ch0zc2cZ.js → Settings-C4yhzfZI.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-Cd04i1nZ.js → SkillReferenceGraph-Ca5z2avC.js} +1 -1
  20. package/dist/assets/{Skills-DLJLLulV.js → Skills-5WWusmKG.js} +1 -1
  21. package/dist/assets/{SystemPrompt-E4VOiMJN.js → SystemPrompt-ofVCdHAW.js} +1 -1
  22. package/dist/assets/{TagLibrary-2sCAw54h.js → TagLibrary-rSrpD-Bz.js} +1 -1
  23. package/dist/assets/{TiptapBody-CzqJev6v.js → TiptapBody-DAYcdcVt.js} +1 -1
  24. package/dist/assets/{Toggle-D5znlR_5.js → Toggle-BzHdpXZI.js} +1 -1
  25. package/dist/assets/{ViewTabs-BENAWvOs.js → ViewTabs-D82jJ4VS.js} +1 -1
  26. package/dist/assets/{historyTotals-DsE82z-I.js → historyTotals-DMGFqK5h.js} +1 -1
  27. package/dist/assets/{index-B_3CV5Po.js → index-DZ5TJKNI.js} +3 -3
  28. package/dist/assets/{settingsSchema-CmuwD4OX.js → settingsSchema-BfnNE6ne.js} +1 -1
  29. package/dist/assets/{skillFrontmatter-CdlHJV9x.js → skillFrontmatter-ED1DjPE1.js} +1 -1
  30. package/dist/assets/{useAgentsDirWatch-RVq9wMFY.js → useAgentsDirWatch-CbpQIu3P.js} +1 -1
  31. package/dist/assets/{useRemotePeers-Cpt47RWb.js → useRemotePeers-Dy_o0WlY.js} +1 -1
  32. package/dist/index.html +1 -1
  33. package/package.json +1 -1
  34. package/plugins/session-manager-dev/skills/develop/SKILL.md +11 -10
  35. package/src/main/build-info.json +4 -4
  36. package/src/main/lib/epic/epicSettle.cjs +105 -0
  37. package/src/main/lib/epic/epicSweepValidator.cjs +74 -0
  38. package/src/main/lib/epic/epicValidationHook.cjs +171 -141
  39. package/src/main/promptSessionEvents.cjs +5 -1
  40. package/src/main/scheduler.cjs +38 -61
@@ -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
  };
@@ -69,7 +69,9 @@ function mintEventId() {
69
69
  * meta arg (not a fifth positional parameter) with `validation`: a
70
70
  * scheduler check-in is born `'unvalidated'` — never `'verified'` —
71
71
  * regardless of the job's self-reported outcome; the authoring Epic's own
72
- * validation pass is what may later move it to 'verified'/'refuted'. All
72
+ * validation pass is what may later move it to 'verified'/'refuted'. `agentType`
73
+ * (the job's agent) and `digestKey` (the settle digest's stable key) ride the
74
+ * same meta arg and are copied the same way. All
73
75
  * keys are added to the event object only when present in `meta`, never as
74
76
  * `undefined`-valued keys, so events appended without them serialize
75
77
  * identically to before.
@@ -99,6 +101,8 @@ async function appendResponseEventWithReason(cwd, sourcePromptId, text, meta = {
99
101
  if (meta && meta.prdSlug) event.prdSlug = meta.prdSlug;
100
102
  if (meta && meta.outcome) event.outcome = meta.outcome;
101
103
  if (meta && meta.validation) event.validation = meta.validation;
104
+ if (meta && meta.agentType) event.agentType = meta.agentType;
105
+ if (meta && meta.digestKey) event.digestKey = meta.digestKey;
102
106
  data.events[sourcePromptId] = [...events, event];
103
107
  await config.writeJson(path, data, { writer: 'scheduler' });
104
108
  broadcast(EVENT_APPENDED_CHANNEL, { cwd, promptSessionId: sourcePromptId, event });
@@ -99,7 +99,8 @@ const sessionsStore = require('./sessionsStore.cjs');
99
99
  const { enqueueExternalPrompt } = require('./chatRunner.cjs');
100
100
  const { appendResponseEventIfKnown, appendResponseEventWithReason } = require('./promptSessionEvents.cjs');
101
101
  const { buildReviewNotice, selectDueReviewNotices, formatReviewNotice, holdMsFromEnv } = require('./lib/scheduler/reviewNotice.cjs');
102
- const { maybeEnqueueValidationPrompt } = require('./lib/epic/epicValidationHook.cjs');
102
+ const epicValidationHook = require('./lib/epic/epicValidationHook.cjs');
103
+ const prdCreate = require('./lib/prd/prdCreate.cjs');
103
104
  const { parseValidationSentinels } = require('./lib/scheduler/validationSentinels.cjs');
104
105
  const { hasDownstreamValidator } = require('./lib/prd/planValidator.cjs');
105
106
  const promptSessionTranscript = require('./promptSessionTranscript.cjs');
@@ -3018,6 +3019,29 @@ async function resolveFixPlanPath(failedJob) {
3018
3019
  return { fixPath, fixSlug, group, livePrdDir };
3019
3020
  }
3020
3021
 
3022
+ /**
3023
+ * defaultScheduleSettle — production wiring for notifyOriginatingTab's
3024
+ * scheduleSettle option: arms the debounced Epic settle runner with live deps.
3025
+ * `remote` is defined later in the module but only read at call time.
3026
+ */
3027
+ function defaultScheduleSettle({ cwd, epicId }) {
3028
+ const deps = {
3029
+ loadJobs: async () => (await readQueue()).jobs || [],
3030
+ loadHistory: () => queueHistory.readHistory({ limit: 500 }),
3031
+ createPrd: (input) => prdCreate.createPrd(input, remote),
3032
+ resolveBaseSha: async (c, commit) => {
3033
+ try {
3034
+ return String(await execGitAt(c, ['rev-parse', `${commit}^`])).trim() || null;
3035
+ } catch {
3036
+ return null;
3037
+ }
3038
+ },
3039
+ sendPrompt: enqueueExternalPrompt,
3040
+ appendResponseEvent: appendResponseEventIfKnown,
3041
+ };
3042
+ epicValidationHook.scheduleEpicSettle({ cwd, epicId }, (args) => epicValidationHook.runEpicSettle(args, deps));
3043
+ }
3044
+
3021
3045
  /**
3022
3046
  * notifyOriginatingTab(job) → void
3023
3047
  *
@@ -3056,12 +3080,7 @@ async function notifyOriginatingTab(job, {
3056
3080
  appendResponseEvent = appendResponseEventIfKnown,
3057
3081
  appendTranscriptTurn = promptSessionTranscript.appendTurn,
3058
3082
  readResultFromLog = extractResultTextFromLog,
3059
- enqueueValidation = maybeEnqueueValidationPrompt,
3060
- // PRD 1408: same queue rows spawnJob/tickQueue already use — read fresh
3061
- // here (not threaded through the terminal-transition snapshot) since this
3062
- // fires well after dispatch, when the plan may have gained/dropped a
3063
- // trailing validator row since this job was spawned.
3064
- loadJobs = async () => (await readQueue()).jobs || [],
3083
+ scheduleSettle = defaultScheduleSettle,
3065
3084
  } = {}) {
3066
3085
  try {
3067
3086
  const prd = await resolveNotifyPrd(job, parsePrdRaw);
@@ -3174,55 +3193,22 @@ async function notifyOriginatingTab(job, {
3174
3193
  prdSlug: job.slug,
3175
3194
  outcome: job.status,
3176
3195
  validation: 'unvalidated',
3196
+ ...(typeof job.agentType === 'string' ? { agentType: job.agentType } : {}),
3177
3197
  }).catch((e) => {
3178
3198
  console.error('[scheduler] notifyOriginatingTab appendResponseEvent error', job?.slug, e);
3179
3199
  return false;
3180
3200
  });
3181
3201
  if (routed) {
3182
- // PRD 986: a successful check-in append triggers ONE validation
3183
- // prompt into the authoring Epic's own session, asking it to verify
3184
- // every acceptance criterion against the real working tree and reply
3185
- // VERIFIED or REFUTED. Fire-and-forget, same convention as this
3186
- // function's own call site in tickQueue: a failure to enqueue is
3187
- // logged inside the hook, never thrown, and never blocks the job's
3188
- // status transition or the response-event append (which already
3189
- // happened above). All gating (kill-switch SM_EPIC_VALIDATION_DISABLE,
3190
- // active-Epic check, once-per-(epicId, prdSlug), loop guard, slot-pool
3191
- // routing) lives in lib/epic/epicValidationHook.cjs.
3192
- //
3193
- // PRD 1407: a validator job's own check-in never enqueues a
3194
- // validation prompt for itself — it IS the plan's validation pass,
3195
- // not a work-item awaiting one.
3196
- //
3197
- // PRD 1408: nor does an ordinary work-item whose plan already ends in
3198
- // a trailing validator job depending on it — that validator's own
3199
- // check-in (above) is the single validation pass for the whole plan,
3200
- // so a per-PRD prompt here would be a second, redundant ask into the
3201
- // same (Opus/Fable-tier) authoring session.
3202
- if (job.agentType !== 'validator') {
3203
- let skipForDownstreamValidator = false;
3204
- try {
3205
- skipForDownstreamValidator = hasDownstreamValidator(job, await loadJobs());
3206
- } catch (e) {
3207
- console.error('[scheduler] notifyOriginatingTab loadJobs error', job?.slug, e);
3208
- }
3209
- if (!skipForDownstreamValidator) {
3210
- try {
3211
- enqueueValidation(
3212
- {
3213
- cwd: job.cwd || null,
3214
- epicId,
3215
- prdSlug: job.slug,
3216
- prdPath: prd?.path || prdPaths.archivedPrdPathForJob(job) || null,
3217
- outcome: job.status,
3218
- eventValidation: 'unvalidated',
3219
- },
3220
- { sendPrompt },
3221
- );
3222
- } catch (e) {
3223
- console.error('[scheduler] notifyOriginatingTab enqueueValidation error', job?.slug, e);
3224
- }
3225
- }
3202
+ // The debounced Epic settle runner (lib/epic/epicValidationHook.cjs)
3203
+ // owns all validation messaging now (replaces the per-PRD prompts of
3204
+ // PRDs 986/1407/1408): every successful terminal check-in, a
3205
+ // validator's own included (its finish usually settles the Epic),
3206
+ // just re-arms the per-Epic debounce. A finished PRD never sends a
3207
+ // chat prompt into its authoring Epic by itself.
3208
+ try {
3209
+ scheduleSettle({ cwd: job.cwd || null, epicId });
3210
+ } catch (e) {
3211
+ console.error('[scheduler] notifyOriginatingTab scheduleSettle error', job?.slug, e);
3226
3212
  }
3227
3213
  return;
3228
3214
  }
@@ -6456,17 +6442,10 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6456
6442
  let mechanicalRecoveryTarget = null;
6457
6443
  let terminalNotifySnapshot = null;
6458
6444
  const newlyCompletedPrds = [];
6459
- // PRD 1408: captured inside the mutate() below (the one in-memory read of
6460
- // `s.jobs` this finalize pass already does) so notifyOriginatingTab's own
6461
- // hasDownstreamValidator check can reuse it instead of a second, redundant
6462
- // readQueue() — that call federates queue.json shards across every known
6463
- // project cwd on the machine, not just this job's own project.
6464
- let jobsSnapshotForNotify = null;
6465
6445
  // Base HEAD for the mechanical-recovery futility check — read here, never inside mutate() (no I/O).
6466
6446
  const finalizeHeadSha = worktreeIntegrationDetail && worktreeIntegrationDetail.failureKind === 'content_conflict'
6467
6447
  ? await gitTreeGuard.gitHead(guardCwd) : null;
6468
6448
  await mutate((s) => {
6469
- jobsSnapshotForNotify = s.jobs.slice();
6470
6449
  const i2 = s.jobs.findIndex((x) => x.slug === job.slug);
6471
6450
  // A job already moved off 'running' by someone else (namely
6472
6451
  // remote.cancelJob, PRD 1024 — it SIGTERMs the process then finalizes
@@ -6883,9 +6862,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6883
6862
  });
6884
6863
 
6885
6864
  if (terminalNotifySnapshot) {
6886
- notifyOriginatingTab(terminalNotifySnapshot, {
6887
- loadJobs: async () => jobsSnapshotForNotify || [],
6888
- }).catch((e) => {
6865
+ notifyOriginatingTab(terminalNotifySnapshot).catch((e) => {
6889
6866
  console.error('[scheduler] notifyOriginatingTab error', job.slug, e);
6890
6867
  });
6891
6868
  }