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
@@ -79,12 +79,24 @@ describe('pickForProject dependsOn eligibility (PRD 832)', () => {
79
79
  expect(res.batch.map((j) => j.slug)).toContain('851-next');
80
80
  });
81
81
 
82
- it('a dep slug with NO queue row counts as done (retired to history)', () => {
82
+ it('a dep slug with NO queue row counts as done when found in history/archive (PRD 1122)', () => {
83
83
  const dependent = job('853-next', 'pending', 853, { dependsOn: ['852-archived-long-ago'] });
84
- const res = pickForProject([dependent], new Set(), 3);
84
+ // Caller (scheduler.cjs's computeDepHistorySatisfaction) precomputes this
85
+ // Set from history.jsonl/prds-archived — pickForProject never does I/O.
86
+ const satisfiedSlugs = new Set(['852-archived-long-ago']);
87
+ const res = pickForProject([dependent], new Set(), 3, new Map(), satisfiedSlugs);
85
88
  expect(res.batch.map((j) => j.slug)).toContain('853-next');
86
89
  });
87
90
 
91
+ it('a dep slug with NO queue row and NO history/archive record HOLDS as unresolved (PRD 1122)', () => {
92
+ const dependent = job('853-next', 'pending', 853, { dependsOn: ['852-archived-long-ago'] });
93
+ const res = pickForProject([dependent], new Set(), 3);
94
+ expect(res.batch).toEqual([]);
95
+ expect(res.reason).toMatch(/unresolvable/);
96
+ expect(res.reason).toContain('853-next');
97
+ expect(res.reason).toContain('852-archived-long-ago');
98
+ });
99
+
88
100
  it('a FAILED dep holds the dependent with an explicit depends-gate reason', () => {
89
101
  const dep = job('854-base', 'failed', 854);
90
102
  const dependent = job('855-next', 'pending', 855, { dependsOn: ['854-base'] });
@@ -313,6 +313,13 @@ const NO_NEWLINE_RE = /^[^\r\n]*$/;
313
313
  // prdCreate.cjs's createPrd via prdAgentType.cjs's assertAgentTypeWritable —
314
314
  // this schema only enforces the name SHAPE.
315
315
  const PrdAgentTypeSchema = z.string().regex(PERSONA_NAME_RE, 'agent name must be lowercase, hyphenated (e.g. "dev-lead")');
316
+ // Shared shape for a `dependsOn` slug entry — schedulerCreatePrd and
317
+ // adminUpdatePrd (scheduler_update_prd, PRD 1124) must accept the exact same
318
+ // slug shape or an update could write a dependsOn value create would have
319
+ // rejected. Array-level constraints (`.max(20)`, `.optional()`) stay at each
320
+ // call site since update also allows an explicit empty array to CLEAR the
321
+ // dependency, which create has no reason to accept.
322
+ const DepSlugSchema = z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/);
316
323
  const schedulerCreatePrd = z.object({
317
324
  title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
318
325
  // Optional (PRD: worktree-cwd Epic-lookup hazard) — when omitted, prdCreate.cjs's
@@ -332,7 +339,7 @@ const schedulerCreatePrd = z.object({
332
339
  parallelGroup: z.number().int().min(1).max(999999).optional(),
333
340
  // Explicit ordering (PRD 832): slugs that must complete before this PRD
334
341
  // becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
335
- dependsOn: z.array(z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/)).max(20).optional(),
342
+ dependsOn: z.array(DepSlugSchema).max(20).optional(),
336
343
  // An EXISTING Epic's promptSessionId (PRD 748) — NOT a PromptTicket.id.
337
344
  // ensureEpic() (epicMint.cjs) joins the Epic whose active-index.json
338
345
  // sessions key literally equals this value; the renderer only ever sends
@@ -455,6 +462,13 @@ const adminPrdFrontmatterPatch = z.object({
455
462
  // Opt-in exclusive-lease flag (PRD 1107) — patchable after creation too,
456
463
  // same as every other recognized key here.
457
464
  quietMachine: z.boolean().optional(),
465
+ // Explicit ordering (PRD 832), promoted to patchable by PRD 1124 — see
466
+ // prdFrontmatter.cjs's header. An explicit `[]` CLEARS the dependency
467
+ // (updatePrd's array-emptiness check skips writing the key at all); a
468
+ // non-empty array is validated write-time against the SAME resolver
469
+ // (depSlugResolve.cjs) scheduler_create_prd uses, so update and create
470
+ // can never disagree about what a dependsOn entry resolves to.
471
+ dependsOn: z.array(DepSlugSchema).max(20).optional(),
458
472
  });
459
473
 
460
474
  const adminUpdatePrd = z.object({
@@ -0,0 +1,164 @@
1
+ /**
2
+ * branchSweep.test.cjs — PRD 1135: recovering `sm-job/<slug>` branches left
3
+ * stranded (unmerged, owning job row terminal or gone) never gets swept away
4
+ * by hand months later.
5
+ *
6
+ * Exercises the real `git` plumbing against throwaway repos under
7
+ * os.tmpdir() (fast — no network, no Electron).
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/branchSweep.test.cjs
10
+ */
11
+ 'use strict';
12
+
13
+ import { test, expect, beforeEach, afterEach } from 'vitest';
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const { execFileSync } = require('node:child_process');
18
+ const { sweepStrandedJobBranches } = require('../branchSweep.cjs');
19
+
20
+ let tmpRoot;
21
+ let repoCwd;
22
+ let worktreeDir;
23
+
24
+ function git(args, cwd) {
25
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
26
+ }
27
+
28
+ function initRepo(dir) {
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ git(['init', '-q'], dir);
31
+ git(['config', 'user.email', 'test@example.com'], dir);
32
+ git(['config', 'user.name', 'Test'], dir);
33
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
34
+ git(['add', '-A'], dir);
35
+ git(['commit', '-q', '-m', 'initial'], dir);
36
+ }
37
+
38
+ beforeEach(() => {
39
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-'));
40
+ repoCwd = path.join(tmpRoot, 'repo');
41
+ initRepo(repoCwd);
42
+ delete process.env.SM_BRANCH_SWEEP_DISABLE;
43
+ });
44
+
45
+ afterEach(() => {
46
+ if (worktreeDir && fs.existsSync(worktreeDir)) {
47
+ try { git(['worktree', 'remove', '--force', worktreeDir], repoCwd); } catch { /* best effort */ }
48
+ }
49
+ worktreeDir = undefined;
50
+ delete process.env.SM_BRANCH_SWEEP_DISABLE;
51
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
52
+ });
53
+
54
+ /** Create an `sm-job/<slug>` branch with one commit beyond HEAD, without checking it out. */
55
+ function makeStrandedBranch(cwd, slug, fileContents = 'stranded work\n') {
56
+ const branch = `sm-job/${slug}`;
57
+ git(['branch', branch], cwd);
58
+ const tmpCheckout = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-checkout-'));
59
+ git(['worktree', 'add', '-q', tmpCheckout, branch], cwd);
60
+ fs.writeFileSync(path.join(tmpCheckout, `${slug}.txt`), fileContents, 'utf8');
61
+ git(['add', '-A'], tmpCheckout);
62
+ git(['commit', '-q', '-m', `job ${slug} work`], tmpCheckout);
63
+ git(['worktree', 'remove', '--force', tmpCheckout], cwd);
64
+ return branch;
65
+ }
66
+
67
+ test('stranded branch with a terminal job row is integrated', async () => {
68
+ const branch = makeStrandedBranch(repoCwd, 'alpha');
69
+ const jobs = [{ slug: 'alpha', status: 'completed' }];
70
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
71
+ const r = results.find((x) => x.branch === branch);
72
+ expect(r.action).toBe('integrated');
73
+ expect(fs.existsSync(path.join(repoCwd, 'alpha.txt'))).toBe(true);
74
+ });
75
+
76
+ test('stranded branch with NO job row at all is also integrated', async () => {
77
+ const branch = makeStrandedBranch(repoCwd, 'ghost');
78
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs: [], attemptedBranches: new Set() });
79
+ const r = results.find((x) => x.branch === branch);
80
+ expect(r.action).toBe('integrated');
81
+ });
82
+
83
+ test('branch already merged into HEAD is a silent no-op', async () => {
84
+ const branch = makeStrandedBranch(repoCwd, 'bravo');
85
+ git(['merge', '--no-ff', '--no-edit', branch], repoCwd);
86
+ const jobs = [{ slug: 'bravo', status: 'completed' }];
87
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
88
+ const r = results.find((x) => x.branch === branch);
89
+ expect(r.action).toBe('no-op-merged');
90
+ });
91
+
92
+ test('branch checked out in a live worktree is skipped entirely', async () => {
93
+ const branch = makeStrandedBranch(repoCwd, 'charlie');
94
+ worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-live-'));
95
+ git(['worktree', 'add', '-q', worktreeDir, branch], repoCwd);
96
+ const jobs = [{ slug: 'charlie', status: 'running' }];
97
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
98
+ const r = results.find((x) => x.branch === branch);
99
+ expect(r.action).toBe('skip-checked-out');
100
+ });
101
+
102
+ test('branch owned by a NON-terminal job row is left alone, never integrated', async () => {
103
+ const branch = makeStrandedBranch(repoCwd, 'delta');
104
+ const jobs = [{ slug: 'delta', status: 'needs_review' }];
105
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
106
+ const r = results.find((x) => x.branch === branch);
107
+ expect(r.action).toBe('skip-active-row');
108
+ expect(fs.existsSync(path.join(repoCwd, 'delta.txt'))).toBe(false);
109
+ });
110
+
111
+ test('conflicting branch is flagged, never forced, and the branch is never deleted', async () => {
112
+ // Same path committed differently on both HEAD and the branch → real conflict.
113
+ fs.writeFileSync(path.join(repoCwd, 'shared.txt'), 'main tree version\n', 'utf8');
114
+ git(['add', '-A'], repoCwd);
115
+ git(['commit', '-q', '-m', 'main tree edits shared.txt'], repoCwd);
116
+
117
+ const branch = 'sm-job/echo';
118
+ git(['branch', branch, 'HEAD~1'], repoCwd);
119
+ const tmpCheckout = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-conflict-'));
120
+ git(['worktree', 'add', '-q', tmpCheckout, branch], repoCwd);
121
+ fs.writeFileSync(path.join(tmpCheckout, 'shared.txt'), 'branch version — conflicts\n', 'utf8');
122
+ git(['add', '-A'], tmpCheckout);
123
+ git(['commit', '-q', '-m', 'job echo edits shared.txt'], tmpCheckout);
124
+ git(['worktree', 'remove', '--force', tmpCheckout], repoCwd);
125
+
126
+ const jobs = [{ slug: 'echo', status: 'failed' }];
127
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
128
+ const r = results.find((x) => x.branch === branch);
129
+ expect(r.action).toBe('conflict');
130
+ expect(r.integration.ok).toBe(false);
131
+ // Branch itself must survive a conflict, untouched.
132
+ const stillExists = git(['rev-parse', '--verify', branch], repoCwd).trim();
133
+ expect(stillExists.length).toBe(40);
134
+ });
135
+
136
+ test('a branch is retried at most once per process lifetime via the shared attempted set', async () => {
137
+ fs.writeFileSync(path.join(repoCwd, 'shared2.txt'), 'main\n', 'utf8');
138
+ git(['add', '-A'], repoCwd);
139
+ git(['commit', '-q', '-m', 'main edits shared2'], repoCwd);
140
+ const branch = 'sm-job/foxtrot';
141
+ git(['branch', branch, 'HEAD~1'], repoCwd);
142
+ const tmpCheckout = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-retry-'));
143
+ git(['worktree', 'add', '-q', tmpCheckout, branch], repoCwd);
144
+ fs.writeFileSync(path.join(tmpCheckout, 'shared2.txt'), 'branch — conflicts\n', 'utf8');
145
+ git(['add', '-A'], tmpCheckout);
146
+ git(['commit', '-q', '-m', 'job foxtrot edits shared2'], tmpCheckout);
147
+ git(['worktree', 'remove', '--force', tmpCheckout], repoCwd);
148
+
149
+ const jobs = [{ slug: 'foxtrot', status: 'failed' }];
150
+ const attemptedBranches = new Set();
151
+ const first = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches });
152
+ expect(first.results.find((x) => x.branch === branch).action).toBe('conflict');
153
+ const second = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches });
154
+ expect(second.results.find((x) => x.branch === branch).action).toBe('skip-already-attempted');
155
+ });
156
+
157
+ test('SM_BRANCH_SWEEP_DISABLE=1 restores today\'s behaviour exactly — no scan, no integration', async () => {
158
+ makeStrandedBranch(repoCwd, 'golf');
159
+ process.env.SM_BRANCH_SWEEP_DISABLE = '1';
160
+ const jobs = [{ slug: 'golf', status: 'completed' }];
161
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
162
+ expect(results).toEqual([]);
163
+ expect(fs.existsSync(path.join(repoCwd, 'golf.txt'))).toBe(false);
164
+ });
@@ -0,0 +1,13 @@
1
+ [scheduler] starting 204-mercury-steam-horse at 2026-09-05T16:32:31.039Z
2
+ [scheduler] cwd=/home/bilko/Projects/starry-night-ships (running isolated in worktree /tmp/session-manager-job-worktrees/443997d5bbc5f8ab/204-mercury-steam-horse)
3
+
4
+ [scheduler] agentType=dev-lead persona=/home/bilko/.claude/agents/dev-lead.md model=fable
5
+ [scheduler] spawned pid=3537290 sessionId=5cb9d69b-e2e7-49f9-806c-2307f1c94b2b (process group)
6
+
7
+ {"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","resetsAt":1789059600,"rateLimitType":"seven_day","utilization":0.59,"isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0,"resetsAt":1788643800},"seven_day":{"utilization":0.59,"resetsAt":1789059600}}},"uuid":"f7ef520a-9c3f-470e-89de-4ba9af7d5968","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b"}
8
+ {"type":"system","subtype":"init","cwd":"/tmp/session-manager-job-worktrees/443997d5bbc5f8ab/204-mercury-steam-horse","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b","tools":["Task","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterWorktree","ExitWorktree","ListAgents","ListMcpResourcesTool","Monitor","NotebookEdit","PushNotification","Read","ReadMcpResourceDirTool","ReadMcpResourceTool","RemoteTrigger","ReportFindings","ScheduleWakeup","SendMessage","Skill","TaskOutput","TaskStop","ToolSearch","WebFetch","WebSearch","Workflow","Write","mcp__bilko-host__get_host_contract","mcp__bilko-host__list_projects","mcp__bilko-host__publish_static_project","mcp__bilko-host__register_static_project","mcp__bilko-host__status","mcp__bilko-host__unregister_project","mcp__bilko-host__usage_report","mcp__claude_ai_Gmail__apply_sensitive_message_label","mcp__claude_ai_Gmail__apply_sensitive_thread_label","mcp__claude_ai_Gmail__create_draft","mcp__claude_ai_Gmail__create_label","mcp__claude_ai_Gmail__delete_label","mcp__claude_ai_Gmail__forward","mcp__claude_ai_Gmail__get_draft","mcp__claude_ai_Gmail__get_message","mcp__claude_ai_Gmail__get_thread","mcp__claude_ai_Gmail__label_message","mcp__claude_ai_Gmail__label_thread","mcp__claude_ai_Gmail__list_drafts","mcp__claude_ai_Gmail__list_labels","mcp__claude_ai_Gmail__mark_message_spam","mcp__claude_ai_Gmail__mark_thread_spam","mcp__claude_ai_Gmail__reply","mcp__claude_ai_Gmail__search_threads","mcp__claude_ai_Gmail__send_message","mcp__claude_ai_Gmail__trash_message","mcp__claude_ai_Gmail__trash_thread","mcp__claude_ai_Gmail__unlabel_message","mcp__claude_ai_Gmail__unlabel_thread","mcp__claude_ai_Gmail__unmark_message_spam","mcp__claude_ai_Gmail__unmark_thread_spam","mcp__claude_ai_Gmail__untrash_message","mcp__claude_ai_Gmail__untrash_thread","mcp__claude_ai_Gmail__update_draft","mcp__claude_ai_Gmail__update_label","mcp__claude_ai_Gmail__update_message_labels","mcp__fetch__fetch","mcp__godot__add_node","mcp__godot__create_scene","mcp__godot__export_mesh_library","mcp__godot__get_debug_output","mcp__godot__get_godot_version","mcp__godot__get_project_info","mcp__godot__get_uid","mcp__godot__launch_editor","mcp__godot__list_projects","mcp__godot__load_sprite","mcp__godot__run_project","mcp__godot__save_scene","mcp__godot__stop_project","mcp__godot__update_project_uids","mcp__google-workspace__batch_modify_gmail_message_labels","mcp__google-workspace__batch_update_doc","mcp__google-workspace__check_drive_file_public_access","mcp__google-workspace__copy_drive_file","mcp__google-workspace__create_calendar","mcp__google-workspace__create_doc","mcp__google-workspace__create_drive_file","mcp__google-workspace__create_drive_folder","mcp__google-workspace__create_reaction","mcp__google-workspace__create_table_with_data","mcp__google-workspace__debug_docs_runtime_info","mcp__google-workspace__debug_table_structure","mcp__google-workspace__download_chat_attachment","mcp__google-workspace__draft_gmail_message","mcp__google-workspace__export_doc_to_pdf","mcp__google-workspace__find_and_replace_doc","mcp__google-workspace__get_doc_as_markdown","mcp__google-workspace__get_doc_content","mcp__google-workspace__get_drive_file_content","mcp__google-workspace__get_drive_file_download_url","mcp__google-workspace__get_drive_file_permissions","mcp__google-workspace__get_drive_shareable_link","mcp__google-workspace__get_events","mcp__google-workspace__get_gmail_attachment_content","mcp__google-workspace__get_gmail_message_content","mcp__google-workspace__get_gmail_messages_content_batch","mcp__google-workspace__get_gmail_thread_content","mcp__google-workspace__get_gmail_threads_content_batch","mcp__google-workspace__get_messages","mcp__google-workspace__import_to_google_doc","mcp__google-workspace__import_to_google_sheets","mcp__google-workspace__import_to_google_slides","mcp__google-workspace__insert_doc_elements","mcp__google-workspace__insert_doc_image","mcp__google-workspace__inspect_doc_structure","mcp__google-workspace__list_calendars","mcp__google-workspace__list_docs_in_folder","mcp__google-workspace__list_document_comments","mcp__google-workspace__list_drive_items","mcp__google-workspace__list_gmail_filters","mcp__google-workspace__list_gmail_labels","mcp__google-workspace__list_spaces","mcp__google-workspace__manage_doc_tab","mcp__google-workspace__manage_document_comment","mcp__google-workspace__manage_drive_access","mcp__google-workspace__manage_event","mcp__google-workspace__manage_focus_time","mcp__google-workspace__manage_gmail_filter","mcp__google-workspace__manage_gmail_label","mcp__google-workspace__manage_out_of_office","mcp__google-workspace__modify_doc_text","mcp__google-workspace__modify_gmail_message_labels","mcp__google-workspace__query_freebusy","mcp__google-workspace__search_docs","mcp__google-workspace__search_drive_files","mcp__google-workspace__search_gmail_messages","mcp__google-workspace__search_messages","mcp__google-workspace__send_gmail_message","mcp__google-workspace__send_message","mcp__google-workspace__set_drive_file_permissions","mcp__google-workspace__start_google_auth","mcp__google-workspace__update_doc_headers_footers","mcp__google-workspace__update_drive_file","mcp__google-workspace__update_paragraph_style","mcp__playwright__browser_click","mcp__playwright__browser_close","mcp__playwright__browser_console_messages","mcp__playwright__browser_drag","mcp__playwright__browser_drop","mcp__playwright__browser_evaluate","mcp__playwright__browser_file_upload","mcp__playwright__browser_fill_form","mcp__playwright__browser_handle_dialog","mcp__playwright__browser_hover","mcp__playwright__browser_navigate","mcp__playwright__browser_navigate_back","mcp__playwright__browser_network_request","mcp__playwright__browser_network_requests","mcp__playwright__browser_press_key","mcp__playwright__browser_resize","mcp__playwright__browser_run_code_unsafe","mcp__playwright__browser_select_option","mcp__playwright__browser_snapshot","mcp__playwright__browser_tabs","mcp__playwright__browser_take_screenshot","mcp__playwright__browser_type","mcp__playwright__browser_wait_for","mcp__session-manager-scheduler__chat_send_prompt","mcp__session-manager-scheduler__feedback_list_projects","mcp__session-manager-scheduler__feedback_open_session","mcp__session-manager-scheduler__project_home_get_contract","mcp__session-manager-scheduler__project_home_render","mcp__session-manager-scheduler__project_home_status","mcp__session-manager-scheduler__project_home_validate_summary","mcp__session-manager-scheduler__scheduler_archive_prd","mcp__session-manager-scheduler__scheduler_cancel_job","mcp__session-manager-scheduler__scheduler_create_prd","mcp__session-manager-scheduler__scheduler_get_prd","mcp__session-manager-scheduler__scheduler_list_jobs","mcp__session-manager-scheduler__scheduler_list_prds","mcp__session-manager-scheduler__scheduler_reset_job","mcp__session-manager-scheduler__scheduler_retag_prd","mcp__session-manager-scheduler__scheduler_update_prd","mcp__session-manager-scheduler__session_manager_help"],"mcp_servers":[{"name":"plugin:stripe:stripe","status":"needs-auth"},{"name":"fetch","status":"connected"},{"name":"sqlite","status":"failed"},{"name":"playwright","status":"connected"},{"name":"google-workspace","status":"connected"},{"name":"n8n","status":"failed"},{"name":"session-manager-scheduler","status":"connected"},{"name":"bilko-host","status":"connected"},{"name":"godot","status":"connected"},{"name":"claude.ai Google Calendar","status":"needs-auth"},{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Gmail","status":"connected"}],"model":"claude-fable-5-1","permissionMode":"bypassPermissions","slash_commands":["refresh-claude-md","deep-research","stripe:explain-error","stripe:test-cards","session-manager-dev:blog-for-project-feature","session-manager-dev:builder","session-manager-dev:develop","session-manager-dev:discover-features","session-manager-dev:explain-to-me","session-manager-dev:find-opportunity","session-manager-dev:issue-address","session-manager-dev:local-project-health","session-manager-dev:memory-sanitation","session-manager-dev:ops-sweep","session-manager-dev:pr-review-sweep","session-manager-dev:pr-signal","session-manager-dev:project-status","session-manager-dev:requesting-code-review","session-manager-dev:send-feedback","stripe:connect-recommend","stripe:connect-required-verification-information","stripe:stripe-apps","stripe:stripe-best-practices","stripe:stripe-directory","stripe:stripe-docs","stripe:stripe-projects","stripe:upgrade-stripe","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator","advisor","agents","auto-mode-setup","autocompact","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","import","model","__remote-workflow","workflow-launch-exec","reload-plugins","reload-skills","rename","ultrareview","security-review","usage-credits","extra-usage","usage","insights","recap","skill-doctor","goal","design","design-consent","design-revoke","list-agents","team-onboarding","mcp__fetch__fetch"],"terminal_slash_commands":["doctor","color","reload-plugins"],"apiKeySource":"none","claude_code_version":"2.1.261","output_style":"default","agents":["architect","bilko-host-publisher","builder","claude","dev-lead","Explore","general-purpose","Plan","project-home-builder","statusline-setup","stripe:Company Researcher"],"skills":["deep-research","session-manager-dev:blog-for-project-feature","session-manager-dev:builder","session-manager-dev:develop","session-manager-dev:discover-features","session-manager-dev:explain-to-me","session-manager-dev:find-opportunity","session-manager-dev:issue-address","session-manager-dev:local-project-health","session-manager-dev:memory-sanitation","session-manager-dev:ops-sweep","session-manager-dev:pr-review-sweep","session-manager-dev:pr-signal","session-manager-dev:project-status","session-manager-dev:requesting-code-review","session-manager-dev:send-feedback","stripe:connect-recommend","stripe:connect-required-verification-information","stripe:stripe-apps","stripe:stripe-best-practices","stripe:stripe-directory","stripe:stripe-docs","stripe:stripe-projects","stripe:upgrade-stripe","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator"],"plugins":[{"name":"session-manager-dev","path":"/home/bilko/Projects/session-manager/plugins/session-manager-dev","source":"session-manager-dev@session-manager","version":"0.1.0"},{"name":"stripe","path":"/home/bilko/.claude/plugins/cache/claude-plugins-official/stripe/0.6.4","source":"stripe@claude-plugins-official","version":"0.6.4"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"7f8e3566-fc40-427c-a9ad-0c0719d7f3b2","memory_paths":{"auto":"/home/bilko/.claude/projects/-home-bilko-Projects-starry-night-ships/memory/"},"messaging_socket_path":"/run/user/1000/cc-socks/3537290.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required"}
9
+ {"type":"rate_limit_event","rate_limit_info":{"status":"rejected","resetsAt":1789059600,"rateLimitType":"seven_day_overage_included","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0,"resetsAt":1788643800},"seven_day":{"utilization":0.59,"resetsAt":1789059600},"seven_day_overage_included":{"utilization":1,"resetsAt":1789059600}}},"uuid":"faa8b505-db69-4afe-9436-9c812b76569a","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b"}
10
+ {"type":"assistant","message":{"diagnostics":null,"id":"dcbde611-72e9-4f07-bc35-c8ec9911f789","container":null,"model":"<synthetic>","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"output_tokens_details":null,"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null},"content":[{"type":"text","text":"You've reached your Fable limit. Switch to another model, or manage usage credits at claude.ai/settings/usage?from=cc_cli_limit_message, to continue."}],"context_management":null},"parent_tool_use_id":null,"session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b","uuid":"a651974e-39c4-440f-a34e-eeec87e65fa4","timestamp":"2026-09-05T16:32:35.275Z","error":"rate_limit","request_id":"req_011CekcYKs4mrLVHXVc5xC2X","is_api_error_message":true}
11
+ {"duration_api_ms":1120,"stop_reason":"stop_sequence","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b","total_cost_usd":0.005226,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":5161,"outputTokens":13,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.005226,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":0,"canonicalModel":"claude-haiku-4-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":true,"num_turns":1,"subtype":"success","api_error_status":429,"result":"You've reached your Fable limit. Switch to another model, or manage usage credits at claude.ai/settings/usage?from=cc_cli_limit_message, to continue.","type":"result","duration_ms":1036,"uuid":"b729d2fe-95bd-41a0-9558-eb7149222eb3","queued_turn_count":0}
12
+
13
+ [scheduler] exit code=1 (raw code=1 signal=null) duration=5s
@@ -186,7 +186,7 @@ test('[job] integration is skipped (carried-wip-only) when the branch only commi
186
186
  await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
187
187
  });
188
188
 
189
- test('[job] a branch that commits carried paths ALONGSIDE its own work still attempts integration normally (no silent drop)', async () => {
189
+ test('[job] a branch that commits carried paths ALONGSIDE its own work auto-resolves the merge (PRD 1125: carried path is byte-identical duplicate, real work still lands)', async () => {
190
190
  const slug = 'test-slug-carried-plus-own-work';
191
191
  fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
192
192
  const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
@@ -198,16 +198,20 @@ test('[job] a branch that commits carried paths ALONGSIDE its own work still att
198
198
  git(['commit', '-q', '-m', 'job commit touching carried path + own work'], worktree.dir);
199
199
 
200
200
  const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
201
- // The base tree still holds README.md dirty, so a real merge attempt here
202
- // is expected to fail exactly like the existing worktreeIntegrationFailure
203
- // path — branch preserved, not merged, not silently discarded.
204
- expect(outcome.ok).toBe(false);
205
- expect(outcome.reason).toBeTruthy();
206
-
207
- const branches = git(['branch', '--list', worktree.branch], repoCwd);
208
- expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
201
+ // The base tree still holds README.md dirty, but its content is BYTE
202
+ // IDENTICAL to the branch's committed copy (the branch never edited it,
203
+ // only carried it through) — this is exactly the shape PRD 1125 teaches
204
+ // integrateBranch to auto-resolve: discard the duplicate, retry once, land
205
+ // the job's real new-file.txt commit too. Not the carried-wip-only
206
+ // shortcut (that only fires when the branch's ONLY changes are carried
207
+ // paths) — here the merge itself is what resolves.
208
+ expect(outcome.ok).toBe(true);
209
+ expect(outcome.integrated).toBe(true);
210
+ expect(outcome.autoResolved).toBe('identical_working_tree_duplicates');
211
+ expect(outcome.resolvedPaths).toEqual(['README.md']);
212
+ expect(fs.existsSync(path.join(repoCwd, 'new-file.txt'))).toBe(true);
209
213
 
210
- await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
214
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
211
215
  });
212
216
 
213
217
  test('[job] a branch that ADDS its own edit on top of the carried content on the SAME path is never classified carried-wip-only (content-verify, not path-only)', async () => {
@@ -683,3 +687,118 @@ test('[job] reconcileWorktreesOnBoot logs a one-shot report line with reclaimed
683
687
  logSpy.mockRestore();
684
688
  }
685
689
  });
690
+
691
+ // ──────────────────────────────────────────── PRD 1125: auto-resolve identical-duplicate merge blocks
692
+
693
+ test('[job] integrateBranch auto-resolves when every blocking path (tracked + untracked) is byte-identical to the branch', async () => {
694
+ const slug = 'test-slug-identical-duplicates';
695
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
696
+ expect(worktree.ok).toBe(true);
697
+
698
+ // The job's own real work, committed on its branch.
699
+ fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'shared-value\n', 'utf8');
700
+ fs.mkdirSync(path.join(worktree.dir, 'newdir'), { recursive: true });
701
+ fs.writeFileSync(path.join(worktree.dir, 'newdir', 'fileU'), 'untracked-shared-value\n', 'utf8');
702
+ git(['add', '-A'], worktree.dir);
703
+ git(['commit', '-q', '-m', 'job commit'], worktree.dir);
704
+
705
+ // The shared main tree independently ends up with the SAME bytes on both
706
+ // a tracked path (dirty, uncommitted) and an untracked path — exactly the
707
+ // 2026-09-06 starry-night-ships incident shape: a prior in-place run left
708
+ // these duplicates behind uncommitted.
709
+ fs.writeFileSync(path.join(repoCwd, 'fileA'), 'shared-value\n', 'utf8');
710
+ fs.mkdirSync(path.join(repoCwd, 'newdir'), { recursive: true });
711
+ fs.writeFileSync(path.join(repoCwd, 'newdir', 'fileU'), 'untracked-shared-value\n', 'utf8');
712
+
713
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
714
+ expect(outcome.ok).toBe(true);
715
+ expect(outcome.integrated).toBe(true);
716
+ expect(outcome.mergeCommit ?? outcome.fastForward).toBeTruthy();
717
+ expect(outcome.autoResolved).toBe('identical_working_tree_duplicates');
718
+ expect(outcome.resolvedPaths.sort()).toEqual(['fileA', 'newdir/fileU'].sort());
719
+
720
+ expect(fs.readFileSync(path.join(repoCwd, 'fileA'), 'utf8')).toBe('shared-value\n');
721
+ expect(fs.readFileSync(path.join(repoCwd, 'newdir', 'fileU'), 'utf8')).toBe('untracked-shared-value\n');
722
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
723
+
724
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
725
+ });
726
+
727
+ test('[job] integrateBranch does NOT auto-resolve when one blocking path differs from the branch — same reason as today', async () => {
728
+ const slug = 'test-slug-one-differing-path';
729
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
730
+ expect(worktree.ok).toBe(true);
731
+
732
+ fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'branch-value\n', 'utf8');
733
+ git(['add', '-A'], worktree.dir);
734
+ git(['commit', '-q', '-m', 'job commit'], worktree.dir);
735
+
736
+ // Main tree's dirty copy diverges from the branch's committed content —
737
+ // real information is at risk, so this must NOT be discarded.
738
+ fs.writeFileSync(path.join(repoCwd, 'fileA'), 'different-local-value\n', 'utf8');
739
+
740
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
741
+ expect(outcome.ok).toBe(false);
742
+ expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
743
+ expect(fs.readFileSync(path.join(repoCwd, 'fileA'), 'utf8')).toBe('different-local-value\n');
744
+ expect(git(['status', '--porcelain'], repoCwd).trim()).not.toBe('');
745
+
746
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
747
+ });
748
+
749
+ test('[job] integrateBranch falls through to today\'s failure when stderr has no parseable blocking-path block (a genuine content conflict)', async () => {
750
+ const slug = 'test-slug-unparseable-stderr';
751
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
752
+ expect(worktree.ok).toBe(true);
753
+
754
+ fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
755
+ git(['add', '-A'], worktree.dir);
756
+ git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
757
+
758
+ // A committed (not dirty) diverging edit on the main tree produces git's
759
+ // ordinary "CONFLICT (content)" stderr shape, not the "would be
760
+ // overwritten" block parseBlockingMergePaths looks for.
761
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
762
+ git(['add', '-A'], repoCwd);
763
+ git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
764
+
765
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
766
+ expect(outcome.ok).toBe(false);
767
+ expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
768
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
769
+
770
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
771
+ });
772
+
773
+ test('[job] integrateBranch aborts cleanly when the single retry also fails on a genuine conflict elsewhere', async () => {
774
+ const slug = 'test-slug-retry-fails';
775
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
776
+ expect(worktree.ok).toBe(true);
777
+
778
+ // The branch's own real work: extends fileA (existing, tracked since
779
+ // initRepo's README.md commit doesn't cover it — add it fresh here) and
780
+ // ADDS a brand-new fileB.txt.
781
+ fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'shared-value\n', 'utf8');
782
+ fs.writeFileSync(path.join(worktree.dir, 'fileB.txt'), 'branch B change\n', 'utf8');
783
+ git(['add', '-A'], worktree.dir);
784
+ git(['commit', '-q', '-m', 'job commit: fileA + fileB'], worktree.dir);
785
+
786
+ // Main tree independently ADDS the SAME path with DIFFERENT content — a
787
+ // genuine "both added, diverging content" conflict on fileB.txt.
788
+ fs.writeFileSync(path.join(repoCwd, 'fileB.txt'), 'base B change\n', 'utf8');
789
+ git(['add', '-A'], repoCwd);
790
+ git(['commit', '-q', '-m', 'main tree diverges fileB'], repoCwd);
791
+ // ...and ALSO leaves fileA dirty with content byte-identical to the branch
792
+ // — the one blocking path the initial merge attempt refuses on, and the
793
+ // one the auto-resolve retry successfully discards.
794
+ fs.writeFileSync(path.join(repoCwd, 'fileA'), 'shared-value\n', 'utf8');
795
+
796
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
797
+ expect(outcome.ok).toBe(false);
798
+ expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
799
+ // The retry's half-applied merge must be aborted, not left mid-merge.
800
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
801
+ expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
802
+
803
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
804
+ });
@@ -13,7 +13,7 @@ const fs = require('node:fs');
13
13
  const os = require('node:os');
14
14
  const path = require('node:path');
15
15
  const { execFileSync } = require('node:child_process');
16
- const { landedSinceRun } = require('../landedSinceRun.cjs');
16
+ const { landedSinceRun, landedOnMainSince } = require('../landedSinceRun.cjs');
17
17
 
18
18
  function mkRepo() {
19
19
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-since-run-'));
@@ -23,6 +23,16 @@ function mkRepo() {
23
23
  return dir;
24
24
  }
25
25
 
26
+ // -b main pins the branch name regardless of the host's init.defaultBranch
27
+ // config — landedOnMainSince's tests need a deterministic 'main' ref.
28
+ function mkRepoOnMain() {
29
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-on-main-'));
30
+ execFileSync('git', ['-C', dir, 'init', '-q', '-b', 'main']);
31
+ execFileSync('git', ['-C', dir, 'config', 'user.email', 'test@test.com']);
32
+ execFileSync('git', ['-C', dir, 'config', 'user.name', 'Test']);
33
+ return dir;
34
+ }
35
+
26
36
  function commitFile(dir, relPath, content, message) {
27
37
  const abs = path.join(dir, relPath);
28
38
  fs.mkdirSync(path.dirname(abs), { recursive: true });
@@ -71,3 +81,53 @@ test('has a bounded default timeout', () => {
71
81
  const { LANDED_SINCE_RUN_TIMEOUT_MS } = require('../landedSinceRun.cjs');
72
82
  assert.ok(LANDED_SINCE_RUN_TIMEOUT_MS > 0 && LANDED_SINCE_RUN_TIMEOUT_MS <= 60_000);
73
83
  });
84
+
85
+ // landedOnMainSince — PRD 1136's already-satisfied-on-main evidence query:
86
+ // scoped to `main` (not --all) and to the PRD's declared paths.
87
+
88
+ test('landedOnMainSince: finds a commit on main after sinceIso touching a declared path', async () => {
89
+ const dir = mkRepoOnMain();
90
+ commitFile(dir, 'README.md', 'hello', 'initial');
91
+ const since = new Date().toISOString();
92
+ await new Promise((r) => setTimeout(r, 1100));
93
+ commitFile(dir, 'src/foo.js', 'hello', 'fix foo');
94
+ const shas = await landedOnMainSince(dir, since, ['src/foo.js']);
95
+ assert.strictEqual(shas.length, 1);
96
+ });
97
+
98
+ test('landedOnMainSince: ignores a commit that does not touch any declared path', async () => {
99
+ const dir = mkRepoOnMain();
100
+ commitFile(dir, 'README.md', 'hello', 'initial');
101
+ const since = new Date().toISOString();
102
+ await new Promise((r) => setTimeout(r, 1100));
103
+ commitFile(dir, 'src/unrelated.js', 'hello', 'touch unrelated');
104
+ const shas = await landedOnMainSince(dir, since, ['src/foo.js']);
105
+ assert.deepStrictEqual(shas, []);
106
+ });
107
+
108
+ test('landedOnMainSince: a commit older than sinceIso is not evidence', async () => {
109
+ const dir = mkRepoOnMain();
110
+ commitFile(dir, 'src/foo.js', 'hello', 'fix foo');
111
+ await new Promise((r) => setTimeout(r, 1100));
112
+ const since = new Date().toISOString();
113
+ const shas = await landedOnMainSince(dir, since, ['src/foo.js']);
114
+ assert.deepStrictEqual(shas, []);
115
+ });
116
+
117
+ test('landedOnMainSince: empty paths never fabricates evidence — resolves []', async () => {
118
+ const dir = mkRepoOnMain();
119
+ commitFile(dir, 'src/foo.js', 'hello', 'fix foo');
120
+ const shas = await landedOnMainSince(dir, new Date(0).toISOString(), []);
121
+ assert.deepStrictEqual(shas, []);
122
+ });
123
+
124
+ test('landedOnMainSince: no cwd resolves [] without throwing', async () => {
125
+ const shas = await landedOnMainSince(null, new Date().toISOString(), ['src/foo.js']);
126
+ assert.deepStrictEqual(shas, []);
127
+ });
128
+
129
+ test('landedOnMainSince: git-unavailable (non-repo cwd) resolves [] without throwing', async () => {
130
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-on-main-norepo-'));
131
+ const shas = await landedOnMainSince(dir, new Date().toISOString(), ['src/foo.js']);
132
+ assert.deepStrictEqual(shas, []);
133
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * rateLimitWindow.test.cjs — resolveBindingRateLimitReset: reads the log's
3
+ * OWN rate_limit_event windows and returns the reset of whichever window
4
+ * actually bound (utilization >= 1.0), not the billing endpoint's five_hour
5
+ * assumption (PRD 1118).
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/rateLimitWindow.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test } from 'vitest';
13
+ const assert = require('node:assert/strict');
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const { resolveBindingRateLimitReset } = require('../rateLimitWindow.cjs');
18
+
19
+ // Repo-tracked, byte-for-byte copy of the real incident log (2026-09-05
20
+ // starry-night-ships 429) so this test is hermetic — CI, a fresh clone, or
21
+ // this machine's own run-log retention sweep (runLogRetention.test.cjs)
22
+ // pruning the live copy must not turn this red for reasons unrelated to the
23
+ // code under test.
24
+ const TRACKED_FIXTURE = path.join(__dirname, 'fixtures', '204-mercury-steam-horse.log.txt');
25
+ const REAL_FIXTURE_LIVE_PATH = '/home/bilko/.claude/session-manager/scheduled-plans/runs/2026-09-05T16-32-18-381Z/204-mercury-steam-horse.log';
26
+
27
+ test('tracked fixture (verbatim copy of the real incident log): seven_day_overage_included (utilization 1.0) binds, not five_hour', () => {
28
+ const reset = resolveBindingRateLimitReset(TRACKED_FIXTURE);
29
+ assert.strictEqual(reset, 1789059600);
30
+ assert.notStrictEqual(reset, 1788643800);
31
+ });
32
+
33
+ test('tracked fixture is byte-identical to the live incident log, when present on this machine', () => {
34
+ if (!fs.existsSync(REAL_FIXTURE_LIVE_PATH)) return; // machine-local artifact, not guaranteed to exist
35
+ assert.strictEqual(
36
+ fs.readFileSync(TRACKED_FIXTURE, 'utf8'),
37
+ fs.readFileSync(REAL_FIXTURE_LIVE_PATH, 'utf8'),
38
+ );
39
+ });
40
+
41
+ function writeTmpLog(lines) {
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rate-limit-window-test-'));
43
+ const file = path.join(dir, 'job.log');
44
+ fs.writeFileSync(file, lines.map((l) => (typeof l === 'string' ? l : JSON.stringify(l))).join('\n') + '\n');
45
+ return file;
46
+ }
47
+
48
+ test('no rate_limit_event in log → null', () => {
49
+ const logPath = writeTmpLog(['[scheduler] starting job', '{"type":"system","subtype":"init"}']);
50
+ assert.strictEqual(resolveBindingRateLimitReset(logPath), null);
51
+ });
52
+
53
+ test('rate_limit_event present but no window at/above 1.0 utilization → null', () => {
54
+ const logPath = writeTmpLog([
55
+ { type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
56
+ five_hour: { utilization: 0, resetsAt: 1788643800 },
57
+ seven_day: { utilization: 0.59, resetsAt: 1789059600 },
58
+ } } },
59
+ ]);
60
+ assert.strictEqual(resolveBindingRateLimitReset(logPath), null);
61
+ });
62
+
63
+ test('multiple windows bind in the last event → the LATEST reset wins', () => {
64
+ const logPath = writeTmpLog([
65
+ { type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
66
+ five_hour: { utilization: 1.0, resetsAt: 1000 },
67
+ seven_day: { utilization: 1.0, resetsAt: 2000 },
68
+ } } },
69
+ ]);
70
+ assert.strictEqual(resolveBindingRateLimitReset(logPath), 2000);
71
+ });
72
+
73
+ test('uses the LAST rate_limit_event in the log, not the first', () => {
74
+ const logPath = writeTmpLog([
75
+ { type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
76
+ seven_day: { utilization: 0.3, resetsAt: 111 },
77
+ } } },
78
+ { type: 'other' },
79
+ { type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
80
+ seven_day_overage_included: { utilization: 1.0, resetsAt: 999 },
81
+ } } },
82
+ ]);
83
+ assert.strictEqual(resolveBindingRateLimitReset(logPath), 999);
84
+ });
85
+
86
+ test('missing file → null (best-effort, matches readTail contract)', () => {
87
+ assert.strictEqual(resolveBindingRateLimitReset('/nonexistent/path/job.log'), null);
88
+ });