@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.
- package/dist/build-provenance.json +2 -2
- package/dist/dispatch/contracts/output-contracts.d.ts +13 -0
- package/dist/dispatch/contracts/output-contracts.d.ts.map +1 -1
- package/dist/dispatch/contracts/output-contracts.js +29 -0
- package/dist/dispatch/contracts/output-contracts.js.map +1 -1
- package/dist/docs/docs-ops.d.ts +8 -0
- package/dist/docs/docs-ops.d.ts.map +1 -1
- package/dist/docs/docs-ops.js +24 -1
- package/dist/docs/docs-ops.js.map +1 -1
- package/dist/docs/docs-update.d.ts +8 -5
- package/dist/docs/docs-update.d.ts.map +1 -1
- package/dist/docs/docs-update.js +16 -5
- package/dist/docs/docs-update.js.map +1 -1
- package/dist/gateway-client/generated/types.gen.d.ts +27 -0
- package/dist/gateway-client/generated/types.gen.d.ts.map +1 -1
- package/dist/hooks/handlers/watchdog-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/watchdog-hooks.js +5 -0
- package/dist/hooks/handlers/watchdog-hooks.js.map +1 -1
- package/dist/internal.d.ts +3 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +5 -2
- package/dist/internal.js.map +1 -1
- package/dist/lifecycle/verification/satisfies-validator.d.ts.map +1 -1
- package/dist/lifecycle/verification/satisfies-validator.js +22 -10
- package/dist/lifecycle/verification/satisfies-validator.js.map +1 -1
- package/dist/memory/brain-writer-thread.d.ts +30 -0
- package/dist/memory/brain-writer-thread.d.ts.map +1 -1
- package/dist/memory/brain-writer-thread.js +46 -1
- package/dist/memory/brain-writer-thread.js.map +1 -1
- package/dist/memory/brain-writer-worker.js +13 -2
- package/dist/memory/brain-writer-worker.js.map +1 -1
- package/dist/memory/embedding-backfill.d.ts +12 -0
- package/dist/memory/embedding-backfill.d.ts.map +1 -1
- package/dist/memory/embedding-backfill.js +51 -31
- package/dist/memory/embedding-backfill.js.map +1 -1
- package/dist/orchestrate/include-deps.d.ts +58 -0
- package/dist/orchestrate/include-deps.d.ts.map +1 -0
- package/dist/orchestrate/include-deps.js +138 -0
- package/dist/orchestrate/include-deps.js.map +1 -0
- package/dist/orchestrate/spawn-ops.d.ts +14 -33
- package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
- package/dist/orchestrate/spawn-ops.js +49 -61
- package/dist/orchestrate/spawn-ops.js.map +1 -1
- package/dist/orchestrate/worktree-complete.d.ts +15 -1
- package/dist/orchestrate/worktree-complete.d.ts.map +1 -1
- package/dist/orchestrate/worktree-complete.js +36 -3
- package/dist/orchestrate/worktree-complete.js.map +1 -1
- package/dist/release/ci-evidence.d.ts +142 -4
- package/dist/release/ci-evidence.d.ts.map +1 -1
- package/dist/release/ci-evidence.js +298 -48
- package/dist/release/ci-evidence.js.map +1 -1
- package/dist/release/open.d.ts +25 -1
- package/dist/release/open.d.ts.map +1 -1
- package/dist/release/open.js +63 -11
- package/dist/release/open.js.map +1 -1
- package/dist/resources/admission-ledger.d.ts +83 -11
- package/dist/resources/admission-ledger.d.ts.map +1 -1
- package/dist/resources/admission-ledger.js +138 -39
- package/dist/resources/admission-ledger.js.map +1 -1
- package/dist/resources/governor.js +1 -1
- package/dist/resources/governor.js.map +1 -1
- package/dist/resources/hook-footprint.d.ts +24 -0
- package/dist/resources/hook-footprint.d.ts.map +1 -1
- package/dist/resources/hook-footprint.js +38 -1
- package/dist/resources/hook-footprint.js.map +1 -1
- package/dist/resources/run-class.d.ts +38 -5
- package/dist/resources/run-class.d.ts.map +1 -1
- package/dist/resources/run-class.js +60 -11
- package/dist/resources/run-class.js.map +1 -1
- package/dist/resources/run-governed.d.ts +13 -3
- package/dist/resources/run-governed.d.ts.map +1 -1
- package/dist/resources/run-governed.js +42 -9
- package/dist/resources/run-governed.js.map +1 -1
- package/dist/resources/slot-holder.d.ts +3 -3
- package/dist/resources/slot-holder.js +3 -3
- package/dist/resources/tool-groups.d.ts +11 -7
- package/dist/resources/tool-groups.d.ts.map +1 -1
- package/dist/resources/tool-groups.js +24 -13
- package/dist/resources/tool-groups.js.map +1 -1
- package/dist/spawn/branch-integration.d.ts +281 -0
- package/dist/spawn/branch-integration.d.ts.map +1 -0
- package/dist/spawn/branch-integration.js +715 -0
- package/dist/spawn/branch-integration.js.map +1 -0
- package/dist/spawn/branch-lock.d.ts +32 -270
- package/dist/spawn/branch-lock.d.ts.map +1 -1
- package/dist/spawn/branch-lock.js +59 -770
- package/dist/spawn/branch-lock.js.map +1 -1
- package/dist/spawn/index.d.ts +3 -1
- package/dist/spawn/index.d.ts.map +1 -1
- package/dist/spawn/index.js +5 -1
- package/dist/spawn/index.js.map +1 -1
- package/dist/store/exodus/recovery.d.ts.map +1 -1
- package/dist/store/exodus/recovery.js +9 -4
- package/dist/store/exodus/recovery.js.map +1 -1
- package/dist/system/health.d.ts.map +1 -1
- package/dist/system/health.js +3 -1
- package/dist/system/health.js.map +1 -1
- package/dist/tasks/ac-coverage-gate.d.ts +18 -2
- package/dist/tasks/ac-coverage-gate.d.ts.map +1 -1
- package/dist/tasks/ac-coverage-gate.js +40 -6
- package/dist/tasks/ac-coverage-gate.js.map +1 -1
- package/dist/tasks/ac-table.d.ts.map +1 -1
- package/dist/tasks/ac-table.js +3 -10
- package/dist/tasks/ac-table.js.map +1 -1
- package/dist/tasks/affected-packages.d.ts.map +1 -1
- package/dist/tasks/affected-packages.js +54 -10
- package/dist/tasks/affected-packages.js.map +1 -1
- package/dist/tasks/affected-scope.d.ts +10 -1
- package/dist/tasks/affected-scope.d.ts.map +1 -1
- package/dist/tasks/affected-scope.js +13 -3
- package/dist/tasks/affected-scope.js.map +1 -1
- package/dist/tasks/change-set.d.ts +9 -6
- package/dist/tasks/change-set.d.ts.map +1 -1
- package/dist/tasks/change-set.js +75 -97
- package/dist/tasks/change-set.js.map +1 -1
- package/dist/tasks/command-line.d.ts.map +1 -1
- package/dist/tasks/command-line.js +6 -2
- package/dist/tasks/command-line.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +7 -2
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/done-plan.d.ts.map +1 -1
- package/dist/tasks/done-plan.js +111 -29
- package/dist/tasks/done-plan.js.map +1 -1
- package/dist/tasks/done-record.d.ts +14 -0
- package/dist/tasks/done-record.d.ts.map +1 -1
- package/dist/tasks/done-record.js +17 -10
- package/dist/tasks/done-record.js.map +1 -1
- package/dist/tasks/evidence-tree.d.ts +33 -0
- package/dist/tasks/evidence-tree.d.ts.map +1 -0
- package/dist/tasks/evidence-tree.js +132 -0
- package/dist/tasks/evidence-tree.js.map +1 -0
- package/dist/tasks/evidence.d.ts +42 -6
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +175 -79
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/gate-preview.d.ts.map +1 -1
- package/dist/tasks/gate-preview.js +3 -2
- package/dist/tasks/gate-preview.js.map +1 -1
- package/dist/tasks/gate-runner.d.ts +11 -3
- package/dist/tasks/gate-runner.d.ts.map +1 -1
- package/dist/tasks/gate-runner.js +156 -37
- package/dist/tasks/gate-runner.js.map +1 -1
- package/dist/tasks/gate-staleness.d.ts +58 -0
- package/dist/tasks/gate-staleness.d.ts.map +1 -0
- package/dist/tasks/gate-staleness.js +78 -0
- package/dist/tasks/gate-staleness.js.map +1 -0
- package/dist/tasks/heavy-tool-env.d.ts +26 -1
- package/dist/tasks/heavy-tool-env.d.ts.map +1 -1
- package/dist/tasks/heavy-tool-env.js +92 -5
- package/dist/tasks/heavy-tool-env.js.map +1 -1
- package/dist/tasks/heavy-tool-limit.d.ts +24 -0
- package/dist/tasks/heavy-tool-limit.d.ts.map +1 -1
- package/dist/tasks/heavy-tool-limit.js +44 -1
- package/dist/tasks/heavy-tool-limit.js.map +1 -1
- package/dist/tasks/show.d.ts.map +1 -1
- package/dist/tasks/show.js +43 -0
- package/dist/tasks/show.js.map +1 -1
- package/dist/tasks/test-run-binding.d.ts +48 -3
- package/dist/tasks/test-run-binding.d.ts.map +1 -1
- package/dist/tasks/test-run-binding.js +111 -3
- package/dist/tasks/test-run-binding.js.map +1 -1
- package/dist/tasks/tool-cache.d.ts +58 -7
- package/dist/tasks/tool-cache.d.ts.map +1 -1
- package/dist/tasks/tool-cache.js +216 -16
- package/dist/tasks/tool-cache.js.map +1 -1
- package/dist/tasks/tool-resolver.d.ts +13 -0
- package/dist/tasks/tool-resolver.d.ts.map +1 -1
- package/dist/tasks/tool-resolver.js +34 -0
- package/dist/tasks/tool-resolver.js.map +1 -1
- package/dist/tasks/tool-runner-guard.d.ts +6 -0
- package/dist/tasks/tool-runner-guard.d.ts.map +1 -1
- package/dist/tasks/tool-runner-guard.js.map +1 -1
- package/dist/tasks/tool-semaphore.js +1 -1
- package/dist/tasks/tool-semaphore.js.map +1 -1
- package/dist/tasks/verification-policy.d.ts +26 -3
- package/dist/tasks/verification-policy.d.ts.map +1 -1
- package/dist/tasks/verification-policy.js +76 -11
- package/dist/tasks/verification-policy.js.map +1 -1
- package/dist/validation/doctor/checks.d.ts +12 -0
- package/dist/validation/doctor/checks.d.ts.map +1 -1
- package/dist/validation/doctor/checks.js +33 -0
- package/dist/validation/doctor/checks.js.map +1 -1
- package/dist/validation/engine-ops.d.ts +6 -0
- package/dist/validation/engine-ops.d.ts.map +1 -1
- package/dist/validation/engine-ops.js +51 -1
- package/dist/validation/engine-ops.js.map +1 -1
- package/dist/worktree/force-unlock.js +2 -2
- package/dist/worktree/force-unlock.js.map +1 -1
- package/dist/worktree/list.d.ts +7 -7
- package/dist/worktree/list.d.ts.map +1 -1
- package/dist/worktree/list.js +11 -56
- package/dist/worktree/list.js.map +1 -1
- package/dist/worktree/prune.d.ts +2 -2
- package/dist/worktree/prune.d.ts.map +1 -1
- package/dist/worktree/prune.js +4 -4
- package/dist/worktree/prune.js.map +1 -1
- 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
|