claude-code-session-manager 0.79.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.
Files changed (63) hide show
  1. package/dist/assets/{AgentLibrary-COtVRqBR.js → AgentLibrary-zS3jw_1e.js} +1 -1
  2. package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-Cy_vxTpi.js} +1 -1
  3. package/dist/assets/{History-CHHovrAO.js → History-C6JRuqfT.js} +1 -1
  4. package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-BafPy9mB.js} +1 -1
  5. package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-BZwhQOFt.js} +1 -1
  6. package/dist/assets/{Library-BtxdyTLz.js → Library-C8JDDliz.js} +1 -1
  7. package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CqiOdwLc.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-CyLyP67L.js} +1 -1
  9. package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-BzMv-_84.js} +1 -1
  10. package/dist/assets/{Memory-CR72KoyP.js → Memory-DSBYQdJR.js} +1 -1
  11. package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CLUhkNNA.js} +1 -1
  12. package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-BfC2-HN4.js} +1 -1
  13. package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-BKi40jT5.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
  15. package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-avk2p9jv.js} +1 -1
  16. package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-Bf_6MdJo.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
  18. package/dist/assets/{Settings-BXuyf4lJ.js → Settings-Djd8OoBA.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
  20. package/dist/assets/{Skills-CHqcpiyt.js → Skills-D_qAqxZ_.js} +1 -1
  21. package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DbHFLQV3.js} +1 -1
  22. package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-C2y91BT0.js} +1 -1
  23. package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-D9iz4xQx.js} +1 -1
  24. package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-BGnFL2E5.js} +1 -1
  25. package/dist/assets/{index-DPYa6jbM.js → index-_2ARyFDj.js} +4 -4
  26. package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-JK15eJU8.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
  30. package/scripts/project-pages-logic/dist/logic.cjs +12 -12
  31. package/scripts/render-project-pages/dist/renderer.cjs +22 -22
  32. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
  33. package/src/main/__tests__/prdCreate.test.cjs +133 -8
  34. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
  35. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
  36. package/src/main/__tests__/queueHistory.test.cjs +33 -0
  37. package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
  38. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
  39. package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
  40. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
  41. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
  42. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
  43. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
  44. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
  45. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
  46. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
  47. package/src/main/ipcSchemas.cjs +15 -1
  48. package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
  49. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
  50. package/src/main/lib/depSlugResolve.cjs +72 -0
  51. package/src/main/lib/epicWorktreeMerge.cjs +3 -3
  52. package/src/main/lib/epicWorktreeMint.cjs +17 -5
  53. package/src/main/lib/fixPlanSlug.cjs +62 -0
  54. package/src/main/lib/gitWorktree.cjs +97 -12
  55. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  56. package/src/main/lib/prdCreate.cjs +84 -5
  57. package/src/main/lib/prdFrontmatter.cjs +56 -8
  58. package/src/main/lib/queueHistory.cjs +50 -5
  59. package/src/main/lib/scheduleJobTransitions.cjs +12 -2
  60. package/src/main/lib/schedulerBatch.cjs +181 -23
  61. package/src/main/scheduler/prdParser.cjs +7 -0
  62. package/src/main/scheduler.cjs +813 -40
  63. package/src/preload/api.d.ts +8 -0
@@ -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
- test('a dep with no row at all is treated as already done (archived/retired)', () => {
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 } = pick(jobs);
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 `null` on any
35
- * failure — createEpicWorktree/createWorktree never throw, so this never
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
- return { dir: result.dir, branch: result.branch, baseCwd: result.baseCwd, status: 'active' };
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
- let allIdentical = true;
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): ${(e && (e.stderrText || e.message)) || e}` };
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,
@@ -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 (e.g. dependsOn) round-trip unchanged.',
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 } },
@@ -28,6 +28,17 @@ const { appendAuditEvent } = require('./auditLog.cjs');
28
28
  const { resolveProjectContext } = require('./projectRootResolve.cjs');
29
29
  const { fixChainDepthOf, baseSlugOf } = require('./fixChainDepth.cjs');
30
30
  const { DEFAULT_PRD_AGENT_TYPE, assertAgentTypeWritable } = require('./prdAgentType.cjs');
31
+ const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
32
+ const { isFixPlanSlug } = require('./fixPlanSlug.cjs');
33
+
34
+ // A caller-supplied slug that already starts with its own `NN-` (e.g.
35
+ // "254-perf-x") used to silently become the double-prefixed row
36
+ // "254-254-perf-x" once allocateParallelGroup() prepended the REAL
37
+ // allocated NN — PRD_CREATE_SLUG_RE only enforces kebab-case shape, so it
38
+ // never caught this. Refused outright rather than auto-stripped: silently
39
+ // rewriting the caller's slug would hide the same mistake the old behavior
40
+ // hid, just one layer further down.
41
+ const SLUG_HAS_NN_PREFIX_RE = /^(\d+)-/;
31
42
 
32
43
  // Fix-chain depth cap (PRD 1113): a fix-of-a-fix (depth >= 2) is refused at
33
44
  // this shared write path rather than caught later — scheduler.cjs's
@@ -157,7 +168,7 @@ function resolveSourcePromptIdFromClaudeSession(cwd, claudeSessionId) {
157
168
  * the renderer-facing IPC handler (chat:create-prd, index.cjs) so the
158
169
  * validation/write logic lives in exactly one place (API-reuse standard).
159
170
  * `input` must already be schema-validated by the caller (schemas.schedulerCreatePrd).
160
- * `remote` is scheduler.cjs's remote object (allocateParallelGroup/readPrd/writePrd).
171
+ * `remote` is scheduler.cjs's remote object (allocateParallelGroup/readPrd/writePrd/listPrds).
161
172
  *
162
173
  * Returns `{ ok: true, nn, filename }` on success, or `{ ok: false, status, error }`
163
174
  * on failure — `status` is the HTTP status code the caller should map errors to
@@ -226,6 +237,18 @@ async function createPrd(input, remote) {
226
237
  if (fallback) input = { ...input, sourcePromptId: fallback };
227
238
  }
228
239
 
240
+ if (input.slug) {
241
+ const nnMatch = SLUG_HAS_NN_PREFIX_RE.exec(input.slug);
242
+ if (nnMatch) {
243
+ return {
244
+ ok: false,
245
+ status: 400,
246
+ error: `slug "${input.slug}" already starts with a numeric prefix "${nnMatch[1]}-" — the allocator ` +
247
+ `owns the NN- prefix, not the caller. Pass the bare name instead (e.g. "${input.slug.slice(nnMatch[0].length)}").`,
248
+ };
249
+ }
250
+ }
251
+
229
252
  const slug = input.slug || deriveSlugFromTitle(input.title);
230
253
  if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
231
254
  return { ok: false, status: 400, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' };
@@ -243,10 +266,10 @@ async function createPrd(input, remote) {
243
266
  const filenameSlug = `${nn}-${slug}`;
244
267
 
245
268
  // Fix-chain depth guard (PRD 1113): refuse a fix-of-a-fix before it's ever
246
- // written. depth 0/1 pass through unchanged (an ordinary PRD, or a first
247
- // fix-plan, is never blocked); depth >= 2 means the base job has already
248
- // failed to close via at least one prior fix attempt for reasons a repeat
249
- // attempt won't resolve.
269
+ // written. depth >= 2 means the base job has already failed to close via
270
+ // at least one prior fix attempt for reasons a repeat attempt won't
271
+ // resolve — checked first so this distinct, human-escalation error still
272
+ // wins over the more general fix-plan-slug guard below for that shape.
250
273
  const chainDepth = fixChainDepthOf(filenameSlug);
251
274
  if (chainDepth > FIX_CHAIN_DEPTH_CAP) {
252
275
  const base = baseSlugOf(filenameSlug);
@@ -259,6 +282,29 @@ async function createPrd(input, remote) {
259
282
  };
260
283
  }
261
284
 
285
+ // Fix-plan-slug collision guard (PRD 1131): a caller-supplied or
286
+ // title-derived slug that happens to start with "fix-" collides with the
287
+ // scheduler's own NN-fix-... naming convention for fix plans it authors
288
+ // itself (spawnInvestigation) — and that shape carries real special-case
289
+ // semantics (depth-capped auto-investigation eligibility,
290
+ // commitGuardVerdict's zero-edit exemption) that a PRD authored through
291
+ // this API never asked for. Refused outright, not silently renamed, same
292
+ // fail-closed posture as the SLUG_HAS_NN_PREFIX_RE check above (PRD 1126:
293
+ // "126-fix-plan-death-reopens-parent" was wrongly stamped
294
+ // investigationDepth before it ever ran and had to be withdrawn). This
295
+ // subsumes the depth-1 case the chain-depth guard above deliberately let
296
+ // through (MAX_INVESTIGATION_DEPTH=1 means a first fix-plan is legitimate
297
+ // — but only when spawnInvestigation authors it, never through this API).
298
+ if (isFixPlanSlug(filenameSlug)) {
299
+ return {
300
+ ok: false,
301
+ status: 400,
302
+ error: `slug "${filenameSlug}" collides with the reserved NN-fix-... fix-plan naming convention ` +
303
+ '(reserved for PRDs the scheduler\'s own investigation loop authors itself). Reword the title/slug ' +
304
+ 'so it does not start with "fix-" — e.g. "resolve-x" or "repair-x" instead of "fix-x".',
305
+ };
306
+ }
307
+
262
308
  // An explicit `parallelGroup` bypasses allocateParallelGroup()'s
263
309
  // collision-proof reservation, so re-check for an existing file at
264
310
  // this exact destination before writing — remote.writePrd itself has
@@ -284,6 +330,39 @@ async function createPrd(input, remote) {
284
330
  }
285
331
  }
286
332
 
333
+ // Write-time FK check for dependsOn (report on read / throw on write — see
334
+ // prdAgentType.cjs's header): every entry must resolve to a PRD that
335
+ // actually exists in this project, using the SAME resolution rule
336
+ // schedulerBatch.cjs's findBlockingDep applies at run time (exact slug,
337
+ // else bare-name after stripping one leading `NN-`) — a dep that
338
+ // validates here is guaranteed to be a dep findBlockingDep can actually
339
+ // see later. A listPrds() read failure is skipped-with-a-warning, never a
340
+ // write outage: an I/O hiccup on the read side must not block every PRD
341
+ // write in the project.
342
+ if (input.dependsOn && input.dependsOn.length) {
343
+ let listing;
344
+ try {
345
+ listing = await remote.listPrds({ cwd: input.cwd, limit: Number.MAX_SAFE_INTEGER });
346
+ } catch (e) {
347
+ console.warn(`[prdCreate] dependsOn validation skipped (listPrds failed): ${e?.message ?? e}`);
348
+ listing = null;
349
+ }
350
+ if (listing) {
351
+ const candidateSlugs = (listing.prds ?? []).map((p) => p.slug);
352
+ for (const dep of input.dependsOn) {
353
+ if (resolveDepSlug(dep, candidateSlugs).length > 0) continue;
354
+ const near = findNearMatches(dep, candidateSlugs);
355
+ const suggestion = near.length ? ` Closest existing slug(s): ${near.join(', ')}.` : '';
356
+ return {
357
+ ok: false,
358
+ status: 400,
359
+ error: `dependsOn entry "${dep}" does not resolve to any existing PRD in this project.${suggestion} ` +
360
+ 'Pass the bare name (preferred) or the exact NN-prefixed slug of an existing PRD.',
361
+ };
362
+ }
363
+ }
364
+ }
365
+
287
366
  const body = buildPrdBody(input);
288
367
  const writeResult = await remote.writePrd(filenameSlug, body, input.cwd);
289
368
  if (!writeResult?.ok) {