@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,786 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Domain command factory. Dependencies are injected once by the thin CLI composition root,
|
|
5
|
+
* keeping this module acyclic and independently reviewable.
|
|
6
|
+
* @param {Record<string, any>} deps
|
|
7
|
+
*/
|
|
8
|
+
export function createCommands(deps) {
|
|
9
|
+
const {
|
|
10
|
+
spawn,
|
|
11
|
+
randomUUID,
|
|
12
|
+
existsSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
realpathSync,
|
|
15
|
+
tmpdir,
|
|
16
|
+
dirname,
|
|
17
|
+
join,
|
|
18
|
+
WorktreeProfileError,
|
|
19
|
+
canonicalizeFuturePath,
|
|
20
|
+
claimWorktreeRepositoryRoot,
|
|
21
|
+
classifyStorage,
|
|
22
|
+
ensureRepositoryIdentity,
|
|
23
|
+
loadRepositoryProfile,
|
|
24
|
+
readRepositoryIdentity,
|
|
25
|
+
resolveBaseRef,
|
|
26
|
+
resolveSpawnPlan,
|
|
27
|
+
validateTaskNaming,
|
|
28
|
+
validateTaskSlug,
|
|
29
|
+
WorktreeTraceError,
|
|
30
|
+
appendTraceEvent,
|
|
31
|
+
initializeTraceStore,
|
|
32
|
+
inspectRecordLock,
|
|
33
|
+
readEventChain,
|
|
34
|
+
rebuildRecordCache,
|
|
35
|
+
recoverRecordLock,
|
|
36
|
+
traceLayout,
|
|
37
|
+
TASK_TRANSITIONS,
|
|
38
|
+
git,
|
|
39
|
+
gitTry,
|
|
40
|
+
commandFailureReason,
|
|
41
|
+
isRootWriteDenied,
|
|
42
|
+
removeWatcherHeartbeat,
|
|
43
|
+
log,
|
|
44
|
+
die,
|
|
45
|
+
flag,
|
|
46
|
+
rejectUnknownFlags,
|
|
47
|
+
oneLine,
|
|
48
|
+
httpUrl,
|
|
49
|
+
aliasedFlag,
|
|
50
|
+
resolveIdentity,
|
|
51
|
+
printIdentity,
|
|
52
|
+
requireFreshPrimaryProfile,
|
|
53
|
+
resolveBaseOverrideReason,
|
|
54
|
+
parseWorktrees,
|
|
55
|
+
loadRecords,
|
|
56
|
+
coexistingSessionRecords,
|
|
57
|
+
resolveDeliveryRelation,
|
|
58
|
+
validateSupersessionPair,
|
|
59
|
+
canonicalSelectorPath,
|
|
60
|
+
selectRecord,
|
|
61
|
+
liveGitSnapshot,
|
|
62
|
+
updateRecord,
|
|
63
|
+
normalizeCodegraphMode,
|
|
64
|
+
ensureWorktreeCodegraph,
|
|
65
|
+
assertHistoryOperationIdle,
|
|
66
|
+
stackParentForRef,
|
|
67
|
+
localBranchExists,
|
|
68
|
+
autoArmReviewWatch,
|
|
69
|
+
isSettledWorktreeState,
|
|
70
|
+
} = deps;
|
|
71
|
+
|
|
72
|
+
function cmdSupersede(args) {
|
|
73
|
+
rejectUnknownFlags(args.flags, ['by', 'reason', 'id', 'by-id', 'config']);
|
|
74
|
+
const bySelector = flag(args.flags, 'by');
|
|
75
|
+
const reason = oneLine(flag(args.flags, 'reason') ?? '', 'reason', 240);
|
|
76
|
+
if (!bySelector) die('supersede 需要 --by <replacement-selector>。', 2);
|
|
77
|
+
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
78
|
+
const records = loadRecords(loaded.context.common_dir);
|
|
79
|
+
let superseded = selectRecord(records, args.positionals[0] ?? null, flag(args.flags, 'id'));
|
|
80
|
+
let replacement = selectRecord(records, bySelector, flag(args.flags, 'by-id'));
|
|
81
|
+
validateSupersessionPair(superseded, replacement);
|
|
82
|
+
|
|
83
|
+
const existingForward = replacement.delivery_relation;
|
|
84
|
+
if (existingForward && (
|
|
85
|
+
existingForward.kind !== 'supersedes' ||
|
|
86
|
+
existingForward.superseded_worktree_id !== superseded.worktree_id
|
|
87
|
+
)) {
|
|
88
|
+
die(`替代树已有冲突 delivery_relation=${existingForward.kind ?? 'unknown'}。`, 2);
|
|
89
|
+
}
|
|
90
|
+
const existingBack = superseded.superseded_by;
|
|
91
|
+
if (existingBack && existingBack.worktree_id !== replacement.worktree_id) {
|
|
92
|
+
die(`被替代树已指向另一替代树 ${existingBack.worktree_id}。`, 2);
|
|
93
|
+
}
|
|
94
|
+
const existingReason = existingForward?.reason ?? existingBack?.reason ?? null;
|
|
95
|
+
if (existingReason && existingReason !== reason) {
|
|
96
|
+
die(`替代关系已用不同原因登记:${existingReason}`, 2);
|
|
97
|
+
}
|
|
98
|
+
const declaredAt = existingForward?.declared_at ?? existingBack?.declared_at ?? new Date().toISOString();
|
|
99
|
+
|
|
100
|
+
if (!existingForward) {
|
|
101
|
+
replacement = updateRecord(replacement, 'delivery_relation_declared', (next) => {
|
|
102
|
+
next.delivery_relation = {
|
|
103
|
+
kind: 'supersedes',
|
|
104
|
+
reason,
|
|
105
|
+
related_worktree_ids: [superseded.worktree_id],
|
|
106
|
+
superseded_worktree_id: superseded.worktree_id,
|
|
107
|
+
declared_at: declaredAt,
|
|
108
|
+
};
|
|
109
|
+
}, {
|
|
110
|
+
superseded_worktree_id: superseded.worktree_id,
|
|
111
|
+
superseded_task: superseded.task,
|
|
112
|
+
reason,
|
|
113
|
+
}, loaded.context.common_dir);
|
|
114
|
+
}
|
|
115
|
+
if (!existingBack) {
|
|
116
|
+
superseded = updateRecord(superseded, 'superseded_by_declared', (next) => {
|
|
117
|
+
next.superseded_by = {
|
|
118
|
+
worktree_id: replacement.worktree_id,
|
|
119
|
+
task: replacement.task,
|
|
120
|
+
reason,
|
|
121
|
+
declared_at: declaredAt,
|
|
122
|
+
};
|
|
123
|
+
}, {
|
|
124
|
+
replacement_worktree_id: replacement.worktree_id,
|
|
125
|
+
replacement_task: replacement.task,
|
|
126
|
+
reason,
|
|
127
|
+
}, loaded.context.common_dir);
|
|
128
|
+
}
|
|
129
|
+
log(`替代关系已登记 ${superseded.task} -> ${replacement.task}(${superseded.worktree_id.slice(0, 8)} -> ${replacement.worktree_id.slice(0, 8)})。`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {string} value */
|
|
133
|
+
|
|
134
|
+
function prepareSpawnRequest(args) {
|
|
135
|
+
rejectUnknownFlags(args.flags, [
|
|
136
|
+
'agent', 'agent-id', 'purpose', 'owner', 'base', 'base-reason', 'config', 'codegraph', 'root',
|
|
137
|
+
'parallel-reason', 'supersedes', 'replacement-reason',
|
|
138
|
+
]);
|
|
139
|
+
const task = args.positionals[0];
|
|
140
|
+
if (!task) die('spawn 需要 <task>。', 2);
|
|
141
|
+
validateTaskSlug(task);
|
|
142
|
+
const identity = resolveIdentity(args.flags, { requirePurpose: true });
|
|
143
|
+
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
144
|
+
requireFreshPrimaryProfile(loaded);
|
|
145
|
+
validateTaskNaming(task, loaded.profile.task_naming);
|
|
146
|
+
let codegraphMode;
|
|
147
|
+
try {
|
|
148
|
+
codegraphMode = normalizeCodegraphMode(flag(args.flags, 'codegraph'));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
die(error instanceof Error ? error.message : String(error), 2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const existingRecords = loadRecords(loaded.context.common_dir);
|
|
154
|
+
const reusable = existingRecords.find((record) =>
|
|
155
|
+
record.worktree_state !== 'reclaimed' &&
|
|
156
|
+
record.task === task &&
|
|
157
|
+
record.agent?.host === identity.actor.host &&
|
|
158
|
+
record.agent?.id === identity.actor.id);
|
|
159
|
+
if (reusable) {
|
|
160
|
+
const snapshot = liveGitSnapshot(reusable);
|
|
161
|
+
if (!snapshot.present) die(`同一 Agent/task 的 record 已存在但 worktree missing: ${reusable.worktree_id};请先 doctor/reclaim。`);
|
|
162
|
+
updateRecord(reusable, 'spawn_reused', (next) => {
|
|
163
|
+
next.last_seen_at = new Date().toISOString();
|
|
164
|
+
next.last_head = snapshot.head;
|
|
165
|
+
}, {}, loaded.context.common_dir);
|
|
166
|
+
ensureWorktreeCodegraph(reusable.path, loaded.context.primary_worktree, codegraphMode);
|
|
167
|
+
log(`复用已登记 worktree id=${reusable.worktree_id}: ${reusable.path}`);
|
|
168
|
+
return { reused: true };
|
|
169
|
+
}
|
|
170
|
+
const coexisting = coexistingSessionRecords(existingRecords, identity.actor, task);
|
|
171
|
+
return {
|
|
172
|
+
reused: false,
|
|
173
|
+
task,
|
|
174
|
+
identity,
|
|
175
|
+
loaded,
|
|
176
|
+
codegraphMode,
|
|
177
|
+
existingRecords,
|
|
178
|
+
deliveryRelation: resolveDeliveryRelation(args.flags, existingRecords, coexisting),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function planSpawn(args, request) {
|
|
183
|
+
const worktreeId = randomUUID();
|
|
184
|
+
const repositoryId = readRepositoryIdentity(request.loaded.context)?.repository_id ?? randomUUID();
|
|
185
|
+
const cliRoot = flag(args.flags, 'root');
|
|
186
|
+
const environmentRoot = process.env.WORKTREE_ROOT?.trim() || null;
|
|
187
|
+
const configuredRoot = cliRoot ?? environmentRoot;
|
|
188
|
+
const rootSelectionSource = cliRoot
|
|
189
|
+
? 'cli'
|
|
190
|
+
: environmentRoot
|
|
191
|
+
? 'environment'
|
|
192
|
+
: request.loaded.profile_source === 'defaults'
|
|
193
|
+
? 'default'
|
|
194
|
+
: 'profile';
|
|
195
|
+
const planOptions = {
|
|
196
|
+
cwd: process.cwd(),
|
|
197
|
+
task: request.task,
|
|
198
|
+
host: request.identity.actor.host,
|
|
199
|
+
worktreeId,
|
|
200
|
+
repositoryId,
|
|
201
|
+
base: flag(args.flags, 'base'),
|
|
202
|
+
explicitConfigPath: flag(args.flags, 'config'),
|
|
203
|
+
worktreeRootOverride: configuredRoot,
|
|
204
|
+
};
|
|
205
|
+
const plan = resolveSpawnPlan(planOptions);
|
|
206
|
+
const baseReason = resolveBaseOverrideReason(args.flags, plan.profile.default_base, plan.base_ref, plan.base_source);
|
|
207
|
+
if (plan.branch_exists) {
|
|
208
|
+
die(
|
|
209
|
+
`BRANCH_ALREADY_EXISTS: 本地 branch ${plan.branch} 已存在;为防同名返工静默继承旧 tip,拒绝 spawn。` +
|
|
210
|
+
'请先完成或修复原 branch cleanup,或改用新的 semantic task;若它属于另一 Agent 的在飞任务,请先 handoff;若属于外部 worktree,请先 adopt。',
|
|
211
|
+
2,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
printIdentity(request.identity, request.task, plan);
|
|
215
|
+
log(`Base: ${plan.base_ref}(source=${plan.base_source}${baseReason ? `, reason=${baseReason}` : ''})`);
|
|
216
|
+
return { worktreeId, configuredRoot, rootSelectionSource, planOptions, plan, baseReason };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function claimSpawnRoot(request, planned) {
|
|
220
|
+
let { plan, rootSelectionSource } = planned;
|
|
221
|
+
gitTry(['worktree', 'prune'], plan.context.current_worktree);
|
|
222
|
+
const baseSha = gitTry(['rev-parse', `${plan.base_ref}^{commit}`], plan.context.current_worktree);
|
|
223
|
+
if (!baseSha.ok) die(`base ref 不存在: ${plan.base_ref}`);
|
|
224
|
+
const repository = ensureRepositoryIdentity(plan.context, planned.planOptions.repositoryId);
|
|
225
|
+
if (repository.repository_id !== plan.repository_id) {
|
|
226
|
+
plan = resolveSpawnPlan({ ...planned.planOptions, repositoryId: repository.repository_id });
|
|
227
|
+
}
|
|
228
|
+
if (!plan.legacy_layout) {
|
|
229
|
+
const claimRoot = () => claimWorktreeRepositoryRoot({
|
|
230
|
+
root_base: plan.worktree_root_base,
|
|
231
|
+
repo_name: plan.context.repo_name,
|
|
232
|
+
repository_id: repository.repository_id,
|
|
233
|
+
primary_worktree: plan.context.primary_worktree,
|
|
234
|
+
});
|
|
235
|
+
let claimedRoot;
|
|
236
|
+
try {
|
|
237
|
+
claimedRoot = claimRoot();
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const mayFallback = planned.configuredRoot === null && request.loaded.profile_source === 'defaults' && isRootWriteDenied(error);
|
|
240
|
+
if (!mayFallback) {
|
|
241
|
+
throw new WorktreeProfileError(
|
|
242
|
+
'WORKTREE_ROOT_UNWRITABLE',
|
|
243
|
+
`无法写入 worktree_root ${plan.worktree_root_base}: ${error instanceof Error ? error.message : String(error)}。` +
|
|
244
|
+
'请用 --root、WORKTREE_ROOT 或 primary Profile worktree_root 指向已授权目录。',
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const fallbacks = [
|
|
248
|
+
{ source: 'fallback:repository-sibling', root: join(dirname(plan.context.primary_worktree), '.worktrees') },
|
|
249
|
+
{ source: 'fallback:system-temp', root: join(tmpdir(), 'agent-worktrees') },
|
|
250
|
+
];
|
|
251
|
+
let lastError = error;
|
|
252
|
+
for (const fallback of fallbacks) {
|
|
253
|
+
try {
|
|
254
|
+
planned.planOptions.worktreeRootOverride = fallback.root;
|
|
255
|
+
plan = resolveSpawnPlan({ ...planned.planOptions, repositoryId: repository.repository_id });
|
|
256
|
+
claimedRoot = claimRoot();
|
|
257
|
+
rootSelectionSource = fallback.source;
|
|
258
|
+
log(`警告: 默认 worktree_root 不可写,降级到 ${fallback.root}(${fallback.source})。`);
|
|
259
|
+
break;
|
|
260
|
+
} catch (fallbackError) {
|
|
261
|
+
lastError = fallbackError;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!claimedRoot) {
|
|
265
|
+
throw new WorktreeProfileError(
|
|
266
|
+
'WORKTREE_ROOT_UNWRITABLE',
|
|
267
|
+
`默认 root 与安全降级 root 均不可写: ${lastError instanceof Error ? lastError.message : String(lastError)}。` +
|
|
268
|
+
'请用 --root、WORKTREE_ROOT 或 primary Profile worktree_root 指向宿主已授权目录。',
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (claimedRoot !== plan.repository_root) {
|
|
273
|
+
plan = resolveSpawnPlan({ ...planned.planOptions, repositoryId: repository.repository_id });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { ...planned, plan, rootSelectionSource, baseSha };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function inspectSpawnSlot(request, planned) {
|
|
280
|
+
const { plan } = planned;
|
|
281
|
+
const worktrees = parseWorktrees(plan.context.current_worktree);
|
|
282
|
+
const occupant = worktrees.find((worktree) => worktree.branch === plan.branch);
|
|
283
|
+
const byPath = worktrees.find((worktree) => worktree.path === plan.path);
|
|
284
|
+
const currentRecords = loadRecords(plan.context.common_dir);
|
|
285
|
+
const tracked = currentRecords.find((record) =>
|
|
286
|
+
record.worktree_state !== 'reclaimed' && (record.path === plan.path || record.branch === plan.branch));
|
|
287
|
+
if (tracked) {
|
|
288
|
+
if (tracked.agent?.host !== request.identity.actor.host || tracked.agent?.id !== request.identity.actor.id) {
|
|
289
|
+
die(`worktree 已由 ${tracked.agent?.host}/${tracked.agent?.id} 登记;请用 handoff,不要覆盖身份。`);
|
|
290
|
+
}
|
|
291
|
+
updateRecord(tracked, 'spawn_reused', (next) => {
|
|
292
|
+
const snapshot = liveGitSnapshot(next);
|
|
293
|
+
next.last_seen_at = new Date().toISOString();
|
|
294
|
+
next.last_head = snapshot.head;
|
|
295
|
+
}, {}, plan.context.common_dir);
|
|
296
|
+
ensureWorktreeCodegraph(plan.path, plan.context.primary_worktree, request.codegraphMode);
|
|
297
|
+
log(`复用已登记 worktree id=${tracked.worktree_id}: ${tracked.path}`);
|
|
298
|
+
return { reused: true, currentRecords };
|
|
299
|
+
}
|
|
300
|
+
if (byPath || occupant) die(`现有 worktree 未登记(UNTRACKED): ${(byPath ?? occupant).path};请先 adopt。`);
|
|
301
|
+
if (existsSync(plan.path)) die(`目标目录已存在但不是已登记 worktree: ${plan.path}`);
|
|
302
|
+
return { reused: false, currentRecords };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function createSpawnWorktree(plan, baseSha) {
|
|
306
|
+
const branchCreated = gitTry(['branch', '--', plan.branch, baseSha.out], plan.context.current_worktree);
|
|
307
|
+
if (!branchCreated.ok) {
|
|
308
|
+
die(`创建 branch 失败: ${plan.branch}: ${commandFailureReason(branchCreated, 'unknown git error')}`);
|
|
309
|
+
}
|
|
310
|
+
const added = gitTry(['worktree', 'add', plan.path, plan.branch], plan.context.current_worktree);
|
|
311
|
+
if (added.ok) return;
|
|
312
|
+
const branchTip = gitTry(['rev-parse', '--verify', `refs/heads/${plan.branch}^{commit}`], plan.context.current_worktree);
|
|
313
|
+
const attached = parseWorktrees(plan.context.current_worktree).some(
|
|
314
|
+
(worktree) => worktree.branch === plan.branch || worktree.path === plan.path,
|
|
315
|
+
);
|
|
316
|
+
let branchCleanup = 'branch absent';
|
|
317
|
+
if (branchTip.ok && !attached && branchTip.out === baseSha.out) {
|
|
318
|
+
const removed = gitTry(['branch', '-D', '--', plan.branch], plan.context.current_worktree);
|
|
319
|
+
branchCleanup = removed.ok ? 'empty branch removed' : `empty branch KEEP: ${commandFailureReason(removed, 'delete failed')}`;
|
|
320
|
+
} else if (branchTip.ok) {
|
|
321
|
+
branchCleanup = attached ? 'branch attached; KEEP' : 'branch tip changed; KEEP';
|
|
322
|
+
}
|
|
323
|
+
die(`git worktree add 失败: ${plan.path}: ${commandFailureReason(added, 'unknown git error')};${branchCleanup}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function recordSpawn(request, planned, currentRecords) {
|
|
327
|
+
const { plan, worktreeId, baseReason, baseSha, rootSelectionSource } = planned;
|
|
328
|
+
initializeTraceStore(plan.context);
|
|
329
|
+
const now = new Date().toISOString();
|
|
330
|
+
const record = {
|
|
331
|
+
schema_version: 1,
|
|
332
|
+
worktree_id: worktreeId,
|
|
333
|
+
task: request.task,
|
|
334
|
+
purpose: request.identity.purpose,
|
|
335
|
+
path: realpathSync(plan.path),
|
|
336
|
+
branch: plan.branch,
|
|
337
|
+
base_ref: plan.base_ref,
|
|
338
|
+
base_sha: baseSha.out,
|
|
339
|
+
base_reason: baseReason,
|
|
340
|
+
stack_parent: stackParentForRef(currentRecords, plan.base_ref, baseSha.out),
|
|
341
|
+
agent: request.identity.actor,
|
|
342
|
+
owner: request.identity.owner,
|
|
343
|
+
task_status: 'active',
|
|
344
|
+
worktree_state: 'present',
|
|
345
|
+
storage_class: classifyStorage(plan.path, plan.profile.ephemeral_path_patterns),
|
|
346
|
+
profile_source: plan.profile_source,
|
|
347
|
+
profile_path: plan.profile_path,
|
|
348
|
+
naming: {
|
|
349
|
+
host_slug: plan.host_slug,
|
|
350
|
+
task_short: plan.task_short,
|
|
351
|
+
id8: worktreeId.replaceAll('-', '').slice(0, 8),
|
|
352
|
+
repository_root: plan.repository_root,
|
|
353
|
+
root_source: rootSelectionSource,
|
|
354
|
+
task_policy: plan.profile.task_naming.mode,
|
|
355
|
+
},
|
|
356
|
+
created_at: now,
|
|
357
|
+
updated_at: now,
|
|
358
|
+
last_seen_at: now,
|
|
359
|
+
last_head: git(['rev-parse', 'HEAD'], plan.path),
|
|
360
|
+
ownership_epochs: [{ agent: request.identity.actor, started_at: now, start_sha: baseSha.out, end_sha: null, ended_at: null }],
|
|
361
|
+
delivery_relation: request.deliveryRelation,
|
|
362
|
+
};
|
|
363
|
+
appendTraceEvent({
|
|
364
|
+
commonDir: plan.context.common_dir,
|
|
365
|
+
worktreeId,
|
|
366
|
+
eventType: 'created',
|
|
367
|
+
actor: request.identity.actor,
|
|
368
|
+
details: {
|
|
369
|
+
identity_sources: request.identity.sources,
|
|
370
|
+
base_source: plan.base_source,
|
|
371
|
+
base_reason: baseReason,
|
|
372
|
+
stack_parent_worktree_id: record.stack_parent?.worktree_id ?? null,
|
|
373
|
+
delivery_relation: request.deliveryRelation,
|
|
374
|
+
},
|
|
375
|
+
mutate: () => record,
|
|
376
|
+
});
|
|
377
|
+
if (request.deliveryRelation?.kind === 'supersedes') {
|
|
378
|
+
const superseded = request.existingRecords.find(
|
|
379
|
+
(candidate) => candidate.worktree_id === request.deliveryRelation.superseded_worktree_id);
|
|
380
|
+
updateRecord(superseded, 'superseded_by_declared', (next) => {
|
|
381
|
+
next.superseded_by = {
|
|
382
|
+
worktree_id: worktreeId,
|
|
383
|
+
task: request.task,
|
|
384
|
+
reason: request.deliveryRelation.reason,
|
|
385
|
+
declared_at: request.deliveryRelation.declared_at,
|
|
386
|
+
};
|
|
387
|
+
}, {
|
|
388
|
+
replacement_worktree_id: worktreeId,
|
|
389
|
+
replacement_task: request.task,
|
|
390
|
+
reason: request.deliveryRelation.reason,
|
|
391
|
+
}, plan.context.common_dir);
|
|
392
|
+
}
|
|
393
|
+
ensureWorktreeCodegraph(record.path, plan.context.primary_worktree, request.codegraphMode);
|
|
394
|
+
log(`worktree 就绪 id=${worktreeId} branch=${plan.branch} path=${record.path}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function cmdSpawn(args) {
|
|
398
|
+
const request = prepareSpawnRequest(args);
|
|
399
|
+
if (request.reused) return;
|
|
400
|
+
const planned = claimSpawnRoot(request, planSpawn(args, request));
|
|
401
|
+
const slot = inspectSpawnSlot(request, planned);
|
|
402
|
+
if (slot.reused) return;
|
|
403
|
+
createSpawnWorktree(planned.plan, planned.baseSha);
|
|
404
|
+
recordSpawn(request, planned, slot.currentRecords);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function inferTask(branch) {
|
|
408
|
+
if (!branch) return null;
|
|
409
|
+
const candidate = branch.split('/').at(-1);
|
|
410
|
+
try { return validateTaskSlug(candidate); } catch { return null; }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function cmdAdopt(args) {
|
|
414
|
+
rejectUnknownFlags(args.flags, ['agent', 'agent-id', 'purpose', 'owner', 'task', 'base', 'base-reason', 'config']);
|
|
415
|
+
const rawPath = args.positionals[0];
|
|
416
|
+
if (!rawPath) die('adopt 需要 <path>。', 2);
|
|
417
|
+
const identity = resolveIdentity(args.flags, { requirePurpose: true });
|
|
418
|
+
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
419
|
+
requireFreshPrimaryProfile(loaded);
|
|
420
|
+
const path = existsSync(rawPath) ? realpathSync(rawPath) : canonicalizeFuturePath(rawPath);
|
|
421
|
+
const worktree = parseWorktrees(loaded.context.current_worktree).find((item) => item.path === path);
|
|
422
|
+
if (!worktree) die(`path 不是当前仓库登记的 worktree: ${path}`);
|
|
423
|
+
if (path === loaded.context.primary_worktree) die('primary worktree 不需要 adopt。');
|
|
424
|
+
const task = flag(args.flags, 'task') ?? inferTask(worktree.branch);
|
|
425
|
+
if (!task) die('detached 或无法从 branch 推断 task;请传 --task。', 2);
|
|
426
|
+
validateTaskNaming(task, loaded.profile.task_naming);
|
|
427
|
+
const records = loadRecords(loaded.context.common_dir);
|
|
428
|
+
const existing = records.find((record) => record.worktree_state !== 'reclaimed' && (record.path === path || (worktree.branch && record.branch === worktree.branch)));
|
|
429
|
+
if (existing) die(`worktree 已登记 id=${existing.worktree_id}。`);
|
|
430
|
+
const base = resolveBaseRef(loaded.context.current_worktree, loaded.profile.default_base, flag(args.flags, 'base'));
|
|
431
|
+
const baseReason = resolveBaseOverrideReason(
|
|
432
|
+
args.flags,
|
|
433
|
+
loaded.profile.default_base,
|
|
434
|
+
base.ref,
|
|
435
|
+
base.source,
|
|
436
|
+
);
|
|
437
|
+
const head = worktree.head ?? git(['rev-parse', 'HEAD'], path);
|
|
438
|
+
const mergeBase = gitTry(['merge-base', head, base.ref], loaded.context.current_worktree);
|
|
439
|
+
initializeTraceStore(loaded.context);
|
|
440
|
+
const worktreeId = randomUUID();
|
|
441
|
+
const now = new Date().toISOString();
|
|
442
|
+
const adoptedBaseSha = mergeBase.ok ? mergeBase.out : head;
|
|
443
|
+
const record = {
|
|
444
|
+
schema_version: 1, worktree_id: worktreeId, task, purpose: identity.purpose, path,
|
|
445
|
+
branch: worktree.branch, base_ref: base.ref, base_sha: adoptedBaseSha,
|
|
446
|
+
base_reason: baseReason,
|
|
447
|
+
stack_parent: stackParentForRef(records, base.ref, adoptedBaseSha),
|
|
448
|
+
agent: identity.actor, owner: identity.owner, task_status: 'active', worktree_state: 'present',
|
|
449
|
+
storage_class: classifyStorage(path, loaded.profile.ephemeral_path_patterns),
|
|
450
|
+
profile_source: loaded.profile_source, profile_path: loaded.profile_path,
|
|
451
|
+
naming: { task_policy: loaded.profile.task_naming.mode },
|
|
452
|
+
created_at: now, updated_at: now, last_seen_at: now, last_head: head,
|
|
453
|
+
ownership_epochs: [{ agent: identity.actor, started_at: now, start_sha: mergeBase.ok ? mergeBase.out : head, end_sha: null, ended_at: null }],
|
|
454
|
+
};
|
|
455
|
+
printIdentity(identity, task, loaded);
|
|
456
|
+
log(`Base: ${base.ref}(source=${base.source}${baseReason ? `, reason=${baseReason}` : ''})`);
|
|
457
|
+
appendTraceEvent({
|
|
458
|
+
commonDir: loaded.context.common_dir,
|
|
459
|
+
worktreeId,
|
|
460
|
+
eventType: 'adopted',
|
|
461
|
+
actor: identity.actor,
|
|
462
|
+
details: {
|
|
463
|
+
task_source: flag(args.flags, 'task') ? 'explicit' : 'branch_inferred',
|
|
464
|
+
base_source: base.source,
|
|
465
|
+
base_reason: baseReason,
|
|
466
|
+
stack_parent_worktree_id: record.stack_parent?.worktree_id ?? null,
|
|
467
|
+
},
|
|
468
|
+
mutate: () => record,
|
|
469
|
+
});
|
|
470
|
+
log(`已接管 id=${worktreeId} branch=${worktree.branch ?? '(detached)'} path=${path}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record */
|
|
474
|
+
function hasPendingBranchCleanup(loaded, record) {
|
|
475
|
+
return record.worktree_state === 'reclaimed' && (
|
|
476
|
+
record.branch_cleanup?.status === 'failed' || localBranchExists(loaded, record)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* @param {boolean} [includeAll] `--all`:额外把已回收但分支清理仍待处理的历史 record 之外的
|
|
482
|
+
* 其他已回收 record 也纳入 historical(与是否 archived 无关,是完全独立的轴)。
|
|
483
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} [loaded]
|
|
484
|
+
* @param {{includeArchived?:boolean}} [options] `includeArchived`:`--archived`,只影响
|
|
485
|
+
* worktree_state === 'archived' 的历史 record 是否出现在 historical 中;默认隐藏。
|
|
486
|
+
*/
|
|
487
|
+
function buildListing(includeAll = false, loaded = loadRepositoryProfile(), options = {}) {
|
|
488
|
+
const includeArchived = Boolean(options.includeArchived);
|
|
489
|
+
const worktrees = parseWorktrees(loaded.context.current_worktree);
|
|
490
|
+
const records = loadRecords(loaded.context.common_dir);
|
|
491
|
+
const mainPath = loaded.context.primary_worktree;
|
|
492
|
+
const rows = worktrees.map((worktree) => {
|
|
493
|
+
const record = records.find((candidate) => !isSettledWorktreeState(candidate.worktree_state) && canonicalSelectorPath(candidate.path) === worktree.path);
|
|
494
|
+
const status = worktree.bare ? null : gitTry(['status', '--porcelain'], worktree.path);
|
|
495
|
+
return {
|
|
496
|
+
kind: worktree.path === mainPath ? 'MAIN' : record ? 'TRACKED' : 'UNTRACKED',
|
|
497
|
+
path: worktree.path, branch: worktree.branch, head: worktree.head,
|
|
498
|
+
dirty: status?.ok ? status.out !== '' : null, record: record ?? null,
|
|
499
|
+
};
|
|
500
|
+
});
|
|
501
|
+
const livePaths = new Set(worktrees.map((worktree) => worktree.path));
|
|
502
|
+
const historical = records
|
|
503
|
+
.filter((record) => {
|
|
504
|
+
if (livePaths.has(canonicalSelectorPath(record.path))) return false;
|
|
505
|
+
const archived = record.worktree_state === 'archived';
|
|
506
|
+
if (archived) return includeArchived;
|
|
507
|
+
return includeAll || record.worktree_state !== 'reclaimed' || hasPendingBranchCleanup(loaded, record);
|
|
508
|
+
})
|
|
509
|
+
.map((record) => ({ ...record, branch_cleanup_pending: hasPendingBranchCleanup(loaded, record) }));
|
|
510
|
+
return { loaded, rows, historical, records };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** @param {Record<string,any>} record */
|
|
514
|
+
function reclaimSummaryFor(record) {
|
|
515
|
+
if (record.worktree_state !== 'reclaimed') return null;
|
|
516
|
+
const fallback = {
|
|
517
|
+
worktree_id: record.worktree_id,
|
|
518
|
+
task: record.task,
|
|
519
|
+
change_ref: record.auto_reclaim?.change_ref ?? null,
|
|
520
|
+
source_sha: record.auto_reclaim?.head_sha ?? record.last_head ?? null,
|
|
521
|
+
target_ref: record.auto_reclaim?.target_ref ?? record.base_ref ?? null,
|
|
522
|
+
target_sha: record.auto_reclaim?.target_sha ?? null,
|
|
523
|
+
completed_at: record.reclaimed_at ?? record.auto_reclaim?.completed_at ?? null,
|
|
524
|
+
};
|
|
525
|
+
return {
|
|
526
|
+
...fallback,
|
|
527
|
+
...(record.reclaim_summary ?? {}),
|
|
528
|
+
branch_cleanup: record.branch_cleanup ?? record.reclaim_summary?.branch_cleanup ?? null,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** @param {Record<string,any>[]} records */
|
|
533
|
+
function latestReclaim(records) {
|
|
534
|
+
return records.map(reclaimSummaryFor).filter(Boolean).sort((left, right) => String(right.completed_at ?? '').localeCompare(String(left.completed_at ?? '')))[0] ?? null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function cmdList(args) {
|
|
538
|
+
rejectUnknownFlags(args.flags, ['json', 'all', 'present', 'archived', 'config']);
|
|
539
|
+
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
540
|
+
const presentOnly = Boolean(args.flags.get('present'));
|
|
541
|
+
const listing = buildListing(Boolean(args.flags.get('all')), loaded, { includeArchived: Boolean(args.flags.get('archived')) });
|
|
542
|
+
// --present 只关心"目录还在不在":既有的 TRACKED/UNTRACKED/MAIN 分类完全不变,只是把
|
|
543
|
+
// historical(目录已经不存在的 record,含 MISSING/HISTORY/BRANCH_PENDING/ARCHIVED)整体
|
|
544
|
+
// 隐藏,避免它们持续刷屏。
|
|
545
|
+
const historical = presentOnly ? [] : listing.historical;
|
|
546
|
+
const summary = {
|
|
547
|
+
worktrees: listing.rows.length,
|
|
548
|
+
tracked: listing.rows.filter((row) => row.kind === 'TRACKED').length,
|
|
549
|
+
untracked: listing.rows.filter((row) => row.kind === 'UNTRACKED').length,
|
|
550
|
+
historical: historical.length,
|
|
551
|
+
};
|
|
552
|
+
const lastReclaim = latestReclaim(listing.records);
|
|
553
|
+
if (args.flags.get('json')) {
|
|
554
|
+
console.log(JSON.stringify({ profile: { source: listing.loaded.profile_source, path: listing.loaded.profile_path }, summary, last_reclaim: lastReclaim, worktrees: listing.rows, records: historical }, null, 2));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
log(`worktrees=${summary.worktrees} TRACKED=${summary.tracked} UNTRACKED=${summary.untracked} history=${summary.historical}`);
|
|
558
|
+
if (lastReclaim) {
|
|
559
|
+
console.log(` [LAST_RECLAIM] ${lastReclaim.completed_at ?? '?'} task=${lastReclaim.task} change=${lastReclaim.change_ref ?? '-'} target=${lastReclaim.target_sha?.slice(0, 12) ?? '-'} branch=${lastReclaim.branch_cleanup?.status ?? 'legacy'}`);
|
|
560
|
+
}
|
|
561
|
+
for (const row of listing.rows) {
|
|
562
|
+
console.log(` [${row.kind}] [${row.dirty === null ? '?' : row.dirty ? 'DIRTY' : 'CLEAN'}] ${row.branch ?? '(detached)'} ${row.path}`);
|
|
563
|
+
if (row.record) console.log(` ${row.record.agent.host}/${row.record.agent.id} task=${row.record.task} status=${row.record.task_status}/${row.record.worktree_state}\n ${row.record.purpose}`);
|
|
564
|
+
}
|
|
565
|
+
for (const record of historical) {
|
|
566
|
+
const label = record.worktree_state === 'archived'
|
|
567
|
+
? 'ARCHIVED'
|
|
568
|
+
: record.branch_cleanup_pending
|
|
569
|
+
? 'BRANCH_PENDING'
|
|
570
|
+
: record.worktree_state === 'reclaimed'
|
|
571
|
+
? 'HISTORY'
|
|
572
|
+
: 'MISSING';
|
|
573
|
+
const cleanup = record.worktree_state === 'reclaimed' ? ` branch=${record.branch_cleanup?.status ?? 'legacy'}` : '';
|
|
574
|
+
console.log(` [${label}] ${record.worktree_id.slice(0, 8)} ${record.task} ${record.agent.host}/${record.agent.id}${cleanup} ${record.path}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** @param {Record<string,any>} record @param {string} command */
|
|
579
|
+
|
|
580
|
+
function cmdTouch(args) {
|
|
581
|
+
rejectUnknownFlags(args.flags, ['status', 'note', 'id', 'config', 'no-watch', 'target', 'watch-target', 'change-ref', 'mr', 'interval-ms', 'notify']);
|
|
582
|
+
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
583
|
+
const record = selectRecord(loadRecords(loaded.context.common_dir), args.positionals[0] ?? null, flag(args.flags, 'id'));
|
|
584
|
+
assertHistoryOperationIdle(record, 'touch');
|
|
585
|
+
if (record.worktree_state === 'reclaimed') {
|
|
586
|
+
die('已回收 record 是不可变历史,不能 touch;同名返工请重新 spawn。');
|
|
587
|
+
}
|
|
588
|
+
const requested = flag(args.flags, 'status') ?? record.task_status;
|
|
589
|
+
if (!TASK_TRANSITIONS[record.task_status]?.has(requested)) die(`非法状态流转: ${record.task_status} -> ${requested}`);
|
|
590
|
+
const note = flag(args.flags, 'note') ? oneLine(flag(args.flags, 'note'), 'note', 240) : null;
|
|
591
|
+
const targetRef = aliasedFlag(args.flags, 'target', 'watch-target');
|
|
592
|
+
const registeredTargetRef = targetRef ?? record.base_ref ?? null;
|
|
593
|
+
const mrUrl = flag(args.flags, 'mr') ? httpUrl(flag(args.flags, 'mr'), 'mr') : null;
|
|
594
|
+
const changeRef = aliasedFlag(args.flags, 'change-ref', 'mr');
|
|
595
|
+
const snapshot = liveGitSnapshot(record);
|
|
596
|
+
const activeWatch = record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)
|
|
597
|
+
? record.auto_reclaim
|
|
598
|
+
: null;
|
|
599
|
+
// HEAD 已前进时,旧冻结证据必须在 touch 的第一条 event 内原子失效。若先写 status_updated、
|
|
600
|
+
// 再另写 disarm,后台 watcher 就能在两条 event 之间用旧 SHA 抢先推进 merge_detected/done。
|
|
601
|
+
const staleWatch = requested === 'ready_for_review'
|
|
602
|
+
&& activeWatch
|
|
603
|
+
&& activeWatch.state !== 'merge_detected'
|
|
604
|
+
&& Boolean(snapshot.head)
|
|
605
|
+
&& activeWatch.head_sha !== snapshot.head
|
|
606
|
+
? activeWatch
|
|
607
|
+
: null;
|
|
608
|
+
const updated = updateRecord(record, staleWatch ? 'auto_reclaim_disarmed' : 'status_updated', (next) => {
|
|
609
|
+
if (next.task_status !== record.task_status) {
|
|
610
|
+
throw new WorktreeTraceError(
|
|
611
|
+
'TOUCH_STATE_CHANGED',
|
|
612
|
+
`touch 期间 task_status 已由 ${record.task_status} 变为 ${next.task_status};请重新读取状态后重试。`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (staleWatch) {
|
|
616
|
+
const currentWatch = next.auto_reclaim;
|
|
617
|
+
if (
|
|
618
|
+
currentWatch?.token !== staleWatch.token
|
|
619
|
+
|| ['merge_detected', 'disarmed', 'reclaimed'].includes(currentWatch?.state)
|
|
620
|
+
) {
|
|
621
|
+
throw new WorktreeTraceError(
|
|
622
|
+
'WATCHER_CHANGED',
|
|
623
|
+
'旧 watcher 已被并发 rearm、解除或推进到 merge_detected;拒绝用陈旧快照覆盖当前状态。',
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
currentWatch.state = 'disarmed';
|
|
627
|
+
currentWatch.disarmed_at = new Date().toISOString();
|
|
628
|
+
currentWatch.disarm_reason = 'stale_frozen_head';
|
|
629
|
+
}
|
|
630
|
+
next.task_status = requested;
|
|
631
|
+
if (next.worktree_state === 'present' && !snapshot.present) next.worktree_state = 'missing';
|
|
632
|
+
else if (next.worktree_state === 'missing' && snapshot.present) next.worktree_state = 'present';
|
|
633
|
+
next.last_seen_at = new Date().toISOString();
|
|
634
|
+
next.last_head = snapshot.head;
|
|
635
|
+
if (mrUrl) {
|
|
636
|
+
const targetBranch = registeredTargetRef?.includes('/') ? registeredTargetRef.slice(registeredTargetRef.indexOf('/') + 1) : registeredTargetRef;
|
|
637
|
+
next.change_request = {
|
|
638
|
+
provider: loaded.profile.change_request?.provider ?? 'external',
|
|
639
|
+
state: 'registered',
|
|
640
|
+
change_ref: mrUrl,
|
|
641
|
+
url: mrUrl,
|
|
642
|
+
source_branch: next.branch,
|
|
643
|
+
target_branch: targetBranch,
|
|
644
|
+
target_ref: registeredTargetRef,
|
|
645
|
+
head_sha: snapshot.head,
|
|
646
|
+
registered_at: new Date().toISOString(),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
}, {
|
|
650
|
+
note,
|
|
651
|
+
git: snapshot,
|
|
652
|
+
...(staleWatch ? {
|
|
653
|
+
source: 'auto_touch_head_drift',
|
|
654
|
+
reason: 'live HEAD 已偏离冻结 SHA;在检查新 HEAD 是否可武装前先原子失效旧 watcher。',
|
|
655
|
+
stale_head_sha: staleWatch.head_sha,
|
|
656
|
+
live_head: snapshot.head,
|
|
657
|
+
target_ref: staleWatch.target_ref,
|
|
658
|
+
token: staleWatch.token,
|
|
659
|
+
} : {}),
|
|
660
|
+
}, loaded.context.common_dir);
|
|
661
|
+
log(`已更新 ${updated.worktree_id.slice(0, 8)} ${record.task_status} -> ${updated.task_status}`);
|
|
662
|
+
if (staleWatch) {
|
|
663
|
+
removeWatcherHeartbeat(loaded.context.common_dir, record.worktree_id, staleWatch.token);
|
|
664
|
+
log(`watch 已解除(原冻结 head=${staleWatch.head_sha.slice(0, 12)} 已过期,当前 HEAD=${snapshot.head.slice(0, 12)})。`);
|
|
665
|
+
}
|
|
666
|
+
if (requested === 'ready_for_review') autoArmReviewWatch(loaded, updated, args, snapshot);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* 进入 ready_for_review 时默认武装合入监听。
|
|
671
|
+
*
|
|
672
|
+
* 纪律来源:监听绑定的是「内容进主干」这一事实,与内容经哪个载体(自建 change request、
|
|
673
|
+
* 聚合 change request、他人代推)无关。靠人在建 change request 时手工挂 watch,一旦中途改成
|
|
674
|
+
* 由别的载体合入,监听就会漏挂、合入后无人回收——默认武装把这个洞堵死。
|
|
675
|
+
*
|
|
676
|
+
* 失败一律 fail-soft:touch 的主职是状态流转,不因为没有 remote / 未推送而失败,
|
|
677
|
+
* 但必须把未武装的原因说清楚,避免「以为挂上了」。
|
|
678
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} loaded
|
|
679
|
+
* @param {Record<string,any>} record
|
|
680
|
+
* @param {{flags:Map<string,unknown>}} args
|
|
681
|
+
* @param {{present:boolean,head:string|null,dirty:boolean|null,upstream:string|null}} snapshot
|
|
682
|
+
*/
|
|
683
|
+
|
|
684
|
+
function cmdHandoff(args) {
|
|
685
|
+
rejectUnknownFlags(args.flags, ['to-agent', 'to-agent-id', 'note', 'id', 'config']);
|
|
686
|
+
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
687
|
+
const record = selectRecord(loadRecords(loaded.context.common_dir), args.positionals[0] ?? null, flag(args.flags, 'id'));
|
|
688
|
+
assertHistoryOperationIdle(record, 'handoff');
|
|
689
|
+
const target = resolveIdentity(args.flags, { target: true });
|
|
690
|
+
const note = oneLine(flag(args.flags, 'note') ?? '', 'note', 240);
|
|
691
|
+
const snapshot = liveGitSnapshot(record);
|
|
692
|
+
if (!snapshot.present) die('handoff 要求 worktree 存在。');
|
|
693
|
+
if (snapshot.dirty !== false) die('handoff 要求工作树干净;请先 WIP commit + push。');
|
|
694
|
+
const now = new Date().toISOString();
|
|
695
|
+
const from = record.agent;
|
|
696
|
+
const updated = appendTraceEvent({
|
|
697
|
+
commonDir: loaded.context.common_dir, worktreeId: record.worktree_id, eventType: 'handed_off', actor: from,
|
|
698
|
+
details: { from, to: target.actor, boundary_sha: snapshot.head, note },
|
|
699
|
+
mutate(current) {
|
|
700
|
+
const next = structuredClone(current);
|
|
701
|
+
const epoch = next.ownership_epochs.at(-1);
|
|
702
|
+
if (epoch && !epoch.ended_at) { epoch.ended_at = now; epoch.end_sha = snapshot.head; }
|
|
703
|
+
next.agent = target.actor;
|
|
704
|
+
next.ownership_epochs.push({ agent: target.actor, started_at: now, start_sha: snapshot.head, end_sha: null, ended_at: null });
|
|
705
|
+
next.updated_at = now; next.last_seen_at = now; next.last_head = snapshot.head;
|
|
706
|
+
return next;
|
|
707
|
+
},
|
|
708
|
+
}).record;
|
|
709
|
+
log(`已交接 ${updated.worktree_id.slice(0, 8)}: ${from.host}/${from.id} -> ${target.actor.host}/${target.actor.id}`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function commitsForEpoch(cwd, epoch, endSha) {
|
|
713
|
+
if (!epoch.start_sha || !endSha) return { degraded: true, reason: 'missing boundary', commits: [] };
|
|
714
|
+
if (!gitTry(['cat-file', '-e', `${epoch.start_sha}^{commit}`], cwd).ok || !gitTry(['cat-file', '-e', `${endSha}^{commit}`], cwd).ok) return { degraded: true, reason: 'boundary unreachable', commits: [] };
|
|
715
|
+
if (!gitTry(['merge-base', '--is-ancestor', epoch.start_sha, endSha], cwd).ok) return { degraded: true, reason: 'boundary rewritten (amend/rebase)', commits: [] };
|
|
716
|
+
const revs = gitTry(['rev-list', '--reverse', `${epoch.start_sha}..${endSha}`], cwd);
|
|
717
|
+
const commits = revs.ok && revs.out ? revs.out.split('\n').map((sha) => {
|
|
718
|
+
const meta = git(['show', '-s', '--format=%H%x09%an%x09%s', sha], cwd).split('\t');
|
|
719
|
+
const files = gitTry(['show', '--pretty=format:', '--name-only', sha], cwd);
|
|
720
|
+
return { sha: meta[0], author: meta[1], subject: meta.slice(2).join('\t'), files: files.ok ? files.out.split('\n').filter(Boolean) : [] };
|
|
721
|
+
}) : [];
|
|
722
|
+
return { degraded: false, reason: null, commits };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function cmdAudit(args) {
|
|
726
|
+
rejectUnknownFlags(args.flags, ['json', 'id', 'config']);
|
|
727
|
+
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
728
|
+
const records = loadRecords(loaded.context.common_dir);
|
|
729
|
+
const record = selectRecord(records, args.positionals[0] ?? null, flag(args.flags, 'id'));
|
|
730
|
+
const chain = readEventChain(loaded.context.common_dir, record.worktree_id);
|
|
731
|
+
const epochs = (record.ownership_epochs ?? []).map((epoch) => ({ ...epoch, attribution: commitsForEpoch(loaded.context.current_worktree, epoch, epoch.end_sha ?? record.last_head) }));
|
|
732
|
+
const result = { record, events: chain.map(({ snapshot, ...event }) => event), ownership_epochs: epochs };
|
|
733
|
+
if (args.flags.get('json')) { console.log(JSON.stringify(result, null, 2)); return; }
|
|
734
|
+
log(`${record.worktree_id} task=${record.task} ${record.task_status}/${record.worktree_state}`);
|
|
735
|
+
for (const epoch of epochs) {
|
|
736
|
+
console.log(` ${epoch.agent.host}/${epoch.agent.id} ${epoch.start_sha?.slice(0, 12)}..${(epoch.end_sha ?? record.last_head)?.slice(0, 12)}`);
|
|
737
|
+
if (epoch.attribution.degraded) console.log(` ATTRIBUTION_DEGRADED: ${epoch.attribution.reason}`);
|
|
738
|
+
for (const commit of epoch.attribution.commits) console.log(` ${commit.sha.slice(0, 12)} ${commit.subject} (${commit.files.length} files)`);
|
|
739
|
+
}
|
|
740
|
+
for (const event of result.events) console.log(` ${event.occurred_at} ${event.event_type} ${event.actor ? `${event.actor.host}/${event.actor.id}` : '-'}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** @param {unknown} value */
|
|
744
|
+
|
|
745
|
+
function cmdRebuild(args) {
|
|
746
|
+
rejectUnknownFlags(args.flags, ['id', 'recover-lock', 'config']);
|
|
747
|
+
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
748
|
+
const records = loadRecords(loaded.context.common_dir);
|
|
749
|
+
const eventsDir = traceLayout(loaded.context.common_dir).events;
|
|
750
|
+
const eventIds = existsSync(eventsDir)
|
|
751
|
+
? readdirSync(eventsDir).filter((name) => /^[0-9a-f-]{36}$/i.test(name))
|
|
752
|
+
: [];
|
|
753
|
+
let targetIds;
|
|
754
|
+
const explicitId = flag(args.flags, 'id');
|
|
755
|
+
if (explicitId) {
|
|
756
|
+
if (!/^(?:[0-9a-f]{8,32}|[0-9a-f-]{36})$/i.test(explicitId)) die('--id 需要完整 UUID 或至少 8 位十六进制前缀。', 2);
|
|
757
|
+
targetIds = eventIds.filter((id) => id.toLowerCase().startsWith(explicitId.toLowerCase()));
|
|
758
|
+
if (targetIds.length !== 1) die(`--id 在 event store 中匹配 ${targetIds.length} 条 chain。`, 2);
|
|
759
|
+
} else if (args.positionals[0]) {
|
|
760
|
+
targetIds = [selectRecord(records, args.positionals[0], null).worktree_id];
|
|
761
|
+
} else {
|
|
762
|
+
targetIds = eventIds;
|
|
763
|
+
}
|
|
764
|
+
for (const worktreeId of targetIds) {
|
|
765
|
+
if (args.flags.get('recover-lock')) {
|
|
766
|
+
const finding = inspectRecordLock(loaded.context.common_dir, worktreeId);
|
|
767
|
+
if (finding.state !== 'absent' && finding.state !== 'held') recoverRecordLock(loaded.context.common_dir, worktreeId, { forceMalformed: true });
|
|
768
|
+
}
|
|
769
|
+
rebuildRecordCache(loaded.context.common_dir, worktreeId);
|
|
770
|
+
log(`rebuilt ${worktreeId}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
return {
|
|
776
|
+
cmdSupersede,
|
|
777
|
+
cmdSpawn,
|
|
778
|
+
cmdAdopt,
|
|
779
|
+
buildListing,
|
|
780
|
+
cmdList,
|
|
781
|
+
cmdTouch,
|
|
782
|
+
cmdHandoff,
|
|
783
|
+
cmdAudit,
|
|
784
|
+
cmdRebuild,
|
|
785
|
+
};
|
|
786
|
+
}
|