claude-code-session-manager 0.112.0 → 0.114.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 (75) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/assets/{AgentLibrary-RwPvEgqm.js → AgentLibrary-DZFM7Lto.js} +2 -2
  3. package/dist/assets/{Button-36j3PzYv.js → Button-BJVFKdGH.js} +1 -1
  4. package/dist/assets/{DataModel-DUhANo4l.js → DataModel-CebvcP_y.js} +1 -1
  5. package/dist/assets/{History-BeZzNjmv.js → History-BwISEUhR.js} +2 -2
  6. package/dist/assets/{Hooks-iQ8845Hp.js → Hooks-Dac3smYP.js} +3 -3
  7. package/dist/assets/{Library-DPE1h-1L.js → Library-DTjsUr9W.js} +7 -7
  8. package/dist/assets/{ListDetail-PStOuQAo.js → ListDetail-B1L5TbjS.js} +1 -1
  9. package/dist/assets/{MarkdownEditor-DPw-N2-m.js → MarkdownEditor-BFRhWZD4.js} +1 -1
  10. package/dist/assets/{McpServers-CUyRTNep.js → McpServers-CBjGCMg0.js} +2 -2
  11. package/dist/assets/Memory-DdLi5fqe.js +8 -0
  12. package/dist/assets/{Panel-_VZ_uXNg.js → Panel-BQkkkjex.js} +1 -1
  13. package/dist/assets/{Permissions-PPRmfNbK.js → Permissions-CaACwY0B.js} +3 -3
  14. package/dist/assets/{Plugins-BFcvfvlx.js → Plugins-DJeBwVoh.js} +2 -2
  15. package/dist/assets/ProvenanceBadge--N5j8xiK.js +1 -0
  16. package/dist/assets/{RemoteMachines-D5V0lzgW.js → RemoteMachines-WjWaDHEA.js} +2 -2
  17. package/dist/assets/{SaveBar-Dw64FVdM.js → SaveBar-DFmOjb4_.js} +1 -1
  18. package/dist/assets/{Scheduler-C8s_pJ3I.js → Scheduler-Miap6_aY.js} +8 -8
  19. package/dist/assets/{ScopeSwitcher-D-nKDMAE.js → ScopeSwitcher-CiEujp4L.js} +1 -1
  20. package/dist/assets/Settings-UGpW4P0j.js +4 -0
  21. package/dist/assets/{SkillReferenceGraph-BbZFMHim.js → SkillReferenceGraph-BgyAnxGW.js} +1 -1
  22. package/dist/assets/Skills-BQUpnNfx.js +2 -0
  23. package/dist/assets/SystemPrompt-CQjOgHGT.js +1 -0
  24. package/dist/assets/TagLibrary-CTVpoLl0.js +1 -0
  25. package/dist/assets/{TiptapBody-Cr-hxX8L.js → TiptapBody-BtOcrdRR.js} +1 -1
  26. package/dist/assets/{Toggle-7WpPzcZH.js → Toggle-Chri4G19.js} +1 -1
  27. package/dist/assets/{ViewTabs-9lQCRwWJ.js → ViewTabs-BIenDGVv.js} +1 -1
  28. package/dist/assets/{historyTotals-BrFxw5J1.js → historyTotals-CX8mUxJY.js} +1 -1
  29. package/dist/assets/{index-CTBpf-26.js → index-AVrhGmUO.js} +163 -163
  30. package/dist/assets/{index-Dutcsly-.css → index-HpEbvDiE.css} +1 -1
  31. package/dist/assets/{settingsSchema-Dh5Kl7VO.js → settingsSchema-uqwlznbH.js} +1 -1
  32. package/dist/assets/{useAgentsDirWatch-CMpx0d04.js → useAgentsDirWatch-BaezRtWw.js} +1 -1
  33. package/dist/assets/{useRemotePeers-CI2axd-L.js → useRemotePeers-BuFbBqDt.js} +1 -1
  34. package/dist/index.html +2 -2
  35. package/package.json +1 -1
  36. package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
  37. package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +13 -0
  38. package/scripts/scheduler-mcp-server.cjs +23 -5
  39. package/src/main/build-info.json +4 -4
  40. package/src/main/chatRunner.cjs +18 -1
  41. package/src/main/config.cjs +18 -1
  42. package/src/main/index.cjs +6 -2
  43. package/src/main/ipcSchemas.cjs +27 -34
  44. package/src/main/lib/activeSessions.cjs +13 -0
  45. package/src/main/lib/claude/rateLimitDetect.cjs +29 -10
  46. package/src/main/lib/claude/rateLimitWindow.cjs +23 -11
  47. package/src/main/lib/credentials.cjs +16 -90
  48. package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +107 -9
  49. package/src/main/lib/feedback-remote/humanPromptLedger.cjs +93 -0
  50. package/src/main/lib/mcpToolCatalog.cjs +9 -3
  51. package/src/main/lib/memoryDirResolve.cjs +139 -0
  52. package/src/main/lib/memoryIndex.cjs +98 -0
  53. package/src/main/lib/prd/depSlugResolve.cjs +16 -1
  54. package/src/main/lib/scheduler/finalizeReason.cjs +30 -0
  55. package/src/main/lib/scheduler/flatSweepThrottle.cjs +17 -0
  56. package/src/main/lib/scheduler/runLogRetention.cjs +34 -13
  57. package/src/main/lib/scheduler/schedulerBatch.cjs +8 -12
  58. package/src/main/lib/telemetry/telemetryClient.cjs +8 -4
  59. package/src/main/lib/util/atomicFs.cjs +42 -0
  60. package/src/main/lib/util/loopDelay.cjs +1 -1
  61. package/src/main/memoryAggregate.cjs +15 -8
  62. package/src/main/memoryTool.cjs +151 -22
  63. package/src/main/runVerify.cjs +5 -2
  64. package/src/main/scheduler.cjs +43 -10
  65. package/src/main/supervisor.cjs +45 -22
  66. package/src/main/webRemote.cjs +15 -0
  67. package/src/preload/api.d.ts +47 -52
  68. package/src/preload/index.cjs +25 -21
  69. package/dist/assets/Memory-bZ2xjBGy.js +0 -8
  70. package/dist/assets/ProvenanceBadge-B1WUknak.js +0 -1
  71. package/dist/assets/Settings-CQSrR4mO.js +0 -4
  72. package/dist/assets/Skills-Bzyrh6EQ.js +0 -2
  73. package/dist/assets/SystemPrompt-DP6i3Dx0.js +0 -1
  74. package/dist/assets/TagLibrary-iip0C6Bl.js +0 -1
  75. package/src/main/agentMemory.cjs +0 -246
@@ -33,7 +33,19 @@
33
33
  * feedback Epic is born 'proposed' like every other Epic (epicMint.cjs's
34
34
  * BORN-PROPOSED check applies to this mint authority verbatim), so the human
35
35
  * in the RECEIVING project is still the one who decides whether a single token
36
- * gets spent. An agent can ask; it cannot start.
36
+ * gets spent. An agent can ask; it cannot start on its own say-so.
37
+ *
38
+ * The one exception is a VERIFIED HUMAN REQUEST. When the human themselves
39
+ * asked, in chat, for work in another project, making them re-confirm in the
40
+ * receiving project is pure friction. So `autoStart: true` plus a
41
+ * `humanRequestQuote` is honored only when main can check the quote against
42
+ * humanPromptLedger (prompts chat:run recorded with human origin) for the
43
+ * originating chat session — `input.originClaudeSessionId`, taken from the MCP
44
+ * server's env, set only for interactive chat runs, never for scheduler jobs —
45
+ * AND that human prompt names the target project. Even then the Epic is still
46
+ * minted 'proposed'; it is only stamped `source.autoApprove` and the renderer
47
+ * is told to approve-and-start it through its normal approveProposed path.
48
+ * Anything unverifiable degrades to the ordinary proposal.
37
49
  *
38
50
  * Guards, all fail-closed:
39
51
  * - both cwds go through config.cjs's validatePath (allowedRoots = home)
@@ -90,6 +102,7 @@ const MAX_SERIES_KEY_CHARS = 60;
90
102
  const MAX_SERIES_TITLE_CHARS = 120;
91
103
  const MAX_SERIES_TOTAL = 50;
92
104
  const MAX_ORIGIN_ASK_CHARS = 300;
105
+ const MAX_HUMAN_QUOTE_CHARS = 2000;
93
106
  const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
94
107
 
95
108
  /** Bound on how many `~/.claude/projects/` folders listFeedbackTargets will
@@ -349,6 +362,18 @@ function validateFeedbackInput(input = {}) {
349
362
  series = { key, title: stitle, index: sr.index, total: sr.total };
350
363
  }
351
364
 
365
+ if (input.autoStart !== undefined && input.autoStart !== null && typeof input.autoStart !== 'boolean') {
366
+ return bad(400, 'autoStart must be a boolean');
367
+ }
368
+ let humanRequestQuote = '';
369
+ if (input.humanRequestQuote !== undefined && input.humanRequestQuote !== null) {
370
+ if (typeof input.humanRequestQuote !== 'string') return bad(400, 'humanRequestQuote must be a string');
371
+ humanRequestQuote = input.humanRequestQuote.trim();
372
+ if (humanRequestQuote.length > MAX_HUMAN_QUOTE_CHARS) {
373
+ return bad(400, `humanRequestQuote is ${humanRequestQuote.length} chars; cap is ${MAX_HUMAN_QUOTE_CHARS}`);
374
+ }
375
+ }
376
+
352
377
  const referencePaths = Array.isArray(input.referencePaths) ? input.referencePaths : [];
353
378
  if (referencePaths.length > MAX_REFERENCES) {
354
379
  return bad(400, `too many referencePaths (${referencePaths.length}); cap is ${MAX_REFERENCES}`);
@@ -410,6 +435,8 @@ function validateFeedbackInput(input = {}) {
410
435
  acceptanceCriteria: lists.acceptanceCriteria,
411
436
  outOfScope: lists.outOfScope,
412
437
  series,
438
+ autoStart: input.autoStart === true,
439
+ humanRequestQuote,
413
440
  referencePaths: referencePaths.map((p) => String(p)),
414
441
  fromEpicId: input.fromEpicId ? String(input.fromEpicId) : null,
415
442
  agentType: input.agentType ? String(input.agentType) : DEFAULT_FEEDBACK_AGENT,
@@ -418,6 +445,44 @@ function validateFeedbackInput(input = {}) {
418
445
  };
419
446
  }
420
447
 
448
+ /** Tell every renderer window to approve-and-start a granted auto-start Epic.
449
+ * Electron is required lazily so unit tests never need it. */
450
+ function defaultNotifyRenderer(payload) {
451
+ try {
452
+ const { BrowserWindow } = require('electron');
453
+ for (const win of BrowserWindow.getAllWindows()) {
454
+ try {
455
+ win.webContents.send('promptSessions:inbound-auto-approve', payload);
456
+ } catch { /* window going away */ }
457
+ }
458
+ } catch { /* not running under Electron */ }
459
+ }
460
+
461
+ /**
462
+ * verifyAutoStart(v, originClaudeSessionId) → { granted: true, quote } | { granted: false, reason }
463
+ *
464
+ * Every check is fail-closed. The session identity is ONLY the caller-supplied
465
+ * originClaudeSessionId (MCP env), never a field an agent can pick freely.
466
+ */
467
+ function verifyAutoStart(v, originClaudeSessionId) {
468
+ if (!v.autoStart) return { granted: false, reason: 'autoStart was not requested' };
469
+ if (!v.humanRequestQuote) return { granted: false, reason: 'no humanRequestQuote was given' };
470
+ if (!originClaudeSessionId) {
471
+ return { granted: false, reason: 'no originClaudeSessionId — only interactive chat sessions can auto-start' };
472
+ }
473
+ const { findHumanPromptContaining } = require('./humanPromptLedger.cjs');
474
+ const found = findHumanPromptContaining(originClaudeSessionId, v.humanRequestQuote);
475
+ if (!found) {
476
+ return { granted: false, reason: 'humanRequestQuote was not found in a prompt the human typed in this chat session' };
477
+ }
478
+ const prompt = found.text.toLowerCase();
479
+ const base = path.basename(v.toCwd).toLowerCase();
480
+ if (!(base && prompt.includes(base)) && !prompt.includes(v.toCwd.toLowerCase())) {
481
+ return { granted: false, reason: `the human's prompt does not name the receiving project (${path.basename(v.toCwd)})` };
482
+ }
483
+ return { granted: true, quote: v.humanRequestQuote };
484
+ }
485
+
421
486
  /**
422
487
  * openFeedbackSession(input, deps?) → { ok, status?, error?, epicId, toCwd, ... }
423
488
  *
@@ -427,8 +492,8 @@ function validateFeedbackInput(input = {}) {
427
492
  * best-effort: a failure there never invalidates a delivered proposal, it just
428
493
  * reports `receiptOnOriginEpic: false`.
429
494
  *
430
- * `deps` exists so tests can inject the two side-effecting collaborators
431
- * without an Electron/main-process boot.
495
+ * `deps` exists so tests can inject the side-effecting collaborators
496
+ * (ensureEpic, appendResponseEventIfKnown, notifyRenderer) without an Electron/main-process boot.
432
497
  */
433
498
  async function openFeedbackSession(input, deps = {}) {
434
499
  const mint = deps.ensureEpic || ensureEpic;
@@ -439,10 +504,19 @@ async function openFeedbackSession(input, deps = {}) {
439
504
  // boot the main process.
440
505
  || ((...args) => require('../../promptSessionEvents.cjs').appendResponseEventIfKnown(...args));
441
506
 
507
+ const notify = deps.notifyRenderer || defaultNotifyRenderer;
508
+
442
509
  const checked = validateFeedbackInput(input);
443
510
  if (!checked.ok) return checked;
444
511
  const v = checked.value;
445
512
 
513
+ const originClaudeSessionId =
514
+ typeof input.originClaudeSessionId === 'string' && input.originClaudeSessionId
515
+ ? input.originClaudeSessionId
516
+ : null;
517
+ const auto = verifyAutoStart(v, originClaudeSessionId);
518
+ const verifiedAt = new Date().toISOString();
519
+
446
520
  // The MCP client can't know its own Epic id — it only inherits the claude
447
521
  // session id chatRunner.cjs stamps on the `claude -p` child. Resolve it
448
522
  // through prdCreate.cjs's existing resolver rather than a second copy of
@@ -488,6 +562,16 @@ async function openFeedbackSession(input, deps = {}) {
488
562
  producer: 'cross-project-feedback',
489
563
  fromCwd: v.fromCwd,
490
564
  ...(v.fromEpicId ? { fromEpicId: v.fromEpicId } : {}),
565
+ ...(auto.granted
566
+ ? {
567
+ autoApprove: {
568
+ requestedBy: 'human',
569
+ quote: auto.quote,
570
+ verifiedAt,
571
+ fromClaudeSessionId: originClaudeSessionId,
572
+ },
573
+ }
574
+ : {}),
491
575
  proposal: {
492
576
  motivation: v.motivation,
493
577
  originAsk,
@@ -511,8 +595,16 @@ async function openFeedbackSession(input, deps = {}) {
511
595
  tag: v.tag,
512
596
  title: v.title,
513
597
  series: v.series,
598
+ autoStartGranted: auto.granted,
599
+ autoStartReason: auto.granted ? 'verified human request' : auto.reason,
514
600
  });
515
601
 
602
+ if (auto.granted) {
603
+ try {
604
+ notify({ cwd: v.toCwd, epicId: minted.epicId });
605
+ } catch { /* the Epic is delivered either way; a human can still approve it */ }
606
+ }
607
+
516
608
  let receiptOnOriginEpic = false;
517
609
  if (v.fromEpicId) {
518
610
  try {
@@ -522,7 +614,9 @@ async function openFeedbackSession(input, deps = {}) {
522
614
  `Feedback delivered to ${v.toCwd} as a proposed session (${minted.epicId}): "${v.title}"`
523
615
  + (v.series ? ` [${seriesLine(v.series)}]` : '')
524
616
  + '. '
525
- + 'It will not run until a human in that project approves it.',
617
+ + (auto.granted
618
+ ? 'It was auto-approved at the human\'s explicit request and is starting in that project.'
619
+ : 'It will not run until a human in that project approves it.'),
526
620
  );
527
621
  } catch {
528
622
  receiptOnOriginEpic = false;
@@ -535,14 +629,18 @@ async function openFeedbackSession(input, deps = {}) {
535
629
  toCwd: v.toCwd,
536
630
  fromCwd: v.fromCwd,
537
631
  tag: v.tag,
538
- status: 'proposed',
632
+ status: auto.granted ? 'auto-approved' : 'proposed',
633
+ ...(v.autoStart ? { autoStart: auto.granted ? { granted: true } : { granted: false, reason: auto.reason } } : {}),
539
634
  receiptOnOriginEpic,
540
635
  // Say plainly what did NOT happen, same posture as create-prd's
541
636
  // `enqueued: false` — never let a caller report this as "work started".
542
- note:
543
- 'Delivered as a PROPOSED session in the receiving project\'s Sessions queue. Nothing runs and no '
544
- + 'tokens are spent until a human in that project presses Approve & start. There is no callback: '
545
- + 'if you need an answer, ask the human to follow up.',
637
+ note: auto.granted
638
+ ? 'Auto-approved at the human\'s explicit request (verified against their chat prompt): the session '
639
+ + 'is starting in the receiving project. There is no callback: if you need an answer, ask the human '
640
+ + 'to follow up.'
641
+ : 'Delivered as a PROPOSED session in the receiving project\'s Sessions queue. Nothing runs and no '
642
+ + 'tokens are spent until a human in that project presses Approve & start. There is no callback: '
643
+ + 'if you need an answer, ask the human to follow up.',
546
644
  goalText,
547
645
  };
548
646
  }
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * humanPromptLedger.cjs — in-memory record of prompts a HUMAN typed into each
5
+ * chat claude session, keyed by claudeSessionId.
6
+ *
7
+ * Why it exists. The cross-project feedback conduit (crossProjectFeedback.cjs)
8
+ * may auto-approve a receiving-project session only when the human explicitly
9
+ * asked for it. An agent's say-so is not evidence of that. This ledger is the
10
+ * ONLY evidence that a human — not an agent, not an external/Web Remote
11
+ * injection, not an inbound proposal's agent-written opening prompt — typed a
12
+ * given prompt. Callers record only prompts that arrived from the human's own
13
+ * chat input, then ask `findHumanPromptContaining` whether a quoted request
14
+ * really appears in one.
15
+ *
16
+ * In-memory by design. Losing the ledger on restart only degrades auto-approve
17
+ * back to the ordinary 'proposed' path — it fails closed, so nothing is
18
+ * persisted to disk.
19
+ *
20
+ * Bounds: 10 prompts per session, 24h TTL, 200 sessions (least-recently-
21
+ * recorded evicted), 20000 chars per stored prompt. Pure Node, no Electron.
22
+ */
23
+
24
+ const MIN_QUOTE_CHARS = 20;
25
+ const MAX_PER_SESSION = 10;
26
+ const TTL_MS = 24 * 60 * 60 * 1000;
27
+ const MAX_SESSIONS = 200;
28
+ const MAX_TEXT_CHARS = 20000;
29
+
30
+ /** @type {Map<string, Array<{ text: string, norm: string, at: number }>>} */
31
+ const ledger = new Map();
32
+
33
+ /** @param {string} s */
34
+ function normalize(s) {
35
+ return s.replace(/\s+/g, ' ').trim().toLowerCase();
36
+ }
37
+
38
+ /**
39
+ * @param {unknown} claudeSessionId
40
+ * @param {unknown} text
41
+ * @param {number} [now]
42
+ */
43
+ function recordHumanPrompt(claudeSessionId, text, now = Date.now()) {
44
+ try {
45
+ if (typeof claudeSessionId !== 'string' || !claudeSessionId) return;
46
+ if (typeof text !== 'string' || !text.trim()) return;
47
+ const stored = text.slice(0, MAX_TEXT_CHARS);
48
+ const prior = (ledger.get(claudeSessionId) || []).filter((e) => now - e.at <= TTL_MS);
49
+ prior.push({ text: stored, norm: normalize(stored), at: now });
50
+ while (prior.length > MAX_PER_SESSION) prior.shift();
51
+ // Re-insert so Map order is least-recently-recorded first.
52
+ ledger.delete(claudeSessionId);
53
+ ledger.set(claudeSessionId, prior);
54
+ while (ledger.size > MAX_SESSIONS) {
55
+ const oldest = ledger.keys().next().value;
56
+ if (oldest === undefined) break;
57
+ ledger.delete(oldest);
58
+ }
59
+ } catch {
60
+ /* never throws */
61
+ }
62
+ }
63
+
64
+ /**
65
+ * @param {unknown} claudeSessionId
66
+ * @param {unknown} quote
67
+ * @param {number} [now]
68
+ * @returns {{ text: string, at: number } | null}
69
+ */
70
+ function findHumanPromptContaining(claudeSessionId, quote, now = Date.now()) {
71
+ try {
72
+ if (typeof claudeSessionId !== 'string' || typeof quote !== 'string') return null;
73
+ const q = normalize(quote);
74
+ if (q.length < MIN_QUOTE_CHARS) return null;
75
+ const entries = ledger.get(claudeSessionId);
76
+ if (!entries) return null;
77
+ const live = entries.filter((e) => now - e.at <= TTL_MS);
78
+ if (live.length === 0) ledger.delete(claudeSessionId);
79
+ else if (live.length !== entries.length) ledger.set(claudeSessionId, live);
80
+ for (let i = live.length - 1; i >= 0; i--) {
81
+ if (live[i].norm.includes(q)) return { text: live[i].text, at: live[i].at };
82
+ }
83
+ return null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function _resetForTests() {
90
+ ledger.clear();
91
+ }
92
+
93
+ module.exports = { recordHumanPrompt, findHumanPromptContaining, MIN_QUOTE_CHARS, _resetForTests };
@@ -243,7 +243,11 @@ const MCP_TOOL_CATALOG = [
243
243
  + '`acceptanceCriteria`; put evidence, symptom and references in `body`. Name the file in THEIR repo '
244
244
  + 'that looks responsible if you know it. When one request is split into several proposals, send '
245
245
  + 'them ALL with one shared `series` {key, title, total} (plus each one\'s `index`) and summarise '
246
- + 'the overall plan in each motivation.',
246
+ + 'the overall plan in each motivation. TWO MODES: (1) agent-initiated (your own idea) = PROPOSED, a '
247
+ + 'human in the receiving project must approve it; pass neither autoStart nor humanRequestQuote. '
248
+ + '(2) human-requested = the human in THIS conversation explicitly asked for the work in that other '
249
+ + 'project: pass `autoStart: true` plus `humanRequestQuote`, copied VERBATIM from their own message, '
250
+ + 'which must name the target project. An unverified request silently falls back to PROPOSED.',
247
251
  whenNotToUse: 'WHEN NOT TO USE IT: for work in the project you are ALREADY in, run /develop inside the Epic you '
248
252
  + 'are already in — this tool refuses toCwd === fromCwd outright.',
249
253
  exampleArgs: {
@@ -258,11 +262,13 @@ const MCP_TOOL_CATALOG = [
258
262
  series: { key: 'shared-admin-api-f', title: 'Shared admin API field F', index: 1, total: 2 },
259
263
  body: 'Symptom: X. Observed while doing Y in session-manager. Expected: Z. Suspected file: <path in their repo>.',
260
264
  },
261
- notes: 'WHAT THIS DOES NOT DO: it does not start anything, queue a PRD, or spend a token. The session '
265
+ notes: 'WHAT THIS DOES NOT DO: it never writes code or queues a PRD itself. Agent-initiated, the session '
262
266
  + 'lands as `proposed` and runs only if a human in the RECEIVING project presses "Approve & start". '
267
+ + 'With a verified autoStart + humanRequestQuote the result status is `auto-approved` and the session '
268
+ + 'starts on its own; otherwise it stays proposed (check the note for the reason). '
263
269
  + 'There is no callback and no reply channel — do not wait for an answer, and do not tell the user '
264
270
  + 'the other project has "been fixed" or "is working on it". Report only that the proposal was '
265
- + 'delivered.',
271
+ + 'delivered, or that the session was auto-approved and is starting.',
266
272
  },
267
273
  {
268
274
  name: 'project_home_write',
@@ -0,0 +1,139 @@
1
+ /**
2
+ * memoryDirResolve.cjs — where Claude Code itself keeps memory.
3
+ *
4
+ * Pure resolver (all IO injected) so the Memory tab reads and writes exactly the native locations:
5
+ * - workspace memory: ~/.claude/projects/<encoded git-repo root>/memory/ (all worktrees of a
6
+ * repo share it), unless the `autoMemoryDirectory` setting overrides it.
7
+ * - subagent memory: enabled by a `memory:` frontmatter field on the agent.
8
+ *
9
+ * Top-level in lib/ because it spans settings, git and agent files (no single domain folder fits).
10
+ */
11
+ 'use strict';
12
+
13
+ const path = require('node:path');
14
+ const { encodeCwd } = require('./util/encodeCwd.cjs');
15
+
16
+ const SCOPES = ['user', 'project', 'local'];
17
+ const NAME_RE = /^[A-Za-z0-9._-]{1,128}$/;
18
+
19
+ /** Highest-precedence value (local > project > user) of `key` passing `pred`. */
20
+ function pick(settings, key, pred) {
21
+ for (const layer of ['local', 'project', 'user']) {
22
+ const s = settings && settings[layer];
23
+ if (s && typeof s === 'object' && pred(s[key])) return s[key];
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ function expandDir(value, home, cwd) {
29
+ if (value === '~') return home;
30
+ if (value.startsWith('~/')) return path.join(home, value.slice(2));
31
+ return path.resolve(cwd, value);
32
+ }
33
+
34
+ function resolveWorkspaceMemoryDir({ home, cwd, settings, repoRoot }) {
35
+ const override = pick(settings, 'autoMemoryDirectory', (v) => typeof v === 'string' && v.trim() !== '');
36
+ if (override !== undefined) {
37
+ return { dir: expandDir(override.trim(), home, cwd), source: 'autoMemoryDirectory' };
38
+ }
39
+ return {
40
+ dir: path.join(home, '.claude', 'projects', encodeCwd(repoRoot || cwd), 'memory'),
41
+ source: 'default',
42
+ };
43
+ }
44
+
45
+ function autoMemoryEnabled(settings) {
46
+ return pick(settings, 'autoMemoryEnabled', (v) => typeof v === 'boolean') !== false;
47
+ }
48
+
49
+ function resolveAgentMemoryDir({ home, root, name, scope }) {
50
+ if (typeof name !== 'string' || !NAME_RE.test(name) || name === '.' || name === '..') {
51
+ throw new Error(`invalid agent name: ${JSON.stringify(name)}`);
52
+ }
53
+ if (!SCOPES.includes(scope)) throw new Error(`unknown memory scope: ${JSON.stringify(scope)}`);
54
+ if (scope === 'user') return path.join(home, '.claude', 'agent-memory', name);
55
+ if (typeof root !== 'string' || !root) throw new Error(`scope '${scope}' requires a project root`);
56
+ return path.join(root, '.claude', scope === 'project' ? 'agent-memory' : 'agent-memory-local', name);
57
+ }
58
+
59
+ /** Parse the leading `---` block into a flat key -> string map. */
60
+ function parseFrontmatter(text) {
61
+ const out = {};
62
+ if (typeof text !== 'string') return out;
63
+ const lines = text.replace(/^/, '').split(/\r?\n/);
64
+ if (lines[0].trim() !== '---') return out;
65
+ for (let i = 1; i < lines.length; i++) {
66
+ if (lines[i].trim() === '---') return out;
67
+ const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(lines[i]);
68
+ if (m) out[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2');
69
+ }
70
+ return {}; // unterminated block: not frontmatter
71
+ }
72
+
73
+ function scanAgents(dir, origin, readDir, readText) {
74
+ let names;
75
+ try {
76
+ names = readDir(dir);
77
+ } catch {
78
+ return [];
79
+ }
80
+ const out = [];
81
+ for (const file of names || []) {
82
+ if (typeof file !== 'string' || !file.endsWith('.md')) continue;
83
+ const agentFile = path.join(dir, file);
84
+ let text;
85
+ try {
86
+ text = readText(agentFile);
87
+ } catch {
88
+ continue;
89
+ }
90
+ if (text == null) continue;
91
+ const fm = parseFrontmatter(text);
92
+ out.push({
93
+ name: fm.name || file.slice(0, -3),
94
+ origin,
95
+ memoryScope: SCOPES.includes(fm.memory) ? fm.memory : null,
96
+ agentFile,
97
+ });
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function listMemoryAgents({ home, root, readDir, readText }) {
103
+ const byName = new Map();
104
+ for (const a of scanAgents(path.join(home, '.claude', 'agents'), 'user', readDir, readText)) byName.set(a.name, a);
105
+ if (root) {
106
+ for (const a of scanAgents(path.join(root, '.claude', 'agents'), 'project', readDir, readText)) byName.set(a.name, a);
107
+ }
108
+ return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
109
+ }
110
+
111
+ const rootCache = new Map();
112
+
113
+ function gitRepoRoot(cwd, execFileSync) {
114
+ if (rootCache.has(cwd)) return rootCache.get(cwd);
115
+ let root = null;
116
+ try {
117
+ const out = String(
118
+ execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
119
+ cwd,
120
+ timeout: 5000,
121
+ encoding: 'utf8',
122
+ stdio: ['ignore', 'pipe', 'ignore'],
123
+ }),
124
+ ).trim();
125
+ if (out.endsWith('/.git')) root = out.slice(0, -'/.git'.length) || null;
126
+ } catch {
127
+ root = null;
128
+ }
129
+ rootCache.set(cwd, root);
130
+ return root;
131
+ }
132
+
133
+ module.exports = {
134
+ resolveWorkspaceMemoryDir,
135
+ autoMemoryEnabled,
136
+ resolveAgentMemoryDir,
137
+ listMemoryAgents,
138
+ gitRepoRoot,
139
+ };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pure helpers for Claude Code's auto-memory index file (MEMORY.md): one line
5
+ * per memory, `- [Title](file.md) — hook`. Claude Code loads only the first
6
+ * 200 lines / 25KB of it per session, so an unlisted memory is invisible.
7
+ * No fs/electron access; callers pass text in and write text out.
8
+ *
9
+ * Top-level (not in a lib/ subfolder) to sit beside its sibling memoryStale.cjs.
10
+ */
11
+
12
+ const MAX_LINES = 200;
13
+ const MAX_BYTES = 25600;
14
+ const NEAR_LINES = 180;
15
+ const NEAR_BYTES = 23040;
16
+
17
+ // `- [Title](name.md)` with optional ` — hook` / ` - hook`. Bare file names only
18
+ // (no path separators), so links into subfolders or URLs never match.
19
+ const LINE_RE = /^\s*[-*]\s+\[([^\]]*)\]\(([^)\s/\\]+\.md)\)(?:\s+(?:—|-)\s+(.*?))?\s*$/;
20
+
21
+ /** Split into lines; a trailing newline does not add a line. */
22
+ function splitLines(text) {
23
+ if (typeof text !== 'string' || text === '') return [];
24
+ const lines = text.split('\n');
25
+ if (lines[lines.length - 1] === '') lines.pop();
26
+ return lines;
27
+ }
28
+
29
+ function matchLine(raw) {
30
+ return LINE_RE.exec(raw.replace(/\r$/, ''));
31
+ }
32
+
33
+ /**
34
+ * Parse index link lines. Complexity: O(lines).
35
+ * @param {string|null} text
36
+ * @returns {{line: string, lineNo: number, title: string, file: string, hook: string}[]}
37
+ */
38
+ function parseIndex(text) {
39
+ const out = [];
40
+ splitLines(text).forEach((raw, i) => {
41
+ const m = matchLine(raw);
42
+ if (!m) return;
43
+ out.push({ line: raw.replace(/\r$/, ''), lineNo: i + 1, title: m[1], file: m[2], hook: m[3] || '' });
44
+ });
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * Append an index line unless `file` is already linked. Complexity: O(lines).
50
+ * @param {string|null} text
51
+ * @param {{file: string, title: string, hook?: string}} entry
52
+ * @returns {string}
53
+ */
54
+ function addIndexLine(text, { file, title, hook }) {
55
+ const base = typeof text === 'string' ? text : '';
56
+ if (parseIndex(base).some((e) => e.file === file)) return base;
57
+ const line = `- [${title}](${file})${hook ? ` — ${hook}` : ''}`;
58
+ const trimmed = base.replace(/(\r?\n)+$/, '');
59
+ return `${trimmed === '' ? '' : `${trimmed}\n`}${line}\n`;
60
+ }
61
+
62
+ /**
63
+ * Remove every line linking to `file`; other lines are untouched.
64
+ * Complexity: O(lines).
65
+ * @param {string|null} text
66
+ * @param {string} file
67
+ * @returns {string}
68
+ */
69
+ function removeIndexLines(text, file) {
70
+ if (typeof text !== 'string' || text === '') return '';
71
+ const parts = text.split('\n');
72
+ const hadTrailing = parts[parts.length - 1] === '';
73
+ if (hadTrailing) parts.pop();
74
+ const kept = parts.filter((raw) => {
75
+ const m = matchLine(raw);
76
+ return !(m && m[2] === file);
77
+ });
78
+ return kept.length === 0 ? '' : kept.join('\n') + (hadTrailing ? '\n' : '');
79
+ }
80
+
81
+ /**
82
+ * Index budget + consistency report. Complexity: O((lines + files) log n).
83
+ * @param {{indexText: string|null, files: string[]}} args
84
+ */
85
+ function indexHealth({ indexText, files }) {
86
+ const text = typeof indexText === 'string' ? indexText : '';
87
+ const lines = splitLines(text).length;
88
+ const bytes = Buffer.byteLength(text, 'utf8');
89
+ const overBudget = lines > MAX_LINES || bytes > MAX_BYTES;
90
+ const nearBudget = !overBudget && (lines >= NEAR_LINES || bytes >= NEAR_BYTES);
91
+ const present = new Set((files || []).filter((f) => f !== 'MEMORY.md'));
92
+ const linked = new Set(parseIndex(text).map((e) => e.file));
93
+ const orphans = [...present].filter((f) => !linked.has(f)).sort();
94
+ const dangling = [...linked].filter((f) => f !== 'MEMORY.md' && !present.has(f)).sort();
95
+ return { lines, bytes, maxLines: MAX_LINES, maxBytes: MAX_BYTES, overBudget, nearBudget, orphans, dangling };
96
+ }
97
+
98
+ module.exports = { parseIndex, addIndexLine, removeIndexLines, indexHealth };
@@ -30,6 +30,21 @@ function resolveDepSlug(depSlug, candidateSlugs) {
30
30
  return candidateSlugs.filter((slug) => bareSlug(slug) === bare);
31
31
  }
32
32
 
33
+ /**
34
+ * Is this dep ROW satisfied? `completed`, or a `skipped` row stamped
35
+ * `needsReviewAutoResolvedSkip` — the bounded needs_review auto-resolve's
36
+ * terminal decision (scheduler.cjs applyNeedsReviewAutoResolve), deliberately
37
+ * not a permanent block. Shared by schedulerBatch.findBlockingDep (dispatch)
38
+ * and runVerify.checkDeps (post-run): when they disagreed, the picker fired a
39
+ * dependent of an auto-skipped row and the verifier downgraded every finished
40
+ * run back to pending on "is skipped (need completed)" — an unbounded billed
41
+ * re-run loop (Bilko-Launch-Kit validator, ~20 runs, 2026-10-11).
42
+ */
43
+ function isSatisfiedDepRow(row) {
44
+ return row?.status === 'completed'
45
+ || (row?.status === 'skipped' && row.needsReviewAutoResolvedSkip === true);
46
+ }
47
+
33
48
  /**
34
49
  * Levenshtein edit distance. O(n*m) in the two strings' lengths — fine here
35
50
  * since it only ever runs over short slug strings (<= 60 chars, PRD_CREATE_SLUG_RE's
@@ -69,4 +84,4 @@ function findNearMatches(depSlug, candidateSlugs, limit = 3) {
69
84
  .map((entry) => entry.slug);
70
85
  }
71
86
 
72
- module.exports = { bareSlug, resolveDepSlug, findNearMatches };
87
+ module.exports = { bareSlug, resolveDepSlug, findNearMatches, isSatisfiedDepRow };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * finalizeReason.cjs — the statusHistory reason for a job's running→terminal
5
+ * finalize transition. Pure; no I/O.
6
+ */
7
+
8
+ /**
9
+ * @param {{
10
+ * effectiveStatus: string,
11
+ * verifyResult?: { verdict?: string, reason?: string } | null,
12
+ * sigtermOverrideReason?: string | null,
13
+ * exitCode?: number | null,
14
+ * }} input
15
+ * @returns {string}
16
+ */
17
+ function finalizeReasonFor({ effectiveStatus, verifyResult, sigtermOverrideReason, exitCode }) {
18
+ if (sigtermOverrideReason) return sigtermOverrideReason;
19
+ if (effectiveStatus === 'completed' && verifyResult?.verdict === 'already_satisfied_on_main') {
20
+ return String(verifyResult.reason);
21
+ }
22
+ if (effectiveStatus === 'needs_review' && verifyResult?.verdict) {
23
+ return verifyResult.reason
24
+ ? `needs_review: ${verifyResult.verdict} — ${verifyResult.reason}`
25
+ : `needs_review: ${verifyResult.verdict}`;
26
+ }
27
+ return `run finished with exit ${exitCode}`;
28
+ }
29
+
30
+ module.exports = { finalizeReasonFor };
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ /** Retired flat `scheduler/prds/` sweep runs at most once per hour per process. */
4
+ const FLAT_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
5
+
6
+ /**
7
+ * @param {number} nowMs
8
+ * @param {number | null} lastRunMs null = never ran this process (always run)
9
+ * @param {number} [intervalMs]
10
+ * @returns {boolean}
11
+ */
12
+ function shouldRunFlatSweep(nowMs, lastRunMs, intervalMs = FLAT_SWEEP_INTERVAL_MS) {
13
+ if (lastRunMs === null || lastRunMs === undefined) return true;
14
+ return nowMs - lastRunMs >= intervalMs;
15
+ }
16
+
17
+ module.exports = { shouldRunFlatSweep, FLAT_SWEEP_INTERVAL_MS };