claude-code-session-manager 0.78.0 → 0.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentLibrary-13pfo8uY.js → AgentLibrary-zS3jw_1e.js} +1 -1
- package/dist/assets/{DataModel-SUyQbFlg.js → DataModel-Cy_vxTpi.js} +1 -1
- package/dist/assets/{History-2GJMS703.js → History-C6JRuqfT.js} +1 -1
- package/dist/assets/{Hooks-DM2nS3RT.js → Hooks-BafPy9mB.js} +1 -1
- package/dist/assets/{HostBilko-BLeC-lpp.js → HostBilko-BZwhQOFt.js} +1 -1
- package/dist/assets/{Library-BaRkU9m0.js → Library-C8JDDliz.js} +1 -1
- package/dist/assets/{ListDetail-D5scjSKq.js → ListDetail-CqiOdwLc.js} +1 -1
- package/dist/assets/{MarkdownEditor-B1lgAo9T.js → MarkdownEditor-CyLyP67L.js} +1 -1
- package/dist/assets/{McpServers-BzyThQSM.js → McpServers-BzMv-_84.js} +1 -1
- package/dist/assets/{Memory-7UdaOTtl.js → Memory-DSBYQdJR.js} +1 -1
- package/dist/assets/{Panel-JbTMaOPq.js → Panel-CLUhkNNA.js} +1 -1
- package/dist/assets/{Permissions-UBam0bJG.js → Permissions-BfC2-HN4.js} +1 -1
- package/dist/assets/{Plugins-B3gUDkeb.js → Plugins-BKi40jT5.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CeHOub7m.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
- package/dist/assets/{SaveBar-BcvQEq6h.js → SaveBar-avk2p9jv.js} +1 -1
- package/dist/assets/{Scheduler-Dc5qiP24.js → Scheduler-Bf_6MdJo.js} +7 -7
- package/dist/assets/{ScopeSwitcher-BvGQmw4Y.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
- package/dist/assets/{Settings-C2dEFb-v.js → Settings-Djd8OoBA.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-CIwlosBc.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
- package/dist/assets/{Skills-C0GzzVrQ.js → Skills-D_qAqxZ_.js} +1 -1
- package/dist/assets/{SystemPrompt-mtGPK8zo.js → SystemPrompt-DbHFLQV3.js} +1 -1
- package/dist/assets/{TagLibrary-DX54-mpd.js → TagLibrary-C2y91BT0.js} +1 -1
- package/dist/assets/{TiptapBody-yADC2RWE.js → TiptapBody-D9iz4xQx.js} +1 -1
- package/dist/assets/{Toggle-CRxaCYLI.js → Toggle-BGnFL2E5.js} +1 -1
- package/dist/assets/{index-D6ymGESc.js → index-_2ARyFDj.js} +4 -4
- package/dist/assets/{settingsSchema-TtMvT5Sx.js → settingsSchema-JK15eJU8.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
- package/scripts/project-pages-logic/dist/logic.cjs +12 -12
- package/scripts/render-project-pages/dist/renderer.cjs +22 -22
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
- package/src/main/__tests__/prdCreate.test.cjs +133 -8
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
- package/src/main/__tests__/queueHistory.test.cjs +33 -0
- package/src/main/__tests__/runLogRetention.test.cjs +59 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-never-stop.test.cjs +157 -0
- package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +81 -0
- package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +123 -0
- package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +158 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +30 -0
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
- package/src/main/ipcSchemas.cjs +15 -1
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +90 -1
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/depSlugResolve.cjs +72 -0
- package/src/main/lib/epicWorktreeMerge.cjs +3 -3
- package/src/main/lib/epicWorktreeMint.cjs +17 -5
- package/src/main/lib/fixPlanSlug.cjs +62 -0
- package/src/main/lib/gitWorktree.cjs +97 -12
- package/src/main/lib/jobDirtFilter.cjs +54 -0
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/prdCreate.cjs +84 -5
- package/src/main/lib/prdFrontmatter.cjs +56 -8
- package/src/main/lib/queueHistory.cjs +50 -5
- package/src/main/lib/rateLimitDetect.cjs +35 -0
- package/src/main/lib/reaperHelpers.cjs +31 -6
- package/src/main/lib/runLogRetention.cjs +82 -4
- package/src/main/lib/scheduleJobTransitions.cjs +12 -2
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +1165 -70
- package/src/preload/api.d.ts +8 -0
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
// vitest, NOT node:test — this repo's suite is vitest-only (CLAUDE.md).
|
|
17
17
|
import { test } from 'vitest';
|
|
18
18
|
const assert = require('node:assert/strict');
|
|
19
|
-
const
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { selectReapableJobs, mapOutcomeToGateOutcome, classifyRunOutcome } = require('../reaperHelpers.cjs');
|
|
23
|
+
const { detectRateLimitInLog } = require('../rateLimitDetect.cjs');
|
|
20
24
|
|
|
21
25
|
const NOW = Date.parse('2026-09-01T12:00:00.000Z');
|
|
22
26
|
const agoMin = (m) => new Date(NOW - m * 60_000).toISOString();
|
|
@@ -131,3 +135,88 @@ test('mapOutcomeToGateOutcome: no_result -> never_ran', () => {
|
|
|
131
135
|
test('mapOutcomeToGateOutcome: unknown -> unknown', () => {
|
|
132
136
|
assert.strictEqual(mapOutcomeToGateOutcome('unknown'), 'unknown');
|
|
133
137
|
});
|
|
138
|
+
|
|
139
|
+
// detectRateLimitInLog / classifyRunOutcome — PRD 1117: the reaper's
|
|
140
|
+
// rate-limit detection must be the SAME single source of truth spawnJob
|
|
141
|
+
// uses (detectRateLimitInLog, shared via lib/rateLimitDetect.cjs), and a
|
|
142
|
+
// rate-limited death must classify as a NEW, distinct 'rate_limited'
|
|
143
|
+
// outcome — never collapsed into 'failed', and never re-labelling
|
|
144
|
+
// 'success'/'failed'/'no_result' (which keep their prior meanings, per the
|
|
145
|
+
// mapOutcomeToGateOutcome tests above, unmodified).
|
|
146
|
+
|
|
147
|
+
function writeTmpLog(contents) {
|
|
148
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reaper-rate-limit-test-'));
|
|
149
|
+
const p = path.join(dir, 'run.log');
|
|
150
|
+
fs.writeFileSync(p, contents);
|
|
151
|
+
return p;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
test('detectRateLimitInLog: rateLimitType":"five_hour" variant', () => {
|
|
155
|
+
const p = writeTmpLog('{"type":"result","rate_limit_info":{"rateLimitType":"five_hour"}}\n');
|
|
156
|
+
assert.strictEqual(detectRateLimitInLog(p), true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('detectRateLimitInLog: rateLimitType":"seven_day" variant (missed pre-PRD-1117)', () => {
|
|
160
|
+
const p = writeTmpLog('{"type":"rate_limit_event","rate_limit_info":{"rateLimitType":"seven_day","unifiedWindows":{"five_hour":{"utilization":0}}}}\n');
|
|
161
|
+
assert.strictEqual(detectRateLimitInLog(p), true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('detectRateLimitInLog: api_error_status":429 variant', () => {
|
|
165
|
+
const p = writeTmpLog('{"type":"result","is_error":true,"api_error_status":429,"result":"nope"}\n');
|
|
166
|
+
assert.strictEqual(detectRateLimitInLog(p), true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('detectRateLimitInLog: "You\'ve reached your <model> limit" variant (missed pre-PRD-1117)', () => {
|
|
170
|
+
const p = writeTmpLog('{"type":"result","result":"You\'ve reached your Fable limit. Switch to another model."}\n');
|
|
171
|
+
assert.strictEqual(detectRateLimitInLog(p), true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('detectRateLimitInLog: real evidence log (2026-09-05 204-mercury-steam-horse 429)', () => {
|
|
175
|
+
const evidencePath = '/home/bilko/.claude/session-manager/scheduled-plans/runs/2026-09-05T16-32-18-381Z/204-mercury-steam-horse.log';
|
|
176
|
+
if (!fs.existsSync(evidencePath)) return; // machine-local fixture; skip elsewhere
|
|
177
|
+
assert.strictEqual(detectRateLimitInLog(evidencePath), true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('classifyRunOutcome: a 429 log tail classifies as the new distinct rate_limited outcome, not failed', () => {
|
|
181
|
+
const p = writeTmpLog('{"type":"result","subtype":"success","is_error":true,"api_error_status":429,"rateLimitType":"seven_day","result":"You\'ve reached your Fable limit."}\n');
|
|
182
|
+
assert.strictEqual(classifyRunOutcome(p), 'rate_limited');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('classifyRunOutcome: a genuine error with no rate-limit signal still classifies as failed', () => {
|
|
186
|
+
const p = writeTmpLog('{"type":"result","subtype":"error","is_error":true,"result":"Error: expected 2 but got 3"}\n');
|
|
187
|
+
assert.strictEqual(classifyRunOutcome(p), 'failed');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// REGRESSION GUARD (2026-09-05). The rate-limit check originally ran as the
|
|
191
|
+
// FIRST statement of classifyRunOutcome, before the result event was parsed.
|
|
192
|
+
// Because the CLI emits an informational rate_limit_event with
|
|
193
|
+
// status:"allowed_warning" on nearly every run once utilization is non-zero,
|
|
194
|
+
// that misclassified genuinely successful runs as 'rate_limited' — which in
|
|
195
|
+
// reapDeadRunningJobs re-queues finished work and spuriously pauses the whole
|
|
196
|
+
// machine. The fixture below is the shape of a REAL successful run log, not a
|
|
197
|
+
// synthetic one-liner: the allowed_warning event that every run carries, then
|
|
198
|
+
// a clean result. If someone moves the check back to the top, this goes red.
|
|
199
|
+
test('classifyRunOutcome: a SUCCESSFUL run whose tail carries an allowed_warning rate_limit_event is still success', () => {
|
|
200
|
+
const p = writeTmpLog([
|
|
201
|
+
'{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","resetsAt":1788643800,"rateLimitType":"five_hour","utilization":0.42,"isUsingOverage":false}}',
|
|
202
|
+
'{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","resetsAt":1789059600,"rateLimitType":"seven_day","utilization":0.59,"isUsingOverage":false}}',
|
|
203
|
+
'{"type":"result","subtype":"success","is_error":false,"num_turns":55,"result":"SCHEDULER_VERDICT: PASS"}',
|
|
204
|
+
].join('\n') + '\n');
|
|
205
|
+
assert.strictEqual(classifyRunOutcome(p), 'success');
|
|
206
|
+
assert.strictEqual(mapOutcomeToGateOutcome(classifyRunOutcome(p)), 'passed');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// The same tail, but the run actually errored: NOW the rate-limit signal is
|
|
210
|
+
// what it claims to be, and the outcome must be the retryable one.
|
|
211
|
+
test('classifyRunOutcome: the same allowed_warning tail on an ERRORED run classifies as rate_limited', () => {
|
|
212
|
+
const p = writeTmpLog([
|
|
213
|
+
'{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","rateLimitType":"seven_day","utilization":0.59}}',
|
|
214
|
+
'{"type":"result","subtype":"success","is_error":true,"api_error_status":429,"result":"You\'ve reached your Fable limit."}',
|
|
215
|
+
].join('\n') + '\n');
|
|
216
|
+
assert.strictEqual(classifyRunOutcome(p), 'rate_limited');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('classifyRunOutcome: a clean success log still classifies as success', () => {
|
|
220
|
+
const p = writeTmpLog('{"type":"result","subtype":"success","is_error":false,"result":"done"}\n');
|
|
221
|
+
assert.strictEqual(classifyRunOutcome(p), 'success');
|
|
222
|
+
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
// vitest globals (test) — same convention as the other .cjs tests.
|
|
13
13
|
const assert = require('node:assert/strict');
|
|
14
|
-
const { pickForProject, DEFAULT_PROJECT_CWD } = require('../schedulerBatch.cjs');
|
|
14
|
+
const { pickForProject, findBlockingDep, DEFAULT_PROJECT_CWD, DEP_HISTORY_FAIL_OPEN } = require('../schedulerBatch.cjs');
|
|
15
15
|
|
|
16
16
|
const CWD = DEFAULT_PROJECT_CWD;
|
|
17
17
|
|
|
@@ -74,14 +74,66 @@ test('still resolves an exact-slug dep (dep written WITH the NN- prefix)', () =>
|
|
|
74
74
|
assert.deepEqual(batch.map((j) => j.slug), ['873-leftnav-two-face-framework']);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
// PRD 1122 — a dep slug with no live row is no longer auto-satisfied by
|
|
78
|
+
// absence alone. It must show up in the caller's precomputed
|
|
79
|
+
// history/archive Set (5th arg, built once per tick by
|
|
80
|
+
// scheduler.cjs's computeDepHistorySatisfaction) or it HOLDS as
|
|
81
|
+
// 'unresolved'. Observed live on starry-night-ships: a typo'd/format-
|
|
82
|
+
// mismatched dep slug used to be silently indistinguishable from one
|
|
83
|
+
// genuinely retired to history.
|
|
84
|
+
test('a dep with no row IS treated as done when the caller supplies it as history/archive-satisfied', () => {
|
|
78
85
|
const jobs = [
|
|
79
86
|
job('874-nav-face-project-home', 'pending', { dependsOn: ['some-long-archived-prd'] }),
|
|
80
87
|
];
|
|
81
|
-
const { batch } =
|
|
88
|
+
const { batch } = pickForProject(jobs, new Set(), 3, new Map(), new Set(['some-long-archived-prd']));
|
|
89
|
+
assert.deepEqual(batch.map((j) => j.slug), ['874-nav-face-project-home']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a dep with no row and NOT in the history/archive set HOLDS as unresolved, with a named actionable reason', () => {
|
|
93
|
+
const jobs = [
|
|
94
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['some-typo-d-prd'] }),
|
|
95
|
+
];
|
|
96
|
+
const { batch, reason, holds } = pick(jobs);
|
|
97
|
+
assert.deepEqual(batch, []);
|
|
98
|
+
assert.match(reason, /depends-gate/);
|
|
99
|
+
assert.match(reason, /unresolvable dependencies/);
|
|
100
|
+
assert.match(reason, /874-nav-face-project-home <- some-typo-d-prd/);
|
|
101
|
+
assert.match(reason, /fix the dependsOn entry or archive the dependent/);
|
|
102
|
+
const hold = holds.find((h) => h.slug === '874-nav-face-project-home');
|
|
103
|
+
assert.equal(hold.depStatus, 'unresolved');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('the double-prefixed-row regression: a row named 253-253-x referenced as bare x holds as unresolved', () => {
|
|
107
|
+
// bareSlug strips exactly ONE leading `NN-` run, so '253-253-x' bares to
|
|
108
|
+
// '253-x' — it does NOT bare-match dependsOn's 'x'. The double-prefixed row
|
|
109
|
+
// is therefore genuinely invisible to both exact and bare matching, the
|
|
110
|
+
// same as a typo — this is the exact 2026-09-06 starry-night-ships report.
|
|
111
|
+
const jobs = [
|
|
112
|
+
job('253-253-x', 'running'),
|
|
113
|
+
job('254-next', 'pending', { dependsOn: ['x'] }),
|
|
114
|
+
];
|
|
115
|
+
const { batch, holds, reason } = pick(jobs);
|
|
116
|
+
assert.deepEqual(batch.map((j) => j.slug), []);
|
|
117
|
+
assert.match(reason, /unresolvable dependencies/);
|
|
118
|
+
const hold = holds.find((h) => h.slug === '254-next');
|
|
119
|
+
assert.equal(hold.dep, 'x');
|
|
120
|
+
assert.equal(hold.depStatus, 'unresolved');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('DEP_HISTORY_FAIL_OPEN preserves the pre-1122 fail-open behaviour for a read failure', () => {
|
|
124
|
+
const jobs = [
|
|
125
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['some-long-archived-prd'] }),
|
|
126
|
+
];
|
|
127
|
+
const { batch } = pickForProject(jobs, new Set(), 3, new Map(), DEP_HISTORY_FAIL_OPEN);
|
|
82
128
|
assert.deepEqual(batch.map((j) => j.slug), ['874-nav-face-project-home']);
|
|
83
129
|
});
|
|
84
130
|
|
|
131
|
+
test('findBlockingDep: bare-name resolution against satisfiedSlugs matches the live-row bare-name rule', () => {
|
|
132
|
+
const dependent = job('254-next', 'pending', { dependsOn: ['x'] });
|
|
133
|
+
assert.equal(findBlockingDep(dependent, [dependent], new Set(['253-x'])), undefined);
|
|
134
|
+
assert.equal(findBlockingDep(dependent, [dependent], new Set()), 'x');
|
|
135
|
+
});
|
|
136
|
+
|
|
85
137
|
test('a FAILED bare-named dep holds the dependent and reports an explicit reason', () => {
|
|
86
138
|
const jobs = [
|
|
87
139
|
job('873-leftnav-two-face-framework', 'failed'),
|
|
@@ -203,7 +255,7 @@ test('reports a per-job hold record naming the blocking dep and its status', ()
|
|
|
203
255
|
const { batch, holds } = pick(jobs, new Set(), 5);
|
|
204
256
|
assert.deepEqual(batch.map((j) => j.slug), ['985-foundation']);
|
|
205
257
|
assert.deepEqual(holds, [
|
|
206
|
-
{ slug: '986-dependent', dep: 'foundation', depStatus: 'pending' },
|
|
258
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'pending', rootSlug: '985-foundation', rootStatus: 'pending' },
|
|
207
259
|
]);
|
|
208
260
|
});
|
|
209
261
|
|
|
@@ -214,7 +266,7 @@ test('hold record carries a running dep status', () => {
|
|
|
214
266
|
];
|
|
215
267
|
const { holds } = pick(jobs, new Set(['985-foundation']), 5);
|
|
216
268
|
assert.deepEqual(holds, [
|
|
217
|
-
{ slug: '986-dependent', dep: 'foundation', depStatus: 'running' },
|
|
269
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'running', rootSlug: '985-foundation', rootStatus: 'running' },
|
|
218
270
|
]);
|
|
219
271
|
});
|
|
220
272
|
|
|
@@ -227,7 +279,7 @@ test('hold record carries a failed dep status alongside the depends-gate reason'
|
|
|
227
279
|
assert.deepEqual(batch, []);
|
|
228
280
|
assert.match(reason, /depends-gate/);
|
|
229
281
|
assert.deepEqual(holds, [
|
|
230
|
-
{ slug: '986-dependent', dep: 'foundation', depStatus: 'failed' },
|
|
282
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'failed', rootSlug: '985-foundation', rootStatus: 'failed' },
|
|
231
283
|
]);
|
|
232
284
|
});
|
|
233
285
|
|
|
@@ -241,7 +293,7 @@ test('hold record carries a skipped dep status alongside the depends-gate reason
|
|
|
241
293
|
assert.match(reason, /depends-gate/);
|
|
242
294
|
assert.match(reason, /never-ran dependencies/);
|
|
243
295
|
assert.deepEqual(holds, [
|
|
244
|
-
{ slug: '986-dependent', dep: 'foundation', depStatus: 'skipped' },
|
|
296
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'skipped', rootSlug: '985-foundation', rootStatus: 'skipped' },
|
|
245
297
|
]);
|
|
246
298
|
});
|
|
247
299
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* depSlugResolve.cjs — the single `dependsOn` slug-resolution rule (PRD 832),
|
|
5
|
+
* shared by schedulerBatch.cjs's findBlockingDep (run-time gating: does a
|
|
6
|
+
* queue row exist for this dep) and prdCreate.cjs's write-time validation
|
|
7
|
+
* (does a PRD exist for this dep at all). Both must agree on what a
|
|
8
|
+
* `dependsOn` entry resolves to, or a dep that validates at PRD-create time
|
|
9
|
+
* could still fail to block at run time (or vice versa) — so the matching
|
|
10
|
+
* rule lives here once, not copied into each caller.
|
|
11
|
+
*
|
|
12
|
+
* Resolution rule: exact slug match first; else bare-name match after
|
|
13
|
+
* stripping one leading `NN-` prefix — a human-authored `dependsOn:` can't
|
|
14
|
+
* know the `NN-` prefix the allocator will hand a sibling PRD.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Strip one leading `NN-` prefix, if present. */
|
|
18
|
+
function bareSlug(slug) {
|
|
19
|
+
return String(slug ?? '').replace(/^\d+-/, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve `depSlug` against `candidateSlugs`: exact match wins outright;
|
|
24
|
+
* otherwise every candidate whose bare name matches. Returns a (possibly
|
|
25
|
+
* empty) array of matching candidate slugs.
|
|
26
|
+
*/
|
|
27
|
+
function resolveDepSlug(depSlug, candidateSlugs) {
|
|
28
|
+
if (candidateSlugs.includes(depSlug)) return [depSlug];
|
|
29
|
+
const bare = bareSlug(depSlug);
|
|
30
|
+
return candidateSlugs.filter((slug) => bareSlug(slug) === bare);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Levenshtein edit distance. O(n*m) in the two strings' lengths — fine here
|
|
35
|
+
* since it only ever runs over short slug strings (<= 60 chars, PRD_CREATE_SLUG_RE's
|
|
36
|
+
* cap) at PRD-create time, never in a hot/per-tick path.
|
|
37
|
+
*/
|
|
38
|
+
function levenshtein(a, b) {
|
|
39
|
+
const m = a.length;
|
|
40
|
+
const n = b.length;
|
|
41
|
+
if (m === 0) return n;
|
|
42
|
+
if (n === 0) return m;
|
|
43
|
+
const prevRow = new Array(n + 1);
|
|
44
|
+
const curRow = new Array(n + 1);
|
|
45
|
+
for (let j = 0; j <= n; j++) prevRow[j] = j;
|
|
46
|
+
for (let i = 1; i <= m; i++) {
|
|
47
|
+
curRow[0] = i;
|
|
48
|
+
for (let j = 1; j <= n; j++) {
|
|
49
|
+
curRow[j] = a[i - 1] === b[j - 1]
|
|
50
|
+
? prevRow[j - 1]
|
|
51
|
+
: 1 + Math.min(prevRow[j - 1], prevRow[j], curRow[j - 1]);
|
|
52
|
+
}
|
|
53
|
+
for (let j = 0; j <= n; j++) prevRow[j] = curRow[j];
|
|
54
|
+
}
|
|
55
|
+
return prevRow[n];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Nearest existing slugs to `depSlug` by bare-name edit distance, closest
|
|
60
|
+
* first, capped at `limit` — used to build an actionable "did you mean"
|
|
61
|
+
* error when a dependsOn entry resolves to nothing.
|
|
62
|
+
*/
|
|
63
|
+
function findNearMatches(depSlug, candidateSlugs, limit = 3) {
|
|
64
|
+
const depBare = bareSlug(depSlug);
|
|
65
|
+
return candidateSlugs
|
|
66
|
+
.map((slug) => ({ slug, dist: levenshtein(depBare, bareSlug(slug)) }))
|
|
67
|
+
.sort((a, b) => a.dist - b.dist)
|
|
68
|
+
.slice(0, limit)
|
|
69
|
+
.map((entry) => entry.slug);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { bareSlug, resolveDepSlug, findNearMatches };
|
|
@@ -37,9 +37,9 @@ const { validatePath } = require('../config.cjs');
|
|
|
37
37
|
* real conflict — never throws (integrateEpicBranch/cleanupEpicWorktree
|
|
38
38
|
* never do either).
|
|
39
39
|
*/
|
|
40
|
-
async function mergeEpicToMainViaIpc(cwd, epicId, branch, dir) {
|
|
40
|
+
async function mergeEpicToMainViaIpc(cwd, epicId, branch, dir, carriedPaths) {
|
|
41
41
|
validatePath(cwd);
|
|
42
|
-
const outcome = await integrateEpicBranch({ cwd, branch, epicId });
|
|
42
|
+
const outcome = await integrateEpicBranch({ cwd, branch, epicId, carriedPaths });
|
|
43
43
|
if (!outcome.ok) {
|
|
44
44
|
console.log(`[epicWorktreeMerge] ${epicId}: needs manual resolution (${outcome.reason})`);
|
|
45
45
|
return { ok: false, status: 'needs_merge_resolution', reason: outcome.reason };
|
|
@@ -56,7 +56,7 @@ function registerEpicWorktreeMergeHandlers() {
|
|
|
56
56
|
const { schemas: s, validated: v } = require('../ipcSchemas.cjs');
|
|
57
57
|
ipcMain.handle(
|
|
58
58
|
'promptSessions:merge-to-main',
|
|
59
|
-
v(s.promptSessionsMergeToMain, ({ cwd, epicId, branch, dir }) => mergeEpicToMainViaIpc(cwd, epicId, branch, dir)),
|
|
59
|
+
v(s.promptSessionsMergeToMain, ({ cwd, epicId, branch, dir, carriedPaths }) => mergeEpicToMainViaIpc(cwd, epicId, branch, dir, carriedPaths)),
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -30,10 +30,10 @@ const { validatePath } = require('../config.cjs');
|
|
|
30
30
|
/**
|
|
31
31
|
* createEpicWorktreeViaIpc(cwd, epicId) → Promise<EpicWorktree | null>
|
|
32
32
|
*
|
|
33
|
-
* Returns the `worktree` field shape (`{ dir, branch, baseCwd, status
|
|
34
|
-
* ready to persist onto the Epic record on success, or
|
|
35
|
-
* failure — createEpicWorktree/createWorktree never throw, so
|
|
36
|
-
* does either.
|
|
33
|
+
* Returns the `worktree` field shape (`{ dir, branch, baseCwd, status,
|
|
34
|
+
* carriedPaths }`) ready to persist onto the Epic record on success, or
|
|
35
|
+
* `null` on any failure — createEpicWorktree/createWorktree never throw, so
|
|
36
|
+
* this never does either.
|
|
37
37
|
*/
|
|
38
38
|
async function createEpicWorktreeViaIpc(cwd, epicId) {
|
|
39
39
|
validatePath(cwd);
|
|
@@ -42,7 +42,19 @@ async function createEpicWorktreeViaIpc(cwd, epicId) {
|
|
|
42
42
|
console.log(`[epicWorktreeMint] ${epicId}: not isolated (${result.reason})`);
|
|
43
43
|
return null;
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
// carriedPaths (base-tree WIP carried into this worktree — PRD 1094) MUST
|
|
46
|
+
// survive onto the persisted record: it's the only way integrateEpicBranch
|
|
47
|
+
// (via epicWorktreeMerge.cjs) can later apply the carried-wip-only skip
|
|
48
|
+
// instead of attempting a doomed merge against the base tree's still-dirty
|
|
49
|
+
// paths. Dropping it here silently (as this used to) meant that skip never
|
|
50
|
+
// fired for Epics, only for scheduler jobs.
|
|
51
|
+
return {
|
|
52
|
+
dir: result.dir,
|
|
53
|
+
branch: result.branch,
|
|
54
|
+
baseCwd: result.baseCwd,
|
|
55
|
+
status: 'active',
|
|
56
|
+
...(Array.isArray(result.carriedPaths) && result.carriedPaths.length ? { carriedPaths: result.carriedPaths } : {}),
|
|
57
|
+
};
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
function registerEpicWorktreeMintHandlers() {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fixPlanSlug.cjs — single source of truth for recognizing a scheduler-
|
|
3
|
+
* authored fix-plan PRD (PRD 1131).
|
|
4
|
+
*
|
|
5
|
+
* `isFixPlanSlug`'s `/^\d+-fix-/` name test used to be treated as sufficient
|
|
6
|
+
* evidence on its own. But any PRD an agent or human authors through
|
|
7
|
+
* `scheduler_create_prd` whose title happens to kebab-case into a
|
|
8
|
+
* `fix-`-leading slug silently inherited every fix-plan special case
|
|
9
|
+
* (depth-capped auto-investigation, `commitGuardVerdict`'s zero-edit
|
|
10
|
+
* exemption) it never asked for — PRD 1126
|
|
11
|
+
* (`126-fix-plan-death-reopens-parent`, `createdVia: scheduler-api`) was
|
|
12
|
+
* wrongly stamped `investigationDepth: 2` before it ever ran and had to be
|
|
13
|
+
* withdrawn and re-slugged.
|
|
14
|
+
*
|
|
15
|
+
* A genuine fix plan is authored directly by scheduler.cjs's
|
|
16
|
+
* `spawnInvestigation` — a raw `Write`, never through `scheduler_create_prd`
|
|
17
|
+
* (see `guard-prd-writes.cjs`'s exemption) — so it never carries a
|
|
18
|
+
* `createdVia` stamp. Its prompt template also stamps an explicit
|
|
19
|
+
* `isFixPlan: true` frontmatter key so classification has a positive
|
|
20
|
+
* signal to check, not just an absence.
|
|
21
|
+
*
|
|
22
|
+
* `classifyDiscoveredFixPlan` is the ONE place a freshly-parsed PRD file
|
|
23
|
+
* (which has real `createdVia`/`isFixPlan` frontmatter) is turned into a
|
|
24
|
+
* boolean fix-plan verdict — `reconcile()` calls it once per newly
|
|
25
|
+
* discovered PRD and persists the verdict onto the queue row's `isFixPlan`
|
|
26
|
+
* field, so every later consumer reads that stamp instead of re-deriving it
|
|
27
|
+
* from the name. `resolveIsFixPlan` is what those consumers call: an
|
|
28
|
+
* explicit `true`/`false` on the row wins; a row with the field entirely
|
|
29
|
+
* absent (any queue.json row persisted before this change shipped) falls
|
|
30
|
+
* back to the legacy slug-only heuristic so live history is never
|
|
31
|
+
* re-classified.
|
|
32
|
+
*/
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
function isFixPlanSlug(slug) {
|
|
36
|
+
return /^\d+-fix-/.test(slug);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Classify a freshly-parsed PRD file (real `createdVia`/`isFixPlan`
|
|
41
|
+
* frontmatter available). `createdVia` is checked FIRST and always wins —
|
|
42
|
+
* proof the PRD was authored through the sanctioned scheduler_create_prd API
|
|
43
|
+
* (a lower-trust path than spawnInvestigation's own raw write) must never be
|
|
44
|
+
* overridable by an `isFixPlan: true` stamp in the same file, even though no
|
|
45
|
+
* current write path can produce both on one file (see prdCreate.cjs's
|
|
46
|
+
* slug-refusal + guard-prd-writes.cjs's write-guard). Structural belt, not
|
|
47
|
+
* just relying on those two other files staying in sync.
|
|
48
|
+
*/
|
|
49
|
+
function classifyDiscoveredFixPlan({ isFixPlan, createdVia } = {}, slug) {
|
|
50
|
+
if (createdVia) return false;
|
|
51
|
+
if (isFixPlan === true) return true;
|
|
52
|
+
return isFixPlanSlug(slug);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Classify a queue-row job, honoring an already-persisted `isFixPlan` stamp over the slug. */
|
|
56
|
+
function resolveIsFixPlan(slug, isFixPlan) {
|
|
57
|
+
if (isFixPlan === true) return true;
|
|
58
|
+
if (isFixPlan === false) return false;
|
|
59
|
+
return isFixPlanSlug(slug);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { isFixPlanSlug, classifyDiscoveredFixPlan, resolveIsFixPlan };
|
|
@@ -544,6 +544,69 @@ async function createWorktree({ kind, cwd, key }) {
|
|
|
544
544
|
return { ok: true, dir, branch, baseCwd: cwd, carriedPaths };
|
|
545
545
|
}
|
|
546
546
|
|
|
547
|
+
/**
|
|
548
|
+
* True iff every path in `paths` has working-tree content in `cwd`
|
|
549
|
+
* byte-identical to its committed blob on `branch`. Used both by the
|
|
550
|
+
* carried-wip-only shortcut above and by integrateBranch's auto-resolve of a
|
|
551
|
+
* "would be overwritten by merge" refusal below — in both cases discarding a
|
|
552
|
+
* working-tree copy is only safe BECAUSE identity with the incoming commit is
|
|
553
|
+
* proven, never assumed. Fails toward false (not identical) on ANY read
|
|
554
|
+
* error — a `git show` failure or an unreadable working-tree file is never
|
|
555
|
+
* proof of identity.
|
|
556
|
+
*/
|
|
557
|
+
async function pathsIdenticalToBranch({ cwd, branch, paths }) {
|
|
558
|
+
if (!Array.isArray(paths) || !paths.length) return false;
|
|
559
|
+
for (const p of paths) {
|
|
560
|
+
try {
|
|
561
|
+
const branchContent = await execGit(['show', `${branch}:${p}`], { cwd, timeout: 10_000 });
|
|
562
|
+
const baseContent = await fsp.readFile(path.join(cwd, p), 'utf8');
|
|
563
|
+
if (branchContent !== baseContent) return false;
|
|
564
|
+
} catch {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Parses the path lists out of git's refusal to merge over dirty working-tree
|
|
573
|
+
* files. Git emits up to two distinct blocks — tracked paths under "local
|
|
574
|
+
* changes ... would be overwritten" (restorable via `git checkout --`, which
|
|
575
|
+
* needs an index entry) and untracked paths under "untracked working tree
|
|
576
|
+
* files ... would be overwritten" (no index entry, so `git checkout --`
|
|
577
|
+
* cannot touch them — they must be removed instead). Each block is a run of
|
|
578
|
+
* TAB-indented path lines terminated by the next non-indented line (a
|
|
579
|
+
* "Please ..." sentence or the next block's own header). Returns null when
|
|
580
|
+
* neither block is present — stderr from a genuine content conflict has no
|
|
581
|
+
* such block.
|
|
582
|
+
*/
|
|
583
|
+
function parseBlockingMergePaths(stderrText) {
|
|
584
|
+
if (!stderrText) return null;
|
|
585
|
+
const tracked = [];
|
|
586
|
+
const untracked = [];
|
|
587
|
+
let mode = null;
|
|
588
|
+
for (const rawLine of stderrText.split('\n')) {
|
|
589
|
+
if (/would be overwritten by merge:\s*$/.test(rawLine) && /local changes/.test(rawLine)) {
|
|
590
|
+
mode = 'tracked';
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (/would be overwritten by merge:\s*$/.test(rawLine) && /untracked working tree files/.test(rawLine)) {
|
|
594
|
+
mode = 'untracked';
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (mode) {
|
|
598
|
+
if (/^\t/.test(rawLine)) {
|
|
599
|
+
const p = rawLine.trim();
|
|
600
|
+
if (p) (mode === 'tracked' ? tracked : untracked).push(p);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
mode = null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!tracked.length && !untracked.length) return null;
|
|
607
|
+
return { tracked: Array.from(new Set(tracked)), untracked: Array.from(new Set(untracked)) };
|
|
608
|
+
}
|
|
609
|
+
|
|
547
610
|
/**
|
|
548
611
|
* Integrate a branch back into `cwd`'s current HEAD — fast-forward when
|
|
549
612
|
* possible, a real merge commit when the main tree advanced underneath (a
|
|
@@ -600,17 +663,7 @@ async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
|
|
|
600
663
|
// committed exactly the carried WIP and nothing more. Any mismatch
|
|
601
664
|
// (including a read failure — fail toward the safer default) falls
|
|
602
665
|
// through to the normal merge attempt below instead of skipping.
|
|
603
|
-
|
|
604
|
-
for (const p of changed) {
|
|
605
|
-
try {
|
|
606
|
-
const branchContent = await execGit(['show', `${branch}:${p}`], { cwd, timeout: 10_000 });
|
|
607
|
-
const baseContent = await fsp.readFile(path.join(cwd, p), 'utf8');
|
|
608
|
-
if (branchContent !== baseContent) { allIdentical = false; break; }
|
|
609
|
-
} catch {
|
|
610
|
-
allIdentical = false;
|
|
611
|
-
break;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
666
|
+
const allIdentical = await pathsIdenticalToBranch({ cwd, branch, paths: changed });
|
|
614
667
|
if (allIdentical) {
|
|
615
668
|
return { ok: true, integrated: false, reason: 'carried-wip-only' };
|
|
616
669
|
}
|
|
@@ -637,9 +690,40 @@ async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
|
|
|
637
690
|
await execGit(['merge', '--no-ff', '--no-edit', '-m', mergeMessage, branch], { cwd, timeout: 30_000 });
|
|
638
691
|
return { ok: true, integrated: true, mergeCommit: true };
|
|
639
692
|
} catch (e) {
|
|
693
|
+
const stderrText = (e && (e.stderrText || e.message)) || String(e);
|
|
694
|
+
// git refuses this class of merge before ever touching MERGE_HEAD, so
|
|
695
|
+
// there is nothing yet to abort here — only the retry below (if it also
|
|
696
|
+
// fails) can leave a half-applied merge behind.
|
|
697
|
+
const blocking = parseBlockingMergePaths(stderrText);
|
|
698
|
+
if (blocking) {
|
|
699
|
+
const allPaths = [...blocking.tracked, ...blocking.untracked];
|
|
700
|
+
const identical = await pathsIdenticalToBranch({ cwd, branch, paths: allPaths });
|
|
701
|
+
if (identical) {
|
|
702
|
+
try {
|
|
703
|
+
if (blocking.tracked.length) {
|
|
704
|
+
await execGit(['checkout', '--', ...blocking.tracked], { cwd, timeout: 30_000 });
|
|
705
|
+
}
|
|
706
|
+
for (const p of blocking.untracked) {
|
|
707
|
+
await fsp.rm(path.join(cwd, p), { force: true });
|
|
708
|
+
}
|
|
709
|
+
await execGit(['merge', '--no-ff', '--no-edit', '-m', mergeMessage, branch], { cwd, timeout: 30_000 });
|
|
710
|
+
return {
|
|
711
|
+
ok: true,
|
|
712
|
+
integrated: true,
|
|
713
|
+
mergeCommit: true,
|
|
714
|
+
autoResolved: 'identical_working_tree_duplicates',
|
|
715
|
+
resolvedPaths: allPaths,
|
|
716
|
+
};
|
|
717
|
+
} catch (retryErr) {
|
|
718
|
+
try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
|
|
719
|
+
const retryText = (retryErr && (retryErr.stderrText || retryErr.message)) || String(retryErr);
|
|
720
|
+
return { ok: false, reason: `merge failed (likely a real content conflict): ${retryText}` };
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
640
724
|
// Abort a half-applied merge so `cwd` isn't left in a mid-merge state.
|
|
641
725
|
try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
|
|
642
|
-
return { ok: false, reason: `merge failed (likely a real content conflict): ${
|
|
726
|
+
return { ok: false, reason: `merge failed (likely a real content conflict): ${stderrText}` };
|
|
643
727
|
}
|
|
644
728
|
}
|
|
645
729
|
|
|
@@ -914,6 +998,7 @@ module.exports = {
|
|
|
914
998
|
keyFromBranch,
|
|
915
999
|
createWorktree,
|
|
916
1000
|
captureAndCarryBaseDiff,
|
|
1001
|
+
pathsIdenticalToBranch,
|
|
917
1002
|
integrateBranch,
|
|
918
1003
|
cleanupWorktree,
|
|
919
1004
|
salvageWorktreeDiff,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* jobDirtFilter.cjs — strip paths a scheduled job can NEVER be responsible
|
|
5
|
+
* for out of its post-run dirty delta.
|
|
6
|
+
*
|
|
7
|
+
* The commit guard parks a job on `needs_review` ("finish protocol
|
|
8
|
+
* incomplete: N uncommitted file(s)") whenever its run leaves tracked files
|
|
9
|
+
* dirty. That is the right rule for source files. It is wrong for
|
|
10
|
+
* `session-manager-operations/`: the SINGLE-WRITER LAW (lib/opsOwnership.cjs)
|
|
11
|
+
* says the app itself owns every namespace under that root — `scheduler`
|
|
12
|
+
* writes queue.json/history.jsonl on every dispatch and every finalize,
|
|
13
|
+
* `prompt-sessions` writes active-index.json and transcripts continuously.
|
|
14
|
+
* Those writes land DURING the job's own run, inside its own guard window,
|
|
15
|
+
* and get attributed to the job.
|
|
16
|
+
*
|
|
17
|
+
* Result before this filter: a job that did everything right and committed
|
|
18
|
+
* cleanly still parked, because the scheduler's own bookkeeping was dirty
|
|
19
|
+
* underneath it. Observed 2026-09-05 on social-signals-trader PRDs 4055 and
|
|
20
|
+
* 4056 (leftover lists led with active-index.json / queue.json /
|
|
21
|
+
* history.jsonl / .max-allocated-group) and on starry-night-ships PRD 206.
|
|
22
|
+
* Each parked row then blocked every job that dependsOn it, which is how a
|
|
23
|
+
* queue with 40 ready PRDs stops dead.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately narrow: ONLY the app-owned operations root. A job that dirties
|
|
26
|
+
* real source and walks away is still a genuine finish-protocol violation and
|
|
27
|
+
* must still park.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The one app-owned root. Matches at any depth, and only as a path segment. */
|
|
31
|
+
const OPS_ROOT_SEGMENT = 'session-manager-operations';
|
|
32
|
+
|
|
33
|
+
/** True when `p` lives under an app-owned operations root. Pure. */
|
|
34
|
+
function isAppOwnedChurn(p) {
|
|
35
|
+
if (typeof p !== 'string' || p === '') return false;
|
|
36
|
+
// Normalise Windows-style separators defensively; git porcelain emits '/'.
|
|
37
|
+
const parts = p.replace(/\\/g, '/').split('/');
|
|
38
|
+
return parts.includes(OPS_ROOT_SEGMENT);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Remove app-owned churn from a dirty-path list.
|
|
43
|
+
*
|
|
44
|
+
* Preserves the caller's null contract: `null` means "git status itself was
|
|
45
|
+
* unavailable", which is NOT the same fact as "the job left nothing", and
|
|
46
|
+
* every caller already branches on it.
|
|
47
|
+
*/
|
|
48
|
+
function stripAppOwnedChurn(paths) {
|
|
49
|
+
if (paths === null || paths === undefined) return paths;
|
|
50
|
+
if (!Array.isArray(paths)) return paths;
|
|
51
|
+
return paths.filter((p) => !isAppOwnedChurn(p));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { stripAppOwnedChurn, isAppOwnedChurn, OPS_ROOT_SEGMENT };
|
|
@@ -133,7 +133,10 @@ const MCP_TOOL_CATALOG = [
|
|
|
133
133
|
+ 'app\'s admin API. Refuses once a queue row exists for the slug and its status is anything but "pending" '
|
|
134
134
|
+ '(running/completed/failed/needs_review) — editing the spec under a live or already-finished executor is refused, '
|
|
135
135
|
+ 'not silently applied. Only recognized frontmatter keys (title, cwd, estimateMinutes, parallelGroup, '
|
|
136
|
-
+ 'sourcePromptId, sourceTabId, tag) may be patched; unrecognized keys
|
|
136
|
+
+ 'sourcePromptId, sourceTabId, tag, dependsOn) may be patched; unrecognized keys round-trip unchanged. '
|
|
137
|
+
+ 'Patching dependsOn to a non-empty array replaces it wholesale (validated against existing PRD slugs, '
|
|
138
|
+
+ 'same resolver scheduler_create_prd uses); patching it to an explicit empty array CLEARS the dependency — '
|
|
139
|
+
+ 'the safe way to fix a wrong dependsOn without archiving (which marks the PRD completed and wrongly frees its dependents).',
|
|
137
140
|
whenToUse: 'Use to correct a PRD scope/estimate/tag before it starts running — e.g. before resetting a needs_review job whose spec needs to change.',
|
|
138
141
|
whenNotToUse: 'Do not use once the job is running or terminal (completed/failed/needs_review) without first resetting it back to pending — the route refuses the edit.',
|
|
139
142
|
exampleArgs: { slug: 'add-mcp-tool-catalog', frontmatter: { estimateMinutes: 45 } },
|