claude-code-session-manager 0.76.0 → 0.78.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 (123) hide show
  1. package/dist/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-13pfo8uY.js} +2 -2
  2. package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-SUyQbFlg.js} +1 -1
  3. package/dist/assets/{History-CpdtWhC8.js → History-2GJMS703.js} +2 -2
  4. package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-DM2nS3RT.js} +2 -2
  5. package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-BLeC-lpp.js} +1 -1
  6. package/dist/assets/{Library-CQmo4QVC.js → Library-BaRkU9m0.js} +1 -1
  7. package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-D5scjSKq.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-B1lgAo9T.js} +1 -1
  9. package/dist/assets/{McpServers-CLarzwqA.js → McpServers-BzyThQSM.js} +1 -1
  10. package/dist/assets/{Memory-B0sCdIy1.js → Memory-7UdaOTtl.js} +6 -6
  11. package/dist/assets/{Panel-BhWPVOCD.js → Panel-JbTMaOPq.js} +1 -1
  12. package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-UBam0bJG.js} +2 -2
  13. package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-B3gUDkeb.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-CeHOub7m.js} +1 -1
  15. package/dist/assets/SaveBar-BcvQEq6h.js +1 -0
  16. package/dist/assets/Scheduler-Dc5qiP24.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-BvGQmw4Y.js} +1 -1
  18. package/dist/assets/{Settings-2Vx3X5SI.js → Settings-C2dEFb-v.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-CIwlosBc.js} +1 -1
  20. package/dist/assets/{Skills-Cmrz_LeN.js → Skills-C0GzzVrQ.js} +2 -2
  21. package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-mtGPK8zo.js} +1 -1
  22. package/dist/assets/TagLibrary-DX54-mpd.js +1 -0
  23. package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-yADC2RWE.js} +1 -1
  24. package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-CRxaCYLI.js} +1 -1
  25. package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
  26. package/dist/assets/{index-B_4PNh9T.js → index-D6ymGESc.js} +469 -469
  27. package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-TtMvT5Sx.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +8 -2
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  31. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  32. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  33. package/scripts/render-project-pages.cjs +70 -0
  34. package/scripts/scheduler-mcp-server.cjs +115 -1
  35. package/scripts/validate-project-pages-summary.cjs +62 -0
  36. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  37. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  38. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  39. package/src/main/__tests__/prdCreate.test.cjs +138 -0
  40. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  41. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  42. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  43. package/src/main/__tests__/projectPages.test.cjs +73 -1
  44. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  45. package/src/main/__tests__/runVerify.test.cjs +94 -0
  46. package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
  47. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  48. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  49. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  50. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  51. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  52. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  53. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  54. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  55. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  56. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  57. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
  58. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  59. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  60. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  61. package/src/main/config.cjs +4 -1
  62. package/src/main/index.cjs +16 -4
  63. package/src/main/ipcSchemas.cjs +53 -0
  64. package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
  65. package/src/main/lib/__tests__/delegationReadiness.test.cjs +197 -2
  66. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
  67. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  68. package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
  69. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  70. package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
  71. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  72. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  73. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
  74. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  75. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  76. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  77. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  78. package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
  79. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  80. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  81. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  82. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  83. package/src/main/lib/agentModelResolve.cjs +58 -0
  84. package/src/main/lib/childWithLog.cjs +40 -5
  85. package/src/main/lib/claudeBin.cjs +54 -1
  86. package/src/main/lib/definitionOfDone.cjs +3 -2
  87. package/src/main/lib/delegationReadiness.cjs +115 -9
  88. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  89. package/src/main/lib/fixChainDepth.cjs +45 -0
  90. package/src/main/lib/gitWorktree.cjs +464 -19
  91. package/src/main/lib/jobWorktree.cjs +1 -0
  92. package/src/main/lib/landedSinceRun.cjs +55 -0
  93. package/src/main/lib/launchFailure.cjs +357 -0
  94. package/src/main/lib/mcpToolCatalog.cjs +87 -2
  95. package/src/main/lib/opsOwnership.cjs +12 -0
  96. package/src/main/lib/prdAgentType.cjs +84 -0
  97. package/src/main/lib/prdCreate.cjs +57 -1
  98. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  99. package/src/main/lib/prdFrontmatter.cjs +17 -3
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  101. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  102. package/src/main/lib/queueHealth.cjs +38 -0
  103. package/src/main/lib/queueStore.cjs +9 -2
  104. package/src/main/lib/quietMachineLease.cjs +48 -0
  105. package/src/main/lib/rcaReport.cjs +53 -3
  106. package/src/main/lib/reaperHelpers.cjs +18 -1
  107. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  108. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  109. package/src/main/lib/schedulerBatch.cjs +133 -29
  110. package/src/main/lib/schedulerConfig.cjs +19 -0
  111. package/src/main/projectPages.cjs +160 -2
  112. package/src/main/runVerify.cjs +50 -9
  113. package/src/main/scheduler/prdParser.cjs +18 -1
  114. package/src/main/scheduler.cjs +1371 -97
  115. package/src/main/seedAgentPersonas.cjs +62 -21
  116. package/src/main/templates/project-pages-catalog.json +741 -0
  117. package/src/main/templates/project-pages-pipeline.md +417 -0
  118. package/src/preload/api.d.ts +126 -6
  119. package/src/preload/index.cjs +8 -0
  120. package/src/seed/agents/project-home-builder.md +59 -0
  121. package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
  122. package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
  123. package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
@@ -1197,6 +1197,100 @@ test('no_verdict_sentinel guard: same run but with SCHEDULER_VERDICT: PASS senti
1197
1197
  }
1198
1198
  });
1199
1199
 
1200
+ // ─── abandoned_background_task: auto-backgrounded Bash + no sentinel/commit ─
1201
+
1202
+ /** A run whose Bash command hit the harness's 120s foreground timeout and was
1203
+ * auto-backgrounded, then the run ended without ever coming back to commit
1204
+ * or emit the finish-protocol sentinel. */
1205
+ function abandonedBackgroundTaskRunEvents(resultText) {
1206
+ return [
1207
+ {
1208
+ type: 'assistant',
1209
+ message: {
1210
+ role: 'assistant',
1211
+ content: [{
1212
+ type: 'tool_use',
1213
+ id: 'toolu_bg_001',
1214
+ name: 'Bash',
1215
+ input: { command: 'npm run test:unit', description: 'Run full unit suite' },
1216
+ }],
1217
+ },
1218
+ },
1219
+ {
1220
+ type: 'user',
1221
+ message: {
1222
+ role: 'user',
1223
+ content: [{
1224
+ type: 'tool_result',
1225
+ tool_use_id: 'toolu_bg_001',
1226
+ content: 'Command did not complete within its 120s timeout and was moved to the background (ID: bcn1lpvpd). Output is being written to: /tmp/claude-1000/tasks/bcn1lpvpd.output. You will be notified when it completes. To check interim output, use Read on that file path.',
1227
+ is_error: false,
1228
+ }],
1229
+ },
1230
+ },
1231
+ { type: 'result', subtype: 'success', result: resultText },
1232
+ ];
1233
+ }
1234
+
1235
+ test('abandoned_background_task: background marker + no sentinel + no commit → abandoned_background_task', async () => {
1236
+ const tmp = makeTmpDir();
1237
+ try {
1238
+ const slug = '1116-encyclopedia-animations-section';
1239
+ writeLog(tmp, slug, abandonedBackgroundTaskRunEvents('Waiting for the backgrounded test run to finish.'));
1240
+ const prdPath = writePrd(tmp, slug, '# Encyclopedia animations section');
1241
+ const verdict = await verifyRun({
1242
+ runDir: tmp,
1243
+ prdPath,
1244
+ queueEntry: { slug, status: 'running' },
1245
+ allJobs: [],
1246
+ committedDuringRun: false,
1247
+ });
1248
+ assert.equal(verdict.verdict, 'abandoned_background_task', `expected abandoned_background_task, got ${verdict.verdict}: ${verdict.reason}`);
1249
+ assert.equal(verdict.downgradeTo, 'needs_review');
1250
+ } finally {
1251
+ rmdir(tmp);
1252
+ }
1253
+ });
1254
+
1255
+ test('abandoned_background_task guard: no background marker, no sentinel, no commit → stays plain no_verdict_sentinel', async () => {
1256
+ const tmp = makeTmpDir();
1257
+ try {
1258
+ const slug = '1116-no-background-marker';
1259
+ writeLog(tmp, slug, noOpRunEvents('I need clarification on which capture API to target before proceeding.'));
1260
+ const prdPath = writePrd(tmp, slug, '# Encyclopedia animations section');
1261
+ const verdict = await verifyRun({
1262
+ runDir: tmp,
1263
+ prdPath,
1264
+ queueEntry: { slug, status: 'running' },
1265
+ allJobs: [],
1266
+ committedDuringRun: false,
1267
+ });
1268
+ assert.equal(verdict.verdict, 'no_verdict_sentinel', `expected plain no_verdict_sentinel, got ${verdict.verdict}: ${verdict.reason}`);
1269
+ } finally {
1270
+ rmdir(tmp);
1271
+ }
1272
+ });
1273
+
1274
+ test('abandoned_background_task guard: background marker present but run DID commit → clean', async () => {
1275
+ const tmp = makeTmpDir();
1276
+ try {
1277
+ const slug = '1116-background-marker-but-committed';
1278
+ writeLog(tmp, slug, abandonedBackgroundTaskRunEvents('Backgrounded test run finished green; work committed.'));
1279
+ const prdPath = writePrd(tmp, slug, '# Encyclopedia animations section');
1280
+ const verdict = await verifyRun({
1281
+ runDir: tmp,
1282
+ prdPath,
1283
+ queueEntry: { slug, status: 'running' },
1284
+ allJobs: [],
1285
+ committedDuringRun: true,
1286
+ });
1287
+ assert.equal(verdict.verdict, 'clean', `background marker with a landed commit should stay clean, got ${verdict.verdict}: ${verdict.reason}`);
1288
+ assert.equal(verdict.downgradeTo, null);
1289
+ } finally {
1290
+ rmdir(tmp);
1291
+ }
1292
+ });
1293
+
1200
1294
  // ─── pass_no_commit: PASS sentinel with no commit is not "clean" ───────────
1201
1295
 
1202
1296
  test('pass_no_commit: SCHEDULER_VERDICT: PASS but no commit landed → needs_review', async () => {
@@ -12,6 +12,7 @@ const {
12
12
  selectAutoFixTargets, isUnresolvableNeedsReview, isRescanCandidate,
13
13
  healTargetForFix, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH,
14
14
  isEligibleForImmediateAutoFix, isExhaustedAutoFix, isPlanUnqueued, fixSlugFor,
15
+ applyRcaClassification,
15
16
  } = require('../scheduler.cjs');
16
17
 
17
18
  const noSiblingOnDisk = () => false;
@@ -211,6 +212,12 @@ test('invariant: plan outcome WITH a queue row → not a target and NOT annotate
211
212
  assert.strictEqual(isAnnotatable, false);
212
213
  });
213
214
 
215
+ test('excludes a job whose rcaRecoveryAction is archive (already-shipped stale re-run must never buy a fix-plan)', () => {
216
+ const jobs = [makeJob({ rcaRecoveryAction: 'archive', rcaFailureClass: 'already-shipped' })];
217
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
218
+ assert.strictEqual(result.length, 0);
219
+ });
220
+
214
221
  test('excludes when fix sibling exists on disk', () => {
215
222
  const jobs = [makeJob()];
216
223
  // fixSlug = '05-fix-my-feature'
@@ -376,4 +383,40 @@ test('isEligibleForImmediateAutoFix: a job whose status is not needs_review (e.g
376
383
  assert.strictEqual(result, false);
377
384
  });
378
385
 
386
+ // ---- applyRcaClassification (persists writeRcaReport's verdict onto the job row) ----
387
+
388
+ test('applyRcaClassification: persists failureClass + recoveryAction onto a needs_review job', () => {
389
+ const job = makeJob({ status: 'needs_review' });
390
+ const applied = applyRcaClassification(job, { filed: true, failureClass: 'already-shipped', recoveryAction: 'archive' });
391
+ assert.strictEqual(applied, true);
392
+ assert.strictEqual(job.rcaFailureClass, 'already-shipped');
393
+ assert.strictEqual(job.rcaRecoveryAction, 'archive');
394
+ });
395
+
396
+ test('applyRcaClassification: no-op when the report was never filed', () => {
397
+ const job = makeJob({ status: 'needs_review' });
398
+ const applied = applyRcaClassification(job, { filed: false, reason: 'disabled' });
399
+ assert.strictEqual(applied, false);
400
+ assert.strictEqual(job.rcaFailureClass, undefined);
401
+ });
402
+
403
+ test('applyRcaClassification: no-op when the job has moved off needs_review', () => {
404
+ const job = makeJob({ status: 'completed' });
405
+ const applied = applyRcaClassification(job, { filed: true, failureClass: 'unknown', recoveryAction: 'investigate' });
406
+ assert.strictEqual(applied, false);
407
+ assert.strictEqual(job.rcaFailureClass, undefined);
408
+ });
409
+
410
+ test('applyRcaClassification: no-op when the job is missing (e.g. slug lookup miss)', () => {
411
+ const applied = applyRcaClassification(undefined, { filed: true, failureClass: 'unknown', recoveryAction: 'investigate' });
412
+ assert.strictEqual(applied, false);
413
+ });
414
+
415
+ test('applyRcaClassification result feeds selectAutoFixTargets: an archive-classified job is excluded', () => {
416
+ const job = makeJob({ status: 'needs_review' });
417
+ applyRcaClassification(job, { filed: true, failureClass: 'already-shipped', recoveryAction: 'archive' });
418
+ const result = selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk });
419
+ assert.strictEqual(result.length, 0);
420
+ });
421
+
379
422
  console.log('scheduler-autofix-select tests: PASS');
@@ -0,0 +1,103 @@
1
+ /**
2
+ * scheduler-bash-timeout-env.test.cjs — PRD 1097.
3
+ *
4
+ * A headless `claude -p` job inherits the harness's 120s default foreground
5
+ * Bash timeout. Any legit gate command (a test suite, a build) that runs
6
+ * long gets auto-backgrounded, and the tool result promises a notification
7
+ * that a single-shot headless run can never receive — the run dead-ends
8
+ * with no commit and no verdict. The fix is to raise
9
+ * BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS on the spawned child's env.
10
+ *
11
+ * This proves:
12
+ * 1. executeJob's real spawned child actually receives both env vars, with
13
+ * the exported constant values (not just that the constants exist).
14
+ * 2. BASH_MAX_TIMEOUT_MS stays strictly below IDLE_OUTPUT_KILL_MS — the
15
+ * coupling that makes this more than a one-liner: a long foreground
16
+ * Bash emits no stream-json events, so the log mtime stalls and the
17
+ * idle-tail watchdog would SIGTERM the job mid-gate if the two ever
18
+ * crossed.
19
+ *
20
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-bash-timeout-env.test.cjs
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
26
+ const fs = require('node:fs');
27
+ const os = require('node:os');
28
+ const path = require('node:path');
29
+
30
+ let tmpHome;
31
+ let originalHome;
32
+ let executeJob;
33
+ let BASH_DEFAULT_TIMEOUT_MS;
34
+ let BASH_MAX_TIMEOUT_MS;
35
+ let IDLE_OUTPUT_KILL_MS;
36
+
37
+ beforeAll(() => {
38
+ originalHome = process.env.HOME;
39
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-bash-timeout-env-'));
40
+ process.env.HOME = tmpHome;
41
+ ({ executeJob, BASH_DEFAULT_TIMEOUT_MS, BASH_MAX_TIMEOUT_MS, IDLE_OUTPUT_KILL_MS } = require('../scheduler.cjs'));
42
+ });
43
+
44
+ afterAll(() => {
45
+ process.env.HOME = originalHome;
46
+ fs.rmSync(tmpHome, { recursive: true, force: true });
47
+ });
48
+
49
+ afterEach(() => {
50
+ delete process.env.SM_CLAUDE_BIN;
51
+ });
52
+
53
+ // Stub `claude` binary: dumps the two env vars it received into a marker
54
+ // file, then emits a stream-json success result and exits 0.
55
+ function writeClaudeStub() {
56
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-env-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
57
+ const body = `
58
+ const fs = require('fs');
59
+ const path = require('path');
60
+ fs.writeFileSync(path.join(process.cwd(), 'env.marker'), JSON.stringify({
61
+ BASH_DEFAULT_TIMEOUT_MS: process.env.BASH_DEFAULT_TIMEOUT_MS,
62
+ BASH_MAX_TIMEOUT_MS: process.env.BASH_MAX_TIMEOUT_MS,
63
+ }), 'utf8');
64
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
65
+ process.exit(0);
66
+ `;
67
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
68
+ return stubPath;
69
+ }
70
+
71
+ test('BASH_MAX_TIMEOUT_MS stays strictly below IDLE_OUTPUT_KILL_MS with real margin', () => {
72
+ expect(typeof BASH_DEFAULT_TIMEOUT_MS).toBe('number');
73
+ expect(typeof BASH_MAX_TIMEOUT_MS).toBe('number');
74
+ expect(typeof IDLE_OUTPUT_KILL_MS).toBe('number');
75
+ expect(BASH_DEFAULT_TIMEOUT_MS).toBeLessThanOrEqual(BASH_MAX_TIMEOUT_MS);
76
+ expect(BASH_MAX_TIMEOUT_MS).toBeLessThan(IDLE_OUTPUT_KILL_MS);
77
+ // Real margin, not a razor's edge.
78
+ expect(IDLE_OUTPUT_KILL_MS - BASH_MAX_TIMEOUT_MS).toBeGreaterThanOrEqual(60_000);
79
+ });
80
+
81
+ test('executeJob spawns the executor child with BASH_DEFAULT_TIMEOUT_MS and BASH_MAX_TIMEOUT_MS set', async () => {
82
+ const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-bash-timeout-main-'));
83
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
84
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-bash-timeout-run-'));
85
+ try {
86
+ const slug = `1097-test-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
87
+ const prdsDir = path.join(mainCwd, 'session-manager-operations', 'scheduler', 'prds');
88
+ fs.mkdirSync(prdsDir, { recursive: true });
89
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Verify the env vars are set.', 'utf8');
90
+ process.env.SM_CLAUDE_BIN = writeClaudeStub();
91
+
92
+ const job = { slug, cwd: mainCwd };
93
+ const result = await executeJob(job, runDir, mainCwd, () => Promise.resolve());
94
+
95
+ expect(result.exitCode).toBe(0);
96
+ const marker = JSON.parse(fs.readFileSync(path.join(mainCwd, 'env.marker'), 'utf8'));
97
+ expect(marker.BASH_DEFAULT_TIMEOUT_MS).toBe(String(BASH_DEFAULT_TIMEOUT_MS));
98
+ expect(marker.BASH_MAX_TIMEOUT_MS).toBe(String(BASH_MAX_TIMEOUT_MS));
99
+ } finally {
100
+ fs.rmSync(mainCwd, { recursive: true, force: true });
101
+ fs.rmSync(runDir, { recursive: true, force: true });
102
+ }
103
+ });
@@ -69,3 +69,13 @@ test('free is net of held slots — a chat run shrinks it without changing cap',
69
69
  expect(payload.effectiveConcurrency).toEqual({ cap: 5, free: 4, source: 'env' });
70
70
  sessionSlots.release(token);
71
71
  });
72
+
73
+ test('a row-level gateOutcome field survives the payload build unchanged (pass-through only, PRD 1109)', () => {
74
+ const state = fakeState();
75
+ state.jobs = [
76
+ { slug: 'a', status: 'failed', gateOutcome: 'never_ran' },
77
+ { slug: 'b', status: 'completed', gateOutcome: 'passed' },
78
+ ];
79
+ const payload = buildScheduleStatePayload(state);
80
+ expect(payload.jobs.map((j) => j.gateOutcome)).toEqual(['never_ran', 'passed']);
81
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * scheduler-foreign-wip-manifest.test.cjs — capDirtyPaths/buildForeignWipSection,
3
+ * the pure helpers behind the pre-run foreign-WIP manifest injected into the
4
+ * executor prompt (see the "starry-night-ships PRD 148" postmortem: a job
5
+ * running in a shared or WIP-carrying tree must be told explicitly which
6
+ * paths it does not own, rather than having to bisect by content).
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import { test, expect } from 'vitest';
14
+ const {
15
+ capDirtyPaths,
16
+ buildForeignWipSection,
17
+ PRE_RUN_DIRTY_PATHS_CAP,
18
+ FOREIGN_WIP_DELIMITER,
19
+ FOREIGN_WIP_END_DELIMITER,
20
+ } = require('../scheduler.cjs');
21
+
22
+ test('capDirtyPaths: [] for null/undefined/empty input', () => {
23
+ expect(capDirtyPaths(null)).toEqual([]);
24
+ expect(capDirtyPaths(undefined)).toEqual([]);
25
+ expect(capDirtyPaths([])).toEqual([]);
26
+ });
27
+
28
+ test('capDirtyPaths: returns the list unchanged when under the cap', () => {
29
+ expect(capDirtyPaths(['a.js', 'b.js'])).toEqual(['a.js', 'b.js']);
30
+ });
31
+
32
+ test('capDirtyPaths: truncates at the cap and appends a "+N more" marker', () => {
33
+ const many = Array.from({ length: PRE_RUN_DIRTY_PATHS_CAP + 7 }, (_, i) => `file-${i}.txt`);
34
+ const capped = capDirtyPaths(many);
35
+ expect(capped).toHaveLength(PRE_RUN_DIRTY_PATHS_CAP + 1);
36
+ expect(capped.slice(0, PRE_RUN_DIRTY_PATHS_CAP)).toEqual(many.slice(0, PRE_RUN_DIRTY_PATHS_CAP));
37
+ expect(capped[PRE_RUN_DIRTY_PATHS_CAP]).toBe('+7 more');
38
+ });
39
+
40
+ test('buildForeignWipSection: "" when both lists are empty/absent (clean spawn) — zero prompt tokens added', () => {
41
+ expect(buildForeignWipSection({})).toBe('');
42
+ expect(buildForeignWipSection({ preRunDirtyPaths: [], carriedPaths: [] })).toBe('');
43
+ expect(buildForeignWipSection()).toBe('');
44
+ });
45
+
46
+ test('buildForeignWipSection: shared dirty tree emits the manifest with the delimiter, paths verbatim, and do-not-touch wording', () => {
47
+ const section = buildForeignWipSection({ preRunDirtyPaths: ['src/foo.ts', 'src/bar.ts'] });
48
+ expect(section).toContain(FOREIGN_WIP_DELIMITER);
49
+ expect(section).toContain(FOREIGN_WIP_END_DELIMITER);
50
+ expect(section).toContain('src/foo.ts');
51
+ expect(section).toContain('src/bar.ts');
52
+ expect(section).toMatch(/SHARED working tree/);
53
+ expect(section).toMatch(/not this job's work/i);
54
+ expect(section).toMatch(/do not stage, commit, revert, or stash/i);
55
+ expect(section).toMatch(/not this job's regression/i);
56
+ });
57
+
58
+ test('buildForeignWipSection: carriedPaths (PRD 1094 base WIP) emits carry-over wording, not the shared-tree wording', () => {
59
+ const section = buildForeignWipSection({ carriedPaths: ['config/settings.json'] });
60
+ expect(section).toContain('config/settings.json');
61
+ expect(section).toMatch(/isolated git worktree/i);
62
+ expect(section).toMatch(/authoritative copy.*main tree/i);
63
+ expect(section).not.toMatch(/SHARED working tree/);
64
+ });
65
+
66
+ test('buildForeignWipSection: carriedPaths takes precedence over preRunDirtyPaths when both are somehow present', () => {
67
+ const section = buildForeignWipSection({
68
+ carriedPaths: ['carried.ts'],
69
+ preRunDirtyPaths: ['dirty.ts'],
70
+ });
71
+ expect(section).toContain('carried.ts');
72
+ expect(section).not.toContain('dirty.ts');
73
+ expect(section).toMatch(/isolated git worktree/i);
74
+ });
75
+
76
+ test('buildForeignWipSection: isolated-clean (worktree ok, no carried WIP) emits nothing', () => {
77
+ expect(buildForeignWipSection({ carriedPaths: [] })).toBe('');
78
+ });
@@ -0,0 +1,242 @@
1
+ /**
2
+ * scheduler-inplace-salvage.test.cjs — PRD 1098: an in-place run (no
3
+ * worktree — SM_JOB_WORKTREE_DISABLE=1 here, but the same finally-block path
4
+ * also covers a non-git cwd, the worktree cap, or a carry-over failure) must
5
+ * still salvage its uncommitted work, exactly like the worktree path already
6
+ * did (gitWorktreeSalvage.test.cjs), and regardless of exit code. Drives
7
+ * spawnJob end-to-end against a real git fixture repo with a stub `claude`
8
+ * binary (SM_CLAUDE_BIN, same pattern as scheduler-worktree-exec-cwd.test.cjs)
9
+ * that dirties the tree then exits 137 (SIGKILL), simulating a job killed
10
+ * mid-run before its finish-protocol commit.
11
+ *
12
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-inplace-salvage.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
22
+
23
+ let tmpHome;
24
+ let originalHome;
25
+ let originalDisable;
26
+ let spawnJob;
27
+ let RUNS_DIR;
28
+
29
+ function git(args, cwd) {
30
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
31
+ }
32
+
33
+ function initRepo(dir) {
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ git(['init', '-q'], dir);
36
+ git(['config', 'user.email', 'test@example.com'], dir);
37
+ git(['config', 'user.name', 'Test'], dir);
38
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
39
+ git(['add', '-A'], dir);
40
+ git(['commit', '-q', '-m', 'initial'], dir);
41
+ }
42
+
43
+ // readQueue()'s per-project discovery walks ~/.claude/projects/*/*.jsonl
44
+ // transcripts for a `cwd` field to find known project queue.json shards — an
45
+ // unregistered project's queue.json is invisible to mutate()/readQueue() and
46
+ // silently never gets written back to. Same helper scheduler-reap-dead-
47
+ // running-jobs.test.cjs uses.
48
+ function registerActiveProject(cwd) {
49
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
50
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
51
+ fs.mkdirSync(slugDir, { recursive: true });
52
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
53
+ }
54
+
55
+ function writeProjectQueue(cwd, jobs) {
56
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
57
+ fs.mkdirSync(stateDir, { recursive: true });
58
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
59
+ return path.join(stateDir, 'queue.json');
60
+ }
61
+
62
+ // Stub `claude` binary: dirties the repo it runs in (a tracked-file edit and
63
+ // a brand-new untracked file — this job's own delta) then exits 137, with NO
64
+ // result:success event — simulating a job SIGKILLed mid-run before its
65
+ // finish-protocol commit. Deliberately emits no success result: scheduler.cjs
66
+ // maps a killed process straight back to exitCode 0 when it already saw
67
+ // result:success (the intentional "cleanup hung after real success" case),
68
+ // which is not the scenario under test here — this must land as a genuine
69
+ // non-zero exit.
70
+ function writeKilledClaudeStub() {
71
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-killed-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
72
+ const body = `
73
+ const fs = require('fs');
74
+ const path = require('path');
75
+ fs.writeFileSync(path.join(process.cwd(), 'README.md'), 'hello\\nedited by job\\n', 'utf8');
76
+ fs.writeFileSync(path.join(process.cwd(), 'job-output.txt'), 'work the job produced before dying\\n', 'utf8');
77
+ process.exit(137);
78
+ `;
79
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
80
+ return stubPath;
81
+ }
82
+
83
+ beforeAll(() => {
84
+ originalHome = process.env.HOME;
85
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-inplace-salvage-'));
86
+ process.env.HOME = tmpHome;
87
+ originalDisable = process.env.SM_JOB_WORKTREE_DISABLE;
88
+ // Force the in-place path deterministically — this test is about the
89
+ // salvage-on-every-exit-code behaviour, not about which condition
90
+ // triggered the fallback (not-a-repo / cap / carry-over failure all land
91
+ // in the same else branch).
92
+ process.env.SM_JOB_WORKTREE_DISABLE = '1';
93
+ ({ spawnJob, RUNS_DIR } = require('../scheduler.cjs'));
94
+ });
95
+
96
+ afterAll(() => {
97
+ process.env.HOME = originalHome;
98
+ if (originalDisable === undefined) delete process.env.SM_JOB_WORKTREE_DISABLE;
99
+ else process.env.SM_JOB_WORKTREE_DISABLE = originalDisable;
100
+ fs.rmSync(tmpHome, { recursive: true, force: true });
101
+ });
102
+
103
+ afterEach(() => {
104
+ delete process.env.SM_CLAUDE_BIN;
105
+ });
106
+
107
+ test('an in-place job killed (exit 137) mid-run salvages a delta-scoped patch that applies cleanly, without disturbing pre-existing WIP', async () => {
108
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-inplace-salvage-project-'));
109
+ initRepo(projectCwd);
110
+ registerActiveProject(projectCwd);
111
+
112
+ // Pre-existing human WIP untouched by the job — must survive completely
113
+ // unmodified, and must never appear in the job's salvage patch.
114
+ fs.writeFileSync(path.join(projectCwd, 'human-wip.txt'), 'human work in progress\n', 'utf8');
115
+ git(['add', '-A'], projectCwd);
116
+ // Leave it staged-but-uncommitted (real dirty WIP), not committed.
117
+ fs.appendFileSync(path.join(projectCwd, 'human-wip.txt'), 'more human edits\n', 'utf8');
118
+
119
+ const slug = `1098-test-inplace-killed-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
120
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
121
+ fs.mkdirSync(prdsDir, { recursive: true });
122
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do a thing that gets killed mid-run.', 'utf8');
123
+
124
+ const queuePath = writeProjectQueue(projectCwd, [
125
+ { slug, status: 'pending', cwd: projectCwd },
126
+ ]);
127
+
128
+ const statusBefore = git(['status', '--porcelain'], projectCwd);
129
+ const stashBefore = git(['stash', 'list'], projectCwd);
130
+
131
+ process.env.SM_CLAUDE_BIN = writeKilledClaudeStub();
132
+
133
+ const runId = `run-${slug}`;
134
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
135
+ fs.mkdirSync(runDir, { recursive: true });
136
+
137
+ try {
138
+ await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
139
+
140
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
141
+ const row = jobs.find((j) => j.slug === slug);
142
+ expect(row).toBeTruthy();
143
+ expect(row.exitCode).toBe(137);
144
+ expect(row.salvagePatch).toBeTruthy();
145
+ expect(fs.existsSync(row.salvagePatch)).toBe(true);
146
+
147
+ // Leftover-attribution fields: this job's own delta (README.md +
148
+ // job-output.txt), never the human's pre-existing baseline WIP.
149
+ expect(row.leftoverCount).toBe(2);
150
+ expect(new Set(row.leftoverPaths)).toEqual(new Set(['README.md', 'job-output.txt']));
151
+ // The pre-run baseline is cleared once the run has finalized — it was
152
+ // only ever needed to compute the delta above.
153
+ expect(row.guardBaseline).toBeUndefined();
154
+ expect(row.guardHeadBefore).toBeUndefined();
155
+
156
+ const patch = fs.readFileSync(row.salvagePatch, 'utf8');
157
+ expect(patch).toContain('README.md');
158
+ expect(patch).toContain('edited by job');
159
+ expect(patch).toContain('job-output.txt');
160
+ expect(patch).toContain('work the job produced before dying');
161
+ // The human's pre-existing WIP (baseline-dirty, not part of this job's
162
+ // delta) must never leak into the job's salvage patch.
163
+ expect(patch).not.toContain('more human edits');
164
+
165
+ // The patch must be self-sufficient and apply cleanly against a clean
166
+ // checkout of the same base commit.
167
+ const cleanCheckout = path.join(os.tmpdir(), `sm-inplace-salvage-clean-${process.pid}-${Math.floor(Math.random() * 1e6)}`);
168
+ git(['clone', '-q', projectCwd, cleanCheckout], os.tmpdir());
169
+ try {
170
+ expect(() => git(['apply', '--check', row.salvagePatch], cleanCheckout)).not.toThrow();
171
+ } finally {
172
+ fs.rmSync(cleanCheckout, { recursive: true, force: true });
173
+ }
174
+
175
+ // The shared tree itself was never mutated by the salvage pass — no
176
+ // add/stash/reset/checkout/clean. (The job's own dirtying of README.md
177
+ // and job-output.txt is real, expected, working-tree state left by the
178
+ // stub process itself, not something salvage did — so we only assert no
179
+ // stash was created and the human's pre-existing WIP file is untouched.)
180
+ const stashAfter = git(['stash', 'list'], projectCwd);
181
+ expect(stashAfter).toBe(stashBefore);
182
+ const humanWipContent = fs.readFileSync(path.join(projectCwd, 'human-wip.txt'), 'utf8');
183
+ expect(humanWipContent).toBe('human work in progress\nmore human edits\n');
184
+ void statusBefore; // baseline captured for readability of intent above
185
+ } finally {
186
+ fs.rmSync(projectCwd, { recursive: true, force: true });
187
+ fs.rmSync(runDir, { recursive: true, force: true });
188
+ }
189
+ }, 30_000);
190
+
191
+ // Stub `claude` binary: touches nothing, emits a success result, and exits 0
192
+ // — a job whose only working-tree dirt is pre-existing human/sibling WIP
193
+ // present before the run even started.
194
+ function writeNoopClaudeStub() {
195
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-noop-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
196
+ const body = `
197
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'nothing to do', is_error: false }) + '\\n');
198
+ process.exit(0);
199
+ `;
200
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
201
+ return stubPath;
202
+ }
203
+
204
+ test('a job whose tree is dirty only from pre-existing baseline WIP (human/sibling), and which itself dirties/commits nothing, gets no leftover attribution', async () => {
205
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-inplace-salvage-project-'));
206
+ initRepo(projectCwd);
207
+ registerActiveProject(projectCwd);
208
+
209
+ // Pre-existing WIP present BEFORE the run — must never be attributed to
210
+ // this job, even though it's still dirty at exit.
211
+ fs.writeFileSync(path.join(projectCwd, 'human-wip.txt'), 'human work in progress\n', 'utf8');
212
+
213
+ const slug = `1098-test-inplace-noop-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
214
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
215
+ fs.mkdirSync(prdsDir, { recursive: true });
216
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do nothing.', 'utf8');
217
+
218
+ const queuePath = writeProjectQueue(projectCwd, [
219
+ { slug, status: 'pending', cwd: projectCwd },
220
+ ]);
221
+
222
+ process.env.SM_CLAUDE_BIN = writeNoopClaudeStub();
223
+
224
+ const runId = `run-${slug}`;
225
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
226
+ fs.mkdirSync(runDir, { recursive: true });
227
+
228
+ try {
229
+ await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
230
+
231
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
232
+ const row = jobs.find((j) => j.slug === slug);
233
+ expect(row).toBeTruthy();
234
+ expect(row.exitCode).toBe(0);
235
+ // No leftover attribution: the only dirt is baseline WIP, not this job's.
236
+ expect(row.leftoverPaths).toBeUndefined();
237
+ expect(row.leftoverCount).toBeUndefined();
238
+ } finally {
239
+ fs.rmSync(projectCwd, { recursive: true, force: true });
240
+ fs.rmSync(runDir, { recursive: true, force: true });
241
+ }
242
+ }, 30_000);
@@ -54,6 +54,37 @@ test('prompt includes the resolved job fields', () => {
54
54
  assert.match(prompt, /05-fix-my-feature\.md/);
55
55
  });
56
56
 
57
+ // ─── abandoned_background_task: investigation prompt directs a commit-first check ─
58
+
59
+ test('prompt for an abandoned_background_task job directs inspecting the working tree and committing before re-implementing', () => {
60
+ const prompt = buildInvestigationPrompt(makeArgs({
61
+ failedJob: { slug: '05-my-feature', title: 'My feature', exitCode: 0, verifierVerdict: 'abandoned_background_task' },
62
+ }));
63
+ assert.match(prompt, /abandoned_background_task/);
64
+ assert.match(prompt, /git status/);
65
+ assert.match(prompt, /COMMIT/);
66
+ assert.match(prompt, /not re-implement or re-plan the PRD from scratch/);
67
+ });
68
+
69
+ test('prompt for a job with no abandoned_background_task verdict omits the note', () => {
70
+ const prompt = buildInvestigationPrompt(makeArgs());
71
+ assert.doesNotMatch(prompt, /abandoned_background_task/);
72
+ });
73
+
74
+ test('prompt for an abandoned_background_task job with a salvage patch names its exact path', () => {
75
+ const prompt = buildInvestigationPrompt(makeArgs({
76
+ failedJob: {
77
+ slug: '05-my-feature',
78
+ title: 'My feature',
79
+ exitCode: 0,
80
+ verifierVerdict: 'abandoned_background_task',
81
+ salvagePatch: '/tmp/runs/2026-09-02/05-my-feature.uncommitted.patch',
82
+ },
83
+ }));
84
+ assert.match(prompt, /05-my-feature\.uncommitted\.patch/);
85
+ assert.match(prompt, /apply it to the working tree BEFORE inspecting/);
86
+ });
87
+
57
88
  // ─── cwd-must-be-git-repo-root guidance (99-fix-e2e-needs-review-test) ────────
58
89
 
59
90
  test('prompt instructs that cwd must be the git repo root where the fix will land', () => {