claude-code-session-manager 0.88.1 → 0.89.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 (35) hide show
  1. package/dist/assets/{AgentLibrary-kQF8_wAw.js → AgentLibrary-tuj-j1kQ.js} +1 -1
  2. package/dist/assets/{DataModel-CY_mMnsr.js → DataModel-DOim5SXW.js} +1 -1
  3. package/dist/assets/{History-Sji2brb0.js → History-DylcasUk.js} +1 -1
  4. package/dist/assets/{Hooks-CtqpO-p5.js → Hooks--2xoag9a.js} +1 -1
  5. package/dist/assets/{HostBilko-DN0ydOCr.js → HostBilko-oaw8paAh.js} +1 -1
  6. package/dist/assets/{Library-CoiLZTKh.js → Library-DzKA09Iy.js} +1 -1
  7. package/dist/assets/{ListDetail-CmqiQ_io.js → ListDetail-CEdcO80v.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-HPoNeKhT.js → MarkdownEditor-BMyo0F7s.js} +1 -1
  9. package/dist/assets/{McpServers-Bj1X3_NT.js → McpServers-BB7ayW7W.js} +1 -1
  10. package/dist/assets/{Memory-1PKjsM3b.js → Memory-BOdYvrL4.js} +1 -1
  11. package/dist/assets/{Panel-I1s6ghPo.js → Panel-Yf3SxsRv.js} +1 -1
  12. package/dist/assets/{Permissions-BhiiZjHQ.js → Permissions-BYCpHNy5.js} +1 -1
  13. package/dist/assets/{Plugins-gv3_fkPX.js → Plugins-B0lXiYqV.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BGcgBd8V.js → ProvenanceBadge-T7x8LWcV.js} +1 -1
  15. package/dist/assets/{SaveBar-BGr9eZ4g.js → SaveBar-BdVb4DbR.js} +1 -1
  16. package/dist/assets/{Scheduler-Bdo_awPq.js → Scheduler-DlOCWioV.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-ukl0qWaA.js → ScopeSwitcher-WVhmNsL5.js} +1 -1
  18. package/dist/assets/{Settings-DJkFj22G.js → Settings-Bt4dVsl6.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-0t1Qblp6.js → SkillReferenceGraph-R-0YykV8.js} +1 -1
  20. package/dist/assets/{Skills-CeVdBGE-.js → Skills-DXV_LdJP.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CfKvNHi3.js → SystemPrompt-DiQRo_WF.js} +1 -1
  22. package/dist/assets/{TagLibrary-DAzNaUPd.js → TagLibrary-B9OuqU6M.js} +1 -1
  23. package/dist/assets/{TiptapBody--S_rGXoA.js → TiptapBody-HKKmB0AE.js} +1 -1
  24. package/dist/assets/{Toggle-Dszm0xnp.js → Toggle-CBggPonH.js} +1 -1
  25. package/dist/assets/{index-Dp4Rc--q.js → index-BcJqLDiY.js} +3 -3
  26. package/dist/assets/{settingsSchema-BLbssMYo.js → settingsSchema-BYE9DVw_.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +41 -0
  30. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +23 -0
  31. package/src/main/build-info.json +4 -4
  32. package/src/main/lib/__tests__/gitWorktree.test.cjs +172 -0
  33. package/src/main/lib/gitWorktree.cjs +195 -3
  34. package/src/main/lib/rcaReport.cjs +8 -1
  35. package/src/main/scheduler.cjs +89 -14
package/dist/index.html CHANGED
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-Dp4Rc--q.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-BcJqLDiY.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
13
  <link rel="stylesheet" crossorigin href="./assets/index-BHTX4OTc.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.88.1",
3
+ "version": "0.89.0",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -0,0 +1,41 @@
1
+ /**
2
+ * scheduler-integration-failure-stamp.test.cjs — a worktree_integration_failed
3
+ * finalize stamps integrateBranch's failure subtype onto the job row, and the
4
+ * RCA report names it.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-integration-failure-stamp.test.cjs
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ import { test } from 'vitest';
12
+ const assert = require('node:assert/strict');
13
+ const { stampIntegrationFailure } = require('../scheduler.cjs');
14
+ const { buildRcaMarkdown } = require('../lib/rcaReport.cjs');
15
+
16
+ test('stamps content_conflict kind and conflicted paths', () => {
17
+ const row = { slug: 'x' };
18
+ stampIntegrationFailure(row, { ok: false, failureKind: 'content_conflict', conflictedPaths: ['a.md'] });
19
+ assert.equal(row.integrationFailureKind, 'content_conflict');
20
+ assert.deepEqual(row.integrationConflictPaths, ['a.md']);
21
+ });
22
+
23
+ test('blocking_paths stamps kind only; no failure clears stale fields', () => {
24
+ const row = { integrationConflictPaths: ['old'] };
25
+ stampIntegrationFailure(row, { ok: false, failureKind: 'blocking_paths', blockingPaths: { tracked: ['f'], untracked: [] } });
26
+ assert.equal(row.integrationFailureKind, 'blocking_paths');
27
+ assert.equal('integrationConflictPaths' in row, false);
28
+ stampIntegrationFailure(row, null);
29
+ assert.equal('integrationFailureKind' in row, false);
30
+ });
31
+
32
+ test('RCA markdown names the subtype and conflicted paths', () => {
33
+ const job = {
34
+ slug: 'x', runId: 'r', integrationFailureKind: 'content_conflict',
35
+ integrationConflictPaths: ['docs/asset-pipeline.md'],
36
+ };
37
+ const md = buildRcaMarkdown({
38
+ job, verdict: 'worktree_integration_failed', meta: null, logTail: '', acText: '', failureClass: 'unknown',
39
+ });
40
+ assert.ok(md.includes('Integration failure subtype: content_conflict (conflicted paths: docs/asset-pipeline.md)'));
41
+ });
@@ -118,6 +118,29 @@ test('selectMechanicalRecoveryTarget: kill-switch SM_MECHANICAL_RECOVERY_DISABLE
118
118
  expect(selectMechanicalRecoveryTarget(eligibleJob())).toBeNull();
119
119
  });
120
120
 
121
+ const conflictJob = (o = {}) => eligibleJob({ integrationFailureKind: 'content_conflict', integrationBaseHeadSha: 'aaa111', ...o });
122
+
123
+ test('selectMechanicalRecoveryTarget: content_conflict with unchanged HEAD is skipped', () => {
124
+ expect(selectMechanicalRecoveryTarget(conflictJob(), 'aaa111')).toBeNull();
125
+ });
126
+
127
+ test('selectMechanicalRecoveryTarget: content_conflict with a moved HEAD returns a target', () => {
128
+ expect(selectMechanicalRecoveryTarget(conflictJob(), 'bbb222')).not.toBeNull();
129
+ });
130
+
131
+ test('selectMechanicalRecoveryTarget: blocking_paths with unchanged HEAD still returns a target', () => {
132
+ const job = eligibleJob({ integrationFailureKind: 'blocking_paths', integrationBaseHeadSha: 'aaa111' });
133
+ expect(selectMechanicalRecoveryTarget(job, 'aaa111')).not.toBeNull();
134
+ });
135
+
136
+ test('selectMechanicalRecoveryTarget: a pre-mc-01 row with no integrationFailureKind still returns a target', () => {
137
+ expect(selectMechanicalRecoveryTarget(eligibleJob(), 'aaa111')).not.toBeNull();
138
+ });
139
+
140
+ test('selectMechanicalRecoveryTarget: null currentHeadSha never skips', () => {
141
+ expect(selectMechanicalRecoveryTarget(conflictJob(), null)).not.toBeNull();
142
+ });
143
+
121
144
  test('MECHANICALLY_RESOLVABLE_VERDICTS: closed set contains exactly worktree_integration_failed', () => {
122
145
  expect(Array.from(MECHANICALLY_RESOLVABLE_VERDICTS)).toEqual(['worktree_integration_failed']);
123
146
  });
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.88.1",
3
- "gitSha": "03a101e69f411db8f3e5dfb465cb92a77365f32a",
4
- "gitShortSha": "03a101e",
2
+ "version": "0.89.0",
3
+ "gitSha": "5ec3affd400f9a33189925d332d9c253fee1120c",
4
+ "gitShortSha": "5ec3aff",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-09-19T14:48:43.339Z",
6
+ "builtAt": "2026-09-19T22:24:34.621Z",
7
7
  "dirty": false
8
8
  }
@@ -1310,3 +1310,175 @@ test('[guard][epic] integrateEpicBranch inherits the same guard and still merges
1310
1310
 
1311
1311
  await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1312
1312
  });
1313
+
1314
+ // ── integrateBranch failure subtype classification (mc-01) ──
1315
+
1316
+ test('[job] integrateBranch classifies a dirty different tracked file as failureKind blocking_paths', async () => {
1317
+ const slug = 'test-slug-kind-blocking';
1318
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1319
+ expect(worktree.ok).toBe(true);
1320
+
1321
+ fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
1322
+ git(['add', '-A'], worktree.dir);
1323
+ git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
1324
+ // Dirty (uncommitted), byte-DIFFERENT edit in the main tree blocks the merge.
1325
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'different local edit\n', 'utf8');
1326
+
1327
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1328
+ expect(outcome.ok).toBe(false);
1329
+ expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
1330
+ expect(outcome.failureKind).toBe('blocking_paths');
1331
+ expect(outcome.blockingPaths.tracked).toEqual(['README.md']);
1332
+ expect(outcome.blockingPaths.untracked).toEqual([]);
1333
+
1334
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1335
+ });
1336
+
1337
+ test('[job] integrateBranch classifies diverging same-line commits as content_conflict with the conflicted path', async () => {
1338
+ const slug = 'test-slug-kind-content';
1339
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1340
+ expect(worktree.ok).toBe(true);
1341
+
1342
+ fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
1343
+ git(['add', '-A'], worktree.dir);
1344
+ git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
1345
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
1346
+ git(['add', '-A'], repoCwd);
1347
+ git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
1348
+
1349
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1350
+ expect(outcome.ok).toBe(false);
1351
+ expect(outcome.failureKind).toBe('content_conflict');
1352
+ expect(outcome.conflictedPaths).toEqual(['README.md']);
1353
+ // The abort still ran.
1354
+ expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
1355
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1356
+
1357
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1358
+ });
1359
+
1360
+ test('[job] integrateBranch classifies disjoint appends to the END of one file as content_conflict when the path is not allowlisted (.js)', async () => {
1361
+ const slug = 'test-slug-kind-append';
1362
+ fs.writeFileSync(path.join(repoCwd, 'src.js'), '// intro\n', 'utf8');
1363
+ git(['add', '-A'], repoCwd);
1364
+ git(['commit', '-q', '-m', 'add src.js'], repoCwd);
1365
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1366
+ expect(worktree.ok).toBe(true);
1367
+
1368
+ fs.appendFileSync(path.join(worktree.dir, 'src.js'), '\n// Section Six\nsix body\n', 'utf8');
1369
+ git(['add', '-A'], worktree.dir);
1370
+ git(['commit', '-q', '-m', 'job appends section six'], worktree.dir);
1371
+ fs.appendFileSync(path.join(repoCwd, 'src.js'), '\n## Section Seven\nseven body\n\n## Section Ten\nten body\n', 'utf8');
1372
+ git(['add', '-A'], repoCwd);
1373
+ git(['commit', '-q', '-m', 'main appends sections seven and ten'], repoCwd);
1374
+
1375
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1376
+ expect(outcome.ok).toBe(false);
1377
+ expect(outcome.failureKind).toBe('content_conflict');
1378
+ expect(outcome.conflictedPaths).toEqual(['src.js']);
1379
+
1380
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1381
+ });
1382
+
1383
+ // ── pure-addition merge-conflict auto-resolve (mc-02) ──
1384
+
1385
+ async function conflictScenario(slug, { base, side, main }) {
1386
+ for (const [f, c] of Object.entries(base)) {
1387
+ fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
1388
+ fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
1389
+ }
1390
+ git(['add', '-A'], repoCwd);
1391
+ git(['commit', '-q', '-m', 'base files'], repoCwd);
1392
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1393
+ expect(worktree.ok).toBe(true);
1394
+ for (const [f, c] of Object.entries(side)) {
1395
+ fs.mkdirSync(path.dirname(path.join(worktree.dir, f)), { recursive: true });
1396
+ fs.writeFileSync(path.join(worktree.dir, f), c, 'utf8');
1397
+ }
1398
+ git(['add', '-A'], worktree.dir);
1399
+ git(['commit', '-q', '-m', 'job side'], worktree.dir);
1400
+ for (const [f, c] of Object.entries(main)) {
1401
+ fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
1402
+ fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
1403
+ }
1404
+ git(['add', '-A'], repoCwd);
1405
+ git(['commit', '-q', '-m', 'main side'], repoCwd);
1406
+ return worktree;
1407
+ }
1408
+
1409
+ const MARKER_RE = /^(<<<<<<<|\|\|\|\|\|\|\||=======|>>>>>>>)/m;
1410
+
1411
+ test('[job] integrateBranch auto-resolves a pure-addition .md conflict by concatenating ours then theirs', async () => {
1412
+ const slug = 'test-slug-pure-add';
1413
+ const worktree = await conflictScenario(slug, {
1414
+ base: { 'd.md': '# Doc\n\nintro\n' },
1415
+ side: { 'd.md': '# Doc\n\nintro\n\n## Section A\na body\n' },
1416
+ main: { 'd.md': '# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n' },
1417
+ });
1418
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1419
+ expect(outcome.ok).toBe(true);
1420
+ expect(outcome.autoResolved).toBe('pure_addition_concat');
1421
+ expect(outcome.resolvedPaths).toEqual(['d.md']);
1422
+ const merged = fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8');
1423
+ expect(merged).toBe('# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n\n## Section A\na body\n');
1424
+ expect(merged).not.toMatch(MARKER_RE);
1425
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1426
+ expect(git(['log', '-1', '--format=%s'], repoCwd).trim()).toBe(`merge scheduler job ${slug}`);
1427
+ expect(git(['rev-list', '--parents', '-n', '1', 'HEAD'], repoCwd).trim().split(' ').length).toBe(3);
1428
+
1429
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1430
+ });
1431
+
1432
+ test('[job] integrateBranch does NOT auto-resolve a same-line .md edit and leaves the tree clean', async () => {
1433
+ const slug = 'test-slug-pure-add-neg-sameline';
1434
+ const worktree = await conflictScenario(slug, {
1435
+ base: { 'd.md': 'intro\nORIGINAL LINE\n' },
1436
+ side: { 'd.md': 'intro\nSIDE EDIT\n' },
1437
+ main: { 'd.md': 'intro\nMAIN EDIT\n' },
1438
+ });
1439
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1440
+ expect(outcome.ok).toBe(false);
1441
+ expect(outcome.autoResolved).toBeUndefined();
1442
+ expect(outcome.failureKind).toBe('content_conflict');
1443
+ expect(outcome.conflictedPaths).toEqual(['d.md']);
1444
+ expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\nMAIN EDIT\n');
1445
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1446
+
1447
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1448
+ });
1449
+
1450
+ test('[job] integrateBranch does NOT auto-resolve a pure-addition conflict on a non-allowlisted path', async () => {
1451
+ const slug = 'test-slug-pure-add-neg-js';
1452
+ const worktree = await conflictScenario(slug, {
1453
+ base: { 'src/thing.js': '// intro\n' },
1454
+ side: { 'src/thing.js': '// intro\n// A\n' },
1455
+ main: { 'src/thing.js': '// intro\n// B\n// C\n' },
1456
+ });
1457
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1458
+ expect(outcome.ok).toBe(false);
1459
+ expect(outcome.autoResolved).toBeUndefined();
1460
+ expect(outcome.failureKind).toBe('content_conflict');
1461
+ expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('// intro\n// B\n// C\n');
1462
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1463
+
1464
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1465
+ });
1466
+
1467
+ test('[job] integrateBranch bails the whole merge when only some conflicted paths are provable (mixed .md + .js)', async () => {
1468
+ const slug = 'test-slug-pure-add-neg-mixed';
1469
+ const worktree = await conflictScenario(slug, {
1470
+ base: { 'd.md': 'intro\n', 'src/thing.js': 'ORIGINAL\n' },
1471
+ side: { 'd.md': 'intro\n## A\n', 'src/thing.js': 'SIDE\n' },
1472
+ main: { 'd.md': 'intro\n## B\n', 'src/thing.js': 'MAIN\n' },
1473
+ });
1474
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1475
+ expect(outcome.ok).toBe(false);
1476
+ expect(outcome.autoResolved).toBeUndefined();
1477
+ expect(outcome.conflictedPaths).toEqual(['d.md', 'src/thing.js']);
1478
+ expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\n## B\n');
1479
+ expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('MAIN\n');
1480
+ expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
1481
+ expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
1482
+
1483
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1484
+ });
@@ -940,6 +940,167 @@ function parseBlockingMergePaths(stderrText) {
940
940
  return { tracked: Array.from(new Set(tracked)), untracked: Array.from(new Set(untracked)) };
941
941
  }
942
942
 
943
+ /**
944
+ * Classifies a failed `git merge` from its stderr — additive metadata for
945
+ * integrateBranch's `ok: false` results (the `reason` string is untouched).
946
+ * 'blocking_paths' = git refused up-front over dirty/untracked paths
947
+ * (auto-resolvable, PRD 1125); 'content_conflict' = a genuine 3-way
948
+ * `CONFLICT (` (deterministic on every retry); 'other' = anything else.
949
+ * For content_conflict, reads the unmerged paths — MUST be called before
950
+ * `git merge --abort`, which clears them. Never throws; a failed read
951
+ * yields `conflictedPaths: []`. O(p log p) in the conflicted-path count.
952
+ */
953
+ async function classifyMergeFailure({ cwd, stderrText, stdoutText }) {
954
+ const blocking = parseBlockingMergePaths(stderrText);
955
+ if (blocking) return { failureKind: 'blocking_paths', blockingPaths: blocking };
956
+ // git prints `CONFLICT (...)` lines on STDOUT, not stderr.
957
+ if (/CONFLICT \(/m.test(`${stdoutText || ''}\n${stderrText || ''}`)) {
958
+ let conflictedPaths = [];
959
+ try {
960
+ const out = await execGit(['diff', '--name-only', '--diff-filter=U'], { cwd, timeout: 10_000 });
961
+ conflictedPaths = Array.from(new Set(out.split('\n').map((l) => l.trim()).filter(Boolean))).sort();
962
+ } catch { /* best effort — abort must still run */ }
963
+ return { failureKind: 'content_conflict', conflictedPaths };
964
+ }
965
+ return { failureKind: 'other' };
966
+ }
967
+
968
+ // Extensions eligible for the pure-addition auto-resolve. All-or-nothing across
969
+ // a merge: one conflicted path outside this list means no auto-resolve at all.
970
+ const AUTO_RESOLVE_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.rst']);
971
+
972
+ /**
973
+ * Renders `git merge-file -p --diff3` over the index's three conflict stages
974
+ * of `p`. Returns the merged text (with markers), or null when any stage is
975
+ * missing/unreadable, a stage is not valid UTF-8 (the round-trip would not be
976
+ * byte-exact), or git itself errors. `merge-file` exits with the conflict
977
+ * count (1..127) on a clean run — only >127 or a signal is an error. Never
978
+ * throws. O(file size).
979
+ */
980
+ async function renderStagesDiff3({ cwd, p }) {
981
+ let tmp = null;
982
+ try {
983
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-mergefile-'));
984
+ const files = [];
985
+ for (const stage of [2, 1, 3]) { // ours, base, theirs — merge-file's argument order
986
+ const content = await execGit(['cat-file', 'blob', `:${stage}:${p}`], { cwd, timeout: 10_000 });
987
+ if (content.includes('�')) return null;
988
+ const f = path.join(tmp, `stage${stage}`);
989
+ await fsp.writeFile(f, content, 'utf8');
990
+ files.push(f);
991
+ }
992
+ try {
993
+ return await execGit(['merge-file', '-p', '--diff3', '-L', 'ours', '-L', 'base', '-L', 'theirs', ...files], { cwd, timeout: 10_000 });
994
+ } catch (e) {
995
+ if (e && typeof e.code === 'number' && e.code >= 1 && e.code <= 127 && typeof e.stdoutText === 'string') return e.stdoutText;
996
+ return null;
997
+ }
998
+ } catch {
999
+ return null;
1000
+ } finally {
1001
+ if (tmp) { try { await fsp.rm(tmp, { recursive: true, force: true }); } catch { /* best effort */ } }
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Parses diff3 merge-file output into segments: `{ text }` for pass-through
1007
+ * regions and `{ ours, base, theirs }` (arrays of lines) for conflict hunks.
1008
+ * Returns null on any out-of-order marker or an unterminated hunk. A marker
1009
+ * prefix is only meaningful in its expected state; `=======` inside the ours
1010
+ * or theirs section (e.g. a markdown setext underline) is treated as
1011
+ * unparseable rather than guessed at. O(n) in output lines.
1012
+ */
1013
+ function parseDiff3Output(output) {
1014
+ const lines = output.split('\n');
1015
+ const segments = [];
1016
+ let plain = [];
1017
+ let hunk = null;
1018
+ let state = 'plain';
1019
+ for (const line of lines) {
1020
+ const isOpen = line.startsWith('<<<<<<< ');
1021
+ const isBase = line.startsWith('||||||| ');
1022
+ const isSep = line.startsWith('=======');
1023
+ const isClose = line.startsWith('>>>>>>> ');
1024
+ if (state === 'plain') {
1025
+ if (isBase || isClose) return null;
1026
+ if (isOpen) {
1027
+ segments.push({ lines: plain });
1028
+ plain = [];
1029
+ hunk = { ours: [], base: [], theirs: [] };
1030
+ state = 'ours';
1031
+ } else {
1032
+ plain.push(line);
1033
+ }
1034
+ } else if (state === 'ours') {
1035
+ if (isOpen || isSep || isClose) return null;
1036
+ if (isBase) state = 'base'; else hunk.ours.push(line);
1037
+ } else if (state === 'base') {
1038
+ if (isOpen || isBase || isClose) return null;
1039
+ if (isSep && line === '=======') state = 'theirs'; else if (isSep) return null; else hunk.base.push(line);
1040
+ } else {
1041
+ if (isOpen || isBase || isSep) return null;
1042
+ if (isClose) { segments.push({ hunk }); hunk = null; state = 'plain'; } else hunk.theirs.push(line);
1043
+ }
1044
+ }
1045
+ if (state !== 'plain') return null;
1046
+ segments.push({ lines: plain });
1047
+ return segments;
1048
+ }
1049
+
1050
+ /**
1051
+ * True ONLY when, for EVERY path, the index holds all three conflict stages
1052
+ * and every diff3 conflict hunk has an EMPTY base section (both sides purely
1053
+ * added text where the merge base had nothing). Proven from the bytes, never
1054
+ * assumed; fails toward false on every error path. Never throws.
1055
+ */
1056
+ async function isPureAdditionConflict({ cwd, paths }) {
1057
+ try {
1058
+ if (!Array.isArray(paths) || !paths.length) return false;
1059
+ for (const p of paths) {
1060
+ const out = await renderStagesDiff3({ cwd, p });
1061
+ if (out === null) return false;
1062
+ const segments = parseDiff3Output(out);
1063
+ if (!segments) return false;
1064
+ const hunks = segments.filter((s) => s.hunk);
1065
+ if (!hunks.length) return false;
1066
+ if (hunks.some((s) => s.hunk.base.length !== 0)) return false;
1067
+ }
1068
+ return true;
1069
+ } catch {
1070
+ return false;
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * Rewrites each conflicted path in the working tree with every conflict hunk
1076
+ * replaced by OURS then THEIRS (no markers, base, reordering, or dedup);
1077
+ * non-conflicted regions pass through unchanged. Renders ALL paths before
1078
+ * writing any so a late failure leaves nothing half-rewritten. Throws on any
1079
+ * failure — the caller aborts the merge.
1080
+ */
1081
+ async function resolveByConcatenation({ cwd, paths }) {
1082
+ const rendered = [];
1083
+ for (const p of paths) {
1084
+ const out = await renderStagesDiff3({ cwd, p });
1085
+ const segments = out === null ? null : parseDiff3Output(out);
1086
+ if (!segments) throw new Error(`cannot render conflict for ${p}`);
1087
+ rendered.push({ p, text: joinSegments(segments) });
1088
+ }
1089
+ for (const { p, text } of rendered) {
1090
+ await fsp.writeFile(path.join(cwd, p), text, 'utf8');
1091
+ }
1092
+ }
1093
+
1094
+ /** Rebuilds file text from parsed segments, hunks contributing ours+theirs lines. */
1095
+ function joinSegments(segments) {
1096
+ const out = [];
1097
+ for (const seg of segments) {
1098
+ if (seg.hunk) out.push(...seg.hunk.ours, ...seg.hunk.theirs);
1099
+ else out.push(...seg.lines);
1100
+ }
1101
+ return out.join('\n');
1102
+ }
1103
+
943
1104
  /**
944
1105
  * Resolves `cwd`'s default branch, without ever hardcoding `main`: prefers
945
1106
  * the remote-tracked default (`origin/HEAD`, set by `git clone`/`git remote
@@ -949,6 +1110,16 @@ function parseBlockingMergePaths(stderrText) {
949
1110
  * this chain (no remote, no config, no matching local branch) simply falls
950
1111
  * through to the next source.
951
1112
  */
1113
+ // HEAD sha of `cwd` (null on any failure) — stamped on a failed integration so
1114
+ // mechanical recovery can tell whether the base tree has moved since.
1115
+ async function readHeadSha(cwd) {
1116
+ try {
1117
+ return (await execGit(['rev-parse', 'HEAD'], { cwd, timeout: 10_000 })).trim() || null;
1118
+ } catch {
1119
+ return null;
1120
+ }
1121
+ }
1122
+
952
1123
  async function resolveDefaultBranch(cwd) {
953
1124
  try {
954
1125
  const out = (await execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd, timeout: 10_000 })).trim();
@@ -1112,15 +1283,36 @@ async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
1112
1283
  resolvedPaths: allPaths,
1113
1284
  };
1114
1285
  } catch (retryErr) {
1115
- try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
1116
1286
  const retryText = (retryErr && (retryErr.stderrText || retryErr.message)) || String(retryErr);
1117
- return { ok: false, reason: `merge failed (likely a real content conflict): ${retryText}` };
1287
+ const retryClass = await classifyMergeFailure({ cwd, stderrText: retryText, stdoutText: retryErr && retryErr.stdoutText });
1288
+ try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
1289
+ return { ok: false, reason: `merge failed (likely a real content conflict): ${retryText}`, ...retryClass, baseHeadSha: await readHeadSha(cwd) };
1118
1290
  }
1119
1291
  }
1120
1292
  }
1293
+ // Read the conflicted paths BEFORE the abort — it clears the index stages.
1294
+ const failureClass = await classifyMergeFailure({ cwd, stderrText, stdoutText: e && e.stdoutText });
1295
+ // Pure-addition auto-resolve: two jobs appended different text to the same
1296
+ // doc. All-or-nothing; any doubt or failure falls through to the abort.
1297
+ if (
1298
+ process.env.SM_PURE_ADDITION_MERGE_DISABLE !== '1'
1299
+ && failureClass.failureKind === 'content_conflict'
1300
+ && failureClass.conflictedPaths.length
1301
+ && failureClass.conflictedPaths.every((p) => AUTO_RESOLVE_EXTENSIONS.has(path.extname(p).toLowerCase()))
1302
+ ) {
1303
+ try {
1304
+ const paths = failureClass.conflictedPaths;
1305
+ if (await isPureAdditionConflict({ cwd, paths })) {
1306
+ await resolveByConcatenation({ cwd, paths });
1307
+ await execGit(['add', ...paths], { cwd, timeout: 30_000 });
1308
+ await execGit(['commit', '-m', mergeMessage], { cwd, timeout: 30_000 });
1309
+ return { ok: true, integrated: true, mergeCommit: true, autoResolved: 'pure_addition_concat', resolvedPaths: paths };
1310
+ }
1311
+ } catch { /* fall through to the abort */ }
1312
+ }
1121
1313
  // Abort a half-applied merge so `cwd` isn't left in a mid-merge state.
1122
1314
  try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
1123
- return { ok: false, reason: `merge failed (likely a real content conflict): ${stderrText}` };
1315
+ return { ok: false, reason: `merge failed (likely a real content conflict): ${stderrText}`, ...failureClass, baseHeadSha: await readHeadSha(cwd) };
1124
1316
  }
1125
1317
  }
1126
1318
 
@@ -295,9 +295,16 @@ function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, i
295
295
  const durationMs = meta?.durationMs != null ? `${meta.durationMs}ms` : 'unknown';
296
296
  const prevention = PREVENTION_HINTS[failureClass] ?? PREVENTION_HINTS[FAILURE_CLASSES.UNKNOWN];
297
297
 
298
+ const integrationLine = job.integrationFailureKind
299
+ ? `\n\nIntegration failure subtype: ${job.integrationFailureKind}` +
300
+ (job.integrationFailureKind === 'content_conflict' && Array.isArray(job.integrationConflictPaths)
301
+ ? ` (conflicted paths: ${job.integrationConflictPaths.join(', ') || 'unknown'})`
302
+ : '')
303
+ : '';
304
+
298
305
  const sections = [
299
306
  `# What happened\n\nJob \`${job.slug}\` was parked in \`needs_review\` with verifier verdict ` +
300
- `\`${verdict}\` (${humanVerdict(verdict)}).${job.error ? `\n\n${job.error}` : ''}`,
307
+ `\`${verdict}\` (${humanVerdict(verdict)}).${job.error ? `\n\n${job.error}` : ''}${integrationLine}`,
301
308
  `# Evidence\n\n- Exit code: ${exitCode}\n- Duration: ${durationMs}\n\nLast 60 log lines:\n\n\`\`\`\n${last60 || '(no log available)'}\n\`\`\``,
302
309
  `# The PRD's acceptance criteria\n\n${acText || '(acceptance criteria not found — original PRD may have been archived or removed)'}`,
303
310
  `# Likely failure class\n\n**${failureClass}**\n\nPrevention: ${prevention}`,