ultra-builder-pro-cli 0.19.1 → 0.21.1

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 (103) hide show
  1. package/.ultra-template/docs/research/README.md +26 -5
  2. package/.ultra-template/specs/discovery.md +1 -1
  3. package/.ultra-template/tasks/tasks.json +1 -1
  4. package/CHANGELOG.md +90 -1
  5. package/README.md +62 -29
  6. package/adapters/_shared/codex-assets.cjs +69 -11
  7. package/adapters/_shared/interaction-contract.cjs +60 -5
  8. package/adapters/_shared/runtime-assets.cjs +41 -1
  9. package/adapters/claude.js +7 -5
  10. package/adapters/codex.js +1 -1
  11. package/adapters/kimi.js +2 -8
  12. package/adapters/opencode.js +30 -28
  13. package/bin/orchestrator.js +64 -24
  14. package/commands/ultra-research.md +1 -1
  15. package/docs/AGENT-CONTEXT.md +79 -45
  16. package/docs/ARCHITECTURE.md +75 -38
  17. package/docs/ARTIFACT-AUTHORITY.md +234 -0
  18. package/docs/DECISIONS.md +20 -10
  19. package/docs/LEGACY-CLI-CROSSWALK.md +15 -11
  20. package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
  21. package/docs/ROADMAP.md +21 -6
  22. package/docs/RUNTIME-COMPAT-MATRIX.md +30 -13
  23. package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
  24. package/docs/WORKFLOW-LIFECYCLE.md +105 -54
  25. package/hooks/README.md +6 -5
  26. package/hooks/active_task_context.py +97 -12
  27. package/hooks/context_spine.py +16 -4
  28. package/hooks/health_check.py +5 -4
  29. package/hooks/pre_stop_check.py +5 -2
  30. package/hooks/runtime_paths.py +396 -0
  31. package/hooks/subagent_tracker.py +5 -2
  32. package/hooks/workflow_checkpoint.py +16 -3
  33. package/hooks/workflow_context.py +11 -3
  34. package/hooks/workflow_resume.py +7 -4
  35. package/mcp-server/breadcrumb.cjs +20 -2
  36. package/mcp-server/lib/archive-journal.cjs +1026 -111
  37. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  38. package/mcp-server/lib/artifact-registry.cjs +1126 -0
  39. package/mcp-server/lib/baseline-workflow.cjs +9 -4
  40. package/mcp-server/lib/change-packet.cjs +818 -0
  41. package/mcp-server/lib/change-workflow.cjs +932 -176
  42. package/mcp-server/lib/context-paths.cjs +111 -0
  43. package/mcp-server/lib/context-spine.cjs +511 -45
  44. package/mcp-server/lib/decision-dialogue.cjs +427 -12
  45. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  46. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  47. package/mcp-server/lib/doctor.cjs +375 -12
  48. package/mcp-server/lib/git-bootstrap.cjs +459 -47
  49. package/mcp-server/lib/init-project.cjs +190 -95
  50. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  51. package/mcp-server/lib/plan-store.cjs +700 -5
  52. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  53. package/mcp-server/lib/projector.cjs +174 -40
  54. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  55. package/mcp-server/lib/safe-project-file.cjs +385 -0
  56. package/mcp-server/lib/spec-learning.cjs +28 -12
  57. package/mcp-server/lib/state-db.cjs +397 -13
  58. package/mcp-server/lib/state-ops.cjs +248 -49
  59. package/mcp-server/lib/telemetry.cjs +12 -3
  60. package/mcp-server/lib/workflow-state.cjs +614 -62
  61. package/mcp-server/server.cjs +282 -33
  62. package/orchestrator/daemon.cjs +2 -10
  63. package/orchestrator/parallel-orchestrator.cjs +314 -50
  64. package/orchestrator/planner/plan-builder.cjs +1 -1
  65. package/orchestrator/recovery.cjs +125 -7
  66. package/orchestrator/session-close-journal-worker.cjs +5 -0
  67. package/orchestrator/session-close-journal.cjs +1014 -0
  68. package/orchestrator/session-runner.cjs +679 -69
  69. package/orchestrator/worktree-manager.cjs +186 -35
  70. package/package.json +2 -3
  71. package/skills/cc-collab/SKILL.md +4 -1
  72. package/skills/codex-collab/SKILL.md +5 -2
  73. package/skills/ultra-change/SKILL.md +38 -13
  74. package/skills/ultra-deliver/SKILL.md +23 -11
  75. package/skills/ultra-dev/SKILL.md +33 -6
  76. package/skills/ultra-doctor/SKILL.md +6 -2
  77. package/skills/ultra-init/SKILL.md +4 -2
  78. package/skills/ultra-plan/SKILL.md +68 -18
  79. package/skills/ultra-research/SKILL.md +45 -32
  80. package/skills/ultra-review/SKILL.md +9 -5
  81. package/skills/ultra-status/SKILL.md +9 -4
  82. package/skills/ultra-test/SKILL.md +15 -7
  83. package/skills/ultra-think/SKILL.md +6 -0
  84. package/skills/ultra-think/references/decision-dialogue.md +61 -14
  85. package/skills/ultra-verify/SKILL.md +4 -1
  86. package/spec/cli-protocol.md +19 -7
  87. package/spec/mcp-tools.yaml +531 -46
  88. package/spec/schemas/change-delta.v1.schema.json +153 -0
  89. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  90. package/spec/schemas/mcp-tools.schema.json +1 -1
  91. package/spec/schemas/state-db.sql +57 -17
  92. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  93. package/ultra-tools/cli.cjs +3 -2
  94. package/ultra-tools/commands/db.cjs +76 -25
  95. package/ultra-tools/commands/migrate.cjs +995 -80
  96. package/ultra-tools/commands/session.cjs +34 -8
  97. package/ultra-tools/commands/status.cjs +34 -7
  98. package/ultra-tools/commands/system.cjs +129 -22
  99. package/commands/learn.md +0 -10
  100. package/orchestrator/skill-miner.cjs +0 -133
  101. package/output-styles/coding-vibes.md +0 -67
  102. package/output-styles/structural-thinking.md +0 -61
  103. package/skills/learn/SKILL.md +0 -58
@@ -16,7 +16,10 @@ const { randomUUID } = require('node:crypto');
16
16
 
17
17
  const ops = require('../mcp-server/lib/state-ops.cjs');
18
18
  const workflows = require('../mcp-server/lib/workflow-state.cjs');
19
- const skillMiner = require('./skill-miner.cjs');
19
+ const contextSpine = require('../mcp-server/lib/context-spine.cjs');
20
+ const runtimePaths = require('../mcp-server/lib/runtime-paths.cjs');
21
+ const gitBootstrap = require('../mcp-server/lib/git-bootstrap.cjs');
22
+ const closeJournal = require('./session-close-journal.cjs');
20
23
 
21
24
  const EXPECTED_EXECUTION_GATE_CODES = new Set([
22
25
  'TASK_EXECUTION_CONTRACT_INCOMPLETE',
@@ -25,6 +28,22 @@ const EXPECTED_EXECUTION_GATE_CODES = new Set([
25
28
  'WORKFLOW_PLAN_NOT_COMPLETED',
26
29
  'WORKFLOW_PLAN_TASK_SET_STALE',
27
30
  'WORKFLOW_PLAN_TASK_CONTRACT_STALE',
31
+ 'WORKFLOW_PLAN_CONTEXT_STALE',
32
+ 'WORKFLOW_IMPLEMENTATION_CONTEXT_REQUIRED',
33
+ 'WORKFLOW_IMPLEMENTATION_CONTEXT_STALE',
34
+ 'ADMISSION_DENIED',
35
+ 'ACTIVE_SESSION_LEASE_CONFLICT',
36
+ 'SESSION_CLOSE_PENDING',
37
+ 'CIRCUIT_TRIPPED',
38
+ 'LEGACY_RUNTIME_CONFLICT',
39
+ 'LEGACY_RUNTIME_MIGRATION_FAILED',
40
+ 'RUNTIME_ADMISSION_ROLLBACK_FAILED',
41
+ 'RUNTIME_STATE_CONFLICT',
42
+ 'RUNTIME_STATE_MIGRATION_FAILED',
43
+ 'RUNTIME_STATE_NOT_QUIESCENT',
44
+ 'SESSION_CLOSE_JOURNAL_INVALID',
45
+ 'SESSION_CLOSE_JOURNAL_CONFLICT',
46
+ 'SESSION_CLOSE_CONFLICT',
28
47
  ]);
29
48
 
30
49
  class SessionRunnerError extends Error {
@@ -36,14 +55,103 @@ class SessionRunnerError extends Error {
36
55
  }
37
56
  }
38
57
 
58
+ function findExpectedExecutionGate(error) {
59
+ const seen = new Set();
60
+ let current = error;
61
+ while (current && (typeof current === 'object' || typeof current === 'function')) {
62
+ if (seen.has(current)) return null;
63
+ seen.add(current);
64
+ if (EXPECTED_EXECUTION_GATE_CODES.has(current.code)) return current;
65
+ current = current.cause;
66
+ }
67
+ return null;
68
+ }
69
+
39
70
  function isExpectedExecutionGate(error) {
40
- return Boolean(error && EXPECTED_EXECUTION_GATE_CODES.has(error.code));
71
+ return Boolean(findExpectedExecutionGate(error));
72
+ }
73
+
74
+ function assertSessionId(sid) {
75
+ if (typeof sid !== 'string'
76
+ || sid.length === 0
77
+ || sid.length > 128
78
+ || sid === '.'
79
+ || sid === '..'
80
+ || path.isAbsolute(sid)
81
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sid)) {
82
+ throw new SessionRunnerError(
83
+ 'WORKTREE_SCOPE_INVALID',
84
+ `session id is not a safe worktree leaf: ${String(sid)}`,
85
+ );
86
+ }
87
+ return sid;
88
+ }
89
+
90
+ function assertManagedWorktreeRoot(repoRoot, { validateRuntimeTree = true } = {}) {
91
+ const managedBase = runtimePaths.validateProjectLayout(
92
+ repoRoot, { env: {}, validateRuntimeTree },
93
+ ).worktreesDir;
94
+ const stat = fs.lstatSync(managedBase, { throwIfNoEntry: false });
95
+ if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
96
+ throw new SessionRunnerError(
97
+ 'WORKTREE_SCOPE_INVALID',
98
+ `managed worktree root must be a real directory: ${managedBase}`,
99
+ );
100
+ }
101
+ return managedBase;
102
+ }
103
+
104
+ function assertManagedWorktreePath(repoRoot, candidate, {
105
+ sid = null,
106
+ validateRuntimeTree = true,
107
+ } = {}) {
108
+ if (!repoRoot || typeof candidate !== 'string' || !candidate) {
109
+ throw new SessionRunnerError(
110
+ 'WORKTREE_SCOPE_INVALID',
111
+ 'repoRoot and a managed worktree path are required',
112
+ );
113
+ }
114
+ const expectedSid = assertSessionId(sid === null ? path.basename(candidate) : sid);
115
+ const managedBase = assertManagedWorktreeRoot(repoRoot, { validateRuntimeTree });
116
+ if (String(candidate).split(/[\\/]+/).includes('..')) {
117
+ throw new SessionRunnerError(
118
+ 'WORKTREE_SCOPE_INVALID',
119
+ `worktree path may not contain traversal segments: ${candidate}`,
120
+ );
121
+ }
122
+ const resolved = path.resolve(repoRoot, candidate);
123
+ if (path.basename(resolved) !== expectedSid) {
124
+ throw new SessionRunnerError(
125
+ 'WORKTREE_SCOPE_INVALID',
126
+ `worktree path must be the exact managed child for session ${expectedSid}: ${candidate}`,
127
+ );
128
+ }
129
+ const candidateStat = fs.lstatSync(resolved, { throwIfNoEntry: false });
130
+ if (candidateStat && (candidateStat.isSymbolicLink() || !candidateStat.isDirectory())) {
131
+ throw new SessionRunnerError(
132
+ 'WORKTREE_SCOPE_INVALID',
133
+ `managed worktree path must be a real directory: ${candidate}`,
134
+ );
135
+ }
136
+ const physicalManaged = physicalCandidate(managedBase);
137
+ const physicalResolved = physicalCandidate(resolved);
138
+ const physicalRelative = path.relative(physicalManaged, physicalResolved);
139
+ if (physicalRelative !== expectedSid) {
140
+ throw new SessionRunnerError(
141
+ 'WORKTREE_SCOPE_INVALID',
142
+ `worktree path must resolve to the exact managed child ${expectedSid}: ${candidate}`,
143
+ );
144
+ }
145
+ return resolved;
41
146
  }
42
147
 
43
148
  function gitWorktreeAdd(repoRoot, worktreePath, ref = 'HEAD') {
44
- fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
149
+ const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
150
+ sid: path.basename(worktreePath),
151
+ });
152
+ fs.mkdirSync(path.dirname(managedPath), { recursive: true });
45
153
  try {
46
- execFileSync('git', ['worktree', 'add', '--detach', worktreePath, ref], {
154
+ execFileSync('git', ['worktree', 'add', '--detach', managedPath, ref], {
47
155
  cwd: repoRoot, stdio: 'pipe',
48
156
  });
49
157
  } catch (err) {
@@ -52,23 +160,137 @@ function gitWorktreeAdd(repoRoot, worktreePath, ref = 'HEAD') {
52
160
  }
53
161
 
54
162
  function gitWorktreeRemove(repoRoot, worktreePath) {
163
+ const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
164
+ sid: path.basename(worktreePath),
165
+ validateRuntimeTree: false,
166
+ });
55
167
  try {
56
- execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
168
+ execFileSync('git', ['worktree', 'remove', managedPath], {
57
169
  cwd: repoRoot, stdio: 'pipe',
58
170
  });
59
- } catch (_err) {
60
- // worktree may already be detached; fall through to fs removal
171
+ } catch (error) {
172
+ throw new SessionRunnerError(
173
+ 'WORKTREE_FAILED',
174
+ `git worktree remove failed: ${
175
+ error.stderr ? error.stderr.toString().trim() : error.message
176
+ }`,
177
+ { cause: error },
178
+ );
61
179
  }
62
- if (fs.existsSync(worktreePath)) {
63
- fs.rmSync(worktreePath, { recursive: true, force: true });
180
+ }
181
+
182
+ function gitWorktreeRegistry(repoRoot) {
183
+ try {
184
+ const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
185
+ cwd: repoRoot,
186
+ encoding: 'utf8',
187
+ stdio: 'pipe',
188
+ });
189
+ return new Set(output
190
+ .split(/\r?\n/)
191
+ .filter((line) => line.startsWith('worktree '))
192
+ .map((line) => physicalCandidate(line.slice('worktree '.length))));
193
+ } catch (error) {
194
+ throw new SessionRunnerError(
195
+ 'WORKTREE_FAILED',
196
+ `git worktree registry read failed: ${
197
+ error.stderr ? error.stderr.toString().trim() : error.message
198
+ }`,
199
+ { cause: error },
200
+ );
64
201
  }
65
202
  }
66
203
 
204
+ function removeExactPrunableWorktreeRegistry(repoRoot, managedPath) {
205
+ let reported;
206
+ try {
207
+ reported = execFileSync('git', ['rev-parse', '--git-common-dir'], {
208
+ cwd: repoRoot,
209
+ encoding: 'utf8',
210
+ stdio: 'pipe',
211
+ }).trim();
212
+ } catch (error) {
213
+ throw new SessionRunnerError(
214
+ 'WORKTREE_FAILED',
215
+ `cannot resolve Git common directory: ${
216
+ error.stderr ? error.stderr.toString().trim() : error.message
217
+ }`,
218
+ { cause: error },
219
+ );
220
+ }
221
+ const commonDir = fs.realpathSync(
222
+ path.isAbsolute(reported) ? reported : path.resolve(repoRoot, reported),
223
+ );
224
+ const registryRoot = path.join(commonDir, 'worktrees');
225
+ const rootStat = fs.lstatSync(registryRoot, { throwIfNoEntry: false });
226
+ if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
227
+ throw new SessionRunnerError(
228
+ 'WORKTREE_FAILED',
229
+ `Git still registers ${managedPath}, but its exact worktree metadata root is unavailable`,
230
+ );
231
+ }
232
+ const expected = path.resolve(managedPath);
233
+ const matches = [];
234
+ for (const entry of fs.readdirSync(registryRoot, { withFileTypes: true })) {
235
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
236
+ const adminDir = path.join(registryRoot, entry.name);
237
+ const gitdirFile = path.join(adminDir, 'gitdir');
238
+ const gitdirStat = fs.lstatSync(gitdirFile, { throwIfNoEntry: false });
239
+ if (!gitdirStat?.isFile() || gitdirStat.isSymbolicLink()) continue;
240
+ const gitdir = fs.readFileSync(gitdirFile, 'utf8').trim();
241
+ if (!gitdir) continue;
242
+ const recorded = path.dirname(
243
+ path.isAbsolute(gitdir) ? path.resolve(gitdir) : path.resolve(adminDir, gitdir),
244
+ );
245
+ if (recorded === expected) matches.push(adminDir);
246
+ }
247
+ if (matches.length !== 1) {
248
+ throw new SessionRunnerError(
249
+ 'WORKTREE_FAILED',
250
+ `cannot identify one exact Git registry entry for removed worktree ${managedPath}`,
251
+ );
252
+ }
253
+ fs.rmSync(matches[0], { recursive: true, force: false });
254
+ }
255
+
256
+ function reconcileRemovedWorktree(repoRoot, worktreePath, { sid } = {}) {
257
+ const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
258
+ sid: sid || path.basename(worktreePath),
259
+ validateRuntimeTree: false,
260
+ });
261
+ const registryKey = physicalCandidate(managedPath);
262
+ let registry = gitWorktreeRegistry(repoRoot);
263
+ if (fs.existsSync(managedPath)) {
264
+ gitWorktreeRemove(repoRoot, managedPath);
265
+ } else if (registry.has(registryKey)) {
266
+ try {
267
+ execFileSync('git', ['worktree', 'remove', '--force', managedPath], {
268
+ cwd: repoRoot,
269
+ stdio: 'pipe',
270
+ });
271
+ } catch {
272
+ removeExactPrunableWorktreeRegistry(repoRoot, managedPath);
273
+ }
274
+ }
275
+ registry = gitWorktreeRegistry(repoRoot);
276
+ if (!fs.existsSync(managedPath) && registry.has(registryKey)) {
277
+ removeExactPrunableWorktreeRegistry(repoRoot, managedPath);
278
+ registry = gitWorktreeRegistry(repoRoot);
279
+ }
280
+ if (registry.has(registryKey)) {
281
+ throw new SessionRunnerError(
282
+ 'WORKTREE_FAILED',
283
+ `Git still registers the managed worktree after removal: ${managedPath}`,
284
+ );
285
+ }
286
+ return managedPath;
287
+ }
288
+
67
289
  function authorityLinkIsIgnored(worktreePath) {
68
290
  try {
69
291
  execFileSync(
70
292
  'git',
71
- ['check-ignore', '--quiet', '--no-index', '--', '.ultra'],
293
+ ['check-ignore', '--quiet', '--no-index', '--', '.ultra/.runtime'],
72
294
  { cwd: worktreePath, stdio: 'ignore' },
73
295
  );
74
296
  return true;
@@ -125,7 +347,7 @@ function ensureAuthorityLinkIgnored(worktreePath) {
125
347
  fs.mkdirSync(path.dirname(excludePath), { recursive: true });
126
348
  fs.writeFileSync(
127
349
  excludePath,
128
- `${prefix}${separator}# Ultra Builder Pro session authority\n.ultra\n`,
350
+ `${prefix}${separator}# Ultra Builder Pro shared session runtime\n/.ultra/.runtime\n`,
129
351
  );
130
352
  if (!authorityLinkIsIgnored(worktreePath)) {
131
353
  throw new Error('Git still does not ignore the session authority link');
@@ -137,17 +359,34 @@ function ensureAuthorityLinkIgnored(worktreePath) {
137
359
  } catch { /* best effort */ }
138
360
  throw new SessionRunnerError(
139
361
  'WORKTREE_AUTHORITY_NOT_IGNORED',
140
- `cannot establish a safe local ignore rule for .ultra: ${error.message}`,
362
+ `cannot establish a safe local ignore rule for .ultra/.runtime: ${error.message}`,
141
363
  { cause: error },
142
364
  );
143
365
  }
144
366
  return { changed: true, path: excludePath };
145
367
  }
146
368
 
147
- function linkAuthorityIntoWorktree(repoRoot, worktreePath) {
148
- const authorityDir = path.resolve(repoRoot, '.ultra');
149
- const stateDbPath = path.join(authorityDir, 'state.db');
150
- const linkPath = path.join(worktreePath, '.ultra');
369
+ function linkAuthorityIntoWorktree(repoRoot, worktreePath, {
370
+ authority: preadmittedAuthority = null,
371
+ } = {}) {
372
+ let authority = preadmittedAuthority;
373
+ if (!authority) {
374
+ try {
375
+ authority = runtimePaths.ensureRuntimeState(repoRoot, {
376
+ admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(repoRoot),
377
+ });
378
+ } catch (error) {
379
+ throw new SessionRunnerError(
380
+ 'WORKTREE_AUTHORITY_NOT_IGNORED',
381
+ `cannot establish the project Ultra storage boundary: ${error.message}`,
382
+ { cause: error },
383
+ );
384
+ }
385
+ }
386
+ const authorityDir = authority.runtimeDir;
387
+ const stateDbPath = authority.stateDbPath;
388
+ const worktreeUltraDir = path.join(worktreePath, '.ultra');
389
+ const linkPath = path.join(worktreeUltraDir, '.runtime');
151
390
  if (!fs.existsSync(stateDbPath)) {
152
391
  throw new SessionRunnerError(
153
392
  'WORKTREE_AUTHORITY_MISSING',
@@ -156,20 +395,31 @@ function linkAuthorityIntoWorktree(repoRoot, worktreePath) {
156
395
  }
157
396
  ensureAuthorityLinkIgnored(worktreePath);
158
397
  try {
398
+ fs.mkdirSync(worktreeUltraDir, { recursive: true });
159
399
  if (fs.existsSync(linkPath) || fs.lstatSync(linkPath, { throwIfNoEntry: false })) {
160
400
  throw new SessionRunnerError(
161
401
  'WORKTREE_AUTHORITY_CONFLICT',
162
- `worktree already contains an .ultra entry at ${linkPath}`,
402
+ `worktree already contains an .ultra/.runtime entry at ${linkPath}`,
163
403
  );
164
404
  }
165
405
  fs.symlinkSync(
166
- process.platform === 'win32' ? authorityDir : path.relative(worktreePath, authorityDir),
406
+ process.platform === 'win32' ? authorityDir : path.relative(worktreeUltraDir, authorityDir),
167
407
  linkPath,
168
408
  process.platform === 'win32' ? 'junction' : 'dir',
169
409
  );
170
410
  if (fs.realpathSync(path.join(linkPath, 'state.db')) !== fs.realpathSync(stateDbPath)) {
171
411
  throw new Error('linked state.db does not resolve to the central authority');
172
412
  }
413
+ try {
414
+ gitBootstrap.assertUltraStorageBoundary(worktreePath);
415
+ } catch (error) {
416
+ fs.rmSync(linkPath, { force: true });
417
+ throw new SessionRunnerError(
418
+ 'WORKTREE_STORAGE_BOUNDARY_STALE',
419
+ `selected worktree revision does not preserve Ultra semantic artifacts: ${error.message}`,
420
+ { cause: error },
421
+ );
422
+ }
173
423
  return { authorityDir, stateDbPath, linkPath };
174
424
  } catch (error) {
175
425
  throw error instanceof SessionRunnerError ? error : new SessionRunnerError(
@@ -181,14 +431,17 @@ function linkAuthorityIntoWorktree(repoRoot, worktreePath) {
181
431
  }
182
432
 
183
433
  function worktreeRemovalBlocker(repoRoot, worktreePath) {
184
- if (!fs.existsSync(worktreePath)) return null;
434
+ const managedPath = assertManagedWorktreePath(repoRoot, worktreePath, {
435
+ sid: path.basename(worktreePath),
436
+ });
437
+ if (!fs.existsSync(managedPath)) return null;
185
438
  try {
186
439
  const status = execFileSync('git', ['status', '--porcelain=v1'], {
187
- cwd: worktreePath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
440
+ cwd: managedPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
188
441
  }).trim();
189
442
  if (status) return 'worktree contains uncommitted changes';
190
443
  const sessionHead = execFileSync('git', ['rev-parse', 'HEAD'], {
191
- cwd: worktreePath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
444
+ cwd: managedPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
192
445
  }).trim();
193
446
  const repositoryHead = execFileSync('git', ['rev-parse', 'HEAD'], {
194
447
  cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
@@ -253,15 +506,28 @@ function changeTaskIntegrationBlocker(db, task, worktreePath) {
253
506
  return null;
254
507
  }
255
508
 
509
+ function physicalCandidate(candidate) {
510
+ let existing = candidate;
511
+ while (!fs.lstatSync(existing, { throwIfNoEntry: false })) {
512
+ const parent = path.dirname(existing);
513
+ if (parent === existing) break;
514
+ existing = parent;
515
+ }
516
+ const physicalExisting = fs.realpathSync(existing);
517
+ return path.resolve(physicalExisting, path.relative(existing, candidate));
518
+ }
519
+
256
520
  function resolveWorktreeBase(repoRoot, requestedBase) {
257
- const managedBase = path.resolve(repoRoot, '.ultra', 'worktrees');
521
+ const managedBase = runtimePaths.pathsFor(repoRoot).worktreesDir;
258
522
  const resolved = requestedBase
259
523
  ? path.resolve(repoRoot, requestedBase)
260
524
  : managedBase;
261
- if (resolved !== managedBase && !resolved.startsWith(`${managedBase}${path.sep}`)) {
525
+ const physicalManaged = physicalCandidate(managedBase);
526
+ const physicalResolved = physicalCandidate(resolved);
527
+ if (resolved !== path.resolve(managedBase) || physicalResolved !== physicalManaged) {
262
528
  throw new SessionRunnerError(
263
529
  'WORKTREE_SCOPE_INVALID',
264
- `worktree_base must stay inside ${managedBase}`,
530
+ `worktree_base must be the canonical managed root ${managedBase}`,
265
531
  );
266
532
  }
267
533
  return resolved;
@@ -295,9 +561,126 @@ function assertSessionTaskReady(db, repoRoot, taskId) {
295
561
  );
296
562
  }
297
563
  workflows.assertCurrentPlan(db, task.change_id, repoRoot);
564
+ const implementationContext = contextSpine.validateContextSnapshot(db, {
565
+ change_id: task.change_id,
566
+ task_id: task.id,
567
+ role: 'implement',
568
+ gate: 'implementation',
569
+ }, {
570
+ rootDir: repoRoot,
571
+ require_current_checkout: true,
572
+ });
573
+ if (!implementationContext.snapshot) {
574
+ throw new workflows.WorkflowStateError(
575
+ 'WORKFLOW_IMPLEMENTATION_CONTEXT_REQUIRED',
576
+ `task ${task.id} requires a ready implementation Context before session admission`,
577
+ { blockers: implementationContext.blockers },
578
+ );
579
+ }
580
+ if (implementationContext.blockers.length > 0) {
581
+ throw new workflows.WorkflowStateError(
582
+ 'WORKFLOW_IMPLEMENTATION_CONTEXT_STALE',
583
+ `task ${task.id} implementation Context is stale`,
584
+ { blockers: implementationContext.blockers },
585
+ );
586
+ }
587
+ const planningContext = contextSpine.validateContextSnapshot(db, {
588
+ change_id: task.change_id,
589
+ task_id: null,
590
+ role: 'plan',
591
+ gate: 'planning',
592
+ }, {
593
+ rootDir: repoRoot,
594
+ require_current_checkout: true,
595
+ });
596
+ if (!planningContext.snapshot || planningContext.blockers.length > 0) {
597
+ throw new workflows.WorkflowStateError(
598
+ 'WORKFLOW_PLAN_CONTEXT_STALE',
599
+ `change ${task.change_id} planning Context is stale at session admission`,
600
+ { blockers: planningContext.blockers },
601
+ );
602
+ }
298
603
  return task;
299
604
  }
300
605
 
606
+ function admissionCheck(db, repoRoot, taskId, options = {}) {
607
+ assertSessionTaskReady(db, repoRoot, taskId);
608
+ const pendingClose = closeJournal.findForTask(repoRoot, taskId);
609
+ if (pendingClose) {
610
+ return {
611
+ can_spawn: false,
612
+ conflict: {
613
+ sid: pendingClose.sid,
614
+ status: 'closing',
615
+ heartbeat_age_ms: 0,
616
+ },
617
+ recommended_action: 'recover_close',
618
+ };
619
+ }
620
+ return ops.admissionCheck(db, taskId, options);
621
+ }
622
+
623
+ function startSessionProcess({
624
+ db,
625
+ repoRoot,
626
+ sid,
627
+ command,
628
+ args = [],
629
+ env = {},
630
+ stdio = 'ignore',
631
+ authority = null,
632
+ }) {
633
+ if (!db || !repoRoot || !sid || typeof command !== 'string' || !command.trim()) {
634
+ throw new SessionRunnerError(
635
+ 'VALIDATION_ERROR',
636
+ 'db, repoRoot, sid, and an executable command are required',
637
+ );
638
+ }
639
+ if (!Array.isArray(args)) {
640
+ throw new SessionRunnerError('VALIDATION_ERROR', 'session process args must be an array');
641
+ }
642
+ const session = ops.readSession(db, sid);
643
+ if (!session) throw new SessionRunnerError('SESSION_NOT_FOUND', `session ${sid} not found`);
644
+ if (session.status !== 'running' || session.pid !== null) {
645
+ throw new SessionRunnerError(
646
+ 'SESSION_NOT_STARTABLE',
647
+ `session ${sid} cannot start from status=${session.status}, pid=${String(session.pid)}`,
648
+ );
649
+ }
650
+ const worktreePath = assertManagedWorktreePath(repoRoot, session.worktree_path, { sid });
651
+ const resolvedAuthority = authority || runtimePaths.ensureRuntimeState(repoRoot, {
652
+ admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(repoRoot),
653
+ });
654
+ let proc;
655
+ try {
656
+ proc = spawn(command, args, {
657
+ cwd: worktreePath,
658
+ env: {
659
+ ...process.env,
660
+ ...env,
661
+ UBP_SESSION_ID: sid,
662
+ UBP_TASK_ID: session.task_id,
663
+ UBP_RUNTIME: session.runtime,
664
+ UBP_WORKTREE: worktreePath,
665
+ UBP_ARTIFACT_DIR: session.artifact_dir,
666
+ UBP_DB_PATH: resolvedAuthority.stateDbPath,
667
+ UBP_ROOT_DIR: worktreePath,
668
+ UBP_AUTHORITY_ROOT: repoRoot,
669
+ },
670
+ stdio,
671
+ detached: false,
672
+ });
673
+ if (Number.isInteger(proc.pid)) {
674
+ ops.updateSession(db, sid, { pid: proc.pid });
675
+ }
676
+ return proc;
677
+ } catch (error) {
678
+ try { if (proc && !proc.killed) proc.kill('SIGTERM'); } catch { /* best effort */ }
679
+ try { ops.updateSession(db, sid, { status: 'crashed' }); } catch { /* preserve original error */ }
680
+ throw error;
681
+ }
682
+ }
683
+
301
684
  function spawnSession({
302
685
  db,
303
686
  repoRoot,
@@ -311,6 +694,9 @@ function spawnSession({
311
694
  worktreeRef = 'HEAD',
312
695
  worktree_base = null,
313
696
  lease_seconds = 1800,
697
+ mark_task_started = false,
698
+ takeover_timeout_ms = 5000,
699
+ takeover_poll_ms = 25,
314
700
  }) {
315
701
  if (!db) throw new SessionRunnerError('VALIDATION_ERROR', 'db handle required');
316
702
  if (!repoRoot) throw new SessionRunnerError('VALIDATION_ERROR', 'repoRoot required');
@@ -332,6 +718,25 @@ function spawnSession({
332
718
  // Validate workflow authority before takeover can terminate the prior worker
333
719
  // or any filesystem mutation creates a new worktree.
334
720
  assertSessionTaskReady(db, repoRoot, task_id);
721
+ let authority;
722
+ try {
723
+ authority = runtimePaths.ensureRuntimeState(repoRoot, {
724
+ admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(repoRoot),
725
+ });
726
+ } catch (error) {
727
+ throw new SessionRunnerError(
728
+ 'WORKTREE_AUTHORITY_NOT_IGNORED',
729
+ `cannot establish the project Ultra storage boundary: ${error.message}`,
730
+ { cause: error },
731
+ );
732
+ }
733
+ const pendingClose = closeJournal.findForTask(repoRoot, task_id);
734
+ if (pendingClose) {
735
+ throw new SessionRunnerError(
736
+ 'SESSION_CLOSE_PENDING',
737
+ `task ${task_id} has an unfinished close intent for session ${pendingClose.sid}; recover it before spawning or taking over`,
738
+ );
739
+ }
335
740
  if (!verdict.can_spawn && !takeover) {
336
741
  const err = new SessionRunnerError(
337
742
  'ADMISSION_DENIED',
@@ -352,6 +757,15 @@ function spawnSession({
352
757
  );
353
758
  }
354
759
  }
760
+ if (!waitForProcessExit(priorSession.pid, {
761
+ timeoutMs: takeover_timeout_ms,
762
+ pollMs: takeover_poll_ms,
763
+ })) {
764
+ throw new SessionRunnerError(
765
+ 'TAKEOVER_FAILED',
766
+ `prior session ${verdict.conflict.sid} process ${priorSession.pid} did not exit; its lease remains authoritative`,
767
+ );
768
+ }
355
769
  }
356
770
  ops.updateSession(db, verdict.conflict.sid, { status: 'crashed' });
357
771
  }
@@ -359,61 +773,78 @@ function spawnSession({
359
773
  // 2. Mint sid + paths
360
774
  const sid = mintSessionId();
361
775
  const worktree_path = path.join(worktreeBase, sid);
362
- const artifact_dir = path.join(repoRoot, '.ultra', 'sessions', sid);
776
+ const artifact_dir = path.join(runtimePaths.pathsFor(repoRoot).sessionsDir, sid);
363
777
 
364
778
  // 3. Git worktree (throws WORKTREE_FAILED on failure)
365
779
  gitWorktreeAdd(repoRoot, worktree_path, worktreeRef);
366
- let authority;
367
780
  try {
368
- authority = linkAuthorityIntoWorktree(repoRoot, worktree_path);
781
+ authority = linkAuthorityIntoWorktree(repoRoot, worktree_path, { authority });
369
782
  fs.mkdirSync(artifact_dir, { recursive: true });
370
783
  } catch (error) {
784
+ try {
785
+ fs.rmSync(path.join(worktree_path, '.ultra', '.runtime'), { force: true });
786
+ } catch { /* best effort */ }
371
787
  gitWorktreeRemove(repoRoot, worktree_path);
372
788
  throw error;
373
789
  }
374
790
 
375
- // 4. Spawn child process (optional — the active host may consume the worktree)
376
- let proc = null;
377
- if (command) {
378
- proc = spawn(command, args, {
379
- cwd: worktree_path,
380
- env: {
381
- ...process.env,
382
- ...env,
383
- UBP_SESSION_ID: sid,
384
- UBP_TASK_ID: task_id,
385
- UBP_RUNTIME: runtime,
386
- UBP_WORKTREE: worktree_path,
387
- UBP_ARTIFACT_DIR: artifact_dir,
388
- UBP_DB_PATH: authority.stateDbPath,
389
- UBP_ROOT_DIR: worktree_path,
390
- UBP_AUTHORITY_ROOT: repoRoot,
391
- },
392
- stdio,
393
- detached: false,
394
- });
395
- }
396
-
397
- // 5. Write session record
791
+ // 4. Persist the task/worktree binding before any worker can run a hook or
792
+ // resolve UBP_DB_PATH. This is the anti-spoof authority hand-off.
398
793
  let session;
399
794
  try {
400
795
  session = ops.createSession(db, {
401
796
  sid,
402
797
  task_id,
403
798
  runtime,
404
- pid: proc ? proc.pid : null,
799
+ pid: null,
405
800
  worktree_path,
406
801
  artifact_dir,
407
802
  lease_seconds,
408
803
  });
409
804
  } catch (err) {
410
- // state.db insert failed — roll back worktree + child
411
- if (proc && !proc.killed) { try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ } }
805
+ // state.db insert failed — no worker has started; roll back the worktree.
412
806
  gitWorktreeRemove(repoRoot, worktree_path);
413
807
  try { fs.rmSync(artifact_dir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
414
808
  throw err;
415
809
  }
416
810
 
811
+ // Automated dispatch must publish the running task state before the worker
812
+ // can observe or complete it. Manual host-owned sessions retain the caller's
813
+ // task state unless they explicitly opt into this ordering contract.
814
+ try {
815
+ if (mark_task_started && ops.readTask(db, task_id)?.status === 'pending') {
816
+ ops.updateTaskStatus(db, task_id, 'in_progress');
817
+ }
818
+ } catch (error) {
819
+ try { ops.updateSession(db, sid, { status: 'crashed' }); } catch { /* preserve original error */ }
820
+ try { gitWorktreeRemove(repoRoot, worktree_path); } catch { /* recovery keeps evidence */ }
821
+ try { fs.rmSync(artifact_dir, { recursive: true, force: true }); } catch { /* ignore */ }
822
+ throw error;
823
+ }
824
+
825
+ // 5. Spawn child process only after the durable binding exists. The active
826
+ // host may also consume the returned worktree without a child process.
827
+ let proc = null;
828
+ try {
829
+ if (command) {
830
+ proc = startSessionProcess({
831
+ db,
832
+ repoRoot,
833
+ sid,
834
+ command,
835
+ args,
836
+ env,
837
+ stdio,
838
+ authority,
839
+ });
840
+ session = ops.readSession(db, sid);
841
+ }
842
+ } catch (error) {
843
+ ops.updateSession(db, sid, { status: 'crashed' });
844
+ try { gitWorktreeRemove(repoRoot, worktree_path); } catch { /* recovery keeps evidence */ }
845
+ throw error;
846
+ }
847
+
417
848
  return {
418
849
  sid,
419
850
  worktree_path,
@@ -425,6 +856,48 @@ function spawnSession({
425
856
  };
426
857
  }
427
858
 
859
+ function processIsExecuting(pid) {
860
+ if (!Number.isInteger(pid) || pid <= 0) return false;
861
+ try {
862
+ process.kill(pid, 0);
863
+ } catch (error) {
864
+ if (error.code === 'ESRCH') return false;
865
+ if (error.code !== 'EPERM') throw error;
866
+ }
867
+ try {
868
+ const state = execFileSync('ps', ['-o', 'stat=', '-p', String(pid)], {
869
+ encoding: 'utf8',
870
+ stdio: ['ignore', 'pipe', 'ignore'],
871
+ }).trim();
872
+ return Boolean(state) && !state.startsWith('Z');
873
+ } catch {
874
+ // A failed ps lookup after signal 0 normally means the process vanished.
875
+ try {
876
+ process.kill(pid, 0);
877
+ return true;
878
+ } catch (error) {
879
+ return error.code !== 'ESRCH';
880
+ }
881
+ }
882
+ }
883
+
884
+ function waitForProcessExit(pid, {
885
+ timeoutMs = 5000,
886
+ pollMs = 25,
887
+ } = {}) {
888
+ const deadline = Date.now() + timeoutMs;
889
+ while (processIsExecuting(pid)) {
890
+ if (Date.now() >= deadline) return false;
891
+ Atomics.wait(
892
+ new Int32Array(new SharedArrayBuffer(4)),
893
+ 0,
894
+ 0,
895
+ Math.min(pollMs, Math.max(1, deadline - Date.now())),
896
+ );
897
+ }
898
+ return true;
899
+ }
900
+
428
901
  function closeSession({
429
902
  db,
430
903
  repoRoot,
@@ -433,34 +906,129 @@ function closeSession({
433
906
  status = 'completed',
434
907
  remove_worktree = false,
435
908
  kill_signal = 'SIGTERM',
436
- mineSkill = false,
437
- skillsRoot = null,
438
909
  autoMerge = false,
439
910
  mergeBaseBranch = 'main',
911
+ kill_timeout_ms = 5000,
912
+ kill_poll_ms = 25,
440
913
  } = {}) {
441
914
  if (!db || !sid) throw new SessionRunnerError('VALIDATION_ERROR', 'db + sid required');
915
+ if (!closeJournal.TERMINAL_STATUSES.has(status)) {
916
+ throw new SessionRunnerError(
917
+ 'VALIDATION_ERROR',
918
+ `session close status must be completed or crashed, got ${String(status)}`,
919
+ );
920
+ }
442
921
  const session = ops.readSession(db, sid);
443
922
  if (!session) {
444
923
  throw new SessionRunnerError('SESSION_NOT_FOUND', `session ${sid} not found`);
445
924
  }
446
- if (remove_worktree && !autoMerge) {
447
- const blocker = worktreeRemovalBlocker(repoRoot, session.worktree_path);
448
- if (blocker) {
925
+ if (closeJournal.TERMINAL_STATUSES.has(session.status)) {
926
+ if (session.status !== status) {
449
927
  throw new SessionRunnerError(
450
- 'WORKTREE_NOT_INTEGRATED',
451
- `refusing to remove ${session.worktree_path}: ${blocker}`,
928
+ 'SESSION_TERMINAL_CONFLICT',
929
+ `session ${sid} is already ${session.status} and cannot be closed as ${status}`,
452
930
  );
453
931
  }
932
+ // A terminal row may contain a legacy PID that has since been reused by an
933
+ // unrelated process. Terminal authority is idempotent: never signal that
934
+ // PID, and clear it before reconciling any durable close intent.
935
+ if (session.pid !== null) ops.updateSession(db, sid, { pid: null });
936
+ const intent = repoRoot ? closeJournal.read(repoRoot, sid) : null;
937
+ if (intent) {
938
+ if (intent.requested_status !== status
939
+ || intent.task_id !== session.task_id
940
+ || path.resolve(intent.worktree_path) !== path.resolve(session.worktree_path)) {
941
+ throw new SessionRunnerError(
942
+ 'SESSION_CLOSE_JOURNAL_CONFLICT',
943
+ `session ${sid} terminal state conflicts with its durable close intent`,
944
+ );
945
+ }
946
+ }
947
+ if ((intent || remove_worktree) && repoRoot && session.worktree_path) {
948
+ assertManagedWorktreePath(repoRoot, session.worktree_path, { sid: session.sid });
949
+ if (fs.existsSync(session.worktree_path)) {
950
+ const blocker = worktreeRemovalBlocker(repoRoot, session.worktree_path);
951
+ if (blocker) {
952
+ throw new SessionRunnerError(
953
+ 'WORKTREE_NOT_INTEGRATED',
954
+ `refusing to remove ${session.worktree_path}: ${blocker}`,
955
+ );
956
+ }
957
+ }
958
+ if (!intent) {
959
+ closeJournal.prepare(repoRoot, {
960
+ sid,
961
+ task_id: session.task_id,
962
+ requested_status: status,
963
+ worktree_path: session.worktree_path,
964
+ });
965
+ }
966
+ try {
967
+ reconcileRemovedWorktree(repoRoot, session.worktree_path, { sid: session.sid });
968
+ closeJournal.update(repoRoot, sid, {
969
+ phase: 'worktree_removed',
970
+ error: null,
971
+ });
972
+ closeJournal.discard(repoRoot, sid);
973
+ } catch (error) {
974
+ closeJournal.update(repoRoot, sid, {
975
+ phase: 'recovery_failed',
976
+ error: error.message,
977
+ });
978
+ throw error;
979
+ }
980
+ }
981
+ return {
982
+ sid,
983
+ status,
984
+ worktree_preserved: Boolean(
985
+ session.worktree_path && fs.existsSync(session.worktree_path),
986
+ ),
987
+ };
454
988
  }
455
- if (session.pid) {
456
- try { process.kill(session.pid, kill_signal); }
457
- catch (_) { /* already dead */ }
989
+ if ((remove_worktree || autoMerge) && session.worktree_path) {
990
+ assertManagedWorktreePath(repoRoot, session.worktree_path, { sid: session.sid });
991
+ }
992
+ let closeIntentPrepared = false;
993
+ if (remove_worktree && !autoMerge && repoRoot && session.worktree_path) {
994
+ closeJournal.prepare(repoRoot, {
995
+ sid,
996
+ task_id: session.task_id,
997
+ requested_status: status,
998
+ worktree_path: session.worktree_path,
999
+ });
1000
+ closeIntentPrepared = true;
458
1001
  }
459
- // Phase 7.3 — opt-in skill mining reads durable task/session events. It
460
- // cannot advance task or workflow state.
461
- if (mineSkill && skillsRoot) {
462
- try { skillMiner.mineSession(db, { sid, skillsRoot }); }
463
- catch (err) { process.stderr.write(`skill-miner error: ${err.message}\n`); }
1002
+ if (session.pid) {
1003
+ try {
1004
+ process.kill(session.pid, kill_signal);
1005
+ } catch (error) {
1006
+ if (error.code !== 'ESRCH') {
1007
+ throw new SessionRunnerError(
1008
+ 'SESSION_TERMINATION_FAILED',
1009
+ `cannot signal session ${sid} process ${session.pid}: ${error.message}`,
1010
+ { cause: error },
1011
+ );
1012
+ }
1013
+ }
1014
+ if (!waitForProcessExit(session.pid, {
1015
+ timeoutMs: kill_timeout_ms,
1016
+ pollMs: kill_poll_ms,
1017
+ })) {
1018
+ if (closeIntentPrepared) {
1019
+ closeJournal.update(repoRoot, sid, {
1020
+ phase: 'worker_running',
1021
+ error: `process ${session.pid} did not exit after ${kill_timeout_ms}ms`,
1022
+ });
1023
+ }
1024
+ throw new SessionRunnerError(
1025
+ 'SESSION_TERMINATION_TIMEOUT',
1026
+ `session ${sid} process ${session.pid} did not exit after ${kill_timeout_ms}ms; lease and worktree were preserved`,
1027
+ );
1028
+ }
1029
+ // The owned worker is gone. Clear its reusable OS identifier even if a
1030
+ // later merge or worktree gate keeps this close intent recoverable.
1031
+ ops.updateSession(db, sid, { pid: null });
464
1032
  }
465
1033
  // Phase 8B.4 — opt-in auto-merge. Conflict preserves worktree for the
466
1034
  // human; clean/no-op merges fall through to normal removal below.
@@ -505,9 +1073,40 @@ function closeSession({
505
1073
  }
506
1074
  }
507
1075
  }
1076
+ if (effectiveRemove && repoRoot && session.worktree_path) {
1077
+ const blocker = worktreeRemovalBlocker(repoRoot, session.worktree_path);
1078
+ if (blocker) {
1079
+ throw new SessionRunnerError(
1080
+ 'WORKTREE_NOT_INTEGRATED',
1081
+ `refusing to remove ${session.worktree_path}: ${blocker}`,
1082
+ );
1083
+ }
1084
+ if (!closeIntentPrepared) {
1085
+ closeJournal.prepare(repoRoot, {
1086
+ sid,
1087
+ task_id: session.task_id,
1088
+ requested_status: status,
1089
+ worktree_path: session.worktree_path,
1090
+ });
1091
+ closeIntentPrepared = true;
1092
+ }
1093
+ try {
1094
+ gitWorktreeRemove(repoRoot, session.worktree_path);
1095
+ closeJournal.update(repoRoot, sid, {
1096
+ phase: 'worktree_removed',
1097
+ error: null,
1098
+ });
1099
+ } catch (error) {
1100
+ closeJournal.update(repoRoot, sid, {
1101
+ phase: 'removal_failed',
1102
+ error: error.message,
1103
+ });
1104
+ throw error;
1105
+ }
1106
+ }
508
1107
  ops.updateSession(db, sid, { status });
509
1108
  if (effectiveRemove && repoRoot && session.worktree_path) {
510
- gitWorktreeRemove(repoRoot, session.worktree_path);
1109
+ closeJournal.discard(repoRoot, sid);
511
1110
  }
512
1111
  const out = {
513
1112
  sid,
@@ -533,21 +1132,32 @@ function attachHeartbeat(db, sid, { intervalMs = 30000 } = {}) {
533
1132
  module.exports = {
534
1133
  SessionRunnerError,
535
1134
  EXPECTED_EXECUTION_GATE_CODES,
1135
+ findExpectedExecutionGate,
536
1136
  isExpectedExecutionGate,
537
1137
  spawnSession,
1138
+ startSessionProcess,
538
1139
  closeSession,
539
1140
  attachHeartbeat,
1141
+ admissionCheck,
540
1142
  assertSessionTaskReady,
541
1143
  // exposed for tests
542
1144
  _internal: {
543
1145
  gitWorktreeAdd,
544
1146
  gitWorktreeRemove,
1147
+ gitWorktreeRegistry,
1148
+ reconcileRemovedWorktree,
545
1149
  mintSessionId,
546
1150
  authorityLinkIsIgnored,
547
1151
  ensureAuthorityLinkIgnored,
548
1152
  linkAuthorityIntoWorktree,
549
1153
  worktreeRemovalBlocker,
550
1154
  changeTaskIntegrationBlocker,
1155
+ processIsExecuting,
1156
+ waitForProcessExit,
1157
+ closeJournal,
551
1158
  resolveWorktreeBase,
1159
+ assertSessionId,
1160
+ assertManagedWorktreeRoot,
1161
+ assertManagedWorktreePath,
552
1162
  },
553
1163
  };