@cr1992/agentkit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.en.md +107 -0
- package/README.md +103 -0
- package/bin/agentkit.mjs +4 -0
- package/bin/cli.mjs +273 -0
- package/core/atomic-fs.mjs +23 -0
- package/core/cli-help.mjs +54 -0
- package/core/content-digest.mjs +66 -0
- package/core/digest.mjs +67 -0
- package/core/json-schema-lite.mjs +60 -0
- package/core/legacy-entry.mjs +37 -0
- package/core/reflection.mjs +142 -0
- package/core/runtime-bundle.mjs +101 -0
- package/docs/loop/embedded-review-adapter.md +41 -0
- package/docs/loop/loop-state-machine.md +43 -0
- package/docs/loop/recovery-and-fuses.md +34 -0
- package/docs/orchestrate/dispatch-contract.md +92 -0
- package/docs/orchestrate/failure-routing-and-recovery.md +46 -0
- package/docs/orchestrate/host-capability-cache.md +170 -0
- package/docs/orchestrate/isolation-fallback.md +18 -0
- package/docs/orchestrate/model-routing-config.md +186 -0
- package/docs/orchestrate/orchestration-runtime.md +261 -0
- package/docs/orchestrate/review-budget.md +90 -0
- package/docs/orchestrate/task-playbooks.md +85 -0
- package/docs/orchestrate/user-facing-reporting.md +14 -0
- package/docs/verify/evidence-schema.md +167 -0
- package/docs/verify/input-preparation.md +44 -0
- package/docs/verify/verification-protocol.md +76 -0
- package/docs/worktree/batch-integration.md +176 -0
- package/docs/worktree/delivery-identity.md +41 -0
- package/docs/worktree/profile.md +107 -0
- package/docs/worktree/reclaim-and-watch.md +96 -0
- package/docs/worktree/review-lifecycle.md +92 -0
- package/docs/worktree/spawn-and-stack.md +74 -0
- package/domains/loop/loop-runtime.mjs +1056 -0
- package/domains/orchestrate/contract-tool.mjs +169 -0
- package/domains/orchestrate/host_capability_cache.mjs +437 -0
- package/domains/orchestrate/orchestration-ledger.mjs +332 -0
- package/domains/orchestrate/orchestration-metadata.mjs +4 -0
- package/domains/orchestrate/orchestration-reflection.mjs +119 -0
- package/domains/orchestrate/resolve_model_policy.mjs +311 -0
- package/domains/orchestrate/review-budget.mjs +162 -0
- package/domains/orchestrate/worker-capability-preflight.mjs +227 -0
- package/domains/verify/verification-runtime.mjs +1638 -0
- package/domains/worktree/worktree-archive.mjs +135 -0
- package/domains/worktree/worktree-artifact.mjs +123 -0
- package/domains/worktree/worktree-batch-integrate.mjs +713 -0
- package/domains/worktree/worktree-batch-plan.mjs +198 -0
- package/domains/worktree/worktree-batch-result.mjs +241 -0
- package/domains/worktree/worktree-core.mjs +908 -0
- package/domains/worktree/worktree-doctor.mjs +493 -0
- package/domains/worktree/worktree-history.mjs +377 -0
- package/domains/worktree/worktree-learning.mjs +110 -0
- package/domains/worktree/worktree-lifecycle.mjs +786 -0
- package/domains/worktree/worktree-merge-preview.mjs +409 -0
- package/domains/worktree/worktree-mgr.mjs +261 -0
- package/domains/worktree/worktree-process.mjs +55 -0
- package/domains/worktree/worktree-profile.mjs +800 -0
- package/domains/worktree/worktree-provider-gitlab.mjs +59 -0
- package/domains/worktree/worktree-reclaim.mjs +683 -0
- package/domains/worktree/worktree-review-refresh.mjs +574 -0
- package/domains/worktree/worktree-review-watch.mjs +661 -0
- package/domains/worktree/worktree-scan.mjs +510 -0
- package/domains/worktree/worktree-trace-test-worker.mjs +23 -0
- package/domains/worktree/worktree-trace.mjs +478 -0
- package/manage-worktrees/SKILL.md +87 -0
- package/manage-worktrees/agents/openai.yaml +4 -0
- package/manage-worktrees/scripts/worktree-mgr.mjs +10 -0
- package/manage-worktrees/scripts/worktree-scan.mjs +10 -0
- package/orchestrate-subagents/SKILL.md +173 -0
- package/orchestrate-subagents/agents/openai.yaml +4 -0
- package/orchestrate-subagents/scripts/contract-tool.mjs +10 -0
- package/orchestrate-subagents/scripts/host_capability_cache.mjs +10 -0
- package/orchestrate-subagents/scripts/orchestration-ledger.mjs +10 -0
- package/orchestrate-subagents/scripts/orchestration-reflection.mjs +10 -0
- package/orchestrate-subagents/scripts/resolve_model_policy.mjs +10 -0
- package/orchestrate-subagents/scripts/review-budget.mjs +10 -0
- package/orchestrate-subagents/scripts/worker-capability-preflight.mjs +10 -0
- package/package.json +48 -0
- package/run-agent-verify-loop/SKILL.md +127 -0
- package/run-agent-verify-loop/agents/openai.yaml +4 -0
- package/run-agent-verify-loop/scripts/loop-runtime.mjs +10 -0
- package/schemas/artifact-ref-v1.schema.json +23 -0
- package/schemas/batch-result-v1.schema.json +138 -0
- package/schemas/controller-recheck-record-v1.schema.json +22 -0
- package/schemas/convergence-report-v1.schema.json +9 -0
- package/schemas/effective-worker-capability-v1.schema.json +36 -0
- package/schemas/embedded-verification-record-v1.schema.json +32 -0
- package/schemas/evidence-package-v1.schema.json +41 -0
- package/schemas/improvement-proposal-v1.schema.json +18 -0
- package/schemas/loop-state-v1.schema.json +34 -0
- package/schemas/model-policy-resolution-v1.schema.json +41 -0
- package/schemas/orchestration-ledger-v1.schema.json +110 -0
- package/schemas/reflection-record-v1.schema.json +24 -0
- package/schemas/review-result-v1.schema.json +37 -0
- package/schemas/task-contract-v1.schema.json +83 -0
- package/schemas/verification-profile-v1.schema.json +60 -0
- package/schemas/worker-capability-requirements-v1.schema.json +21 -0
- package/schemas/worktree-binding-v1.schema.json +14 -0
- package/shell-manifest.json +79 -0
- package/verify-agent-output/SKILL.md +119 -0
- package/verify-agent-output/agents/openai.yaml +4 -0
- package/verify-agent-output/scripts/verification-runtime.mjs +10 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { runFileCapture, runFileTry } from './worktree-process.mjs';
|
|
10
|
+
import {
|
|
11
|
+
PROFILE_FILENAME,
|
|
12
|
+
loadRepositoryProfile,
|
|
13
|
+
} from './worktree-profile.mjs';
|
|
14
|
+
import {
|
|
15
|
+
appendTraceEvent,
|
|
16
|
+
listRecordCacheEntries,
|
|
17
|
+
traceLayout,
|
|
18
|
+
} from './worktree-trace.mjs';
|
|
19
|
+
import { distributionDigest, skillDistributionRoots } from '../../core/content-digest.mjs';
|
|
20
|
+
|
|
21
|
+
export { runFileCapture, runFileTry } from './worktree-process.mjs';
|
|
22
|
+
|
|
23
|
+
export const PREFIX = '[worktree-mgr]';
|
|
24
|
+
export const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'manage-worktrees');
|
|
25
|
+
const PACKAGE_ROOT = resolve(SKILL_ROOT, '..');
|
|
26
|
+
const DOMAIN_ROOT = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
// 与另外三个域同一套摘要口径:Skill 目录 + 共享 core + canonical schemas。
|
|
28
|
+
export function worktreeSkillDigest(root = SKILL_ROOT) {
|
|
29
|
+
return distributionDigest(skillDistributionRoots({ packageRoot: PACKAGE_ROOT, skillRoot: root, domainRoot: DOMAIN_ROOT, docsRoot: join(PACKAGE_ROOT, 'docs', 'worktree') }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
33
|
+
export const FETCH_TIMEOUT_MS = 10_000;
|
|
34
|
+
export const SUBMIT_PUSH_TIMEOUT_MS = 60_000;
|
|
35
|
+
export const CODEGRAPH_TIMEOUT_MS = 10 * 60_000;
|
|
36
|
+
export const WATCH_DEFAULT_INTERVAL_MS = 30_000;
|
|
37
|
+
export const WATCH_MIN_INTERVAL_MS = 100;
|
|
38
|
+
export const WATCH_MAX_INTERVAL_MS = 60 * 60_000;
|
|
39
|
+
export const WATCH_HEARTBEAT_MIN_STALE_MS = 5_000;
|
|
40
|
+
export const WATCH_TARGET_CACHE_MAX_AGE_MS = 15_000;
|
|
41
|
+
export const WATCH_TARGET_LEASE_STALE_MS = FETCH_TIMEOUT_MS * 2;
|
|
42
|
+
export const WATCH_SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
|
|
43
|
+
export const TERMINAL_TASK_STATES = new Set(['done', 'abandoned']);
|
|
44
|
+
export const TASK_TRANSITIONS = {
|
|
45
|
+
active: new Set(['active', 'blocked', 'ready_for_review', 'abandoned']),
|
|
46
|
+
blocked: new Set(['blocked', 'active', 'abandoned']),
|
|
47
|
+
ready_for_review: new Set(['ready_for_review', 'integrating', 'active', 'abandoned']),
|
|
48
|
+
integrating: new Set(['integrating', 'done', 'active', 'abandoned']),
|
|
49
|
+
done: new Set(['done']),
|
|
50
|
+
abandoned: new Set(['abandoned']),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** @param {string[]} args @param {string} [cwd] */
|
|
54
|
+
export function git(args, cwd = process.cwd()) {
|
|
55
|
+
return execFileSync('git', args, {
|
|
56
|
+
cwd,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
59
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
60
|
+
}).trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {string[]} args @param {string} [cwd] @param {{timeoutMs?:number,stdio?:any,env?:NodeJS.ProcessEnv}} [options] */
|
|
64
|
+
export function gitTry(args, cwd = process.cwd(), options = {}) {
|
|
65
|
+
return runFileTry('git', args, { cwd, ...options });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {string|Buffer} value */
|
|
69
|
+
export function contentDigest(value) {
|
|
70
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Installation-path-independent digest used in cross-Skill contracts. */
|
|
74
|
+
/** @param {{error?:unknown}} result @param {string} fallback */
|
|
75
|
+
export function commandFailureReason(result, fallback) {
|
|
76
|
+
const error = result.error;
|
|
77
|
+
if (error && typeof error === 'object' && 'stderr' in error) {
|
|
78
|
+
const stderr = String(error.stderr ?? '').trim();
|
|
79
|
+
if (stderr) return stderr.slice(0, 500);
|
|
80
|
+
}
|
|
81
|
+
if (error instanceof Error && error.message) return error.message.slice(0, 500);
|
|
82
|
+
return fallback;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {unknown} error */
|
|
86
|
+
export function isRootWriteDenied(error) {
|
|
87
|
+
return Boolean(
|
|
88
|
+
error &&
|
|
89
|
+
typeof error === 'object' &&
|
|
90
|
+
'code' in error &&
|
|
91
|
+
['EACCES', 'EPERM', 'EROFS'].includes(String(error.code)),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {number} milliseconds */
|
|
96
|
+
export function sleep(milliseconds) {
|
|
97
|
+
Atomics.wait(WATCH_SLEEP_CELL, 0, 0, milliseconds);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @param {number} pid @param {(pid:number,signal:0)=>void} [probe] */
|
|
101
|
+
export function processIsAlive(pid, probe = process.kill) {
|
|
102
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
103
|
+
try {
|
|
104
|
+
probe(pid, 0);
|
|
105
|
+
return true;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// kill(2) 的 EPERM 表示进程存在、只是当前 sandbox/user 无权 signal;不能误报 stale。
|
|
108
|
+
return Boolean(error && typeof error === 'object' && error.code === 'EPERM');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {string} commonDir */
|
|
113
|
+
export function watcherDirectory(commonDir) {
|
|
114
|
+
return join(traceLayout(commonDir).root, 'watchers');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {string} commonDir @param {string} worktreeId */
|
|
118
|
+
export function watcherPath(commonDir, worktreeId) {
|
|
119
|
+
return join(watcherDirectory(commonDir), `${worktreeId}.json`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {string} commonDir @param {string} worktreeId */
|
|
123
|
+
export function readWatcherHeartbeat(commonDir, worktreeId) {
|
|
124
|
+
const path = watcherPath(commonDir, worktreeId);
|
|
125
|
+
if (!existsSync(path)) return { ok: false, state: null, error: 'missing' };
|
|
126
|
+
try {
|
|
127
|
+
return { ok: true, state: JSON.parse(readFileSync(path, 'utf8')), error: null };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return { ok: false, state: null, error: error instanceof Error ? error.message : String(error) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @param {string} commonDir @param {Record<string,unknown>} state */
|
|
134
|
+
export function writeWatcherHeartbeat(commonDir, state) {
|
|
135
|
+
const directory = watcherDirectory(commonDir);
|
|
136
|
+
mkdirSync(directory, { recursive: true });
|
|
137
|
+
const path = watcherPath(commonDir, String(state.worktree_id));
|
|
138
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
139
|
+
writeFileSync(temporary, `${JSON.stringify({ ...state, heartbeat_at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
140
|
+
renameSync(temporary, path);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @param {string} commonDir @param {string} worktreeId @param {string} token */
|
|
144
|
+
export function removeWatcherHeartbeat(commonDir, worktreeId, token) {
|
|
145
|
+
const current = readWatcherHeartbeat(commonDir, worktreeId);
|
|
146
|
+
if (current.ok && current.state?.token !== token) return;
|
|
147
|
+
rmSync(watcherPath(commonDir, worktreeId), { force: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @param {unknown} raw */
|
|
151
|
+
export function parseWatchInterval(raw) {
|
|
152
|
+
const value = raw === null || raw === undefined ? WATCH_DEFAULT_INTERVAL_MS : Number(raw);
|
|
153
|
+
if (!Number.isInteger(value) || value < WATCH_MIN_INTERVAL_MS || value > WATCH_MAX_INTERVAL_MS) {
|
|
154
|
+
die(`--interval-ms 必须是 ${WATCH_MIN_INTERVAL_MS}-${WATCH_MAX_INTERVAL_MS} 的整数。`, 2);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {Record<string,any>} record @param {{ok:boolean,state:any,error:string|null}} heartbeat */
|
|
160
|
+
export function watcherHealth(record, heartbeat) {
|
|
161
|
+
const armed = record.auto_reclaim;
|
|
162
|
+
if (!armed || armed.state === 'disarmed' || armed.state === 'reclaimed') return { healthy: false, reason: 'not armed' };
|
|
163
|
+
if (!heartbeat.ok) return { healthy: false, reason: heartbeat.error ?? 'missing heartbeat' };
|
|
164
|
+
if (heartbeat.state?.token !== armed.token) return { healthy: false, reason: 'token mismatch' };
|
|
165
|
+
if (!processIsAlive(Number(heartbeat.state?.pid))) return { healthy: false, reason: 'pid not alive' };
|
|
166
|
+
const heartbeatAt = Date.parse(String(heartbeat.state?.heartbeat_at ?? ''));
|
|
167
|
+
const staleAfter = Math.max(Number(armed.interval_ms ?? WATCH_DEFAULT_INTERVAL_MS) * 4, WATCH_HEARTBEAT_MIN_STALE_MS);
|
|
168
|
+
if (!Number.isFinite(heartbeatAt) || Date.now() - heartbeatAt > staleAfter) return { healthy: false, reason: 'heartbeat stale' };
|
|
169
|
+
return { healthy: true, reason: null };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {string} targetRef @param {string} cwd */
|
|
173
|
+
export function refreshTargetRef(targetRef, cwd) {
|
|
174
|
+
const remotes = gitTry(['remote'], cwd);
|
|
175
|
+
const remote = remotes.ok
|
|
176
|
+
? remotes.out.split('\n').filter(Boolean).find((name) => targetRef.startsWith(`${name}/`))
|
|
177
|
+
: null;
|
|
178
|
+
let fetch = { ok: true, out: '' };
|
|
179
|
+
if (remote) {
|
|
180
|
+
const branch = targetRef.slice(remote.length + 1);
|
|
181
|
+
fetch = gitTry(['fetch', remote, branch, '-q'], cwd, { timeoutMs: FETCH_TIMEOUT_MS, stdio: 'ignore' });
|
|
182
|
+
}
|
|
183
|
+
const resolved = gitTry(['rev-parse', `${targetRef}^{commit}`], cwd);
|
|
184
|
+
return { ok: resolved.ok, target_sha: resolved.ok ? resolved.out : null, fetch_ok: fetch.ok };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @param {string} commonDir */
|
|
188
|
+
export function targetCacheDirectory(commonDir) {
|
|
189
|
+
return join(traceLayout(commonDir).root, 'watch-targets');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** @param {string} targetRef */
|
|
193
|
+
export function targetCacheKey(targetRef) {
|
|
194
|
+
return createHash('sha256').update(targetRef).digest('hex');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @param {string} commonDir @param {string} targetRef */
|
|
198
|
+
export function readTargetCache(commonDir, targetRef) {
|
|
199
|
+
const path = join(targetCacheDirectory(commonDir), `${targetCacheKey(targetRef)}.json`);
|
|
200
|
+
if (!existsSync(path)) return null;
|
|
201
|
+
try {
|
|
202
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
203
|
+
return state?.target_ref === targetRef ? state : null;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @param {Record<string,any>|null} state @param {number} maxAgeMs */
|
|
210
|
+
export function freshTargetCache(state, maxAgeMs) {
|
|
211
|
+
const fetchedAt = Date.parse(String(state?.fetched_at ?? ''));
|
|
212
|
+
const age = Date.now() - fetchedAt;
|
|
213
|
+
return Number.isFinite(fetchedAt) && age >= 0 && age <= maxAgeMs ? state : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 多个 per-record worker 通过 common-dir cache + 原子 mkdir lease 合并同 target fetch。
|
|
218
|
+
* cache 只减少远端请求;调用方仍必须逐 record 执行 merge-base 祖先校验。
|
|
219
|
+
* @param {string} commonDir
|
|
220
|
+
* @param {string} targetRef
|
|
221
|
+
* @param {string} cwd
|
|
222
|
+
* @param {number} [maxAgeMs]
|
|
223
|
+
*/
|
|
224
|
+
export function refreshTargetRefCached(commonDir, targetRef, cwd, maxAgeMs = WATCH_TARGET_CACHE_MAX_AGE_MS) {
|
|
225
|
+
const cached = freshTargetCache(readTargetCache(commonDir, targetRef), maxAgeMs);
|
|
226
|
+
if (cached) return { ok: cached.ok, target_sha: cached.target_sha, fetch_ok: cached.fetch_ok, cache_hit: true, fetch_count: cached.fetch_count };
|
|
227
|
+
|
|
228
|
+
const directory = targetCacheDirectory(commonDir);
|
|
229
|
+
mkdirSync(directory, { recursive: true });
|
|
230
|
+
const key = targetCacheKey(targetRef);
|
|
231
|
+
const lockPath = join(directory, `${key}.lock`);
|
|
232
|
+
let ownsLease = false;
|
|
233
|
+
try {
|
|
234
|
+
mkdirSync(lockPath);
|
|
235
|
+
ownsLease = true;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (!error || typeof error !== 'object' || error.code !== 'EEXIST') throw error;
|
|
238
|
+
try {
|
|
239
|
+
if (Date.now() - statSync(lockPath).mtimeMs > WATCH_TARGET_LEASE_STALE_MS) {
|
|
240
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
241
|
+
mkdirSync(lockPath);
|
|
242
|
+
ownsLease = true;
|
|
243
|
+
}
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!ownsLease) {
|
|
248
|
+
const deadline = Date.now() + Math.min(FETCH_TIMEOUT_MS, 2_000);
|
|
249
|
+
while (Date.now() < deadline) {
|
|
250
|
+
sleep(25);
|
|
251
|
+
const shared = freshTargetCache(readTargetCache(commonDir, targetRef), maxAgeMs);
|
|
252
|
+
if (shared) return { ok: shared.ok, target_sha: shared.target_sha, fetch_ok: shared.fetch_ok, cache_hit: true, fetch_count: shared.fetch_count };
|
|
253
|
+
}
|
|
254
|
+
return { ...refreshTargetRef(targetRef, cwd), cache_hit: false, cache_bypassed: true, fetch_count: null };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const afterLease = freshTargetCache(readTargetCache(commonDir, targetRef), maxAgeMs);
|
|
259
|
+
if (afterLease) return { ok: afterLease.ok, target_sha: afterLease.target_sha, fetch_ok: afterLease.fetch_ok, cache_hit: true, fetch_count: afterLease.fetch_count };
|
|
260
|
+
const previous = readTargetCache(commonDir, targetRef);
|
|
261
|
+
const refreshed = refreshTargetRef(targetRef, cwd);
|
|
262
|
+
const state = {
|
|
263
|
+
schema_version: 1,
|
|
264
|
+
target_ref: targetRef,
|
|
265
|
+
...refreshed,
|
|
266
|
+
fetched_at: new Date().toISOString(),
|
|
267
|
+
fetch_count: Number(previous?.fetch_count ?? 0) + 1,
|
|
268
|
+
};
|
|
269
|
+
const path = join(directory, `${key}.json`);
|
|
270
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
271
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
272
|
+
renameSync(temporary, path);
|
|
273
|
+
return { ...refreshed, cache_hit: false, fetch_count: state.fetch_count };
|
|
274
|
+
} finally {
|
|
275
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** @param {unknown} raw */
|
|
280
|
+
export function parseNotifyMode(raw) {
|
|
281
|
+
const mode = raw === null || raw === undefined ? 'auto' : String(raw);
|
|
282
|
+
if (!['auto', 'off'].includes(mode)) die('--notify 只接受 auto/off。', 2);
|
|
283
|
+
return mode;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 固定 argv 的 best-effort 通知 adapter;不拼 shell,也不执行仓库/Profile 字符串。
|
|
288
|
+
* @param {Record<string,any>} record
|
|
289
|
+
* @param {{platform?:string, runner?:typeof runFileTry}} [options]
|
|
290
|
+
*/
|
|
291
|
+
export function deliverReclaimNotification(record, options = {}) {
|
|
292
|
+
const mode = record.auto_reclaim?.notify ?? 'auto';
|
|
293
|
+
if (mode === 'off') return { attempted: false, delivered: false, adapter: 'off', reason: 'disabled' };
|
|
294
|
+
const platform = options.platform ?? process.platform;
|
|
295
|
+
const runner = options.runner ?? runFileTry;
|
|
296
|
+
const summary = record.reclaim_summary ?? {};
|
|
297
|
+
const change = summary.change_ref ? ` (${summary.change_ref})` : '';
|
|
298
|
+
const message = `${record.task}${change} 已自动回收`;
|
|
299
|
+
let adapter;
|
|
300
|
+
let command;
|
|
301
|
+
let args;
|
|
302
|
+
if (platform === 'darwin') {
|
|
303
|
+
adapter = 'macos-osascript';
|
|
304
|
+
command = 'osascript';
|
|
305
|
+
args = ['-e', 'on run argv', '-e', 'display notification (item 1 of argv) with title (item 2 of argv)', '-e', 'end run', message, 'Worktree 已回收'];
|
|
306
|
+
} else if (platform === 'linux') {
|
|
307
|
+
adapter = 'linux-notify-send';
|
|
308
|
+
command = 'notify-send';
|
|
309
|
+
args = ['Worktree 已回收', message];
|
|
310
|
+
} else {
|
|
311
|
+
return { attempted: false, delivered: false, adapter: 'unavailable', reason: `unsupported platform: ${platform}` };
|
|
312
|
+
}
|
|
313
|
+
const result = runner(command, args, { timeoutMs: 3_000, stdio: 'ignore' });
|
|
314
|
+
return { attempted: true, delivered: result.ok, adapter, reason: result.ok ? null : 'notification command unavailable/failed' };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function log(message) {
|
|
318
|
+
console.log(`${PREFIX} ${message}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function die(message, code = 1) {
|
|
322
|
+
console.error(`${PREFIX} ${message}`);
|
|
323
|
+
process.exit(code);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 读取并解析用户提供的 JSON 文件。读取/解析失败都按用法错误 fail-closed(exit 2)并给出
|
|
328
|
+
* 可诊断信息,而不是让原始异常堆栈冒出——那看起来像内部崩溃,exit code 语义也是错的。
|
|
329
|
+
* @param {string} path @param {string} label
|
|
330
|
+
*/
|
|
331
|
+
export function readJsonFileOrDie(path, label) {
|
|
332
|
+
let raw = '';
|
|
333
|
+
try {
|
|
334
|
+
raw = readFileSync(path, 'utf8');
|
|
335
|
+
} catch (error) {
|
|
336
|
+
die(`${label} 读取失败: ${error instanceof Error ? error.message : String(error)}`, 2);
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
return JSON.parse(raw);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
die(`${label} 不是合法 JSON: ${error instanceof Error ? error.message : String(error)}`, 2);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** @param {string[]} argv */
|
|
346
|
+
export function parseArgs(argv) {
|
|
347
|
+
const flags = new Map();
|
|
348
|
+
const positionals = [];
|
|
349
|
+
const booleanFlags = new Set(['json', 'all', 'present', 'archived', 'verbose', 'recover-lock', 'no-watch', 'abort-on-conflict', 'no-rerere', 'recompose', 'scan-conflicts', 'abort', 'continue', 'pause-before-push']);
|
|
350
|
+
for (let index = 0; index < argv.length; index++) {
|
|
351
|
+
const value = argv[index];
|
|
352
|
+
if (!value.startsWith('--')) {
|
|
353
|
+
positionals.push(value);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const equal = value.indexOf('=');
|
|
357
|
+
const key = value.slice(2, equal >= 0 ? equal : undefined);
|
|
358
|
+
if (booleanFlags.has(key)) {
|
|
359
|
+
flags.set(key, true);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const flagValue = equal >= 0 ? value.slice(equal + 1) : argv[++index];
|
|
363
|
+
if (flagValue === undefined || flagValue.startsWith('--')) die(`--${key} 后需要值。`, 2);
|
|
364
|
+
flags.set(key, flagValue);
|
|
365
|
+
}
|
|
366
|
+
return { flags, positionals };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** @param {Map<string, unknown>} flags @param {string} name */
|
|
370
|
+
export function flag(flags, name) {
|
|
371
|
+
const value = flags.get(name);
|
|
372
|
+
return typeof value === 'string' ? value : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** @param {Map<string, unknown>} flags @param {string[]} allowed */
|
|
376
|
+
export function rejectUnknownFlags(flags, allowed) {
|
|
377
|
+
const allow = new Set(allowed);
|
|
378
|
+
for (const key of flags.keys()) if (!allow.has(key)) die(`未知 flag: --${key}`, 2);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @param {string} value @param {string} label @param {number} max */
|
|
382
|
+
export function oneLine(value, label, max) {
|
|
383
|
+
const normalized = value.trim();
|
|
384
|
+
if (!normalized || normalized.length > max || /[\u0000-\u001f\u007f\r\n]/.test(normalized)) {
|
|
385
|
+
die(`${label} 必须是 1-${max} 字符的单行文本。`, 2);
|
|
386
|
+
}
|
|
387
|
+
return normalized;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** @param {string} value @param {string} label */
|
|
391
|
+
export function httpUrl(value, label) {
|
|
392
|
+
const normalized = oneLine(value, label, 1000);
|
|
393
|
+
let parsed;
|
|
394
|
+
try { parsed = new URL(normalized); } catch { die(`${label} 必须是合法 http(s) URL。`, 2); }
|
|
395
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) die(`${label} 只接受 http(s) URL。`, 2);
|
|
396
|
+
return normalized;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** 同义 flag 只允许给出一个值,避免调用者以为其中一个被忽略。 */
|
|
400
|
+
export function aliasedFlag(flags, primary, alias) {
|
|
401
|
+
const left = flag(flags, primary);
|
|
402
|
+
const right = flag(flags, alias);
|
|
403
|
+
if (left && right && left !== right) die(`--${primary} 与 --${alias} 指向不同值,拒绝歧义。`, 2);
|
|
404
|
+
return left ?? right;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** @param {Map<string, unknown>} flags @param {{target?:boolean, requirePurpose?:boolean}} [options] */
|
|
408
|
+
export function resolveIdentity(flags, options = {}) {
|
|
409
|
+
const target = options.target ?? false;
|
|
410
|
+
const hostFlag = target ? 'to-agent' : 'agent';
|
|
411
|
+
const idFlag = target ? 'to-agent-id' : 'agent-id';
|
|
412
|
+
const hostEnv = target ? null : process.env.WORKTREE_AGENT_HOST;
|
|
413
|
+
const idEnv = target ? null : process.env.WORKTREE_AGENT_ID;
|
|
414
|
+
const host = flag(flags, hostFlag) ?? hostEnv ?? null;
|
|
415
|
+
const id = flag(flags, idFlag) ?? idEnv ?? null;
|
|
416
|
+
if (!host || !id) {
|
|
417
|
+
die(
|
|
418
|
+
[
|
|
419
|
+
`需要 --${hostFlag} 和 --${idFlag}(或标准 WORKTREE_AGENT_* 环境变量)。`,
|
|
420
|
+
` --${hostFlag}: 宿主名,取 claude / codex / kiro / cursor / aider / human / custom:<name>。`,
|
|
421
|
+
` --${idFlag}: 宿主的真实 session/thread ID(如 Claude Code 会话 UUID、Codex thread id)——去宿主上下文里查,禁止现编占位值,否则 trace 记录无法回溯到会话。`,
|
|
422
|
+
` 取值规范:使用可读 semantic task,branch/path 保留完整 host 与 task。`,
|
|
423
|
+
].join('\n'),
|
|
424
|
+
2,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
const normalizedHost = oneLine(host, hostFlag, 64).toLowerCase();
|
|
428
|
+
if (!/^(?:[a-z][a-z0-9._:-]{0,63}|custom:[a-z0-9._-]+)$/.test(normalizedHost)) {
|
|
429
|
+
die(`${hostFlag} 格式非法。`, 2);
|
|
430
|
+
}
|
|
431
|
+
const actor = { host: normalizedHost, id: oneLine(id, idFlag, 128) };
|
|
432
|
+
const purposeRaw = flag(flags, 'purpose') ?? process.env.WORKTREE_TASK_PURPOSE ?? null;
|
|
433
|
+
const purpose = options.requirePurpose
|
|
434
|
+
? oneLine(purposeRaw ?? '', 'purpose', 240)
|
|
435
|
+
: purposeRaw
|
|
436
|
+
? oneLine(purposeRaw, 'purpose', 240)
|
|
437
|
+
: null;
|
|
438
|
+
return {
|
|
439
|
+
actor,
|
|
440
|
+
purpose,
|
|
441
|
+
owner: flag(flags, 'owner') ?? process.env.WORKTREE_OWNER ?? null,
|
|
442
|
+
sources: {
|
|
443
|
+
host: flag(flags, hostFlag) ? 'CLI' : 'ENV',
|
|
444
|
+
id: flag(flags, idFlag) ? 'CLI' : 'ENV',
|
|
445
|
+
purpose: purposeRaw ? (flag(flags, 'purpose') ? 'CLI' : 'ENV') : null,
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function printIdentity(identity, task, profile) {
|
|
451
|
+
log(`Agent: ${identity.actor.host} / ${identity.actor.id}(host=${identity.sources.host}, id=${identity.sources.id})`);
|
|
452
|
+
log(`Task: ${task}`);
|
|
453
|
+
if (identity.purpose) log(`Purpose: ${identity.purpose}(${identity.sources.purpose})`);
|
|
454
|
+
if (identity.owner) log(`Owner: ${identity.owner}`);
|
|
455
|
+
log(`Profile: ${profile.profile_source} (${profile.profile_path})`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* primary Profile 决定 branch/path 命名,必须先与其声明的 remote base 对齐。
|
|
460
|
+
* 否则继续 spawn 会用旧模板创建一个“技术合法、治理错误”的 worktree。
|
|
461
|
+
* 显式 --config 是恢复出口:调用者可提供已核对过的 base Profile。
|
|
462
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} loaded
|
|
463
|
+
*/
|
|
464
|
+
export function requireFreshPrimaryProfile(loaded) {
|
|
465
|
+
if (loaded.profile_source !== 'primary' || !loaded.profile.default_base) return;
|
|
466
|
+
const [remote, ...branchParts] = loaded.profile.default_base.split('/');
|
|
467
|
+
const branch = branchParts.join('/');
|
|
468
|
+
if (!remote || !branch) {
|
|
469
|
+
die(
|
|
470
|
+
`Profile default_base=${loaded.profile.default_base} 不是可刷新 remote/branch;无法证明命名 DoD,请用 --config 指向已核对 Profile。`,
|
|
471
|
+
2,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
const fetched = gitTry(['fetch', remote, branch, '-q'], loaded.context.current_worktree, {
|
|
475
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
476
|
+
stdio: 'ignore',
|
|
477
|
+
});
|
|
478
|
+
if (!fetched.ok) {
|
|
479
|
+
die(
|
|
480
|
+
`无法刷新 Profile baseline ${loaded.profile.default_base};为避免使用陈旧 branch/path 模板,拒绝继续。请恢复网络或用 --config 指向已核对 Profile。`,
|
|
481
|
+
2,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
const drift = primaryProfileDriftFinding(loaded);
|
|
485
|
+
if (drift) die(`${drift.detail}\n恢复方式:同步 primary Profile,或用 --config <已核对的 .worktree-trace.json>。`, 2);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Profile 的 default_base 是项目默认治理边界。portable core 仍允许依赖分支,
|
|
490
|
+
* 但显式偏离时必须留下原因,避免“技术上已登记、语义上无人知道为何偏离”。
|
|
491
|
+
* @param {Map<string, unknown>} flags
|
|
492
|
+
* @param {string|null} defaultBase
|
|
493
|
+
* @param {string} baseRef
|
|
494
|
+
* @param {string} baseSource
|
|
495
|
+
*/
|
|
496
|
+
export function resolveBaseOverrideReason(flags, defaultBase, baseRef, baseSource) {
|
|
497
|
+
if (baseSource !== 'cli' || !defaultBase || baseRef === defaultBase) return null;
|
|
498
|
+
const raw = flag(flags, 'base-reason');
|
|
499
|
+
if (!raw) {
|
|
500
|
+
die(`--base ${baseRef} 偏离 Profile default_base=${defaultBase};必须同时提供 --base-reason <原因>。`, 2);
|
|
501
|
+
}
|
|
502
|
+
return oneLine(raw, 'base-reason', 240);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** @returns {{path:string,branch:string|null,head:string|null,bare:boolean,detached:boolean}[]} */
|
|
506
|
+
export function parseWorktrees(cwd = process.cwd()) {
|
|
507
|
+
const listed = gitTry(['worktree', 'list', '--porcelain'], cwd);
|
|
508
|
+
if (!listed.ok) return [];
|
|
509
|
+
const records = [];
|
|
510
|
+
let current = null;
|
|
511
|
+
for (const line of listed.out.split('\n')) {
|
|
512
|
+
if (line.startsWith('worktree ')) {
|
|
513
|
+
const rawPath = line.slice('worktree '.length);
|
|
514
|
+
current = {
|
|
515
|
+
path: existsSync(rawPath) ? realpathSync(rawPath) : resolve(rawPath),
|
|
516
|
+
branch: null,
|
|
517
|
+
head: null,
|
|
518
|
+
bare: false,
|
|
519
|
+
detached: false,
|
|
520
|
+
};
|
|
521
|
+
records.push(current);
|
|
522
|
+
} else if (current && line.startsWith('HEAD ')) current.head = line.slice(5);
|
|
523
|
+
else if (current && line.startsWith('branch ')) current.branch = line.slice(7).replace(/^refs\/heads\//, '');
|
|
524
|
+
else if (current && line === 'detached') current.detached = true;
|
|
525
|
+
else if (current && line === 'bare') current.bare = true;
|
|
526
|
+
}
|
|
527
|
+
return records;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** @param {string} commonDir */
|
|
531
|
+
export function loadRecords(commonDir) {
|
|
532
|
+
return listRecordCacheEntries(commonDir)
|
|
533
|
+
.filter((entry) => entry.record)
|
|
534
|
+
.map((entry) => entry.record);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** worktree_state 值本身没有独立的终态集合导出,这里内联判断,避免和
|
|
538
|
+
* TERMINAL_TASK_STATES(task_status 维度)混淆。archived 与 reclaimed 一样代表
|
|
539
|
+
* "已经不需要再当作在办 worktree 处理"的记录,只是 archived 不删除任何 Git 对象。
|
|
540
|
+
* @param {string} worktreeState */
|
|
541
|
+
export function isSettledWorktreeState(worktreeState) {
|
|
542
|
+
return worktreeState === 'reclaimed' || worktreeState === 'archived';
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** @param {Record<string, any>} record */
|
|
546
|
+
export function isActiveRecord(record) {
|
|
547
|
+
return !TERMINAL_TASK_STATES.has(record.task_status) && !isSettledWorktreeState(record.worktree_state);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 同一宿主会话默认只延续一条交付链。task 名不是交付身份:如果仅因刷新 base、
|
|
552
|
+
* 整理提交或继续验收而换名,必须继续使用已有 worktree。确需并存/替代时由 CLI
|
|
553
|
+
* 显式留下关系和原因,不能靠新 task slug 绕过复用。
|
|
554
|
+
* @param {Record<string, any>[]} records
|
|
555
|
+
* @param {{host:string,id:string}} actor
|
|
556
|
+
* @param {string} task
|
|
557
|
+
*/
|
|
558
|
+
export function coexistingSessionRecords(records, actor, task) {
|
|
559
|
+
return records.filter((record) =>
|
|
560
|
+
!isSettledWorktreeState(record.worktree_state) &&
|
|
561
|
+
record.task !== task &&
|
|
562
|
+
record.agent?.host === actor.host &&
|
|
563
|
+
record.agent?.id === actor.id);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* @param {Map<string, unknown>} flags
|
|
568
|
+
* @param {Record<string, any>[]} records
|
|
569
|
+
* @param {Record<string, any>[]} coexisting
|
|
570
|
+
*/
|
|
571
|
+
export function resolveDeliveryRelation(flags, records, coexisting) {
|
|
572
|
+
const parallelReasonRaw = flag(flags, 'parallel-reason');
|
|
573
|
+
const supersedesSelector = flag(flags, 'supersedes');
|
|
574
|
+
const replacementReasonRaw = flag(flags, 'replacement-reason');
|
|
575
|
+
if (parallelReasonRaw && (supersedesSelector || replacementReasonRaw)) {
|
|
576
|
+
die('--parallel-reason 与 --supersedes/--replacement-reason 互斥;请明确是独立并行还是替代。', 2);
|
|
577
|
+
}
|
|
578
|
+
if (replacementReasonRaw && !supersedesSelector) {
|
|
579
|
+
die('--replacement-reason 必须与 --supersedes <selector> 一起使用。', 2);
|
|
580
|
+
}
|
|
581
|
+
if (supersedesSelector && !replacementReasonRaw) {
|
|
582
|
+
die('--supersedes 必须同时提供 --replacement-reason <原因>。', 2);
|
|
583
|
+
}
|
|
584
|
+
if (coexisting.length === 0) {
|
|
585
|
+
if (parallelReasonRaw || supersedesSelector) {
|
|
586
|
+
die('当前会话没有其他未回收 worktree;不应声明 parallel/supersedes 关系。', 2);
|
|
587
|
+
}
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const summary = coexisting
|
|
592
|
+
.map((record) => `${record.task}(${record.task_status}/${record.worktree_state})=${record.path}`)
|
|
593
|
+
.join('\n - ');
|
|
594
|
+
if (!parallelReasonRaw && !supersedesSelector) {
|
|
595
|
+
die(
|
|
596
|
+
`DELIVERY_WORKTREE_EXISTS: 同一 Agent 会话已有未回收 worktree,默认复用而不是换 task 名新建:\n` +
|
|
597
|
+
` - ${summary}\n` +
|
|
598
|
+
'继续同一事项:直接进入原路径工作;确属独立并行:加 --parallel-reason <原因>;' +
|
|
599
|
+
'确属替代:先冻结旧树并标记 abandoned,再加 --supersedes <selector> --replacement-reason <原因>。',
|
|
600
|
+
2,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const declaredAt = new Date().toISOString();
|
|
605
|
+
if (parallelReasonRaw) {
|
|
606
|
+
return {
|
|
607
|
+
kind: 'parallel',
|
|
608
|
+
reason: oneLine(parallelReasonRaw, 'parallel-reason', 240),
|
|
609
|
+
related_worktree_ids: coexisting.map((record) => record.worktree_id),
|
|
610
|
+
declared_at: declaredAt,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const superseded = selectRecord(records, supersedesSelector, null);
|
|
615
|
+
if (!coexisting.some((record) => record.worktree_id === superseded.worktree_id)) {
|
|
616
|
+
die('--supersedes 必须指向同一 Agent 会话的一棵未回收 worktree。', 2);
|
|
617
|
+
}
|
|
618
|
+
if (superseded.task_status !== 'abandoned') {
|
|
619
|
+
die(`替代前必须先冻结旧树并 touch ${superseded.task} --status abandoned --note <迁移边界>;当前为 ${superseded.task_status}。`, 2);
|
|
620
|
+
}
|
|
621
|
+
const snapshot = liveGitSnapshot(superseded);
|
|
622
|
+
if (!snapshot.present) die(`被替代 worktree missing: ${superseded.path};请先 doctor/reclaim。`, 2);
|
|
623
|
+
if (snapshot.dirty !== false) die(`被替代 worktree 必须干净,当前 dirty=${snapshot.dirty}: ${superseded.path}`, 2);
|
|
624
|
+
return {
|
|
625
|
+
kind: 'supersedes',
|
|
626
|
+
reason: oneLine(replacementReasonRaw, 'replacement-reason', 240),
|
|
627
|
+
related_worktree_ids: [superseded.worktree_id],
|
|
628
|
+
superseded_worktree_id: superseded.worktree_id,
|
|
629
|
+
declared_at: declaredAt,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** @param {Record<string,any>} left @param {Record<string,any>} right */
|
|
634
|
+
export function sameAgentSession(left, right) {
|
|
635
|
+
return left.agent?.host === right.agent?.host && left.agent?.id === right.agent?.id;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* 校验一对 record 确实能形成“旧树被新树替代”的交付关系。
|
|
640
|
+
* 这既供 spawn 后的补登记使用,也供 superseded reclaim 做强前置校验。
|
|
641
|
+
* @param {Record<string,any>} superseded
|
|
642
|
+
* @param {Record<string,any>} replacement
|
|
643
|
+
*/
|
|
644
|
+
export function validateSupersessionPair(superseded, replacement) {
|
|
645
|
+
if (superseded.worktree_id === replacement.worktree_id) die('旧树和替代树不能是同一条 record。', 2);
|
|
646
|
+
if (!sameAgentSession(superseded, replacement)) {
|
|
647
|
+
die('替代关系只允许连接同一 Agent 会话的 worktree;跨会话请走 handoff 或人工说明。', 2);
|
|
648
|
+
}
|
|
649
|
+
if (superseded.owner && replacement.owner && superseded.owner !== replacement.owner) {
|
|
650
|
+
die(`替代关系 owner 不一致:${superseded.owner} != ${replacement.owner}。`, 2);
|
|
651
|
+
}
|
|
652
|
+
if (superseded.task_status !== 'abandoned') {
|
|
653
|
+
die(`被替代树必须先标记 abandoned;当前 ${superseded.task}=${superseded.task_status}。`, 2);
|
|
654
|
+
}
|
|
655
|
+
if (superseded.worktree_state === 'reclaimed') die('被替代树已经 reclaimed,无需再登记替代关系。', 2);
|
|
656
|
+
if (TERMINAL_TASK_STATES.has(replacement.task_status) || replacement.worktree_state === 'reclaimed') {
|
|
657
|
+
die(`替代树必须仍是可交付状态;当前 ${replacement.task}=${replacement.task_status}/${replacement.worktree_state}。`, 2);
|
|
658
|
+
}
|
|
659
|
+
if (String(replacement.created_at ?? '') < String(superseded.created_at ?? '')) {
|
|
660
|
+
die('替代树的 created_at 早于被替代树,拒绝反向登记。', 2);
|
|
661
|
+
}
|
|
662
|
+
for (const record of [superseded, replacement]) {
|
|
663
|
+
const snapshot = liveGitSnapshot(record);
|
|
664
|
+
if (!snapshot.present) die(`worktree missing: ${record.path};请先 doctor。`, 2);
|
|
665
|
+
if (snapshot.dirty !== false) die(`替代关系两端必须干净,当前 dirty=${snapshot.dirty}: ${record.path}`, 2);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* 对已经存在的两棵树补登记双向 supersession。命令可幂等重跑;任何冲突关系均 fail-closed。
|
|
671
|
+
* @param {{positionals:string[],flags:Map<string,unknown>}} args
|
|
672
|
+
*/
|
|
673
|
+
|
|
674
|
+
export function canonicalSelectorPath(value) {
|
|
675
|
+
try {
|
|
676
|
+
return existsSync(value) ? realpathSync(value) : resolve(value);
|
|
677
|
+
} catch {
|
|
678
|
+
return resolve(value);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** @param {Record<string, any>[]} records @param {string|null} selector @param {string|null} explicitId */
|
|
683
|
+
export function selectRecord(records, selector, explicitId) {
|
|
684
|
+
const idQuery = explicitId ?? null;
|
|
685
|
+
let idMatches = [];
|
|
686
|
+
if (idQuery) {
|
|
687
|
+
if (!/^(?:[0-9a-f]{8,32}|[0-9a-f-]{36})$/i.test(idQuery)) die('--id 需要完整 UUID 或至少 8 位十六进制前缀。', 2);
|
|
688
|
+
idMatches = records.filter((record) => record.worktree_id.toLowerCase().startsWith(idQuery.toLowerCase()));
|
|
689
|
+
if (idMatches.length !== 1) die(`--id 匹配 ${idMatches.length} 条 record,必须唯一。`, 2);
|
|
690
|
+
}
|
|
691
|
+
if (!selector) {
|
|
692
|
+
if (idMatches.length === 1) return idMatches[0];
|
|
693
|
+
die('缺少 record selector。', 2);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const matches = new Map();
|
|
697
|
+
if (/^[0-9a-f]{8,}$/i.test(selector) || /^[0-9a-f-]{36}$/i.test(selector)) {
|
|
698
|
+
for (const record of records) if (record.worktree_id.toLowerCase().startsWith(selector.toLowerCase())) matches.set(record.worktree_id, record);
|
|
699
|
+
}
|
|
700
|
+
const pathCandidate = canonicalSelectorPath(selector);
|
|
701
|
+
const semanticMatches = (pool) => pool.filter((record) =>
|
|
702
|
+
record.task === selector || record.branch === selector || canonicalSelectorPath(record.path) === pathCandidate,
|
|
703
|
+
);
|
|
704
|
+
const activeSemantic = semanticMatches(records.filter(isActiveRecord));
|
|
705
|
+
const selectedSemantic = activeSemantic.length > 0 ? activeSemantic : semanticMatches(records);
|
|
706
|
+
for (const record of selectedSemantic) matches.set(record.worktree_id, record);
|
|
707
|
+
if (idMatches.length === 1) {
|
|
708
|
+
for (const record of matches.values()) if (record.worktree_id !== idMatches[0].worktree_id) die('selector 与 --id 指向不同 record,拒绝歧义。', 2);
|
|
709
|
+
return idMatches[0];
|
|
710
|
+
}
|
|
711
|
+
if (matches.size !== 1) die(`selector 匹配 ${matches.size} 条 record;请传 --id。`, 2);
|
|
712
|
+
return [...matches.values()][0];
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** @param {Record<string, any>} record */
|
|
716
|
+
export function liveGitSnapshot(record) {
|
|
717
|
+
const live = parseWorktrees().find((worktree) => worktree.path === canonicalSelectorPath(record.path));
|
|
718
|
+
if (!live) return { present: false, head: record.last_head ?? null, dirty: null, upstream: null };
|
|
719
|
+
const status = gitTry(['status', '--porcelain'], live.path);
|
|
720
|
+
const upstream = gitTry(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], live.path);
|
|
721
|
+
return {
|
|
722
|
+
present: true,
|
|
723
|
+
head: live.head ?? gitTry(['rev-parse', 'HEAD'], live.path).out ?? null,
|
|
724
|
+
dirty: status.ok ? status.out !== '' : null,
|
|
725
|
+
upstream: upstream.ok ? upstream.out : null,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** @param {Record<string, any>} record @param {string} eventType @param {(next:Record<string,any>)=>void} update @param {Record<string,unknown>} [details] @param {string|null} [commonDir] */
|
|
730
|
+
export function updateRecord(record, eventType, update, details = {}, commonDir = null) {
|
|
731
|
+
return appendTraceEvent({
|
|
732
|
+
commonDir: commonDir ?? loadRepositoryProfile().context.common_dir,
|
|
733
|
+
worktreeId: record.worktree_id,
|
|
734
|
+
eventType,
|
|
735
|
+
actor: record.agent ?? null,
|
|
736
|
+
details,
|
|
737
|
+
mutate(current) {
|
|
738
|
+
if (!current) throw new Error('record snapshot missing');
|
|
739
|
+
const next = structuredClone(current);
|
|
740
|
+
update(next);
|
|
741
|
+
next.updated_at = new Date().toISOString();
|
|
742
|
+
return next;
|
|
743
|
+
},
|
|
744
|
+
}).record;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** @param {string|null|undefined} value */
|
|
748
|
+
export function normalizeCodegraphMode(value) {
|
|
749
|
+
const mode = value || 'auto';
|
|
750
|
+
if (!['auto', 'on', 'off'].includes(mode)) {
|
|
751
|
+
throw new Error(`--codegraph 只接受 auto/on/off,收到: ${mode}`);
|
|
752
|
+
}
|
|
753
|
+
return mode;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* 交互终端保留 CodeGraph 自己的进度展示;Agent/CI 等非 TTY 调用只捕获输出,
|
|
758
|
+
* 成功时由 manager 给一行摘要,失败时也只回显有界错误,避免 ANSI 进度条污染工具输出。
|
|
759
|
+
* @param {boolean} [interactive]
|
|
760
|
+
*/
|
|
761
|
+
export function codegraphStdio(interactive = Boolean(process.stdout.isTTY && process.stderr.isTTY)) {
|
|
762
|
+
return interactive ? 'inherit' : ['ignore', 'pipe', 'pipe'];
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** @param {string} worktreePath @param {string} primaryWorktree @param {string} mode */
|
|
766
|
+
export function ensureWorktreeCodegraph(worktreePath, primaryWorktree, mode) {
|
|
767
|
+
if (mode === 'off') {
|
|
768
|
+
log('CodeGraph: 已按 --codegraph off 跳过。');
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const database = resolve(worktreePath, '.codegraph', 'codegraph.db');
|
|
772
|
+
if (existsSync(database)) {
|
|
773
|
+
log(`CodeGraph: 独立索引已存在,复用 ${database}`);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const sourceDatabase = resolve(primaryWorktree, '.codegraph', 'codegraph.db');
|
|
777
|
+
if (mode === 'auto' && !existsSync(sourceDatabase)) {
|
|
778
|
+
log('CodeGraph: 主工作树未初始化,auto 模式跳过(需要时用 --codegraph on)。');
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const available = runFileTry('codegraph', ['--version'], { timeoutMs: 5_000 });
|
|
782
|
+
if (!available.ok) {
|
|
783
|
+
if (mode === 'on') die('CodeGraph: 本机找不到 codegraph CLI,--codegraph on 无法完成。');
|
|
784
|
+
log('警告: 主树有 CodeGraph,但本机 CLI 不可用;隔离树暂未建立索引。');
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const initializedDir = resolve(worktreePath, '.codegraph');
|
|
788
|
+
const commandArgs = existsSync(initializedDir)
|
|
789
|
+
? ['index', worktreePath]
|
|
790
|
+
: ['init', '-i', worktreePath];
|
|
791
|
+
log(`CodeGraph: 为隔离树建立独立索引(mode=${mode})...`);
|
|
792
|
+
const indexed = runFileTry('codegraph', commandArgs, {
|
|
793
|
+
cwd: worktreePath,
|
|
794
|
+
timeoutMs: CODEGRAPH_TIMEOUT_MS,
|
|
795
|
+
stdio: codegraphStdio(),
|
|
796
|
+
env: process.stdout.isTTY && process.stderr.isTTY
|
|
797
|
+
? process.env
|
|
798
|
+
: { ...process.env, CI: process.env.CI ?? '1', NO_COLOR: process.env.NO_COLOR ?? '1', TERM: 'dumb' },
|
|
799
|
+
});
|
|
800
|
+
if (!indexed.ok) {
|
|
801
|
+
if (mode === 'on') die('CodeGraph 独立索引初始化失败(--codegraph on 为强制模式)。');
|
|
802
|
+
log('警告: CodeGraph 独立索引初始化失败;worktree 已保留,可稍后手工重试。');
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
log(`CodeGraph: 独立索引就绪 ${database}`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
export function assertHistoryOperationIdle(record, command) {
|
|
810
|
+
if (record.history_operation) {
|
|
811
|
+
die(
|
|
812
|
+
`${command} 被未完成的 ${record.history_operation.kind ?? 'history'} 操作阻塞` +
|
|
813
|
+
`(token=${record.history_operation.token ?? 'unknown'}, state=${record.history_operation.state ?? 'unknown'})。` +
|
|
814
|
+
'先重跑 rebase 完成恢复,或使用 rebase --abort。',
|
|
815
|
+
2,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
if (record.review_refresh) {
|
|
819
|
+
die(
|
|
820
|
+
`${command} 被未完成的 review refresh 阻塞(state=${record.review_refresh.state ?? 'unknown'})。` +
|
|
821
|
+
`运行 refresh-review ${record.task} --continue 恢复,或用 --abort 放弃冲突/暂停在 push 前的刷新。`,
|
|
822
|
+
2,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Recursively sort object keys before hashing or serializing contracts. */
|
|
828
|
+
export function canonicalJson(value) {
|
|
829
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
830
|
+
if (value && typeof value === 'object') {
|
|
831
|
+
return Object.fromEntries(
|
|
832
|
+
Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
833
|
+
.map(([key, child]) => [key, canonicalJson(child)]),
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
return value;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** @param {string} content */
|
|
840
|
+
export function normalizedProfileContent(content) {
|
|
841
|
+
try {
|
|
842
|
+
return JSON.stringify(canonicalJson(JSON.parse(content)));
|
|
843
|
+
} catch {
|
|
844
|
+
return content.trim();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** @param {string} cwd @param {string} value @param {string} label */
|
|
849
|
+
export function exactCommitOid(cwd, value, label) {
|
|
850
|
+
const normalized = oneLine(value, label, 128).toLowerCase();
|
|
851
|
+
const resolved = gitTry(['rev-parse', '--verify', `${normalized}^{commit}`], cwd);
|
|
852
|
+
if (!resolved.ok || resolved.out.toLowerCase() !== normalized) {
|
|
853
|
+
die(`${label} 必须是当前仓库可解析的完整 commit object ID。`, 2);
|
|
854
|
+
}
|
|
855
|
+
return resolved.out.toLowerCase();
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* 面向纯证据输入的 commit 缩写解析:只接受十六进制 object-id/唯一前缀,立即展开为完整 OID。
|
|
860
|
+
* Artifact、discard 等 CAS 边界继续使用 exactCommitOid,不共享这条人体工学放宽。
|
|
861
|
+
* @param {string} cwd @param {string} value @param {string} label
|
|
862
|
+
*/
|
|
863
|
+
export function resolvableCommitOid(cwd, value, label) {
|
|
864
|
+
const normalized = oneLine(value, label, 128).toLowerCase();
|
|
865
|
+
if (!/^[0-9a-f]{4,64}$/u.test(normalized)) {
|
|
866
|
+
die(`${label} 必须是十六进制 commit object ID 或唯一短前缀。`, 2);
|
|
867
|
+
}
|
|
868
|
+
const resolved = gitTry(['rev-parse', '--verify', `${normalized}^{commit}`], cwd);
|
|
869
|
+
if (!resolved.ok) die(`${label} 不是当前仓库可唯一解析的 commit object ID。`, 2);
|
|
870
|
+
return resolved.out.toLowerCase();
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** @param {string} cwd */
|
|
874
|
+
export function gitOperationState(cwd) {
|
|
875
|
+
for (const marker of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD']) {
|
|
876
|
+
if (gitTry(['rev-parse', '--verify', '--quiet', marker], cwd).ok) return marker.toLowerCase();
|
|
877
|
+
}
|
|
878
|
+
for (const marker of ['rebase-merge', 'rebase-apply']) {
|
|
879
|
+
const path = gitTry(['rev-parse', '--git-path', marker], cwd);
|
|
880
|
+
if (path.ok && path.out && existsSync(resolve(cwd, path.out))) return marker;
|
|
881
|
+
}
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** @param {string} cwd @param {string} ancestor @param {string} descendant */
|
|
886
|
+
export function isAncestor(cwd, ancestor, descendant) {
|
|
887
|
+
return gitTry(['merge-base', '--is-ancestor', ancestor, descendant], cwd).ok;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded */
|
|
891
|
+
export function primaryProfileDriftFinding(loaded) {
|
|
892
|
+
const defaultBase = loaded.profile.default_base;
|
|
893
|
+
if (loaded.profile_source !== 'primary' || !defaultBase || !existsSync(loaded.profile_path)) return null;
|
|
894
|
+
const baseline = gitTry(
|
|
895
|
+
['show', `${defaultBase}:${PROFILE_FILENAME}`],
|
|
896
|
+
loaded.context.current_worktree,
|
|
897
|
+
);
|
|
898
|
+
if (!baseline.ok) return null;
|
|
899
|
+
const current = readFileSync(loaded.profile_path, 'utf8');
|
|
900
|
+
if (normalizedProfileContent(current) === normalizedProfileContent(baseline.out)) return null;
|
|
901
|
+
return {
|
|
902
|
+
code: 'PRIMARY_PROFILE_DRIFT_FROM_BASE',
|
|
903
|
+
severity: 'error',
|
|
904
|
+
path: loaded.profile_path,
|
|
905
|
+
baseline_ref: defaultBase,
|
|
906
|
+
detail: `primary worktree Profile 与 ${defaultBase}:${PROFILE_FILENAME} 不一致;spawn/adopt 必须 fail-closed,禁止继续使用陈旧命名配置。`,
|
|
907
|
+
};
|
|
908
|
+
}
|