claude-code-session-manager 0.79.0 → 0.81.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 (82) hide show
  1. package/dist/assets/{AgentLibrary-COtVRqBR.js → AgentLibrary-psZYVM2w.js} +1 -1
  2. package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-BMied5pg.js} +1 -1
  3. package/dist/assets/{History-CHHovrAO.js → History-BFC0oaKc.js} +1 -1
  4. package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-CTLfO9G8.js} +1 -1
  5. package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-D4I0Cpwn.js} +1 -1
  6. package/dist/assets/{Library-BtxdyTLz.js → Library-BTzS8KsS.js} +1 -1
  7. package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CuRmT008.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-BgQGtvWo.js} +1 -1
  9. package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-DiVQUK57.js} +1 -1
  10. package/dist/assets/{Memory-CR72KoyP.js → Memory-DSu15JmL.js} +1 -1
  11. package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CD5wxGSR.js} +1 -1
  12. package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-C_tAE6yJ.js} +1 -1
  13. package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-kwI7W-eK.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-CQceOgsH.js} +1 -1
  15. package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-BDk5e3Pp.js} +1 -1
  16. package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-DRnvEYzv.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-CeifaOlq.js} +1 -1
  18. package/dist/assets/{Settings-BXuyf4lJ.js → Settings-BWQ1Utop.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
  20. package/dist/assets/{Skills-CHqcpiyt.js → Skills-BLwFB0E4.js} +1 -1
  21. package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DC4ZTArJ.js} +1 -1
  22. package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-pMeGfjUb.js} +1 -1
  23. package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-BHFid2pZ.js} +1 -1
  24. package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-D9eYoZh4.js} +1 -1
  25. package/dist/assets/{index-DPYa6jbM.js → index-CuyM9vAP.js} +5 -5
  26. package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-DrxC67uZ.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/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  31. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
  32. package/src/main/__tests__/prdCreate.test.cjs +133 -8
  33. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
  34. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
  35. package/src/main/__tests__/queueHistory.test.cjs +33 -0
  36. package/src/main/__tests__/rcaReport.test.cjs +24 -0
  37. package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
  38. package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
  39. package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
  40. package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
  41. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
  42. package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
  43. package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
  44. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
  45. package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
  46. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
  47. package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
  48. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
  49. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
  50. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
  51. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
  52. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
  53. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
  54. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
  55. package/src/main/ipcSchemas.cjs +15 -1
  56. package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
  57. package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
  58. package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
  59. package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
  60. package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
  61. package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
  62. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
  63. package/src/main/lib/branchSweep.cjs +127 -0
  64. package/src/main/lib/depSlugResolve.cjs +72 -0
  65. package/src/main/lib/epicWorktreeMerge.cjs +3 -3
  66. package/src/main/lib/epicWorktreeMint.cjs +17 -5
  67. package/src/main/lib/fixPlanSlug.cjs +62 -0
  68. package/src/main/lib/gitWorktree.cjs +117 -12
  69. package/src/main/lib/landedSinceRun.cjs +41 -1
  70. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  71. package/src/main/lib/prdCreate.cjs +84 -5
  72. package/src/main/lib/prdFrontmatter.cjs +56 -8
  73. package/src/main/lib/queueHistory.cjs +50 -5
  74. package/src/main/lib/rateLimitWindow.cjs +62 -0
  75. package/src/main/lib/rcaReport.cjs +18 -3
  76. package/src/main/lib/reaperHelpers.cjs +169 -3
  77. package/src/main/lib/scheduleJobTransitions.cjs +28 -5
  78. package/src/main/lib/schedulerBatch.cjs +181 -23
  79. package/src/main/runVerify.cjs +71 -3
  80. package/src/main/scheduler/prdParser.cjs +7 -0
  81. package/src/main/scheduler.cjs +1554 -71
  82. package/src/preload/api.d.ts +8 -0
@@ -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
 
@@ -775,6 +859,25 @@ async function salvageDirtyDelta({ cwd, paths, outFile }) {
775
859
  }
776
860
  }
777
861
 
862
+ /**
863
+ * True when every commit on `branch` is already an ancestor of (or equal to)
864
+ * `cwd`'s current HEAD — i.e. the branch holds no work that isn't already
865
+ * landed. Used by the stranded-branch sweep (branchSweep.cjs) to tell a
866
+ * genuinely stranded `sm-job/*` branch from one that's already a no-op.
867
+ * Never throws; a branch that doesn't exist, or any other git failure, is
868
+ * conservatively reported as NOT merged so a caller never treats an
869
+ * unreadable branch as safe to skip.
870
+ */
871
+ async function isBranchMergedIntoHead(cwd, branch) {
872
+ try {
873
+ const branchHead = (await execGit(['rev-parse', '--verify', branch], { cwd, timeout: 10_000 })).trim();
874
+ const mergeBase = (await execGit(['merge-base', 'HEAD', branch], { cwd, timeout: 10_000 })).trim();
875
+ return mergeBase !== '' && mergeBase === branchHead;
876
+ } catch {
877
+ return false;
878
+ }
879
+ }
880
+
778
881
  /** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
779
882
  function parseWorktreeListPorcelain(text) {
780
883
  const entries = [];
@@ -914,10 +1017,12 @@ module.exports = {
914
1017
  keyFromBranch,
915
1018
  createWorktree,
916
1019
  captureAndCarryBaseDiff,
1020
+ pathsIdenticalToBranch,
917
1021
  integrateBranch,
918
1022
  cleanupWorktree,
919
1023
  salvageWorktreeDiff,
920
1024
  salvageDirtyDelta,
1025
+ isBranchMergedIntoHead,
921
1026
  parseWorktreeListPorcelain,
922
1027
  reconcileWorktreesOnBoot,
923
1028
  sweepStaleWorktreeCheckouts,
@@ -52,4 +52,44 @@ function landedSinceRun(cwd, sinceIso, paths, { timeoutMs = LANDED_SINCE_RUN_TIM
52
52
  });
53
53
  }
54
54
 
55
- module.exports = { landedSinceRun, LANDED_SINCE_RUN_TIMEOUT_MS };
55
+ /**
56
+ * Commits reachable from `main` in `cwd` since `sinceIso` that touch any of
57
+ * `paths` — the evidence query behind PRD 1136's already-satisfied-on-main
58
+ * commit-guard exemption. Deliberately narrower than landedSinceRun in one
59
+ * axis (scoped to `main`, not `--all` — a commit on a stray branch is not
60
+ * "satisfied") and identical in the other (path-scoped, so an unrelated
61
+ * commit elsewhere in the repo is never credited to this job).
62
+ *
63
+ * Falls back from local `main` to `origin/main` when the local branch ref
64
+ * doesn't exist (a worktree/CI checkout that never checked main out
65
+ * locally) — never throws either way; git-unavailable, a non-repo cwd, an
66
+ * unresolvable ref, or an empty `paths` list all resolve to `[]`.
67
+ *
68
+ * @param {string} cwd
69
+ * @param {string} sinceIso
70
+ * @param {string[]} paths
71
+ * @param {{ timeoutMs?: number }} [opts]
72
+ * @returns {Promise<string[]>} full commit SHAs, newest first
73
+ */
74
+ function landedOnMainSince(cwd, sinceIso, paths, { timeoutMs = LANDED_SINCE_RUN_TIMEOUT_MS } = {}) {
75
+ const runLog = (ref) => new Promise((resolve) => {
76
+ execFile(
77
+ 'git',
78
+ ['-C', cwd, 'log', ref, `--since=${sinceIso}`, '--format=%H', '--', ...paths],
79
+ { timeout: timeoutMs, windowsHide: true },
80
+ (err, stdout) => {
81
+ if (err) { resolve(null); return; }
82
+ resolve(String(stdout || '').trim().split('\n').filter(Boolean));
83
+ },
84
+ );
85
+ });
86
+ return (async () => {
87
+ if (!cwd || !sinceIso || !Array.isArray(paths) || paths.length === 0) return [];
88
+ const viaLocal = await runLog('main');
89
+ if (viaLocal !== null) return viaLocal;
90
+ const viaRemote = await runLog('origin/main');
91
+ return viaRemote ?? [];
92
+ })();
93
+ }
94
+
95
+ module.exports = { landedSinceRun, landedOnMainSince, LANDED_SINCE_RUN_TIMEOUT_MS };
@@ -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) {
@@ -62,7 +62,7 @@ function splitFrontmatter(raw) {
62
62
  * either surface never reorders or drops a key it didn't touch. Recognized
63
63
  * key set intentionally mirrors the TS module exactly (title, cwd,
64
64
  * estimateMinutes, parallelGroup, sourcePromptId, sourceTabId, tag,
65
- * agentType, createdVia, issuedAt); every other frontmatter key (e.g. dependsOn)
65
+ * agentType, createdVia, issuedAt, dependsOn); every other frontmatter key
66
66
  * round-trips via `extras`, same as the renderer's editor today.
67
67
  *
68
68
  * `createdVia`/`issuedAt` (PRD provenance-lockdown) are recognized here so
@@ -70,10 +70,15 @@ function splitFrontmatter(raw) {
70
70
  * after creation — see prdCreate.cjs's buildPrdBody for the create-time
71
71
  * stamp) round-trips them like any other known key rather than shunting them
72
72
  * into `extras`.
73
+ *
74
+ * `dependsOn` (PRD 1124) is the first ARRAY-valued recognized key — parsed/
75
+ * emitted only in its inline `[a, b]` list form (the sole form prdCreate.cjs
76
+ * ever writes; block-style YAML lists are out of scope). An empty array is
77
+ * never emitted, which is how `scheduler_update_prd` clears a dependency.
73
78
  */
74
79
 
75
- const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'quietMachine']);
76
- const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'quietMachine'];
80
+ const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine']);
81
+ const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine'];
77
82
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
78
83
 
79
84
  function indentOf(line) {
@@ -99,6 +104,28 @@ function parseScalar(raw) {
99
104
  return v;
100
105
  }
101
106
 
107
+ /**
108
+ * Parse the inline `[a, b]` list form used by `dependsOn` — the sole form
109
+ * prdCreate.cjs's buildPrdBody ever writes. Returns null (not recognized as
110
+ * a list) when `raw` isn't bracketed, so a hand-edited non-list value is
111
+ * left unset rather than mis-parsed.
112
+ */
113
+ function parseInlineList(raw) {
114
+ const v = raw.trim();
115
+ if (!v.startsWith('[') || !v.endsWith(']')) return null;
116
+ const inner = v.slice(1, -1).trim();
117
+ if (inner === '') return [];
118
+ return inner
119
+ .split(',')
120
+ .map((s) => s.trim().replace(/^(['"])(.*)\1$/, '$2'))
121
+ .filter((s) => s.length > 0);
122
+ }
123
+
124
+ function arraysEqual(a, b) {
125
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
126
+ return a.length === b.length && a.every((v, i) => v === b[i]);
127
+ }
128
+
102
129
  function applyKey(fm, key, after) {
103
130
  const v = parseScalar(after);
104
131
  if (v === null) return;
@@ -138,6 +165,13 @@ function applyKey(fm, key, after) {
138
165
  case 'issuedAt':
139
166
  fm.issuedAt = String(v);
140
167
  return;
168
+ case 'dependsOn': {
169
+ // Only the inline `[a, b]` list form is recognized (see parseInlineList's
170
+ // header) — a malformed/hand-edited non-list value is left unset.
171
+ const list = parseInlineList(after);
172
+ if (list) fm.dependsOn = list;
173
+ return;
174
+ }
141
175
  case 'quietMachine':
142
176
  // Opt-in exclusive-lease flag (PRD 1107) — only a literal `true`
143
177
  // frontmatter value opts in; anything else (including an explicit
@@ -180,10 +214,17 @@ function parsePrdFile(text) {
180
214
 
181
215
  if (RECOGNIZED_KEYS.has(key)) {
182
216
  applyKey(fm, key, after);
183
- const parsed = parseScalar(after);
184
- if (parsed !== null && (typeof parsed === 'string' || typeof parsed === 'number')) {
185
- if (!fm._raw) fm._raw = {};
186
- fm._raw[key] = { line, parsed };
217
+ if (key === 'dependsOn') {
218
+ if (fm.dependsOn) {
219
+ if (!fm._raw) fm._raw = {};
220
+ fm._raw[key] = { line, parsed: fm.dependsOn };
221
+ }
222
+ } else {
223
+ const parsed = parseScalar(after);
224
+ if (parsed !== null && (typeof parsed === 'string' || typeof parsed === 'number')) {
225
+ if (!fm._raw) fm._raw = {};
226
+ fm._raw[key] = { line, parsed };
227
+ }
187
228
  }
188
229
  } else {
189
230
  extras[key] = { lines: band };
@@ -218,8 +259,11 @@ function serializePrdFile(fm, body) {
218
259
  for (const key of EMIT_ORDER) {
219
260
  const v = fm[key];
220
261
  if (v === undefined || v === null || v === '') continue;
262
+ // An empty dependsOn array is how scheduler_update_prd CLEARS the
263
+ // dependency — never emitted, same as an absent key.
264
+ if (Array.isArray(v) && v.length === 0) continue;
221
265
  const cached = fm._raw?.[key];
222
- if (cached && cached.parsed === v) {
266
+ if (cached && (Array.isArray(v) ? arraysEqual(cached.parsed, v) : cached.parsed === v)) {
223
267
  out.push(cached.line);
224
268
  continue;
225
269
  }
@@ -227,6 +271,10 @@ function serializePrdFile(fm, body) {
227
271
  out.push(`${key}: ${v}`);
228
272
  continue;
229
273
  }
274
+ if (Array.isArray(v)) {
275
+ out.push(`${key}: [${v.join(', ')}]`);
276
+ continue;
277
+ }
230
278
  out.push(`${key}: ${serializeScalar(v)}`);
231
279
  }
232
280
  if (fm.extras) {
@@ -15,6 +15,7 @@ const path = require('path');
15
15
  const fsp = require('fs').promises;
16
16
  const { HISTORY_RETENTION_MS } = require('./schedulerConfig.cjs');
17
17
  const { projectHistoryPath, stateCwds } = require('./queueStore.cjs');
18
+ const { resolveIsFixPlan } = require('./fixPlanSlug.cjs');
18
19
 
19
20
  // Legacy global sidecar — READ-ONLY since 2026-07-31 (federated per-project
20
21
  // history under <cwd>/session-manager-operations/scheduler/state/history.jsonl,
@@ -52,10 +53,6 @@ function historyPathFor(entry) {
52
53
  return HISTORY_PATH;
53
54
  }
54
55
 
55
- function isFixPlanSlug(slug) {
56
- return /^\d+-fix-/.test(slug);
57
- }
58
-
59
56
  function jobKey(job) {
60
57
  return `${job?.slug ?? ''}|${job?.runId ?? ''}`;
61
58
  }
@@ -79,9 +76,13 @@ function partitionJobs(jobs, nowMs, opts = {}) {
79
76
 
80
77
  // Slugs of jobs that a still-pending/running fix-plan is going to try to
81
78
  // heal (see healTargetForFix in scheduler.cjs — same regex, kept in sync).
79
+ // Honors the job's own persisted `isFixPlan` provenance stamp (PRD 1131)
80
+ // over the slug shape, same as every other fix-plan consumer in
81
+ // scheduler.cjs — a slug that merely starts with "fix-" but was classified
82
+ // isFixPlan:false must not get treated as a real fix-plan here either.
82
83
  const protectedSlugs = new Set();
83
84
  for (const j of list) {
84
- if (!j || !isFixPlanSlug(j.slug)) continue;
85
+ if (!j || !resolveIsFixPlan(j.slug, j.isFixPlan)) continue;
85
86
  if (j.status !== 'pending' && j.status !== 'running') continue;
86
87
  protectedSlugs.add(j.slug.replace(/^(\d+)-fix-/, '$1-'));
87
88
  }
@@ -269,10 +270,54 @@ async function historyTerminalBySlug() {
269
270
  return map;
270
271
  }
271
272
 
273
+ /**
274
+ * completedSlugsForCwd(cwd) → Set<string> of every slug whose most recent
275
+ * appended record in THIS project's own history shard
276
+ * (`<cwd>/session-manager-operations/scheduler/state/history.jsonl`, via
277
+ * projectHistoryPath) has status 'completed'. Scoped to one project's shard
278
+ * — unlike historyTerminalBySlug's federated, machine-wide map — because
279
+ * this feeds PRD 1122's dependsOn history lookup, where a same-named PRD in
280
+ * an unrelated project must never count as satisfying a dep. Missing file
281
+ * (ENOENT) returns an empty Set, not an error — a project with no history
282
+ * yet is not a read failure.
283
+ */
284
+ async function completedSlugsForCwd(cwd) {
285
+ let text = '';
286
+ try {
287
+ text = await fsp.readFile(projectHistoryPath(cwd), 'utf8');
288
+ } catch (e) {
289
+ if (e.code === 'ENOENT') return new Set();
290
+ throw e;
291
+ }
292
+ // Last-line-wins per slug (file is append-only chronological) — same
293
+ // pattern as historyTerminalBySlug above. A slug can legitimately appear
294
+ // more than once (scheduler_reset_job resets a completed row back to
295
+ // pending, it runs again, and eventually re-retires to history) — unioning
296
+ // every 'completed' line ever seen, without letting a LATER 'failed' line
297
+ // for the same slug revoke it, would keep a since-refailed dep permanently
298
+ // satisfied for its dependents.
299
+ const statusBySlug = new Map();
300
+ for (const line of text.split('\n')) {
301
+ if (!line.trim()) continue;
302
+ try {
303
+ const j = JSON.parse(line);
304
+ if (j?.slug) statusBySlug.set(j.slug, j.status);
305
+ } catch {
306
+ // corrupt/partial line — ignore
307
+ }
308
+ }
309
+ const slugs = new Set();
310
+ for (const [slug, status] of statusBySlug) {
311
+ if (status === 'completed') slugs.add(slug);
312
+ }
313
+ return slugs;
314
+ }
315
+
272
316
  module.exports = {
273
317
  HISTORY_PATH,
274
318
  partitionJobs,
275
319
  appendHistory,
276
320
  readHistory,
277
321
  historyTerminalBySlug,
322
+ completedSlugsForCwd,
278
323
  };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * rateLimitWindow.cjs — reads the BINDING rate-limit window's reset time
5
+ * straight off a run's own log, instead of trusting the billing usage
6
+ * endpoint's five_hour window (PRD 1118). A run can be 429'd by a
7
+ * different window than five_hour (seven_day, seven_day_overage_included)
8
+ * while five_hour utilization is still 0 — pausing against five_hour's
9
+ * reset in that case arms the resume timer against the wrong clock, and
10
+ * the billing endpoint can itself be 429'ing when this is needed most.
11
+ *
12
+ * Pure: log path in, reset value out. No electron import, so it is
13
+ * unit-testable without scheduler.cjs (mirrors reaperHelpers.cjs's split).
14
+ */
15
+
16
+ const { readTail } = require('./fileTail.cjs');
17
+
18
+ // Authoritative window block rides on the LAST rate_limit_event in the log
19
+ // (often the one attached to the final result), so mirror classifyRunOutcome's
20
+ // 64 KB tail rather than detectRateLimitInLog's smaller 16 KB one.
21
+ const TAIL_BYTES = 64 * 1024;
22
+
23
+ /**
24
+ * Scans a run log's tail for rate_limit_event entries and returns the
25
+ * UNIX-seconds resetsAt of the window that actually bound (utilization >= 1.0)
26
+ * in the LAST such event. When multiple windows bind in that event, the
27
+ * latest resetsAt wins. Returns null when no rate_limit_event is found, or
28
+ * none of its windows are at/above 1.0 utilization.
29
+ */
30
+ function resolveBindingRateLimitReset(logPath) {
31
+ const text = readTail(logPath, TAIL_BYTES);
32
+ if (!text) return null;
33
+
34
+ let lastWindows = null;
35
+ for (const line of text.split('\n')) {
36
+ const trimmed = line.trim();
37
+ if (!trimmed || trimmed[0] !== '{') continue;
38
+ let event;
39
+ try {
40
+ event = JSON.parse(trimmed);
41
+ } catch {
42
+ continue;
43
+ }
44
+ const windows = event?.rate_limit_info?.unifiedWindows;
45
+ if (event?.type === 'rate_limit_event' && windows && typeof windows === 'object') {
46
+ lastWindows = windows;
47
+ }
48
+ }
49
+ if (!lastWindows) return null;
50
+
51
+ let latestBoundReset = null;
52
+ for (const window of Object.values(lastWindows)) {
53
+ if (!window || typeof window.utilization !== 'number' || typeof window.resetsAt !== 'number') continue;
54
+ if (window.utilization < 1.0) continue;
55
+ if (latestBoundReset === null || window.resetsAt > latestBoundReset) {
56
+ latestBoundReset = window.resetsAt;
57
+ }
58
+ }
59
+ return latestBoundReset;
60
+ }
61
+
62
+ module.exports = { resolveBindingRateLimitReset };