claude-code-session-manager 0.57.1 → 0.59.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/README.md +2 -2
- package/dist/assets/{TiptapBody-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
- package/dist/assets/index-BPnfPdLW.js +3222 -0
- package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -2
- package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
- package/scripts/lib/watchdogHelpers.cjs +0 -434
- package/scripts/mint-epic.cjs +6 -6
- package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
- package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
- package/src/main/__tests__/epicMint.test.cjs +165 -207
- package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
- package/src/main/__tests__/prdMigration.test.cjs +160 -0
- package/src/main/__tests__/projectPages.test.cjs +151 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
- package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
- package/src/main/__tests__/rcaReport.test.cjs +188 -0
- package/src/main/__tests__/runVerify.test.cjs +109 -6
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
- package/src/main/chatRunner.cjs +10 -1
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +3 -5
- package/src/main/ipcSchemas.cjs +72 -4
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/agentModelResolve.cjs +97 -0
- package/src/main/lib/auditLog.cjs +2 -2
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicContextDigest.cjs +78 -0
- package/src/main/lib/epicMint.cjs +86 -164
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +95 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
- package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
- package/src/main/lib/schedulerBatch.cjs +70 -111
- package/src/main/lib/schedulerConfig.cjs +0 -1
- package/src/main/otel.cjs +3 -1
- package/src/main/projectPages.cjs +60 -14
- package/src/main/promptSessionEvents.cjs +16 -1
- package/src/main/runVerify.cjs +17 -2
- package/src/main/scheduler.cjs +302 -68
- package/src/main/templates/project-pages-default-home.html +123 -0
- package/src/main/transcripts.cjs +191 -32
- package/src/main/webRemote.cjs +8 -7
- package/src/preload/api.d.ts +100 -57
- package/src/preload/index.cjs +9 -7
- package/dist/assets/index-DCa3QvAi.js +0 -3227
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
- package/scripts/propose-epic.cjs +0 -56
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
- package/src/main/repoAnalyzer.cjs +0 -346
- package/src/main/search.cjs +0 -332
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* epicMint.cjs —
|
|
2
|
+
* epicMint.cjs — resolve the Epic (PromptSession) a PRD dispatch belongs to,
|
|
3
|
+
* and mint the one Epic-creation path there is.
|
|
3
4
|
*
|
|
4
5
|
* Domain rule (CLAUDE.md "Domain model"): every PRD belongs to an Epic; the
|
|
5
|
-
* hierarchy TAB → operations-root → EPIC → PRD is total. Dispatch paths
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* hierarchy TAB → operations-root → EPIC → PRD is total. Dispatch paths call
|
|
7
|
+
* ensureEpic() to JOIN the Epic they already belong to instead of writing
|
|
8
|
+
* epicless PRDs — they can never create one.
|
|
9
|
+
*
|
|
10
|
+
* SINGLE-CREATOR LAW (fail-closed, mirrors opsOwnership.cjs's assertOpsWrite):
|
|
11
|
+
* an Epic comes into existence in exactly ONE place — the human pressing
|
|
12
|
+
* "New Epic" in the app. That is the only caller allowed to pass
|
|
13
|
+
* `mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI` (today: lib/
|
|
14
|
+
* promptSessionsCreateEpic.cjs, the New Epic card's IPC handler). Every other
|
|
15
|
+
* caller is join-only and throws rather than conjuring an Epic. Agents that
|
|
16
|
+
* are sure work is needed run /develop inside the Epic they are already in;
|
|
17
|
+
* agents that are not sure say so and let the human open an Epic.
|
|
8
18
|
*
|
|
9
19
|
* The Epic registry is the renderer's own store: the per-cwd
|
|
10
20
|
* `session-manager-operations/prompt-sessions/active-index.json`
|
|
@@ -23,6 +33,17 @@ const crypto = require('node:crypto');
|
|
|
23
33
|
const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
|
|
24
34
|
const { assertOpsWrite } = require('./opsOwnership.cjs');
|
|
25
35
|
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
36
|
+
// Required as the module object (not destructured) so a test can
|
|
37
|
+
// monkeypatch promptSessionSchema.assertValidPromptSession in place to
|
|
38
|
+
// simulate a corrupted construction — the real construction below is
|
|
39
|
+
// hardcoded and always valid.
|
|
40
|
+
const promptSessionSchema = require('./promptSessionSchema.cjs');
|
|
41
|
+
|
|
42
|
+
// The one token that unlocks ensureEpic's mint branch. Held by
|
|
43
|
+
// lib/promptSessionsCreateEpic.cjs (the New Epic card's IPC handler) and
|
|
44
|
+
// nothing else — grep for it before adding a second holder, that is a
|
|
45
|
+
// deliberate domain-model change, not a convenience.
|
|
46
|
+
const MINT_AUTHORITY_NEW_EPIC_UI = 'new-epic-ui';
|
|
26
47
|
|
|
27
48
|
function activeIndexPath(cwd) {
|
|
28
49
|
return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
|
|
@@ -63,7 +84,7 @@ function writeActiveIndex(cwd, index) {
|
|
|
63
84
|
// "constructor" resolves through the prototype chain to a truthy
|
|
64
85
|
// Object.prototype member even though no such Epic was ever written,
|
|
65
86
|
// bypassing every "does this Epic exist" gate below (including the
|
|
66
|
-
//
|
|
87
|
+
// join-only existence check that PRD-authoring paths rely on).
|
|
67
88
|
// Always use this instead of `obj[key]`/`!obj[key]` for existence checks.
|
|
68
89
|
function hasOwn(obj, key) {
|
|
69
90
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
@@ -77,85 +98,6 @@ function slugify(text) {
|
|
|
77
98
|
.slice(0, 48) || 'epic';
|
|
78
99
|
}
|
|
79
100
|
|
|
80
|
-
const STOPWORDS = new Set([
|
|
81
|
-
'the', 'a', 'an', 'is', 'of', 'to', 'for', 'and', 'in', 'on', 'at', 'this', 'that',
|
|
82
|
-
]);
|
|
83
|
-
|
|
84
|
-
// Lowercase, strip punctuation, split on whitespace, drop stopwords — shared
|
|
85
|
-
// by findJoinableEpic's similarity check. Kept standalone so its behavior is
|
|
86
|
-
// independently testable rather than inlined into the Jaccard computation.
|
|
87
|
-
function tokenize(text) {
|
|
88
|
-
return String(text || '')
|
|
89
|
-
.toLowerCase()
|
|
90
|
-
.replace(/[^a-z0-9\s]+/g, ' ')
|
|
91
|
-
.split(/\s+/)
|
|
92
|
-
.filter((token) => token && !STOPWORDS.has(token));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function jaccardSimilarity(tokensA, tokensB) {
|
|
96
|
-
const setA = new Set(tokensA);
|
|
97
|
-
const setB = new Set(tokensB);
|
|
98
|
-
if (setA.size === 0 && setB.size === 0) return 0;
|
|
99
|
-
let intersection = 0;
|
|
100
|
-
for (const token of setA) {
|
|
101
|
-
if (setB.has(token)) intersection += 1;
|
|
102
|
-
}
|
|
103
|
-
const union = setA.size + setB.size - intersection;
|
|
104
|
-
return union === 0 ? 0 : intersection / union;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const JOIN_SIMILARITY_THRESHOLD = 0.35;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* findJoinableEpicInIndex(index, { goalText, preferEpicId, status }) → { epicId, matchedBy, score? } | null
|
|
111
|
-
*
|
|
112
|
-
* Same contract as findJoinableEpic() but takes an already-loaded index —
|
|
113
|
-
* lets ensureEpic() consult this without a second readActiveIndex() call
|
|
114
|
-
* inside its own withPathLock critical section. Two strategies, in order:
|
|
115
|
-
* 1. `preferEpicId` — an explicitly-known origin Epic the caller already has
|
|
116
|
-
* in hand. Joined immediately (no similarity check) as long as it exists
|
|
117
|
-
* and is still open ('proposed' or 'active') — a 'completed' or unknown
|
|
118
|
-
* preferEpicId falls through to strategy 2 rather than joining a dead Epic.
|
|
119
|
-
* 2. Keyword-similarity — Jaccard token-set overlap between `goalText` and
|
|
120
|
-
* every other OPEN Epic's goalText in the same cwd (open = 'proposed' or
|
|
121
|
-
* 'active', regardless of the specific requested `status` — a proposed
|
|
122
|
-
* RCA report and an active one about the same topic are still the same
|
|
123
|
-
* underlying issue). Highest score wins if it clears
|
|
124
|
-
* JOIN_SIMILARITY_THRESHOLD; otherwise no join. `status` is accepted for
|
|
125
|
-
* signature symmetry with ensureEpic()/reuseByGoal but only participates
|
|
126
|
-
* in strategy 1's preferEpicId open-check, not the similarity filter.
|
|
127
|
-
*/
|
|
128
|
-
function findJoinableEpicInIndex(index, { goalText, preferEpicId = null, status: _status = 'proposed' } = {}) {
|
|
129
|
-
if (preferEpicId && hasOwn(index.sessions, preferEpicId)) {
|
|
130
|
-
const preferred = index.sessions[preferEpicId];
|
|
131
|
-
if (preferred && (preferred.status === 'proposed' || preferred.status === 'active')) {
|
|
132
|
-
return { epicId: preferEpicId, matchedBy: 'preferEpicId' };
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const candidateTokens = tokenize(goalText);
|
|
137
|
-
let best = null;
|
|
138
|
-
for (const s of Object.values(index.sessions)) {
|
|
139
|
-
if (!s || (s.status !== 'proposed' && s.status !== 'active')) continue;
|
|
140
|
-
const score = jaccardSimilarity(candidateTokens, tokenize(s.goalText));
|
|
141
|
-
if (score >= JOIN_SIMILARITY_THRESHOLD && (!best || score > best.score)) {
|
|
142
|
-
best = { epicId: s.id, matchedBy: 'similarity', score };
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return best;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* findJoinableEpic(cwd, { goalText, preferEpicId, status }) → { epicId, matchedBy, score? } | null
|
|
150
|
-
*
|
|
151
|
-
* Public entry point for callers outside ensureEpic()'s own critical section
|
|
152
|
-
* (tests, future PRD 899/900 wiring) — loads the index itself. See
|
|
153
|
-
* findJoinableEpicInIndex() for the matching logic.
|
|
154
|
-
*/
|
|
155
|
-
function findJoinableEpic(cwd, opts = {}) {
|
|
156
|
-
return findJoinableEpicInIndex(readActiveIndex(cwd), opts);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
101
|
// Serializes read-modify-write cycles per active-index.json path, mirroring
|
|
160
102
|
// promptSessionEvents.cjs's own pendingWritesByPath/withPathLock. The current
|
|
161
103
|
// read/write pair below is synchronous (fs.readFileSync/writeFileSync), so
|
|
@@ -180,40 +122,26 @@ function withPathLock(lockPath, task) {
|
|
|
180
122
|
}
|
|
181
123
|
|
|
182
124
|
/**
|
|
183
|
-
* ensureEpic(cwd, { goalText, tag?,
|
|
184
|
-
*
|
|
185
|
-
* Before minting brand-new, the mint branch consults findJoinableEpic() —
|
|
186
|
-
* minting is the LAST resort, not the default, for automated callers. Pass
|
|
187
|
-
* `forceNewEpic: true` to skip that check and always mint (the one
|
|
188
|
-
* legitimate case being explicit human-authored creation).
|
|
125
|
+
* ensureEpic(cwd, { epicId?, goalText, tag?, status?, openingPrompt?, source?, agentType?, mintAuthority? }) → Promise<{ epicId, prdDir, created }>
|
|
189
126
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* never calls ensureEpic(). Joining an already-'active' Epic (this function's
|
|
198
|
-
* join branches, above the mint branch) remains legal and unchanged.
|
|
127
|
+
* Two behaviors, and only two:
|
|
128
|
+
* - JOIN (the default, for every automated caller): `epicId` names an Epic
|
|
129
|
+
* that already exists and is still open ('proposed'/'active') → its prds/
|
|
130
|
+
* directory is returned with `created: false`. Anything else throws.
|
|
131
|
+
* - MINT (the New Epic UI alone): `mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI`
|
|
132
|
+
* creates a brand-new Epic. No similarity/keyword matching runs first — the
|
|
133
|
+
* human asked for a new Epic, so they get a new Epic.
|
|
199
134
|
*
|
|
200
|
-
* `
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* a session's behalf) must pass `mintIfMissing: false` — that path may only
|
|
206
|
-
* JOIN an Epic that already exists; it throws instead of silently creating a
|
|
207
|
-
* new one, so no PRD-authoring surface can conjure Epics on its own.
|
|
208
|
-
*
|
|
209
|
-
* Mints a new Epic — or, with `reuseByGoal`, joins the existing Epic (of the
|
|
210
|
-
* same `status`) whose goalText matches (used by the recurring feedback sweep
|
|
211
|
-
* so successive sweeps chain into one Epic instead of minting one per tick).
|
|
135
|
+
* Every Epic is BORN 'proposed'; `status` defaults to it and the mint branch
|
|
136
|
+
* rejects any other value outright (fail-closed, mirroring opsOwnership.cjs's
|
|
137
|
+
* assertOpsWrite). Activation ('proposed' → 'active') happens exactly once, in
|
|
138
|
+
* the renderer store's `approveProposed` (`state/promptSessions.ts`) — a path
|
|
139
|
+
* that never calls ensureEpic().
|
|
212
140
|
*
|
|
213
141
|
* The Epic's id doubles as its directory name under scheduler/epics/, so the
|
|
214
142
|
* PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
|
|
215
143
|
*/
|
|
216
|
-
function ensureEpic(cwd, { goalText, tag,
|
|
144
|
+
function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, sections = null, source = null, agentType = null, mintAuthority = null } = {}) {
|
|
217
145
|
if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
|
|
218
146
|
// A relative cwd (e.g. a caller passing '.') would otherwise get stored
|
|
219
147
|
// verbatim on the minted Epic's `cwd` field — the renderer's EpicsWorkspace
|
|
@@ -230,8 +158,7 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
230
158
|
// let a stale/hallucinated sourcePromptId silently attach a PRD (and its
|
|
231
159
|
// follow-on events/chat activity) to an unrelated or even completed
|
|
232
160
|
// Epic — the "this session ran again without my knowledge, and it was
|
|
233
|
-
// really another Epic's prompt" cross-contamination bug.
|
|
234
|
-
// findJoinableEpicInIndex()'s preferEpicId open-check (line ~124).
|
|
161
|
+
// really another Epic's prompt" cross-contamination bug.
|
|
235
162
|
if (explicitEpicId && hasOwn(index.sessions, explicitEpicId)) {
|
|
236
163
|
const preferred = index.sessions[explicitEpicId];
|
|
237
164
|
if (preferred && (preferred.status === 'proposed' || preferred.status === 'active')) {
|
|
@@ -239,57 +166,26 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
239
166
|
fs.mkdirSync(prdDir, { recursive: true });
|
|
240
167
|
return { epicId: explicitEpicId, prdDir, created: false };
|
|
241
168
|
}
|
|
242
|
-
console.warn(`[epicMint] ensureEpic: explicit epicId ${explicitEpicId} exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join
|
|
169
|
+
console.warn(`[epicMint] ensureEpic: explicit epicId ${explicitEpicId} exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join`);
|
|
243
170
|
appendAuditEvent('epic_mint_refused', {
|
|
244
171
|
cwd,
|
|
245
172
|
epicId: explicitEpicId,
|
|
246
173
|
status,
|
|
247
|
-
reason: `explicit epicId exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join
|
|
174
|
+
reason: `explicit epicId exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join`,
|
|
248
175
|
});
|
|
249
176
|
}
|
|
250
177
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// later investigation pass) enriches the pending proposal in place
|
|
259
|
-
// rather than filing a duplicate. Never done for an active Epic —
|
|
260
|
-
// its first turn is already history.
|
|
261
|
-
if (s.status === 'proposed' && openingPrompt && openingPrompt !== s.openingPrompt) {
|
|
262
|
-
s.openingPrompt = String(openingPrompt);
|
|
263
|
-
const chain = index.events[s.id];
|
|
264
|
-
if (Array.isArray(chain) && chain[0] && chain[0].kind === 'prompt') {
|
|
265
|
-
chain[0].text = String(openingPrompt);
|
|
266
|
-
}
|
|
267
|
-
writeActiveIndex(cwd, index);
|
|
268
|
-
}
|
|
269
|
-
const prdDir = resolveEpicPrdWriteDir(cwd, s.id);
|
|
270
|
-
fs.mkdirSync(prdDir, { recursive: true });
|
|
271
|
-
return { epicId: s.id, prdDir, created: false };
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (!mintIfMissing) {
|
|
277
|
-
const reason = 'no existing Epic found and mintIfMissing is false — a new Epic can only be created by '
|
|
278
|
-
+ 'explicit human intent (New Epic UI, or /propose-epic + Approve & start), never implicitly by a '
|
|
279
|
-
+ 'PRD-authoring path';
|
|
178
|
+
// SINGLE-CREATOR LAW (see this file's header). Only the New Epic UI may
|
|
179
|
+
// mint; every other caller reaching this line was trying to JOIN an Epic
|
|
180
|
+
// that isn't there, which is a bug in the caller, not a cue to create one.
|
|
181
|
+
if (mintAuthority !== MINT_AUTHORITY_NEW_EPIC_UI) {
|
|
182
|
+
const reason = 'no open Epic to join — an Epic is created in exactly one place, the New Epic UI '
|
|
183
|
+
+ '(mintAuthority). Agents that are sure the work is needed run /develop inside the Epic they are '
|
|
184
|
+
+ 'already in; a PRD-authoring path may never conjure one';
|
|
280
185
|
appendAuditEvent('epic_mint_refused', { cwd, epicId: explicitEpicId ?? null, status, reason });
|
|
281
186
|
throw new Error(`ensureEpic: ${reason} (epicId=${explicitEpicId ?? 'none'})`);
|
|
282
187
|
}
|
|
283
188
|
|
|
284
|
-
if (!forceNewEpic) {
|
|
285
|
-
const joinable = findJoinableEpicInIndex(index, { goalText, preferEpicId: explicitEpicId, status });
|
|
286
|
-
if (joinable) {
|
|
287
|
-
const prdDir = resolveEpicPrdWriteDir(cwd, joinable.epicId);
|
|
288
|
-
fs.mkdirSync(prdDir, { recursive: true });
|
|
289
|
-
return { epicId: joinable.epicId, prdDir, created: false };
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
189
|
// BORN-PROPOSED LAW (fail-closed, mirrors opsOwnership.cjs's
|
|
294
190
|
// assertOpsWrite): a mint always writes 'proposed', regardless of what
|
|
295
191
|
// status the caller requested. A caller explicitly asking to mint
|
|
@@ -313,9 +209,8 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
313
209
|
// Independently minted, never shared with a SessionTab — same invariant
|
|
314
210
|
// as renderer-created PromptSessions (state/promptSessions.ts).
|
|
315
211
|
claudeSessionId: crypto.randomUUID(),
|
|
316
|
-
// 'proposed' files the Epic WITHOUT starting it — nothing runs until
|
|
317
|
-
// human
|
|
318
|
-
// replaced the feedback-folder intake (see lib/rcaFeedbackHook.cjs).
|
|
212
|
+
// 'proposed' files the Epic WITHOUT starting it — nothing runs until the
|
|
213
|
+
// human presses Approve & start in the Epics workspace.
|
|
319
214
|
status,
|
|
320
215
|
createdAt: now,
|
|
321
216
|
completedAt: null,
|
|
@@ -323,10 +218,39 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
323
218
|
// Full body for a proposal whose goalText is only a one-line title;
|
|
324
219
|
// sent verbatim as the first prompt when a human approves it.
|
|
325
220
|
...(openingPrompt ? { openingPrompt: String(openingPrompt) } : {}),
|
|
326
|
-
// Structured
|
|
327
|
-
//
|
|
221
|
+
// Structured slices of the same openingPrompt (composeEpicIntake's
|
|
222
|
+
// EpicIntakeSection[]) — carried alongside it so the Epic's first turn
|
|
223
|
+
// can render an AIM briefing card instead of re-parsing the flat
|
|
224
|
+
// string. Absent whenever openingPrompt is absent too.
|
|
225
|
+
...(Array.isArray(sections) && sections.length ? { sections } : {}),
|
|
226
|
+
// Structured trace of which surface minted this Epic — see EpicSource in
|
|
227
|
+
// state/promptSessions.ts.
|
|
328
228
|
...(source ? { source } : {}),
|
|
229
|
+
// Display-only "who is working" persona name (New Epic card's Agent
|
|
230
|
+
// picker). The renderer no longer constructs a PromptSession itself —
|
|
231
|
+
// PRD 955 / commit ba54269 routed createPromptSession through this same
|
|
232
|
+
// ensureEpic IPC path (state/promptSessions.ts). Never affects which
|
|
233
|
+
// claude CLI spawns.
|
|
234
|
+
...(agentType ? { agentType } : {}),
|
|
329
235
|
};
|
|
236
|
+
|
|
237
|
+
// Validate the constructed record against the canonical PromptSession
|
|
238
|
+
// schema (promptSessionSchema.cjs) before it ever reaches disk — closes
|
|
239
|
+
// the drift risk between this hand-constructed literal and the
|
|
240
|
+
// renderer's own createPromptSession, same fail-closed spirit as the
|
|
241
|
+
// BORN-PROPOSED LAW check above.
|
|
242
|
+
try {
|
|
243
|
+
promptSessionSchema.assertValidPromptSession(session);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
appendAuditEvent('epic_mint_refused', {
|
|
246
|
+
cwd,
|
|
247
|
+
epicId,
|
|
248
|
+
status,
|
|
249
|
+
reason: `constructed session object failed PromptSession schema validation: ${err.message}`,
|
|
250
|
+
});
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
|
|
330
254
|
const firstEvent = {
|
|
331
255
|
id: crypto.randomUUID(),
|
|
332
256
|
promptSessionId: epicId,
|
|
@@ -339,9 +263,8 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
339
263
|
index.events[epicId] = [firstEvent];
|
|
340
264
|
writeActiveIndex(cwd, index);
|
|
341
265
|
|
|
342
|
-
// Every mint is logged
|
|
343
|
-
//
|
|
344
|
-
// for "who/what created this Epic" (see auditLog.cjs).
|
|
266
|
+
// Every mint is logged — the trace-back point for "who created this Epic"
|
|
267
|
+
// (see auditLog.cjs).
|
|
345
268
|
appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText, source: source ?? null });
|
|
346
269
|
|
|
347
270
|
const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
@@ -399,12 +322,11 @@ function removeEpic(cwd, epicId) {
|
|
|
399
322
|
|
|
400
323
|
module.exports = {
|
|
401
324
|
ensureEpic,
|
|
325
|
+
MINT_AUTHORITY_NEW_EPIC_UI,
|
|
402
326
|
appendPrdCreatedEvent,
|
|
403
327
|
removeEpic,
|
|
404
328
|
activeIndexPath,
|
|
405
329
|
readActiveIndex,
|
|
406
|
-
findJoinableEpic,
|
|
407
|
-
tokenize,
|
|
408
330
|
// Exported so lib/activeIndexMerge.cjs's renderer-facing merge IPC handler
|
|
409
331
|
// serializes through the SAME lock instance as ensureEpic/
|
|
410
332
|
// appendPrdCreatedEvent (module-level Map, shared via Node's require
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* epicValidationHook.cjs — a PRD check-in triggers validation in the
|
|
5
|
+
* authoring Epic; it never asserts the PRD is done (PRD 986).
|
|
6
|
+
*
|
|
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.
|
|
17
|
+
*
|
|
18
|
+
* Shape mirrors lib/dodDrainHook.cjs: fire-and-forget (never throws to the
|
|
19
|
+
* caller — errors are logged), kill-switched, idempotent.
|
|
20
|
+
*
|
|
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/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.
|
|
35
|
+
*
|
|
36
|
+
* 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.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fs = require('node:fs');
|
|
41
|
+
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
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Default active-index reader: the same on-disk file
|
|
59
|
+
* promptSessionEvents.cjs writes. Read-only here (no lock needed — a torn
|
|
60
|
+
* read degrades to "skip", never to a bad write). Returns null on any
|
|
61
|
+
* error/missing file so callers treat it as "no active Epic".
|
|
62
|
+
*/
|
|
63
|
+
function defaultReadActiveIndex(cwd) {
|
|
64
|
+
try {
|
|
65
|
+
const p = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
|
|
66
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* buildValidationPrompt — pure prompt builder.
|
|
74
|
+
*
|
|
75
|
+
* The prompt must carry: the PRD slug, the absolute path to its .md, the
|
|
76
|
+
* job's self-reported outcome explicitly labelled as an UNVERIFIED CLAIM,
|
|
77
|
+
* the instruction to check every Acceptance Criterion against the actual
|
|
78
|
+
* working tree, and the VERIFIED/REFUTED reply contract with per-criterion
|
|
79
|
+
* evidence. It also warns against the exact failure mode that produced
|
|
80
|
+
* PRD 986: exit 0 / a green queue row / a confident report are not evidence.
|
|
81
|
+
*/
|
|
82
|
+
function buildValidationPrompt({ prdSlug, prdPath, outcome }) {
|
|
83
|
+
const pathLine = prdPath
|
|
84
|
+
? `PRD file (absolute path): ${prdPath}`
|
|
85
|
+
: 'PRD file: path could not be resolved — locate it under session-manager-operations/scheduler/epics/*/prds-archived/ by slug.';
|
|
86
|
+
return [
|
|
87
|
+
`VALIDATION REQUEST for PRD ${prdSlug} — this is a request to validate, NOT a completion notice.`,
|
|
88
|
+
pathLine,
|
|
89
|
+
`The scheduler job self-reported outcome "${outcome}". Treat that strictly as an UNVERIFIED CLAIM — it carries no authority about whether the work actually landed.`,
|
|
90
|
+
'',
|
|
91
|
+
'Do the following, independently:',
|
|
92
|
+
`1. Read the PRD's own "Acceptance criteria" section from the file above.`,
|
|
93
|
+
'2. Check EACH criterion against the actual working tree (read the real files, run the real commands).',
|
|
94
|
+
'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.',
|
|
95
|
+
'',
|
|
96
|
+
'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.)',
|
|
97
|
+
'',
|
|
98
|
+
'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.',
|
|
99
|
+
].join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* maybeEnqueueValidationPrompt(args, deps) → { enqueued: boolean, reason?: string }
|
|
104
|
+
*
|
|
105
|
+
* Called by scheduler.cjs's notifyOriginatingTab immediately after a
|
|
106
|
+
* SUCCESSFUL appendResponseEventIfKnown for a terminal (completed/failed)
|
|
107
|
+
* PRD outcome. Fire-and-forget: never throws; every refusal returns a
|
|
108
|
+
* reason so tests (and log lines) can tell the gates apart.
|
|
109
|
+
*
|
|
110
|
+
* Guards, in order (all four AC gates):
|
|
111
|
+
* 1. SM_EPIC_VALIDATION_DISABLE=1 kill-switch → skip.
|
|
112
|
+
* 2. LOOP GUARD — how the guard distinguishes a check-in from a
|
|
113
|
+
* validation result: a scheduler check-in event is born with
|
|
114
|
+
* `validation: 'unvalidated'` (stamped by appendResponseEventIfKnown's
|
|
115
|
+
* meta), while a validation RESULT event carries 'validating' /
|
|
116
|
+
* 'verified' / 'refuted' (and a plain chat response carries no
|
|
117
|
+
* validation field at all). Only `eventValidation === 'unvalidated'`
|
|
118
|
+
* may trigger a prompt, so an appended validation result can never
|
|
119
|
+
* enqueue a further prompt — no loop.
|
|
120
|
+
* 3. Epic must exist AND have status 'active' in the on-disk
|
|
121
|
+
* active-index.json (re-checked here even though the append already
|
|
122
|
+
* enforced it, so the gate holds for any future call site too).
|
|
123
|
+
* 4. Once per (epicId, prdSlug): in-memory `_fired` Set for the common
|
|
124
|
+
* path, plus a durable re-check of the Epic's own event chain — the
|
|
125
|
+
* check-in event just appended for this pair accounts for ONE
|
|
126
|
+
* validation-stamped response event with this prdSlug; two or more
|
|
127
|
+
* means an earlier check-in already requested validation (e.g. a
|
|
128
|
+
* re-notify after an app restart emptied `_fired`), so skip.
|
|
129
|
+
*
|
|
130
|
+
* (Gate: slot pool — see the module doc comment; no spawn happens here.)
|
|
131
|
+
*
|
|
132
|
+
* Complexity: O(n) over the Epic's event chain for the durable dedup scan.
|
|
133
|
+
*/
|
|
134
|
+
function maybeEnqueueValidationPrompt(
|
|
135
|
+
{ cwd, epicId, prdSlug, prdPath = null, outcome, eventValidation },
|
|
136
|
+
{ sendPrompt, readActiveIndex = defaultReadActiveIndex, log = console } = {},
|
|
137
|
+
) {
|
|
138
|
+
try {
|
|
139
|
+
// Gate 1: kill-switch (SM_EPIC_VALIDATION_DISABLE, per SM_DOD_DISABLE precedent).
|
|
140
|
+
if (process.env.SM_EPIC_VALIDATION_DISABLE === '1') return { enqueued: false, reason: 'disabled' };
|
|
141
|
+
|
|
142
|
+
// Gate 2: LOOP GUARD (see doc comment above for how the field value
|
|
143
|
+
// distinguishes a check-in from a validation result).
|
|
144
|
+
if (eventValidation !== 'unvalidated') return { enqueued: false, reason: 'not-a-checkin' };
|
|
145
|
+
|
|
146
|
+
if (!cwd || !epicId || !prdSlug || typeof sendPrompt !== 'function') {
|
|
147
|
+
return { enqueued: false, reason: 'missing-args' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Gate 4a: in-memory once-per-pair (checked before the disk read — cheap first).
|
|
151
|
+
const key = pairKey(epicId, prdSlug);
|
|
152
|
+
if (_fired.has(key)) return { enqueued: false, reason: 'already-fired' };
|
|
153
|
+
|
|
154
|
+
// Gate 3: authoring Epic must be a known, still-active session.
|
|
155
|
+
const index = readActiveIndex(cwd);
|
|
156
|
+
const session = index && index.sessions && index.sessions[epicId];
|
|
157
|
+
if (!session || session.status !== 'active') {
|
|
158
|
+
return { enqueued: false, reason: 'epic-not-active' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Gate 4b: durable once-per-pair — the just-appended check-in accounts
|
|
162
|
+
// for one validation-stamped response event for this prdSlug; a second
|
|
163
|
+
// one means a previous check-in already asked.
|
|
164
|
+
const events = (index.events && index.events[epicId]) || [];
|
|
165
|
+
const priorCheckins = events.filter(
|
|
166
|
+
(e) => e && e.kind === 'response' && e.prdSlug === prdSlug && e.validation !== undefined,
|
|
167
|
+
);
|
|
168
|
+
if (priorCheckins.length >= 2) {
|
|
169
|
+
_fired.add(key); // remember so later re-notifies skip the disk read too
|
|
170
|
+
return { enqueued: false, reason: 'already-fired-durable' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const prompt = buildValidationPrompt({ prdSlug, prdPath, outcome });
|
|
174
|
+
sendPrompt(epicId, prompt);
|
|
175
|
+
_fired.add(key);
|
|
176
|
+
return { enqueued: true };
|
|
177
|
+
} catch (e) {
|
|
178
|
+
try { (log || console).error('[epicValidationHook] enqueue error', prdSlug, e); } catch { /* noop */ }
|
|
179
|
+
return { enqueued: false, reason: 'error' };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Test hook: clear the once-per-pair memory. */
|
|
184
|
+
function __resetForTests() {
|
|
185
|
+
_fired.clear();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
buildValidationPrompt,
|
|
190
|
+
maybeEnqueueValidationPrompt,
|
|
191
|
+
__resetForTests,
|
|
192
|
+
};
|
|
@@ -18,6 +18,7 @@ const fsp = require('node:fs/promises');
|
|
|
18
18
|
const path = require('node:path');
|
|
19
19
|
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
20
20
|
const { resolvePrdWriteDir } = require('./prdLocations.cjs');
|
|
21
|
+
const { projectQueuePath } = require('./queueStore.cjs');
|
|
21
22
|
const { expandHome } = require('./expandHome.cjs');
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -95,22 +96,92 @@ async function migratePrds(legacyPrdsDir) {
|
|
|
95
96
|
* Name collisions in prds-archived/ get a `-legacy-<n>` suffix, never an
|
|
96
97
|
* overwrite. Idempotent: an emptied flat dir is a no-op readdir.
|
|
97
98
|
*
|
|
98
|
-
*
|
|
99
|
+
* LIVE JOBS ARE NEVER ARCHIVED (PRD 992). The scheduler still scans this flat
|
|
100
|
+
* dir as a PRD *source* (prdLocations.cjs's resolvePrdsDirs, "scan sources
|
|
101
|
+
* alongside the legacy flat dir"), so a file sitting here can legitimately
|
|
102
|
+
* have a pending/running job. Archiving it out from under that job strands the
|
|
103
|
+
* queue row with no resolvable source. Observed live 2026-08-02:
|
|
104
|
+
* `980-fix-chat-typed-event-renderers.md` sat in this dir with status
|
|
105
|
+
* `running` — a restart in that window would have moved its source mid-run.
|
|
106
|
+
* Such files are left in place and reported as `skipped`, mirroring how the
|
|
107
|
+
* sibling migratePrds() reports `unresolved` rather than dropping anything.
|
|
108
|
+
*
|
|
109
|
+
* Returns { moved, failed: [{ file, reason }], skipped: [{ file, reason }] }.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/** Job statuses that mean "this PRD's source must survive". `needs_review` is
|
|
113
|
+
* live on purpose: it is awaiting human action and will be re-read. */
|
|
114
|
+
const LIVE_JOB_STATUSES = new Set(['pending', 'running', 'needs_review', 'investigating']);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Slugs with a live job in this project's own queue shard.
|
|
118
|
+
*
|
|
119
|
+
* Returns null when liveness cannot be determined (unreadable/unparseable
|
|
120
|
+
* queue.json) — the caller then FAILS CLOSED and archives nothing, since it
|
|
121
|
+
* cannot prove a file is safe to move. A merely ABSENT queue.json is not an
|
|
122
|
+
* error: a project with no scheduler state has no jobs, so an empty set is
|
|
123
|
+
* the correct answer and consolidation proceeds normally.
|
|
99
124
|
*/
|
|
100
|
-
async function
|
|
125
|
+
async function liveSlugsForCwd(cwd) {
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = await fsp.readFile(projectQueuePath(cwd), 'utf8');
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e?.code === 'ENOENT') return new Set(); // fresh project — nothing queued
|
|
131
|
+
return null; // unreadable — caller fails closed
|
|
132
|
+
}
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw);
|
|
136
|
+
} catch {
|
|
137
|
+
return null; // corrupt — caller fails closed
|
|
138
|
+
}
|
|
139
|
+
const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
140
|
+
const live = new Set();
|
|
141
|
+
for (const job of jobs) {
|
|
142
|
+
if (job && typeof job.slug === 'string' && LIVE_JOB_STATUSES.has(job.status)) {
|
|
143
|
+
live.add(job.slug);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return live;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A queue job's slug is its PRD filename minus the `.md` suffix. */
|
|
150
|
+
function slugForPrdFile(name) {
|
|
151
|
+
return name.slice(0, -3);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function consolidateFlatPrds(cwd, opts = {}) {
|
|
101
155
|
const flatDir = resolvePrdWriteDir(cwd);
|
|
102
156
|
const archiveDir = path.join(path.dirname(flatDir), 'prds-archived');
|
|
103
157
|
let entries;
|
|
104
158
|
try {
|
|
105
159
|
entries = await fsp.readdir(flatDir);
|
|
106
160
|
} catch {
|
|
107
|
-
return { moved: 0, failed: [] };
|
|
161
|
+
return { moved: 0, failed: [], skipped: [] };
|
|
108
162
|
}
|
|
109
163
|
|
|
164
|
+
const liveSlugs = opts.liveSlugs !== undefined ? opts.liveSlugs : await liveSlugsForCwd(cwd);
|
|
165
|
+
|
|
110
166
|
let moved = 0;
|
|
111
167
|
const failed = [];
|
|
168
|
+
const skipped = [];
|
|
169
|
+
|
|
170
|
+
// Fail closed: liveness unknown means every file might belong to a live job.
|
|
171
|
+
if (liveSlugs === null) {
|
|
172
|
+
for (const name of entries) {
|
|
173
|
+
if (!name.endsWith('.md') || name.startsWith('.')) continue;
|
|
174
|
+
skipped.push({ file: name, reason: 'queue state unreadable — cannot prove no live job' });
|
|
175
|
+
}
|
|
176
|
+
return { moved: 0, failed, skipped };
|
|
177
|
+
}
|
|
178
|
+
|
|
112
179
|
for (const name of entries) {
|
|
113
180
|
if (!name.endsWith('.md') || name.startsWith('.')) continue;
|
|
181
|
+
if (liveSlugs.has(slugForPrdFile(name))) {
|
|
182
|
+
skipped.push({ file: name, reason: 'live queue job — source must survive' });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
114
185
|
const src = path.join(flatDir, name);
|
|
115
186
|
try {
|
|
116
187
|
await fsp.mkdir(archiveDir, { recursive: true });
|
|
@@ -125,7 +196,7 @@ async function consolidateFlatPrds(cwd) {
|
|
|
125
196
|
failed.push({ file: name, reason: e?.message ?? 'move failed' });
|
|
126
197
|
}
|
|
127
198
|
}
|
|
128
|
-
return { moved, failed };
|
|
199
|
+
return { moved, failed, skipped };
|
|
129
200
|
}
|
|
130
201
|
|
|
131
|
-
module.exports = { migratePrds, consolidateFlatPrds };
|
|
202
|
+
module.exports = { migratePrds, consolidateFlatPrds, LIVE_JOB_STATUSES };
|