@cleocode/core 2026.10.25 → 2026.10.26

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
@@ -0,0 +1,715 @@
1
+ // @ts-nocheck — moved verbatim from branch-lock.ts (T10862), which carries the same marker (T9977)
2
+ /**
3
+ * Branch integration for agent worktrees (T10862): default-branch resolution,
4
+ * upstream fetch and landed-detection, the merge decision, the `--no-ff` merge
5
+ * of `task/<id>` into the local target branch (ADR-062) and its audit row.
6
+ *
7
+ * Owns merge / fetch / checkout policy only. Worktree add, remove, list and
8
+ * lock stay in the lifecycle module ({@link ./branch-lock.ts}) and the
9
+ * `@cleocode/worktree` facade; this module calls {@link pruneWorktree} after a
10
+ * merge and never touches `git worktree` itself.
11
+ *
12
+ * @task T10862
13
+ * @task T10863
14
+ * @task T10864
15
+ * @adr ADR-062
16
+ */
17
+ import { execFileSync } from 'node:child_process';
18
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { getGitRoot, gitSilent, gitSync, integrateWorktree, isCleoProvisionedWorktree, WORKTREE_LIFECYCLE_AUDIT_FILE, } from '@cleocode/worktree';
21
+ import { pruneWorktree, resolveAgentWorktreeRoot, shellQuote } from './branch-lock.js';
22
+ // ---------------------------------------------------------------------------
23
+ // Project-agnostic default-branch resolution (ADR-062 / T1587)
24
+ // ---------------------------------------------------------------------------
25
+ /**
26
+ * Probe order for default-branch fallback when neither config nor
27
+ * `origin/HEAD` resolves. Order matches industry convention frequency.
28
+ */
29
+ const DEFAULT_BRANCH_PROBE_ORDER = [
30
+ 'main',
31
+ 'master',
32
+ 'develop',
33
+ 'trunk',
34
+ ];
35
+ /**
36
+ * Resolve the project's default integration branch in a project-agnostic way.
37
+ *
38
+ * Resolution order (per ADR-062):
39
+ *
40
+ * 1. `.cleo/config.json::git.defaultBranch` (explicit override).
41
+ * 2. `git symbolic-ref refs/remotes/origin/HEAD` (what the remote calls
42
+ * default — works for `master`, `main`, `trunk`, etc.).
43
+ * 3. Probe local branches in order: `main`, `master`, `develop`, `trunk`.
44
+ * 4. Fallback to `'main'`.
45
+ *
46
+ * @param projectRoot - Absolute path to the project root.
47
+ * @returns The resolved default branch name (never throws).
48
+ *
49
+ * @task T1587
50
+ * @adr ADR-062
51
+ */
52
+ export function getDefaultBranch(projectRoot) {
53
+ // (1) .cleo/config.json override.
54
+ const configPath = join(projectRoot, '.cleo', 'config.json');
55
+ if (existsSync(configPath)) {
56
+ try {
57
+ const raw = readFileSync(configPath, 'utf-8');
58
+ const cfg = JSON.parse(raw);
59
+ const fromCfg = cfg.git?.defaultBranch;
60
+ if (typeof fromCfg === 'string' && fromCfg.length > 0) {
61
+ return fromCfg;
62
+ }
63
+ }
64
+ catch {
65
+ // malformed config — fall through.
66
+ }
67
+ }
68
+ let gitRoot;
69
+ try {
70
+ gitRoot = getGitRoot(projectRoot);
71
+ }
72
+ catch {
73
+ return 'main';
74
+ }
75
+ // (2) origin/HEAD.
76
+ try {
77
+ const ref = gitSync(['symbolic-ref', 'refs/remotes/origin/HEAD'], gitRoot);
78
+ const stripped = ref.replace(/^refs\/remotes\/origin\//, '').trim();
79
+ if (stripped.length > 0)
80
+ return stripped;
81
+ }
82
+ catch {
83
+ // origin/HEAD not set — fall through.
84
+ }
85
+ // (3) probe local branches.
86
+ for (const candidate of DEFAULT_BRANCH_PROBE_ORDER) {
87
+ try {
88
+ const out = gitSync(['branch', '--list', candidate], gitRoot);
89
+ if (out.length > 0)
90
+ return candidate;
91
+ }
92
+ catch {
93
+ /* ignore — try next */
94
+ }
95
+ }
96
+ // (4) last-resort fallback.
97
+ return 'main';
98
+ }
99
+ /**
100
+ * Default timeout for the upstream `git fetch` in
101
+ * {@link assessUpstreamIntegration}. Short on purpose: the fetch is
102
+ * best-effort, and a hung network or credential prompt must never stall
103
+ * `cleo done` for the 180s generic git timeout.
104
+ *
105
+ * @task T12773
106
+ */
107
+ export const UPSTREAM_FETCH_TIMEOUT_MS = 20_000;
108
+ /**
109
+ * ssh options for the upstream fetch: batch mode (no prompt), a bounded
110
+ * connect, and keepalives so a stalled connection fails fast.
111
+ */
112
+ const SSH_NON_INTERACTIVE_OPTS = '-oBatchMode=yes -oConnectTimeout=10 -oServerAliveInterval=5';
113
+ /**
114
+ * Environment for a non-interactive `git fetch`: no terminal credential
115
+ * prompt, and ssh with {@link SSH_NON_INTERACTIVE_OPTS} (an existing
116
+ * `GIT_SSH_COMMAND` is preserved with the options appended).
117
+ */
118
+ function nonInteractiveGitEnv() {
119
+ const existingSsh = process.env['GIT_SSH_COMMAND']?.trim();
120
+ return {
121
+ ...process.env,
122
+ GIT_TERMINAL_PROMPT: '0',
123
+ GIT_SSH_COMMAND: `${existingSsh || 'ssh'} ${SSH_NON_INTERACTIVE_OPTS}`,
124
+ };
125
+ }
126
+ /**
127
+ * Best-effort, bounded, non-interactive `git fetch <remote> <branch>`.
128
+ *
129
+ * @returns true when git exited 0; false on failure, offline, or timeout.
130
+ */
131
+ function fetchUpstreamBranch(gitRoot, remote, branch, timeoutMs) {
132
+ try {
133
+ execFileSync('git', ['fetch', '--quiet', remote, branch], {
134
+ cwd: gitRoot,
135
+ // No pipes: a timed-out git's surviving ssh grandchild must not hold
136
+ // stdout/stderr open and keep this synchronous call waiting.
137
+ stdio: 'ignore',
138
+ timeout: timeoutMs,
139
+ // Known small window: a SIGKILL landing while git updates the
140
+ // remote-tracking ref can leave `refs/remotes/<remote>/<branch>.lock`
141
+ // behind, which makes the next fetch of that ref fail until the stale
142
+ // lock file is removed. SSH connect/keepalive bounds (above) make a
143
+ // timeout — and so this kill — rare.
144
+ killSignal: 'SIGKILL',
145
+ env: nonInteractiveGitEnv(),
146
+ });
147
+ return true;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ /**
154
+ * `git rev-list --count <range>`, or 0 when git fails.
155
+ */
156
+ function revListCount(gitRoot, range) {
157
+ try {
158
+ const n = Number.parseInt(gitSync(['rev-list', '--count', range], gitRoot), 10);
159
+ return Number.isFinite(n) ? n : 0;
160
+ }
161
+ catch {
162
+ return 0;
163
+ }
164
+ }
165
+ /**
166
+ * True when the worktree at `worktreePath` has uncommitted tracked or
167
+ * untracked changes. An unreadable status counts as dirty (never discard
168
+ * work on uncertainty).
169
+ */
170
+ function isWorktreeDirty(worktreePath) {
171
+ try {
172
+ return gitSync(['status', '--porcelain'], worktreePath).length > 0;
173
+ }
174
+ catch {
175
+ return true;
176
+ }
177
+ }
178
+ /**
179
+ * True when merging `branch` into `upstreamRef` would leave the upstream tree
180
+ * unchanged — the branch's changes already landed (e.g. a squash-merged PR,
181
+ * whose commits are never ancestors of upstream). Uses `git merge-tree
182
+ * --write-tree`; older git or a conflict yields false.
183
+ */
184
+ function branchChangesContainedIn(gitRoot, branch, upstreamRef) {
185
+ try {
186
+ const merged = gitSync(['merge-tree', '--write-tree', upstreamRef, branch], gitRoot)
187
+ .split('\n')[0]
188
+ ?.trim();
189
+ const upstreamTree = gitSync(['rev-parse', `${upstreamRef}^{tree}`], gitRoot);
190
+ return merged !== undefined && merged.length > 0 && merged === upstreamTree;
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ }
196
+ /**
197
+ * Decide whether a local ADR-062 `--no-ff` integration of `branch` into
198
+ * `targetBranch` is safe, by comparing against the upstream default branch
199
+ * (`<remote>/<targetBranch>`).
200
+ *
201
+ * T12773: `cleo done --pr <n>` for a task whose PR had already merged on
202
+ * origin ran `git merge --no-ff task/<id>` into a local `main` that was 199
203
+ * commits behind origin, creating a local merge commit that forked `main`
204
+ * from `origin/main`. This check runs BEFORE any checkout or merge:
205
+ *
206
+ * - branch has no commits beyond the local default branch → `nothing`.
207
+ * - branch already on upstream → `landed`; when the local default branch is
208
+ * behind, the hint names the exact sync command. The checkout is never
209
+ * moved (hint-only).
210
+ * - local default branch behind upstream → `stale-target`; a distinct hint
211
+ * when it is also ahead (diverged: unpushed local commits).
212
+ * - otherwise (no remote, no upstream ref, current target) → `proceed`.
213
+ *
214
+ * The fetch is best-effort, non-interactive (`GIT_TERMINAL_PROMPT=0`, ssh
215
+ * `BatchMode`) and bounded by {@link UPSTREAM_FETCH_TIMEOUT_MS}; offline, the
216
+ * last-known remote-tracking ref is used. Never creates a commit and never
217
+ * moves any ref other than the remote-tracking one the fetch updates.
218
+ *
219
+ * @param gitRoot - Absolute git root of the project checkout.
220
+ * @param branch - Task branch (e.g. `task/T123`).
221
+ * @param targetBranch - Resolved default branch (e.g. `main`).
222
+ * @param opts.skipFetch - Skip `git fetch <remote> <targetBranch>` (fixtures).
223
+ * @param opts.remote - Remote name (default `origin`).
224
+ * @param opts.fetchTimeoutMs - Fetch timeout (default {@link UPSTREAM_FETCH_TIMEOUT_MS}).
225
+ * @returns The assessment.
226
+ *
227
+ * @task T12773
228
+ * @adr ADR-062
229
+ */
230
+ export function assessUpstreamIntegration(gitRoot, branch, targetBranch, opts = {}) {
231
+ const remote = opts.remote ?? 'origin';
232
+ const proceed = {
233
+ kind: 'proceed',
234
+ upstreamRef: null,
235
+ fetched: false,
236
+ behind: 0,
237
+ ahead: 0,
238
+ syncCommand: '',
239
+ hint: '',
240
+ };
241
+ if (!gitSilent(['remote', 'get-url', remote], gitRoot))
242
+ return proceed;
243
+ const fetched = !opts.skipFetch;
244
+ if (fetched) {
245
+ // Best-effort: offline must not block an otherwise-local integration.
246
+ fetchUpstreamBranch(gitRoot, remote, targetBranch, opts.fetchTimeoutMs ?? UPSTREAM_FETCH_TIMEOUT_MS);
247
+ }
248
+ const upstreamRef = `refs/remotes/${remote}/${targetBranch}`;
249
+ const upstreamShort = `${remote}/${targetBranch}`;
250
+ if (!gitSilent(['rev-parse', '--verify', '--quiet', upstreamRef], gitRoot)) {
251
+ return { ...proceed, fetched };
252
+ }
253
+ const localRef = `refs/heads/${targetBranch}`;
254
+ const hasLocal = gitSilent(['rev-parse', '--verify', '--quiet', localRef], gitRoot);
255
+ const behind = hasLocal ? revListCount(gitRoot, `${localRef}..${upstreamRef}`) : 0;
256
+ const ahead = hasLocal ? revListCount(gitRoot, `${upstreamRef}..${localRef}`) : 0;
257
+ const base = { upstreamRef, fetched, behind, ahead };
258
+ // Single-quoted for the shell: macOS roots often contain spaces, and branch
259
+ // / remote names are operator-controlled.
260
+ const quotedRoot = shellQuote(gitRoot);
261
+ const quotedTarget = shellQuote(targetBranch);
262
+ const quotedUpstream = shellQuote(upstreamShort);
263
+ // Diverged (ahead AND behind): merge, never `pull --rebase` — a rebase
264
+ // would drop the local ADR-062 `--no-ff` task merge commits and rewrite
265
+ // agent SHAs. Behind only: fast-forward.
266
+ const syncCommand = behind === 0
267
+ ? ''
268
+ : ahead > 0
269
+ ? `git -C ${quotedRoot} switch ${quotedTarget} && git -C ${quotedRoot} merge ${quotedUpstream}`
270
+ : `git -C ${quotedRoot} switch ${quotedTarget} && git -C ${quotedRoot} merge --ff-only ${quotedUpstream}`;
271
+ const syncState = behind === 0
272
+ ? ''
273
+ : ahead > 0
274
+ ? `local '${targetBranch}' has ${ahead} unpushed commit(s) and is ${behind} behind ${upstreamShort}`
275
+ : `local '${targetBranch}' is ${behind} commit(s) behind ${upstreamShort}`;
276
+ // A branch with no commits beyond the local default branch has nothing to
277
+ // integrate — it trivially "is an ancestor of upstream", which must not be
278
+ // reported as a landed PR.
279
+ if (hasLocal && revListCount(gitRoot, `${localRef}..${branch}`) === 0) {
280
+ return {
281
+ ...base,
282
+ kind: 'nothing',
283
+ syncCommand,
284
+ hint: `'${branch}' has no commits beyond local '${targetBranch}'; nothing to integrate.`,
285
+ };
286
+ }
287
+ const landed = gitSilent(['merge-base', '--is-ancestor', branch, upstreamRef], gitRoot) ||
288
+ branchChangesContainedIn(gitRoot, branch, upstreamRef);
289
+ if (landed) {
290
+ return {
291
+ ...base,
292
+ kind: 'landed',
293
+ syncCommand,
294
+ hint: behind === 0
295
+ ? `'${branch}' already landed on ${upstreamShort}; no local merge was made.`
296
+ : `'${branch}' already landed on ${upstreamShort}; no local merge was made and your checkout was not moved. ${syncState}; to sync it run: ${syncCommand}`,
297
+ };
298
+ }
299
+ if (behind > 0) {
300
+ const rerun = `re-run \`cleo orchestrate worktree-complete ${branch.replace(/^task\//, '')}\``;
301
+ return {
302
+ ...base,
303
+ kind: 'stale-target',
304
+ syncCommand,
305
+ hint: ahead > 0
306
+ ? `${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.`
307
+ : `${syncState}, so a merge there would fork it from ${upstreamShort}. Land '${branch}' through a PR, or sync first (${syncCommand}) and ${rerun}.`,
308
+ };
309
+ }
310
+ return { ...proceed, ...base };
311
+ }
312
+ /**
313
+ * Why a local merge must not run in the main checkout at `gitRoot`, or null
314
+ * when it may (T14053). The merge checks out `targetBranch` there, so the
315
+ * checkout must already be on it with no uncommitted tracked changes, and must
316
+ * be the main checkout rather than a linked worktree (another task's). CLEO
317
+ * never switches a checkout's branch.
318
+ */
319
+ function mainCheckoutRefusal(gitRoot, branch, targetBranch) {
320
+ let linked;
321
+ try {
322
+ const gitDir = gitSync(['rev-parse', '--absolute-git-dir'], gitRoot);
323
+ const commonDir = resolve(gitRoot, gitSync(['rev-parse', '--git-common-dir'], gitRoot));
324
+ linked = realpathSync(gitDir) !== realpathSync(commonDir);
325
+ }
326
+ catch (err) {
327
+ return `could not inspect the main checkout (${err instanceof Error ? err.message : String(err)}), so '${branch}' is not merged.`;
328
+ }
329
+ if (linked) {
330
+ return `'${gitRoot}' is a linked worktree, not the main checkout; CLEO merges '${branch}' only from the project's main checkout.`;
331
+ }
332
+ let head;
333
+ try {
334
+ head = gitSync(['symbolic-ref', '--quiet', '--short', 'HEAD'], gitRoot);
335
+ }
336
+ catch {
337
+ head = '(detached HEAD)';
338
+ }
339
+ if (head !== targetBranch) {
340
+ 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.`;
341
+ }
342
+ let dirty;
343
+ try {
344
+ dirty = gitSync(['status', '--porcelain', '--untracked-files=no'], gitRoot).length > 0;
345
+ }
346
+ catch {
347
+ dirty = true;
348
+ }
349
+ if (dirty) {
350
+ 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.`;
351
+ }
352
+ return null;
353
+ }
354
+ /**
355
+ * Decide what completion does with a task's worktree branch (T14053): the one
356
+ * decision both `cleo done --plan` and the merge run use. Checks, in order:
357
+ * switched off; not a git repo; nothing to integrate; not provisioned by CLEO
358
+ * (a hand-made `task/<id>` branch is never merged); already landed upstream or
359
+ * a stale local target (T12773); a main checkout a merge would disturb.
360
+ * Only reads git, plus the upstream fetch unless `skipFetch`.
361
+ */
362
+ function decideWorktreeIntegration(taskId, projectRoot, opts) {
363
+ const branch = `task/${taskId}`;
364
+ const worktreePath = join(resolveAgentWorktreeRoot(projectRoot), taskId);
365
+ const targetBranch = opts.targetBranch ?? getDefaultBranch(projectRoot);
366
+ const base = { branch, worktreePath, targetBranch, gitRoot: null, upstream: null };
367
+ if (opts.disabledReason) {
368
+ return { ...base, rule: 'disabled', action: 'skip', reason: opts.disabledReason };
369
+ }
370
+ let gitRoot;
371
+ try {
372
+ gitRoot = getGitRoot(projectRoot);
373
+ }
374
+ catch (err) {
375
+ const message = err instanceof Error ? err.message : String(err);
376
+ return { ...base, rule: 'no-git', action: 'skip', reason: `Not a git repo: ${message}` };
377
+ }
378
+ const at = { ...base, gitRoot };
379
+ // T12153 (GH #1223) — most tasks are worked on a feature branch and merged
380
+ // by PR, so no `task/<id>` branch and no agent worktree ever exist. That is
381
+ // "nothing to integrate", not a failure.
382
+ const branchExists = gitSync(['branch', '--list', branch], gitRoot).trim().length > 0;
383
+ if (!branchExists && !existsSync(worktreePath)) {
384
+ return {
385
+ ...at,
386
+ rule: 'nothing',
387
+ action: 'skip',
388
+ reason: `no task branch '${branch}' and no worktree — nothing to integrate`,
389
+ };
390
+ }
391
+ if (!isCleoProvisionedWorktree(projectRoot, taskId, worktreePath)) {
392
+ return {
393
+ ...at,
394
+ rule: 'not-provisioned',
395
+ action: 'skip',
396
+ 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.`,
397
+ };
398
+ }
399
+ // T12773 — never merge locally what already landed upstream, and never
400
+ // merge into a local default branch that is behind origin.
401
+ const upstream = branchExists
402
+ ? assessUpstreamIntegration(gitRoot, branch, targetBranch, {
403
+ skipFetch: opts.skipFetch ?? false,
404
+ fetchTimeoutMs: opts.fetchTimeoutMs,
405
+ })
406
+ : null;
407
+ const known = { ...at, upstream };
408
+ if (upstream?.kind === 'landed' || upstream?.kind === 'nothing') {
409
+ return { ...known, rule: 'upstream', action: 'skip', reason: upstream.hint };
410
+ }
411
+ if (upstream?.kind === 'stale-target') {
412
+ return {
413
+ ...known,
414
+ rule: 'upstream',
415
+ action: 'refuse',
416
+ reason: `refusing local merge of '${branch}' into '${targetBranch}': ${upstream.hint}`,
417
+ };
418
+ }
419
+ const refusal = mainCheckoutRefusal(gitRoot, branch, targetBranch);
420
+ if (refusal !== null)
421
+ return { ...known, rule: 'root', action: 'refuse', reason: refusal };
422
+ return {
423
+ ...known,
424
+ rule: 'merge',
425
+ action: 'merge',
426
+ reason: `merge '${branch}' into local '${targetBranch}' with --no-ff (never pushed)`,
427
+ };
428
+ }
429
+ /**
430
+ * Preview what completing `taskId` does with its worktree branch, without
431
+ * changing git: the same decision the merge run makes, from local refs only
432
+ * (no fetch). Shown by `cleo done --plan` (T14053).
433
+ *
434
+ * @param taskId - The CLEO task ID.
435
+ * @param projectRoot - Absolute path to the project root.
436
+ * @param opts - Target override and the switched-off reason, if any.
437
+ * @returns Branch, worktree path, target branch, `merge`/`skip`/`refuse` and why.
438
+ *
439
+ * @example
440
+ * ```ts
441
+ * planWorktreeIntegration('T123', root).action; // 'skip' for a task merged by PR
442
+ * ```
443
+ * @task T14053
444
+ */
445
+ export function planWorktreeIntegration(taskId, projectRoot, opts = {}) {
446
+ const { branch, worktreePath, targetBranch, action, reason } = decideWorktreeIntegration(taskId, projectRoot, { ...opts, skipFetch: true });
447
+ return { branch, worktreePath, targetBranch, action, reason };
448
+ }
449
+ /**
450
+ * Complete a worker task's worktree via `git merge --no-ff` (ADR-062).
451
+ *
452
+ * Canonical worktree integration per ADR-062. Preserves the full agent
453
+ * commit graph instead of rewriting SHAs, so `git log --grep "T<id>"`
454
+ * returns the originating commits with their original authorship.
455
+ *
456
+ * Steps performed inside the worktree at
457
+ * `~/.local/share/cleo/worktrees/<projectHash>/<taskId>/`:
458
+ *
459
+ * 1. Resolve target branch via {@link getDefaultBranch} (or `opts.targetBranch`
460
+ * override). NEVER hardcodes "main".
461
+ * 1. T14053: {@link decideWorktreeIntegration} first — the same decision
462
+ * `cleo done --plan` shows. A branch CLEO did not provision is skipped
463
+ * (`notProvisioned`), never merged; a main checkout on another branch,
464
+ * with uncommitted tracked changes, or a linked worktree is refused
465
+ * (`rootRefused`) without switching or merging anything.
466
+ * 1a. T12773: {@link assessUpstreamIntegration} — when `task/<id>` already
467
+ * landed on `origin/<targetBranch>` the merge is skipped
468
+ * (`landedUpstream: true`); when it has no commits beyond the local
469
+ * target it is `nothingToIntegrate` without `landedUpstream`; when the
470
+ * local target is behind (or diverged from) origin the merge is refused
471
+ * (`staleTarget: true`) with a `hint`. None of these paths creates a
472
+ * commit or moves the operator's checkout — syncing is hint-only.
473
+ * 2. Optionally `git fetch` (skipped when step 1a already fetched), then
474
+ * `git checkout <targetBranch>` in the project's git root. The task
475
+ * branch is NOT rebased — agent SHAs are preserved.
476
+ * 3. Run `git merge --no-ff task/<taskId> -m "<taskId>: <title> (worktree
477
+ * merge)"`; a failed merge is aborted so the repo is left clean, and the
478
+ * worktree is preserved.
479
+ * 4. Capture the merge commit SHA and report it.
480
+ * 5. Delegate worktree+branch removal to {@link pruneWorktree} (T1462).
481
+ *
482
+ * Project-agnostic: no string in this function hardcodes "main", "master",
483
+ * or any other branch name. Test fixtures pass arbitrary `targetBranch`.
484
+ *
485
+ * @param taskId - The CLEO task ID (e.g. `"T1587"`).
486
+ * @param projectRoot - Absolute path to the project root.
487
+ * @param opts.targetBranch - Override the resolved default branch.
488
+ * @param opts.taskTitle - Task title used in the merge commit message subject.
489
+ * @param opts.skipFetch - Skip every `git fetch` (test fixtures).
490
+ * @param opts.evidence - The task's evidence atoms; the prune keeps a worktree holding the only copy (T13696).
491
+ * @param opts.fetchTimeoutMs - Timeout for the upstream fetch (default
492
+ * {@link UPSTREAM_FETCH_TIMEOUT_MS}).
493
+ * @param opts.disabledReason - Integration is switched off; nothing is touched (T14053).
494
+ * @returns Merge integration result, with the decision it acted on in `plan`.
495
+ *
496
+ * @task T1587
497
+ * @task T14053
498
+ * @adr ADR-062
499
+ */
500
+ export function completeAgentWorktreeViaMerge(taskId, projectRoot, opts = {}) {
501
+ // T14053: one decision, shared with `cleo done --plan`.
502
+ const decision = decideWorktreeIntegration(taskId, projectRoot, opts);
503
+ const { branch, worktreePath, targetBranch, upstream } = decision;
504
+ const plan = {
505
+ branch,
506
+ worktreePath,
507
+ targetBranch,
508
+ action: decision.action,
509
+ reason: decision.reason,
510
+ };
511
+ const noMerge = {
512
+ taskId,
513
+ targetBranch,
514
+ merged: false,
515
+ mergeCommit: '',
516
+ commitCount: 0,
517
+ rebased: false,
518
+ worktreeRemoved: false,
519
+ branchDeleted: false,
520
+ plan,
521
+ };
522
+ if (decision.rule === 'disabled')
523
+ return { ...noMerge, hint: decision.reason };
524
+ if (decision.rule === 'no-git')
525
+ return { ...noMerge, error: decision.reason };
526
+ if (decision.rule === 'nothing') {
527
+ // `error` is kept as a human-readable MESSAGE, while `nothingToIntegrate`
528
+ // is the machine-readable CLASSIFICATION (T12153): a caller distinguishes
529
+ // no-work from failure by the FLAG, never by the presence of a string.
530
+ return { ...noMerge, error: decision.reason, nothingToIntegrate: true };
531
+ }
532
+ if (decision.rule === 'not-provisioned') {
533
+ return { ...noMerge, notProvisioned: true, hint: decision.reason };
534
+ }
535
+ if (decision.rule === 'root') {
536
+ return { ...noMerge, rootRefused: true, error: decision.reason, hint: decision.reason };
537
+ }
538
+ const gitRoot = decision.gitRoot;
539
+ if (upstream?.kind === 'landed' || upstream?.kind === 'nothing') {
540
+ // The commits are safe on origin (or there are none); clean up the
541
+ // worktree unless it holds uncommitted work, which is left for the
542
+ // operator. The checkout itself is never moved (hint-only).
543
+ const dirty = existsSync(worktreePath) && isWorktreeDirty(worktreePath);
544
+ const pruneResult = dirty
545
+ ? null
546
+ : pruneWorktree(taskId, projectRoot, { evidence: opts.evidence });
547
+ let branchDeleted = pruneResult?.branchDeleted ?? false;
548
+ // pruneWorktree only deletes a branch with zero commits ahead of the
549
+ // checkout HEAD, so a stale local default branch, a squash merge, or a
550
+ // checkout on another branch would leave `task/<id>` behind forever.
551
+ // For a PROVEN-landed branch the content is on upstream, so delete it
552
+ // explicitly — but only once its (clean) worktree is gone.
553
+ if (upstream.kind === 'landed' &&
554
+ pruneResult !== null &&
555
+ !branchDeleted &&
556
+ !existsSync(worktreePath)) {
557
+ branchDeleted = gitSilent(['branch', '-D', branch], gitRoot);
558
+ }
559
+ return {
560
+ ...noMerge,
561
+ worktreeRemoved: pruneResult?.worktreeRemoved ?? false,
562
+ ...(pruneResult?.kept ? { worktreeKept: pruneResult.kept } : {}),
563
+ branchDeleted,
564
+ nothingToIntegrate: true,
565
+ ...(upstream.kind === 'landed' ? { landedUpstream: true } : {}),
566
+ ...(upstream.syncCommand ? { syncCommand: upstream.syncCommand } : {}),
567
+ hint: dirty
568
+ ? `${upstream.hint} Worktree ${worktreePath} has uncommitted changes and was preserved.`
569
+ : pruneResult?.kept
570
+ ? `${upstream.hint} ${pruneResult.error}`
571
+ : upstream.hint,
572
+ };
573
+ }
574
+ if (upstream?.kind === 'stale-target') {
575
+ return {
576
+ ...noMerge,
577
+ error: decision.reason,
578
+ staleTarget: true,
579
+ syncCommand: upstream.syncCommand,
580
+ hint: upstream.hint,
581
+ };
582
+ }
583
+ // T11124: Delegate to Rust NAPI SSoT
584
+ const result = integrateWorktree({
585
+ repoRoot: gitRoot,
586
+ worktreePath,
587
+ branch,
588
+ targetBranch,
589
+ taskTitle: opts.taskTitle,
590
+ // The upstream assessment already fetched (bounded + non-interactive);
591
+ // do not fetch a second time from Rust.
592
+ skipFetch: (opts.skipFetch ?? false) || (upstream?.fetched ?? false),
593
+ });
594
+ if (!result.merged) {
595
+ return {
596
+ ...noMerge,
597
+ commitCount: result.commitCount,
598
+ rebased: result.rebased,
599
+ error: result.error,
600
+ };
601
+ }
602
+ const pruneResult = pruneWorktree(taskId, projectRoot, { evidence: opts.evidence });
603
+ return {
604
+ ...noMerge,
605
+ merged: true,
606
+ mergeCommit: result.mergeCommit,
607
+ commitCount: result.commitCount,
608
+ rebased: result.rebased,
609
+ worktreeRemoved: pruneResult.worktreeRemoved,
610
+ ...(pruneResult.kept ? { worktreeKept: pruneResult.kept } : {}),
611
+ branchDeleted: pruneResult.branchDeleted,
612
+ error: pruneResult.error,
613
+ };
614
+ }
615
+ /**
616
+ * Complete a worker task's worktree integration via merge, cleanup, and audit log.
617
+ *
618
+ * This is the orchestrator-facing convenience wrapper around
619
+ * `completeAgentWorktreeViaMerge`. In addition to the merge+prune steps it:
620
+ *
621
+ * 1. Delegates all merge and cleanup to `completeAgentWorktreeViaMerge`.
622
+ * 2. Appends a structured entry to `.cleo/audit/worktree-integration.jsonl`
623
+ * recording the merge commit, task ID, and cleanup outcome.
624
+ *
625
+ * Orchestrators MUST call this (not `completeAgentWorktreeViaMerge` directly)
626
+ * so every integration is auditable.
627
+ *
628
+ * @param taskId - The CLEO task ID (e.g. `"T1587"`).
629
+ * @param projectRoot - Absolute path to the project root.
630
+ * @param opts.targetBranch - Override the resolved default branch.
631
+ * @param opts.taskTitle - Task title used in the merge commit message subject.
632
+ * @param opts.skipFetch - Skip the `git fetch origin` step (test fixtures).
633
+ * @param opts.auditLogPath - Override the audit JSONL path (testing).
634
+ * @param opts.evidence - The task's evidence atoms; the prune keeps a worktree holding the only copy (T13696).
635
+ * @returns Integration result including audit log path.
636
+ *
637
+ * @task T9043
638
+ * @adr ADR-062
639
+ */
640
+ export function completeAgentWorktreeIntegration(taskId, projectRoot, opts = {}) {
641
+ const mergeResult = completeAgentWorktreeViaMerge(taskId, projectRoot, {
642
+ targetBranch: opts.targetBranch,
643
+ taskTitle: opts.taskTitle,
644
+ skipFetch: opts.skipFetch,
645
+ evidence: opts.evidence,
646
+ });
647
+ // Write audit log entry.
648
+ let auditLogEntry = null;
649
+ try {
650
+ const auditDir = opts.auditLogPath
651
+ ? dirname(opts.auditLogPath)
652
+ : join(projectRoot, '.cleo', 'audit');
653
+ mkdirSync(auditDir, { recursive: true });
654
+ const logPath = opts.auditLogPath ?? join(auditDir, 'worktree-integration.jsonl');
655
+ const entry = JSON.stringify({
656
+ timestamp: new Date().toISOString(),
657
+ taskId,
658
+ mergeCommit: mergeResult.mergeCommit,
659
+ merged: mergeResult.merged,
660
+ worktreeRemoved: mergeResult.worktreeRemoved,
661
+ worktreeKept: mergeResult.worktreeKept ?? null,
662
+ branchDeleted: mergeResult.branchDeleted,
663
+ error: mergeResult.error ?? null,
664
+ agent: process.env['CLEO_AGENT_ID'] ?? 'cleo',
665
+ });
666
+ appendFileSync(logPath, entry + '\n', 'utf-8');
667
+ auditLogEntry = logPath;
668
+ }
669
+ catch {
670
+ // Audit is best-effort — never block merge on logging failure.
671
+ }
672
+ return { ...mergeResult, auditLogEntry };
673
+ }
674
+ /**
675
+ * The single integration result for a completion envelope (T14053): what the
676
+ * run actually did, so it can never contradict the merge. `merge` only when a
677
+ * merge commit was made; a decided merge that failed reads as `refuse`.
678
+ *
679
+ * @param result - The merge run's result (its `plan` is the decision it acted on).
680
+ * @returns `{branch, worktreePath, targetBranch, action, reason, mergeCommit?, pushed: false}`.
681
+ *
682
+ * @example
683
+ * ```ts
684
+ * integrationOutcome(completeAgentWorktreeIntegration('T1', root)).action; // 'skip'
685
+ * ```
686
+ * @task T14053
687
+ */
688
+ export function integrationOutcome(result) {
689
+ const plan = result.plan ?? {
690
+ branch: `task/${result.taskId}`,
691
+ worktreePath: '',
692
+ targetBranch: result.targetBranch,
693
+ action: 'skip',
694
+ reason: result.error ?? result.hint ?? 'no integration decision recorded',
695
+ };
696
+ if (result.merged) {
697
+ return {
698
+ ...plan,
699
+ action: 'merge',
700
+ reason: `merged into local '${result.targetBranch}' with --no-ff; not pushed`,
701
+ ...(result.mergeCommit ? { mergeCommit: result.mergeCommit } : {}),
702
+ pushed: false,
703
+ };
704
+ }
705
+ if (plan.action === 'merge') {
706
+ return {
707
+ ...plan,
708
+ action: 'refuse',
709
+ reason: `merge failed (${result.error ?? 'unknown error'}); branch and worktree kept`,
710
+ pushed: false,
711
+ };
712
+ }
713
+ return { ...plan, pushed: false };
714
+ }
715
+ //# sourceMappingURL=branch-integration.js.map