@cleocode/core 2026.10.25 → 2026.10.26-rc.ci.697

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 (198) hide show
  1. package/dist/build-provenance.json +2 -2
  2. package/dist/dispatch/contracts/output-contracts.d.ts +13 -0
  3. package/dist/dispatch/contracts/output-contracts.d.ts.map +1 -1
  4. package/dist/dispatch/contracts/output-contracts.js +29 -0
  5. package/dist/dispatch/contracts/output-contracts.js.map +1 -1
  6. package/dist/docs/docs-ops.d.ts +8 -0
  7. package/dist/docs/docs-ops.d.ts.map +1 -1
  8. package/dist/docs/docs-ops.js +24 -1
  9. package/dist/docs/docs-ops.js.map +1 -1
  10. package/dist/docs/docs-update.d.ts +8 -5
  11. package/dist/docs/docs-update.d.ts.map +1 -1
  12. package/dist/docs/docs-update.js +16 -5
  13. package/dist/docs/docs-update.js.map +1 -1
  14. package/dist/gateway-client/generated/types.gen.d.ts +27 -0
  15. package/dist/gateway-client/generated/types.gen.d.ts.map +1 -1
  16. package/dist/hooks/handlers/watchdog-hooks.d.ts.map +1 -1
  17. package/dist/hooks/handlers/watchdog-hooks.js +5 -0
  18. package/dist/hooks/handlers/watchdog-hooks.js.map +1 -1
  19. package/dist/internal.d.ts +3 -2
  20. package/dist/internal.d.ts.map +1 -1
  21. package/dist/internal.js +5 -2
  22. package/dist/internal.js.map +1 -1
  23. package/dist/lifecycle/verification/satisfies-validator.d.ts.map +1 -1
  24. package/dist/lifecycle/verification/satisfies-validator.js +22 -10
  25. package/dist/lifecycle/verification/satisfies-validator.js.map +1 -1
  26. package/dist/memory/brain-writer-thread.d.ts +30 -0
  27. package/dist/memory/brain-writer-thread.d.ts.map +1 -1
  28. package/dist/memory/brain-writer-thread.js +46 -1
  29. package/dist/memory/brain-writer-thread.js.map +1 -1
  30. package/dist/memory/brain-writer-worker.js +13 -2
  31. package/dist/memory/brain-writer-worker.js.map +1 -1
  32. package/dist/memory/embedding-backfill.d.ts +12 -0
  33. package/dist/memory/embedding-backfill.d.ts.map +1 -1
  34. package/dist/memory/embedding-backfill.js +51 -31
  35. package/dist/memory/embedding-backfill.js.map +1 -1
  36. package/dist/orchestrate/include-deps.d.ts +58 -0
  37. package/dist/orchestrate/include-deps.d.ts.map +1 -0
  38. package/dist/orchestrate/include-deps.js +138 -0
  39. package/dist/orchestrate/include-deps.js.map +1 -0
  40. package/dist/orchestrate/spawn-ops.d.ts +14 -33
  41. package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
  42. package/dist/orchestrate/spawn-ops.js +49 -61
  43. package/dist/orchestrate/spawn-ops.js.map +1 -1
  44. package/dist/orchestrate/worktree-complete.d.ts +15 -1
  45. package/dist/orchestrate/worktree-complete.d.ts.map +1 -1
  46. package/dist/orchestrate/worktree-complete.js +36 -3
  47. package/dist/orchestrate/worktree-complete.js.map +1 -1
  48. package/dist/release/ci-evidence.d.ts +142 -4
  49. package/dist/release/ci-evidence.d.ts.map +1 -1
  50. package/dist/release/ci-evidence.js +298 -48
  51. package/dist/release/ci-evidence.js.map +1 -1
  52. package/dist/release/open.d.ts +25 -1
  53. package/dist/release/open.d.ts.map +1 -1
  54. package/dist/release/open.js +63 -11
  55. package/dist/release/open.js.map +1 -1
  56. package/dist/resources/admission-ledger.d.ts +83 -11
  57. package/dist/resources/admission-ledger.d.ts.map +1 -1
  58. package/dist/resources/admission-ledger.js +138 -39
  59. package/dist/resources/admission-ledger.js.map +1 -1
  60. package/dist/resources/governor.js +1 -1
  61. package/dist/resources/governor.js.map +1 -1
  62. package/dist/resources/hook-footprint.d.ts +24 -0
  63. package/dist/resources/hook-footprint.d.ts.map +1 -1
  64. package/dist/resources/hook-footprint.js +38 -1
  65. package/dist/resources/hook-footprint.js.map +1 -1
  66. package/dist/resources/run-class.d.ts +38 -5
  67. package/dist/resources/run-class.d.ts.map +1 -1
  68. package/dist/resources/run-class.js +60 -11
  69. package/dist/resources/run-class.js.map +1 -1
  70. package/dist/resources/run-governed.d.ts +13 -3
  71. package/dist/resources/run-governed.d.ts.map +1 -1
  72. package/dist/resources/run-governed.js +42 -9
  73. package/dist/resources/run-governed.js.map +1 -1
  74. package/dist/resources/slot-holder.d.ts +3 -3
  75. package/dist/resources/slot-holder.js +3 -3
  76. package/dist/resources/tool-groups.d.ts +11 -7
  77. package/dist/resources/tool-groups.d.ts.map +1 -1
  78. package/dist/resources/tool-groups.js +24 -13
  79. package/dist/resources/tool-groups.js.map +1 -1
  80. package/dist/spawn/branch-integration.d.ts +281 -0
  81. package/dist/spawn/branch-integration.d.ts.map +1 -0
  82. package/dist/spawn/branch-integration.js +715 -0
  83. package/dist/spawn/branch-integration.js.map +1 -0
  84. package/dist/spawn/branch-lock.d.ts +32 -270
  85. package/dist/spawn/branch-lock.d.ts.map +1 -1
  86. package/dist/spawn/branch-lock.js +59 -770
  87. package/dist/spawn/branch-lock.js.map +1 -1
  88. package/dist/spawn/index.d.ts +3 -1
  89. package/dist/spawn/index.d.ts.map +1 -1
  90. package/dist/spawn/index.js +5 -1
  91. package/dist/spawn/index.js.map +1 -1
  92. package/dist/store/exodus/recovery.d.ts.map +1 -1
  93. package/dist/store/exodus/recovery.js +9 -4
  94. package/dist/store/exodus/recovery.js.map +1 -1
  95. package/dist/system/health.d.ts.map +1 -1
  96. package/dist/system/health.js +3 -1
  97. package/dist/system/health.js.map +1 -1
  98. package/dist/tasks/ac-coverage-gate.d.ts +18 -2
  99. package/dist/tasks/ac-coverage-gate.d.ts.map +1 -1
  100. package/dist/tasks/ac-coverage-gate.js +40 -6
  101. package/dist/tasks/ac-coverage-gate.js.map +1 -1
  102. package/dist/tasks/ac-table.d.ts.map +1 -1
  103. package/dist/tasks/ac-table.js +3 -10
  104. package/dist/tasks/ac-table.js.map +1 -1
  105. package/dist/tasks/affected-packages.d.ts.map +1 -1
  106. package/dist/tasks/affected-packages.js +54 -10
  107. package/dist/tasks/affected-packages.js.map +1 -1
  108. package/dist/tasks/affected-scope.d.ts +10 -1
  109. package/dist/tasks/affected-scope.d.ts.map +1 -1
  110. package/dist/tasks/affected-scope.js +13 -3
  111. package/dist/tasks/affected-scope.js.map +1 -1
  112. package/dist/tasks/change-set.d.ts +9 -6
  113. package/dist/tasks/change-set.d.ts.map +1 -1
  114. package/dist/tasks/change-set.js +75 -97
  115. package/dist/tasks/change-set.js.map +1 -1
  116. package/dist/tasks/command-line.d.ts.map +1 -1
  117. package/dist/tasks/command-line.js +6 -2
  118. package/dist/tasks/command-line.js.map +1 -1
  119. package/dist/tasks/complete.d.ts.map +1 -1
  120. package/dist/tasks/complete.js +7 -2
  121. package/dist/tasks/complete.js.map +1 -1
  122. package/dist/tasks/done-plan.d.ts.map +1 -1
  123. package/dist/tasks/done-plan.js +111 -29
  124. package/dist/tasks/done-plan.js.map +1 -1
  125. package/dist/tasks/done-record.d.ts +14 -0
  126. package/dist/tasks/done-record.d.ts.map +1 -1
  127. package/dist/tasks/done-record.js +17 -10
  128. package/dist/tasks/done-record.js.map +1 -1
  129. package/dist/tasks/evidence-tree.d.ts +33 -0
  130. package/dist/tasks/evidence-tree.d.ts.map +1 -0
  131. package/dist/tasks/evidence-tree.js +132 -0
  132. package/dist/tasks/evidence-tree.js.map +1 -0
  133. package/dist/tasks/evidence.d.ts +42 -6
  134. package/dist/tasks/evidence.d.ts.map +1 -1
  135. package/dist/tasks/evidence.js +175 -79
  136. package/dist/tasks/evidence.js.map +1 -1
  137. package/dist/tasks/gate-preview.d.ts.map +1 -1
  138. package/dist/tasks/gate-preview.js +3 -2
  139. package/dist/tasks/gate-preview.js.map +1 -1
  140. package/dist/tasks/gate-runner.d.ts +11 -3
  141. package/dist/tasks/gate-runner.d.ts.map +1 -1
  142. package/dist/tasks/gate-runner.js +156 -37
  143. package/dist/tasks/gate-runner.js.map +1 -1
  144. package/dist/tasks/gate-staleness.d.ts +58 -0
  145. package/dist/tasks/gate-staleness.d.ts.map +1 -0
  146. package/dist/tasks/gate-staleness.js +78 -0
  147. package/dist/tasks/gate-staleness.js.map +1 -0
  148. package/dist/tasks/heavy-tool-env.d.ts +26 -1
  149. package/dist/tasks/heavy-tool-env.d.ts.map +1 -1
  150. package/dist/tasks/heavy-tool-env.js +92 -5
  151. package/dist/tasks/heavy-tool-env.js.map +1 -1
  152. package/dist/tasks/heavy-tool-limit.d.ts +24 -0
  153. package/dist/tasks/heavy-tool-limit.d.ts.map +1 -1
  154. package/dist/tasks/heavy-tool-limit.js +44 -1
  155. package/dist/tasks/heavy-tool-limit.js.map +1 -1
  156. package/dist/tasks/show.d.ts.map +1 -1
  157. package/dist/tasks/show.js +43 -0
  158. package/dist/tasks/show.js.map +1 -1
  159. package/dist/tasks/test-run-binding.d.ts +48 -3
  160. package/dist/tasks/test-run-binding.d.ts.map +1 -1
  161. package/dist/tasks/test-run-binding.js +111 -3
  162. package/dist/tasks/test-run-binding.js.map +1 -1
  163. package/dist/tasks/tool-cache.d.ts +58 -7
  164. package/dist/tasks/tool-cache.d.ts.map +1 -1
  165. package/dist/tasks/tool-cache.js +216 -16
  166. package/dist/tasks/tool-cache.js.map +1 -1
  167. package/dist/tasks/tool-resolver.d.ts +13 -0
  168. package/dist/tasks/tool-resolver.d.ts.map +1 -1
  169. package/dist/tasks/tool-resolver.js +34 -0
  170. package/dist/tasks/tool-resolver.js.map +1 -1
  171. package/dist/tasks/tool-runner-guard.d.ts +6 -0
  172. package/dist/tasks/tool-runner-guard.d.ts.map +1 -1
  173. package/dist/tasks/tool-runner-guard.js.map +1 -1
  174. package/dist/tasks/tool-semaphore.js +1 -1
  175. package/dist/tasks/tool-semaphore.js.map +1 -1
  176. package/dist/tasks/verification-policy.d.ts +26 -3
  177. package/dist/tasks/verification-policy.d.ts.map +1 -1
  178. package/dist/tasks/verification-policy.js +76 -11
  179. package/dist/tasks/verification-policy.js.map +1 -1
  180. package/dist/validation/doctor/checks.d.ts +12 -0
  181. package/dist/validation/doctor/checks.d.ts.map +1 -1
  182. package/dist/validation/doctor/checks.js +33 -0
  183. package/dist/validation/doctor/checks.js.map +1 -1
  184. package/dist/validation/engine-ops.d.ts +6 -0
  185. package/dist/validation/engine-ops.d.ts.map +1 -1
  186. package/dist/validation/engine-ops.js +51 -1
  187. package/dist/validation/engine-ops.js.map +1 -1
  188. package/dist/worktree/force-unlock.js +2 -2
  189. package/dist/worktree/force-unlock.js.map +1 -1
  190. package/dist/worktree/list.d.ts +7 -7
  191. package/dist/worktree/list.d.ts.map +1 -1
  192. package/dist/worktree/list.js +11 -56
  193. package/dist/worktree/list.js.map +1 -1
  194. package/dist/worktree/prune.d.ts +2 -2
  195. package/dist/worktree/prune.d.ts.map +1 -1
  196. package/dist/worktree/prune.js +4 -4
  197. package/dist/worktree/prune.js.map +1 -1
  198. package/package.json +13 -13
@@ -20,19 +20,16 @@
20
20
  * @adr ADR-062
21
21
  */
22
22
  import { execFileSync } from 'node:child_process';
23
- import { accessSync, appendFileSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, } from 'node:fs';
23
+ import { accessSync, appendFileSync, chmodSync, constants, existsSync, mkdirSync, realpathSync, } from 'node:fs';
24
24
  import { platform } from 'node:os';
25
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
25
+ import { dirname, isAbsolute, join, relative } from 'node:path';
26
26
  import { installGitShimLaunchers, resolveShimBinPath } from '@cleocode/git-shim';
27
27
  import { computeProjectHash, findOnPath, pathEnvKey, prependPathEntry, resolveWorktreeRootForHash, } from '@cleocode/paths';
28
28
  // ---------------------------------------------------------------------------
29
29
  // Re-exports from @cleocode/worktree
30
30
  // ---------------------------------------------------------------------------
31
- import { acquireWorktreeTaskLock, describeDisposalBlockers, getGitRoot, gitSilent, gitSync, guardWorktreeDisposal, integrateWorktree, isCleoProvisionedWorktree, napiDestroyWorktree, pruneWorktrees, releaseWorktreeTaskLock, WORKTREE_LIFECYCLE_AUDIT_FILE, } from '@cleocode/worktree';
32
- import { appendWorktreeAuditEntry, resolveWorktreeAuditActor } from '../worktree/audit.js';
31
+ import { createWorktree, describeDisposalBlockers, forceRemoveWorktreeDir, getGitRoot, gitSync, guardWorktreeDisposal, locateTaskWorktree, napiDestroyWorktree, pruneWorktrees, readWorktreeProvisioning, releaseWorktreeTaskLock, } from '@cleocode/worktree';
33
32
  import { resolveSpawnLockHolder } from './worktree-lock-holder.js';
34
- // Re-export getGitRoot for barrel consumers
35
- export { getGitRoot };
36
33
  // ---------------------------------------------------------------------------
37
34
  // L1 — Worktree lifecycle
38
35
  // ---------------------------------------------------------------------------
@@ -62,11 +59,51 @@ export function resolveAgentWorktreeRoot(projectRoot) {
62
59
  const projectHash = computeProjectHash(projectRoot);
63
60
  return resolveWorktreeRootForHash(projectHash);
64
61
  }
62
+ /**
63
+ * Read a task's worktree lifecycle state through one typed model (T10871):
64
+ * the worktree git has registered for the task (its path, branch and lock),
65
+ * else the canonical path; how CLEO provisioned it (newest `create`/`adopt`
66
+ * lifecycle row); and whether it has uncommitted changes. Read-only.
67
+ *
68
+ * @param projectRoot - Absolute path to the project root.
69
+ * @param taskId - Task whose worktree is read.
70
+ * @returns The state; `registered`/`exists` false when there is no worktree.
71
+ *
72
+ * @task T10871
73
+ */
74
+ export function readWorktreeLifecycleState(projectRoot, taskId) {
75
+ const located = locateTaskWorktree(projectRoot, taskId);
76
+ const path = located?.path ?? join(resolveAgentWorktreeRoot(projectRoot), taskId);
77
+ const exists = existsSync(path);
78
+ let dirty = false;
79
+ if (exists) {
80
+ try {
81
+ dirty = gitSync(['status', '--porcelain'], path) !== '';
82
+ }
83
+ catch {
84
+ // Not a readable checkout: report clean rather than guess.
85
+ }
86
+ }
87
+ return {
88
+ taskId,
89
+ path,
90
+ branch: located?.branch ?? `task/${taskId}`,
91
+ registered: located !== null,
92
+ exists,
93
+ provisioning: readWorktreeProvisioning(projectRoot, taskId, path),
94
+ locked: located?.locked ?? false,
95
+ dirty,
96
+ };
97
+ }
65
98
  /**
66
99
  * Create a git worktree for a spawned agent task.
67
100
  *
68
- * Creates branch `task/<taskId>` off the current HEAD of the orchestrator's
69
- * branch and locks the worktree to prevent accidental pruning.
101
+ * Delegates to the `@cleocode/worktree` facade ({@link createWorktree}), which
102
+ * owns `git worktree add`/`lock`, the per-task lock, the lifecycle audit row
103
+ * (`create`, or `adopt` for a re-attached worktree) and the sentinel index
104
+ * (T10858). Branch `task/<taskId>` is cut from the orchestrator's HEAD; an
105
+ * existing worktree for the task is re-attached, never destroyed. No
106
+ * `.worktreeinclude` copy or dependency install runs, as before.
70
107
  *
71
108
  * @param taskId - The task ID driving the spawn.
72
109
  * @param projectRoot - Absolute path to the project root.
@@ -76,75 +113,23 @@ export function resolveAgentWorktreeRoot(projectRoot) {
76
113
  * @task T1120
77
114
  * @task T11122
78
115
  * @task T11123
116
+ * @task T10858
79
117
  */
80
- export function createAgentWorktree(taskId, projectRoot) {
81
- const gitRoot = getGitRoot(projectRoot);
82
- const worktreeRoot = resolveAgentWorktreeRoot(projectRoot);
83
- mkdirSync(worktreeRoot, { recursive: true });
84
- const branch = `task/${taskId}`;
85
- const worktreePath = join(worktreeRoot, taskId);
86
- // Determine base ref — current HEAD on orchestrator branch.
87
- let baseRef;
88
- try {
89
- baseRef = gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], gitRoot);
90
- }
91
- catch {
92
- baseRef = 'main';
93
- }
94
- // T12506: take the per-task worktree lock first (E_WORKTREE_LOCKED when a
95
- // live holder owns it), and NEVER destroy an existing worktree — the prior
96
- // force-remove + `branch -D` deleted live agents' work whenever their
97
- // changes were gitignored or already committed. Re-attach it instead.
98
- const projectHashForLock = computeProjectHash(projectRoot);
99
- acquireWorktreeTaskLock({
100
- projectHash: projectHashForLock,
118
+ export async function createAgentWorktree(taskId, projectRoot) {
119
+ const result = await createWorktree(projectRoot, {
101
120
  taskId,
102
121
  holder: resolveSpawnLockHolder(),
122
+ applyIncludePatterns: false,
123
+ skipInstall: true,
103
124
  });
104
- if (existsSync(worktreePath)) {
105
- return {
106
- path: worktreePath,
107
- branch,
108
- taskId,
109
- baseRef,
110
- projectHash: projectHashForLock,
111
- createdAt: new Date().toISOString(),
112
- locked: true,
113
- };
114
- }
115
- // Create the worktree with a new branch.
116
- gitSync(['worktree', 'add', worktreePath, '-b', branch, baseRef], gitRoot); // raw-git-worktree-ok: legacy branch-lock provisioning pending full SDK promotion
117
- // T14053: completion merges only worktrees whose creation is recorded.
118
- appendWorktreeAuditEntry(projectRoot, {
119
- actor: resolveWorktreeAuditActor(),
120
- action: 'create',
121
- target: worktreePath,
122
- branch,
123
- taskId,
124
- reason: 'spawn',
125
- success: true,
126
- });
127
- // Apply git worktree lock to prevent accidental pruning.
128
- // Try with --reason first (git ≥ 2.37), fall back without.
129
- if (!gitSilent([
130
- 'worktree' /* raw-git-worktree-ok: legacy branch-lock provisioning pending full SDK promotion */,
131
- 'lock',
132
- '--reason',
133
- `cleo-agent-${taskId}`,
134
- worktreePath,
135
- ], gitRoot)) {
136
- gitSilent(['worktree', 'lock', worktreePath], gitRoot); // raw-git-worktree-ok: legacy git compatibility fallback without --reason
137
- }
138
- // T9984: route projectHash through @cleocode/paths SSoT.
139
- const projectHash = computeProjectHash(projectRoot);
140
125
  return {
141
- path: worktreePath,
142
- branch,
126
+ path: result.path,
127
+ branch: result.branch,
143
128
  taskId,
144
- baseRef,
145
- projectHash,
146
- createdAt: new Date().toISOString(),
147
- locked: true,
129
+ baseRef: result.baseRef,
130
+ projectHash: result.projectHash,
131
+ createdAt: result.createdAt,
132
+ locked: result.locked,
148
133
  };
149
134
  }
150
135
  /**
@@ -156,7 +141,7 @@ export function createAgentWorktree(taskId, projectRoot) {
156
141
  * @param value - Raw path.
157
142
  * @returns The single-quoted shell word.
158
143
  */
159
- function shellQuote(value) {
144
+ export function shellQuote(value) {
160
145
  return `'${value.replace(/'/g, `'\\''`)}'`;
161
146
  }
162
147
  /**
@@ -439,12 +424,9 @@ export function pruneWorktree(taskId, projectRoot, opts = {}) {
439
424
  // napi failed — fall through to filesystem removal
440
425
  }
441
426
  if (!worktreeRemoved) {
442
- // Filesystem fallback: unlock via git, then brute-force rmSync.
443
- gitSilent(['worktree', 'unlock', worktreePath], gitRoot); // raw-git-worktree-ok: cleanup fallback after NAPI destroy reports incomplete removal
427
+ // Filesystem fallback (T10858: owned by the worktree facade).
444
428
  try {
445
- rmSync(worktreePath, { recursive: true, force: true });
446
- // Prune stale git admin entries.
447
- gitSilent(['worktree', 'prune'], gitRoot); // raw-git-worktree-ok: cleanup fallback after filesystem removal of stale worktree
429
+ forceRemoveWorktreeDir(gitRoot, worktreePath);
448
430
  worktreeRemoved = true;
449
431
  }
450
432
  catch (err) {
@@ -498,699 +480,6 @@ export function pruneWorktree(taskId, projectRoot, opts = {}) {
498
480
  }
499
481
  return { taskId, status: 'pruned', worktreeRemoved, branchDeleted, wasDirty };
500
482
  }
501
- // ---------------------------------------------------------------------------
502
- // Project-agnostic default-branch resolution (ADR-062 / T1587)
503
- // ---------------------------------------------------------------------------
504
- /**
505
- * Probe order for default-branch fallback when neither config nor
506
- * `origin/HEAD` resolves. Order matches industry convention frequency.
507
- */
508
- const DEFAULT_BRANCH_PROBE_ORDER = [
509
- 'main',
510
- 'master',
511
- 'develop',
512
- 'trunk',
513
- ];
514
- /**
515
- * Resolve the project's default integration branch in a project-agnostic way.
516
- *
517
- * Resolution order (per ADR-062):
518
- *
519
- * 1. `.cleo/config.json::git.defaultBranch` (explicit override).
520
- * 2. `git symbolic-ref refs/remotes/origin/HEAD` (what the remote calls
521
- * default — works for `master`, `main`, `trunk`, etc.).
522
- * 3. Probe local branches in order: `main`, `master`, `develop`, `trunk`.
523
- * 4. Fallback to `'main'`.
524
- *
525
- * @param projectRoot - Absolute path to the project root.
526
- * @returns The resolved default branch name (never throws).
527
- *
528
- * @task T1587
529
- * @adr ADR-062
530
- */
531
- export function getDefaultBranch(projectRoot) {
532
- // (1) .cleo/config.json override.
533
- const configPath = join(projectRoot, '.cleo', 'config.json');
534
- if (existsSync(configPath)) {
535
- try {
536
- const raw = readFileSync(configPath, 'utf-8');
537
- const cfg = JSON.parse(raw);
538
- const fromCfg = cfg.git?.defaultBranch;
539
- if (typeof fromCfg === 'string' && fromCfg.length > 0) {
540
- return fromCfg;
541
- }
542
- }
543
- catch {
544
- // malformed config — fall through.
545
- }
546
- }
547
- let gitRoot;
548
- try {
549
- gitRoot = getGitRoot(projectRoot);
550
- }
551
- catch {
552
- return 'main';
553
- }
554
- // (2) origin/HEAD.
555
- try {
556
- const ref = gitSync(['symbolic-ref', 'refs/remotes/origin/HEAD'], gitRoot);
557
- const stripped = ref.replace(/^refs\/remotes\/origin\//, '').trim();
558
- if (stripped.length > 0)
559
- return stripped;
560
- }
561
- catch {
562
- // origin/HEAD not set — fall through.
563
- }
564
- // (3) probe local branches.
565
- for (const candidate of DEFAULT_BRANCH_PROBE_ORDER) {
566
- try {
567
- const out = gitSync(['branch', '--list', candidate], gitRoot);
568
- if (out.length > 0)
569
- return candidate;
570
- }
571
- catch {
572
- /* ignore — try next */
573
- }
574
- }
575
- // (4) last-resort fallback.
576
- return 'main';
577
- }
578
- /**
579
- * Default timeout for the upstream `git fetch` in
580
- * {@link assessUpstreamIntegration}. Short on purpose: the fetch is
581
- * best-effort, and a hung network or credential prompt must never stall
582
- * `cleo done` for the 180s generic git timeout.
583
- *
584
- * @task T12773
585
- */
586
- export const UPSTREAM_FETCH_TIMEOUT_MS = 20_000;
587
- /**
588
- * ssh options for the upstream fetch: batch mode (no prompt), a bounded
589
- * connect, and keepalives so a stalled connection fails fast.
590
- */
591
- const SSH_NON_INTERACTIVE_OPTS = '-oBatchMode=yes -oConnectTimeout=10 -oServerAliveInterval=5';
592
- /**
593
- * Environment for a non-interactive `git fetch`: no terminal credential
594
- * prompt, and ssh with {@link SSH_NON_INTERACTIVE_OPTS} (an existing
595
- * `GIT_SSH_COMMAND` is preserved with the options appended).
596
- */
597
- function nonInteractiveGitEnv() {
598
- const existingSsh = process.env['GIT_SSH_COMMAND']?.trim();
599
- return {
600
- ...process.env,
601
- GIT_TERMINAL_PROMPT: '0',
602
- GIT_SSH_COMMAND: `${existingSsh || 'ssh'} ${SSH_NON_INTERACTIVE_OPTS}`,
603
- };
604
- }
605
- /**
606
- * Best-effort, bounded, non-interactive `git fetch <remote> <branch>`.
607
- *
608
- * @returns true when git exited 0; false on failure, offline, or timeout.
609
- */
610
- function fetchUpstreamBranch(gitRoot, remote, branch, timeoutMs) {
611
- try {
612
- execFileSync('git', ['fetch', '--quiet', remote, branch], {
613
- cwd: gitRoot,
614
- // No pipes: a timed-out git's surviving ssh grandchild must not hold
615
- // stdout/stderr open and keep this synchronous call waiting.
616
- stdio: 'ignore',
617
- timeout: timeoutMs,
618
- // Known small window: a SIGKILL landing while git updates the
619
- // remote-tracking ref can leave `refs/remotes/<remote>/<branch>.lock`
620
- // behind, which makes the next fetch of that ref fail until the stale
621
- // lock file is removed. SSH connect/keepalive bounds (above) make a
622
- // timeout — and so this kill — rare.
623
- killSignal: 'SIGKILL',
624
- env: nonInteractiveGitEnv(),
625
- });
626
- return true;
627
- }
628
- catch {
629
- return false;
630
- }
631
- }
632
- /**
633
- * `git rev-list --count <range>`, or 0 when git fails.
634
- */
635
- function revListCount(gitRoot, range) {
636
- try {
637
- const n = Number.parseInt(gitSync(['rev-list', '--count', range], gitRoot), 10);
638
- return Number.isFinite(n) ? n : 0;
639
- }
640
- catch {
641
- return 0;
642
- }
643
- }
644
- /**
645
- * True when the worktree at `worktreePath` has uncommitted tracked or
646
- * untracked changes. An unreadable status counts as dirty (never discard
647
- * work on uncertainty).
648
- */
649
- function isWorktreeDirty(worktreePath) {
650
- try {
651
- return gitSync(['status', '--porcelain'], worktreePath).length > 0;
652
- }
653
- catch {
654
- return true;
655
- }
656
- }
657
- /**
658
- * True when merging `branch` into `upstreamRef` would leave the upstream tree
659
- * unchanged — the branch's changes already landed (e.g. a squash-merged PR,
660
- * whose commits are never ancestors of upstream). Uses `git merge-tree
661
- * --write-tree`; older git or a conflict yields false.
662
- */
663
- function branchChangesContainedIn(gitRoot, branch, upstreamRef) {
664
- try {
665
- const merged = gitSync(['merge-tree', '--write-tree', upstreamRef, branch], gitRoot)
666
- .split('\n')[0]
667
- ?.trim();
668
- const upstreamTree = gitSync(['rev-parse', `${upstreamRef}^{tree}`], gitRoot);
669
- return merged !== undefined && merged.length > 0 && merged === upstreamTree;
670
- }
671
- catch {
672
- return false;
673
- }
674
- }
675
- /**
676
- * Decide whether a local ADR-062 `--no-ff` integration of `branch` into
677
- * `targetBranch` is safe, by comparing against the upstream default branch
678
- * (`<remote>/<targetBranch>`).
679
- *
680
- * T12773: `cleo done --pr <n>` for a task whose PR had already merged on
681
- * origin ran `git merge --no-ff task/<id>` into a local `main` that was 199
682
- * commits behind origin, creating a local merge commit that forked `main`
683
- * from `origin/main`. This check runs BEFORE any checkout or merge:
684
- *
685
- * - branch has no commits beyond the local default branch → `nothing`.
686
- * - branch already on upstream → `landed`; when the local default branch is
687
- * behind, the hint names the exact sync command. The checkout is never
688
- * moved (hint-only).
689
- * - local default branch behind upstream → `stale-target`; a distinct hint
690
- * when it is also ahead (diverged: unpushed local commits).
691
- * - otherwise (no remote, no upstream ref, current target) → `proceed`.
692
- *
693
- * The fetch is best-effort, non-interactive (`GIT_TERMINAL_PROMPT=0`, ssh
694
- * `BatchMode`) and bounded by {@link UPSTREAM_FETCH_TIMEOUT_MS}; offline, the
695
- * last-known remote-tracking ref is used. Never creates a commit and never
696
- * moves any ref other than the remote-tracking one the fetch updates.
697
- *
698
- * @param gitRoot - Absolute git root of the project checkout.
699
- * @param branch - Task branch (e.g. `task/T123`).
700
- * @param targetBranch - Resolved default branch (e.g. `main`).
701
- * @param opts.skipFetch - Skip `git fetch <remote> <targetBranch>` (fixtures).
702
- * @param opts.remote - Remote name (default `origin`).
703
- * @param opts.fetchTimeoutMs - Fetch timeout (default {@link UPSTREAM_FETCH_TIMEOUT_MS}).
704
- * @returns The assessment.
705
- *
706
- * @task T12773
707
- * @adr ADR-062
708
- */
709
- export function assessUpstreamIntegration(gitRoot, branch, targetBranch, opts = {}) {
710
- const remote = opts.remote ?? 'origin';
711
- const proceed = {
712
- kind: 'proceed',
713
- upstreamRef: null,
714
- fetched: false,
715
- behind: 0,
716
- ahead: 0,
717
- syncCommand: '',
718
- hint: '',
719
- };
720
- if (!gitSilent(['remote', 'get-url', remote], gitRoot))
721
- return proceed;
722
- const fetched = !opts.skipFetch;
723
- if (fetched) {
724
- // Best-effort: offline must not block an otherwise-local integration.
725
- fetchUpstreamBranch(gitRoot, remote, targetBranch, opts.fetchTimeoutMs ?? UPSTREAM_FETCH_TIMEOUT_MS);
726
- }
727
- const upstreamRef = `refs/remotes/${remote}/${targetBranch}`;
728
- const upstreamShort = `${remote}/${targetBranch}`;
729
- if (!gitSilent(['rev-parse', '--verify', '--quiet', upstreamRef], gitRoot)) {
730
- return { ...proceed, fetched };
731
- }
732
- const localRef = `refs/heads/${targetBranch}`;
733
- const hasLocal = gitSilent(['rev-parse', '--verify', '--quiet', localRef], gitRoot);
734
- const behind = hasLocal ? revListCount(gitRoot, `${localRef}..${upstreamRef}`) : 0;
735
- const ahead = hasLocal ? revListCount(gitRoot, `${upstreamRef}..${localRef}`) : 0;
736
- const base = { upstreamRef, fetched, behind, ahead };
737
- // Single-quoted for the shell: macOS roots often contain spaces, and branch
738
- // / remote names are operator-controlled.
739
- const quotedRoot = shellQuote(gitRoot);
740
- const quotedTarget = shellQuote(targetBranch);
741
- const quotedUpstream = shellQuote(upstreamShort);
742
- // Diverged (ahead AND behind): merge, never `pull --rebase` — a rebase
743
- // would drop the local ADR-062 `--no-ff` task merge commits and rewrite
744
- // agent SHAs. Behind only: fast-forward.
745
- const syncCommand = behind === 0
746
- ? ''
747
- : ahead > 0
748
- ? `git -C ${quotedRoot} switch ${quotedTarget} && git -C ${quotedRoot} merge ${quotedUpstream}`
749
- : `git -C ${quotedRoot} switch ${quotedTarget} && git -C ${quotedRoot} merge --ff-only ${quotedUpstream}`;
750
- const syncState = behind === 0
751
- ? ''
752
- : ahead > 0
753
- ? `local '${targetBranch}' has ${ahead} unpushed commit(s) and is ${behind} behind ${upstreamShort}`
754
- : `local '${targetBranch}' is ${behind} commit(s) behind ${upstreamShort}`;
755
- // A branch with no commits beyond the local default branch has nothing to
756
- // integrate — it trivially "is an ancestor of upstream", which must not be
757
- // reported as a landed PR.
758
- if (hasLocal && revListCount(gitRoot, `${localRef}..${branch}`) === 0) {
759
- return {
760
- ...base,
761
- kind: 'nothing',
762
- syncCommand,
763
- hint: `'${branch}' has no commits beyond local '${targetBranch}'; nothing to integrate.`,
764
- };
765
- }
766
- const landed = gitSilent(['merge-base', '--is-ancestor', branch, upstreamRef], gitRoot) ||
767
- branchChangesContainedIn(gitRoot, branch, upstreamRef);
768
- if (landed) {
769
- return {
770
- ...base,
771
- kind: 'landed',
772
- syncCommand,
773
- hint: behind === 0
774
- ? `'${branch}' already landed on ${upstreamShort}; no local merge was made.`
775
- : `'${branch}' already landed on ${upstreamShort}; no local merge was made and your checkout was not moved. ${syncState}; to sync it run: ${syncCommand}`,
776
- };
777
- }
778
- if (behind > 0) {
779
- const rerun = `re-run \`cleo orchestrate worktree-complete ${branch.replace(/^task\//, '')}\``;
780
- return {
781
- ...base,
782
- kind: 'stale-target',
783
- syncCommand,
784
- hint: ahead > 0
785
- ? `${syncState}: sync it with \`${syncCommand}\` (a merge, not a rebase, so the local --no-ff task merges keep their SHAs), then ${rerun}. A task merge now would fork '${targetBranch}' further from ${upstreamShort}; alternatively land '${branch}' through a PR.`
786
- : `${syncState}, so a merge there would fork it from ${upstreamShort}. Land '${branch}' through a PR, or sync first (${syncCommand}) and ${rerun}.`,
787
- };
788
- }
789
- return { ...proceed, ...base };
790
- }
791
- /**
792
- * Why a local merge must not run in the main checkout at `gitRoot`, or null
793
- * when it may (T14053). The merge checks out `targetBranch` there, so the
794
- * checkout must already be on it with no uncommitted tracked changes, and must
795
- * be the main checkout rather than a linked worktree (another task's). CLEO
796
- * never switches a checkout's branch.
797
- */
798
- function mainCheckoutRefusal(gitRoot, branch, targetBranch) {
799
- let linked;
800
- try {
801
- const gitDir = gitSync(['rev-parse', '--absolute-git-dir'], gitRoot);
802
- const commonDir = resolve(gitRoot, gitSync(['rev-parse', '--git-common-dir'], gitRoot));
803
- linked = realpathSync(gitDir) !== realpathSync(commonDir);
804
- }
805
- catch (err) {
806
- return `could not inspect the main checkout (${err instanceof Error ? err.message : String(err)}), so '${branch}' is not merged.`;
807
- }
808
- if (linked) {
809
- return `'${gitRoot}' is a linked worktree, not the main checkout; CLEO merges '${branch}' only from the project's main checkout.`;
810
- }
811
- let head;
812
- try {
813
- head = gitSync(['symbolic-ref', '--quiet', '--short', 'HEAD'], gitRoot);
814
- }
815
- catch {
816
- head = '(detached HEAD)';
817
- }
818
- if (head !== targetBranch) {
819
- return `the main checkout is on '${head}', not '${targetBranch}', and CLEO does not switch it; switch it yourself and run \`cleo orchestrate worktree-complete\`, or land '${branch}' through a PR.`;
820
- }
821
- let dirty;
822
- try {
823
- dirty = gitSync(['status', '--porcelain', '--untracked-files=no'], gitRoot).length > 0;
824
- }
825
- catch {
826
- dirty = true;
827
- }
828
- if (dirty) {
829
- return `the main checkout has uncommitted changes, so CLEO does not merge into it; commit or stash them and run \`cleo orchestrate worktree-complete\`, or land '${branch}' through a PR.`;
830
- }
831
- return null;
832
- }
833
- /**
834
- * Decide what completion does with a task's worktree branch (T14053): the one
835
- * decision both `cleo done --plan` and the merge run use. Checks, in order:
836
- * switched off; not a git repo; nothing to integrate; not provisioned by CLEO
837
- * (a hand-made `task/<id>` branch is never merged); already landed upstream or
838
- * a stale local target (T12773); a main checkout a merge would disturb.
839
- * Only reads git, plus the upstream fetch unless `skipFetch`.
840
- */
841
- function decideWorktreeIntegration(taskId, projectRoot, opts) {
842
- const branch = `task/${taskId}`;
843
- const worktreePath = join(resolveAgentWorktreeRoot(projectRoot), taskId);
844
- const targetBranch = opts.targetBranch ?? getDefaultBranch(projectRoot);
845
- const base = { branch, worktreePath, targetBranch, gitRoot: null, upstream: null };
846
- if (opts.disabledReason) {
847
- return { ...base, rule: 'disabled', action: 'skip', reason: opts.disabledReason };
848
- }
849
- let gitRoot;
850
- try {
851
- gitRoot = getGitRoot(projectRoot);
852
- }
853
- catch (err) {
854
- const message = err instanceof Error ? err.message : String(err);
855
- return { ...base, rule: 'no-git', action: 'skip', reason: `Not a git repo: ${message}` };
856
- }
857
- const at = { ...base, gitRoot };
858
- // T12153 (GH #1223) — most tasks are worked on a feature branch and merged
859
- // by PR, so no `task/<id>` branch and no agent worktree ever exist. That is
860
- // "nothing to integrate", not a failure.
861
- const branchExists = gitSync(['branch', '--list', branch], gitRoot).trim().length > 0;
862
- if (!branchExists && !existsSync(worktreePath)) {
863
- return {
864
- ...at,
865
- rule: 'nothing',
866
- action: 'skip',
867
- reason: `no task branch '${branch}' and no worktree — nothing to integrate`,
868
- };
869
- }
870
- if (!isCleoProvisionedWorktree(projectRoot, taskId, worktreePath)) {
871
- return {
872
- ...at,
873
- rule: 'not-provisioned',
874
- action: 'skip',
875
- reason: `'${branch}' was not provisioned by CLEO (no create or adopt for ${worktreePath} in ${WORKTREE_LIFECYCLE_AUDIT_FILE}), so it is not merged; land it through a PR.`,
876
- };
877
- }
878
- // T12773 — never merge locally what already landed upstream, and never
879
- // merge into a local default branch that is behind origin.
880
- const upstream = branchExists
881
- ? assessUpstreamIntegration(gitRoot, branch, targetBranch, {
882
- skipFetch: opts.skipFetch ?? false,
883
- fetchTimeoutMs: opts.fetchTimeoutMs,
884
- })
885
- : null;
886
- const known = { ...at, upstream };
887
- if (upstream?.kind === 'landed' || upstream?.kind === 'nothing') {
888
- return { ...known, rule: 'upstream', action: 'skip', reason: upstream.hint };
889
- }
890
- if (upstream?.kind === 'stale-target') {
891
- return {
892
- ...known,
893
- rule: 'upstream',
894
- action: 'refuse',
895
- reason: `refusing local merge of '${branch}' into '${targetBranch}': ${upstream.hint}`,
896
- };
897
- }
898
- const refusal = mainCheckoutRefusal(gitRoot, branch, targetBranch);
899
- if (refusal !== null)
900
- return { ...known, rule: 'root', action: 'refuse', reason: refusal };
901
- return {
902
- ...known,
903
- rule: 'merge',
904
- action: 'merge',
905
- reason: `merge '${branch}' into local '${targetBranch}' with --no-ff (never pushed)`,
906
- };
907
- }
908
- /**
909
- * Preview what completing `taskId` does with its worktree branch, without
910
- * changing git: the same decision the merge run makes, from local refs only
911
- * (no fetch). Shown by `cleo done --plan` (T14053).
912
- *
913
- * @param taskId - The CLEO task ID.
914
- * @param projectRoot - Absolute path to the project root.
915
- * @param opts - Target override and the switched-off reason, if any.
916
- * @returns Branch, worktree path, target branch, `merge`/`skip`/`refuse` and why.
917
- *
918
- * @example
919
- * ```ts
920
- * planWorktreeIntegration('T123', root).action; // 'skip' for a task merged by PR
921
- * ```
922
- * @task T14053
923
- */
924
- export function planWorktreeIntegration(taskId, projectRoot, opts = {}) {
925
- const { branch, worktreePath, targetBranch, action, reason } = decideWorktreeIntegration(taskId, projectRoot, { ...opts, skipFetch: true });
926
- return { branch, worktreePath, targetBranch, action, reason };
927
- }
928
- /**
929
- * Complete a worker task's worktree via `git merge --no-ff` (ADR-062).
930
- *
931
- * Canonical worktree integration per ADR-062. Preserves the full agent
932
- * commit graph instead of rewriting SHAs, so `git log --grep "T<id>"`
933
- * returns the originating commits with their original authorship.
934
- *
935
- * Steps performed inside the worktree at
936
- * `~/.local/share/cleo/worktrees/<projectHash>/<taskId>/`:
937
- *
938
- * 1. Resolve target branch via {@link getDefaultBranch} (or `opts.targetBranch`
939
- * override). NEVER hardcodes "main".
940
- * 1. T14053: {@link decideWorktreeIntegration} first — the same decision
941
- * `cleo done --plan` shows. A branch CLEO did not provision is skipped
942
- * (`notProvisioned`), never merged; a main checkout on another branch,
943
- * with uncommitted tracked changes, or a linked worktree is refused
944
- * (`rootRefused`) without switching or merging anything.
945
- * 1a. T12773: {@link assessUpstreamIntegration} — when `task/<id>` already
946
- * landed on `origin/<targetBranch>` the merge is skipped
947
- * (`landedUpstream: true`); when it has no commits beyond the local
948
- * target it is `nothingToIntegrate` without `landedUpstream`; when the
949
- * local target is behind (or diverged from) origin the merge is refused
950
- * (`staleTarget: true`) with a `hint`. None of these paths creates a
951
- * commit or moves the operator's checkout — syncing is hint-only.
952
- * 2. Optionally `git fetch` (skipped when step 1a already fetched), then
953
- * `git checkout <targetBranch>` in the project's git root. The task
954
- * branch is NOT rebased — agent SHAs are preserved.
955
- * 3. Run `git merge --no-ff task/<taskId> -m "<taskId>: <title> (worktree
956
- * merge)"`; a failed merge is aborted so the repo is left clean, and the
957
- * worktree is preserved.
958
- * 4. Capture the merge commit SHA and report it.
959
- * 5. Delegate worktree+branch removal to {@link pruneWorktree} (T1462).
960
- *
961
- * Project-agnostic: no string in this function hardcodes "main", "master",
962
- * or any other branch name. Test fixtures pass arbitrary `targetBranch`.
963
- *
964
- * @param taskId - The CLEO task ID (e.g. `"T1587"`).
965
- * @param projectRoot - Absolute path to the project root.
966
- * @param opts.targetBranch - Override the resolved default branch.
967
- * @param opts.taskTitle - Task title used in the merge commit message subject.
968
- * @param opts.skipFetch - Skip every `git fetch` (test fixtures).
969
- * @param opts.evidence - The task's evidence atoms; the prune keeps a worktree holding the only copy (T13696).
970
- * @param opts.fetchTimeoutMs - Timeout for the upstream fetch (default
971
- * {@link UPSTREAM_FETCH_TIMEOUT_MS}).
972
- * @param opts.disabledReason - Integration is switched off; nothing is touched (T14053).
973
- * @returns Merge integration result, with the decision it acted on in `plan`.
974
- *
975
- * @task T1587
976
- * @task T14053
977
- * @adr ADR-062
978
- */
979
- export function completeAgentWorktreeViaMerge(taskId, projectRoot, opts = {}) {
980
- // T14053: one decision, shared with `cleo done --plan`.
981
- const decision = decideWorktreeIntegration(taskId, projectRoot, opts);
982
- const { branch, worktreePath, targetBranch, upstream } = decision;
983
- const plan = {
984
- branch,
985
- worktreePath,
986
- targetBranch,
987
- action: decision.action,
988
- reason: decision.reason,
989
- };
990
- const noMerge = {
991
- taskId,
992
- targetBranch,
993
- merged: false,
994
- mergeCommit: '',
995
- commitCount: 0,
996
- rebased: false,
997
- worktreeRemoved: false,
998
- branchDeleted: false,
999
- plan,
1000
- };
1001
- if (decision.rule === 'disabled')
1002
- return { ...noMerge, hint: decision.reason };
1003
- if (decision.rule === 'no-git')
1004
- return { ...noMerge, error: decision.reason };
1005
- if (decision.rule === 'nothing') {
1006
- // `error` is kept as a human-readable MESSAGE, while `nothingToIntegrate`
1007
- // is the machine-readable CLASSIFICATION (T12153): a caller distinguishes
1008
- // no-work from failure by the FLAG, never by the presence of a string.
1009
- return { ...noMerge, error: decision.reason, nothingToIntegrate: true };
1010
- }
1011
- if (decision.rule === 'not-provisioned') {
1012
- return { ...noMerge, notProvisioned: true, hint: decision.reason };
1013
- }
1014
- if (decision.rule === 'root') {
1015
- return { ...noMerge, rootRefused: true, error: decision.reason, hint: decision.reason };
1016
- }
1017
- const gitRoot = decision.gitRoot;
1018
- if (upstream?.kind === 'landed' || upstream?.kind === 'nothing') {
1019
- // The commits are safe on origin (or there are none); clean up the
1020
- // worktree unless it holds uncommitted work, which is left for the
1021
- // operator. The checkout itself is never moved (hint-only).
1022
- const dirty = existsSync(worktreePath) && isWorktreeDirty(worktreePath);
1023
- const pruneResult = dirty
1024
- ? null
1025
- : pruneWorktree(taskId, projectRoot, { evidence: opts.evidence });
1026
- let branchDeleted = pruneResult?.branchDeleted ?? false;
1027
- // pruneWorktree only deletes a branch with zero commits ahead of the
1028
- // checkout HEAD, so a stale local default branch, a squash merge, or a
1029
- // checkout on another branch would leave `task/<id>` behind forever.
1030
- // For a PROVEN-landed branch the content is on upstream, so delete it
1031
- // explicitly — but only once its (clean) worktree is gone.
1032
- if (upstream.kind === 'landed' &&
1033
- pruneResult !== null &&
1034
- !branchDeleted &&
1035
- !existsSync(worktreePath)) {
1036
- branchDeleted = gitSilent(['branch', '-D', branch], gitRoot);
1037
- }
1038
- return {
1039
- ...noMerge,
1040
- worktreeRemoved: pruneResult?.worktreeRemoved ?? false,
1041
- ...(pruneResult?.kept ? { worktreeKept: pruneResult.kept } : {}),
1042
- branchDeleted,
1043
- nothingToIntegrate: true,
1044
- ...(upstream.kind === 'landed' ? { landedUpstream: true } : {}),
1045
- ...(upstream.syncCommand ? { syncCommand: upstream.syncCommand } : {}),
1046
- hint: dirty
1047
- ? `${upstream.hint} Worktree ${worktreePath} has uncommitted changes and was preserved.`
1048
- : pruneResult?.kept
1049
- ? `${upstream.hint} ${pruneResult.error}`
1050
- : upstream.hint,
1051
- };
1052
- }
1053
- if (upstream?.kind === 'stale-target') {
1054
- return {
1055
- ...noMerge,
1056
- error: decision.reason,
1057
- staleTarget: true,
1058
- syncCommand: upstream.syncCommand,
1059
- hint: upstream.hint,
1060
- };
1061
- }
1062
- // T11124: Delegate to Rust NAPI SSoT
1063
- const result = integrateWorktree({
1064
- repoRoot: gitRoot,
1065
- worktreePath,
1066
- branch,
1067
- targetBranch,
1068
- taskTitle: opts.taskTitle,
1069
- // The upstream assessment already fetched (bounded + non-interactive);
1070
- // do not fetch a second time from Rust.
1071
- skipFetch: (opts.skipFetch ?? false) || (upstream?.fetched ?? false),
1072
- });
1073
- if (!result.merged) {
1074
- return {
1075
- ...noMerge,
1076
- commitCount: result.commitCount,
1077
- rebased: result.rebased,
1078
- error: result.error,
1079
- };
1080
- }
1081
- const pruneResult = pruneWorktree(taskId, projectRoot, { evidence: opts.evidence });
1082
- return {
1083
- ...noMerge,
1084
- merged: true,
1085
- mergeCommit: result.mergeCommit,
1086
- commitCount: result.commitCount,
1087
- rebased: result.rebased,
1088
- worktreeRemoved: pruneResult.worktreeRemoved,
1089
- ...(pruneResult.kept ? { worktreeKept: pruneResult.kept } : {}),
1090
- branchDeleted: pruneResult.branchDeleted,
1091
- error: pruneResult.error,
1092
- };
1093
- }
1094
- /**
1095
- * Complete a worker task's worktree integration via merge, cleanup, and audit log.
1096
- *
1097
- * This is the orchestrator-facing convenience wrapper around
1098
- * `completeAgentWorktreeViaMerge`. In addition to the merge+prune steps it:
1099
- *
1100
- * 1. Delegates all merge and cleanup to `completeAgentWorktreeViaMerge`.
1101
- * 2. Appends a structured entry to `.cleo/audit/worktree-integration.jsonl`
1102
- * recording the merge commit, task ID, and cleanup outcome.
1103
- *
1104
- * Orchestrators MUST call this (not `completeAgentWorktreeViaMerge` directly)
1105
- * so every integration is auditable.
1106
- *
1107
- * @param taskId - The CLEO task ID (e.g. `"T1587"`).
1108
- * @param projectRoot - Absolute path to the project root.
1109
- * @param opts.targetBranch - Override the resolved default branch.
1110
- * @param opts.taskTitle - Task title used in the merge commit message subject.
1111
- * @param opts.skipFetch - Skip the `git fetch origin` step (test fixtures).
1112
- * @param opts.auditLogPath - Override the audit JSONL path (testing).
1113
- * @param opts.evidence - The task's evidence atoms; the prune keeps a worktree holding the only copy (T13696).
1114
- * @returns Integration result including audit log path.
1115
- *
1116
- * @task T9043
1117
- * @adr ADR-062
1118
- */
1119
- export function completeAgentWorktreeIntegration(taskId, projectRoot, opts = {}) {
1120
- const mergeResult = completeAgentWorktreeViaMerge(taskId, projectRoot, {
1121
- targetBranch: opts.targetBranch,
1122
- taskTitle: opts.taskTitle,
1123
- skipFetch: opts.skipFetch,
1124
- evidence: opts.evidence,
1125
- });
1126
- // Write audit log entry.
1127
- let auditLogEntry = null;
1128
- try {
1129
- const auditDir = opts.auditLogPath
1130
- ? dirname(opts.auditLogPath)
1131
- : join(projectRoot, '.cleo', 'audit');
1132
- mkdirSync(auditDir, { recursive: true });
1133
- const logPath = opts.auditLogPath ?? join(auditDir, 'worktree-integration.jsonl');
1134
- const entry = JSON.stringify({
1135
- timestamp: new Date().toISOString(),
1136
- taskId,
1137
- mergeCommit: mergeResult.mergeCommit,
1138
- merged: mergeResult.merged,
1139
- worktreeRemoved: mergeResult.worktreeRemoved,
1140
- worktreeKept: mergeResult.worktreeKept ?? null,
1141
- branchDeleted: mergeResult.branchDeleted,
1142
- error: mergeResult.error ?? null,
1143
- agent: process.env['CLEO_AGENT_ID'] ?? 'cleo',
1144
- });
1145
- appendFileSync(logPath, entry + '\n', 'utf-8');
1146
- auditLogEntry = logPath;
1147
- }
1148
- catch {
1149
- // Audit is best-effort — never block merge on logging failure.
1150
- }
1151
- return { ...mergeResult, auditLogEntry };
1152
- }
1153
- /**
1154
- * The single integration result for a completion envelope (T14053): what the
1155
- * run actually did, so it can never contradict the merge. `merge` only when a
1156
- * merge commit was made; a decided merge that failed reads as `refuse`.
1157
- *
1158
- * @param result - The merge run's result (its `plan` is the decision it acted on).
1159
- * @returns `{branch, worktreePath, targetBranch, action, reason, mergeCommit?, pushed: false}`.
1160
- *
1161
- * @example
1162
- * ```ts
1163
- * integrationOutcome(completeAgentWorktreeIntegration('T1', root)).action; // 'skip'
1164
- * ```
1165
- * @task T14053
1166
- */
1167
- export function integrationOutcome(result) {
1168
- const plan = result.plan ?? {
1169
- branch: `task/${result.taskId}`,
1170
- worktreePath: '',
1171
- targetBranch: result.targetBranch,
1172
- action: 'skip',
1173
- reason: result.error ?? result.hint ?? 'no integration decision recorded',
1174
- };
1175
- if (result.merged) {
1176
- return {
1177
- ...plan,
1178
- action: 'merge',
1179
- reason: `merged into local '${result.targetBranch}' with --no-ff; not pushed`,
1180
- ...(result.mergeCommit ? { mergeCommit: result.mergeCommit } : {}),
1181
- pushed: false,
1182
- };
1183
- }
1184
- if (plan.action === 'merge') {
1185
- return {
1186
- ...plan,
1187
- action: 'refuse',
1188
- reason: `merge failed (${result.error ?? 'unknown error'}); branch and worktree kept`,
1189
- pushed: false,
1190
- };
1191
- }
1192
- return { ...plan, pushed: false };
1193
- }
1194
483
  /**
1195
484
  * Install the git shim launcher in the project's `.cleo/bin/git-shim/` dir
1196
485
  * (T1118, wired in by T13522). Idempotent.