@cr1992/agentkit 1.0.0 → 1.1.1
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 +25 -0
- package/README.en.md +13 -1
- package/README.md +12 -1
- package/docs/worktree/reclaim-and-watch.md +16 -2
- package/docs/worktree/review-lifecycle.md +27 -3
- package/domains/worktree/worktree-core.mjs +1 -1
- package/domains/worktree/worktree-doctor.mjs +62 -5
- package/domains/worktree/worktree-mgr.mjs +13 -3
- package/domains/worktree/worktree-reclaim.mjs +56 -14
- package/domains/worktree/worktree-review-watch.mjs +200 -22
- package/domains/worktree/worktree-watch-service.mjs +224 -0
- package/manage-worktrees/SKILL.md +11 -7
- package/orchestrate-subagents/SKILL.md +15 -15
- package/package.json +1 -1
- package/run-agent-verify-loop/SKILL.md +4 -4
- package/shell-manifest.json +1 -1
- package/verify-agent-output/SKILL.md +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [Semantic Versioning](https://semver.org/)。
|
|
4
4
|
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 1.1.1 - 2026-09-08
|
|
8
|
+
|
|
9
|
+
- 修复 `doctor` 对已回收 record 仍生成需要活树才能收敛的 metadata finding。`stack_parent`、
|
|
10
|
+
`history_operation` 和 `review_refresh` 三个分支缺少 `worktree_state` 过滤,对已删除目录报
|
|
11
|
+
`STACK_PARENT_ADVANCED` 等提示,其补救动作(managed rebase/retarget、`rebase --continue`、
|
|
12
|
+
`refresh-review`)在回收后都无法执行,噪声永远清不掉;其中 error 级的还会按「任何 error 都暂停
|
|
13
|
+
`spawn/adopt`」把后续派工钉死。生命周期与 watcher 两个收集器早已是这个口径,本次补齐 metadata。
|
|
14
|
+
- `manage-worktrees` 补齐 `--no-watch` 的使用判据:只用于确定不会合入,或武装失败原因为永久性
|
|
15
|
+
(否则 pending 会积一条清不掉的 `AUTO_RECLAIM_NOT_ARMED`)。明确禁止因 watcher 活不过会话或
|
|
16
|
+
为压后台进程数而关闭——前者是 `watch-service` 的职责,后者不成立(`disabled` 持久且不重试)。
|
|
17
|
+
- `watch-service status` 进入强制流程表的「恢复/盘点」行,不再只以表外散文形式存在。
|
|
18
|
+
- 四个 SKILL.md 对 `docs/<域>/` 的引用从相对链接改为 `agentkit docs <域> <主题>` 命令。宿主把 Skill
|
|
19
|
+
基目录报成安装路径(常为软链),Read 工具按词法折叠 `..`,原 `../docs/...` 链接在安装态全部不可达,
|
|
20
|
+
agent 只剩摘要表。`validate-skills` 新增安装态检查:SKILL.md 相对链接越出 Skill 目录即报错,
|
|
21
|
+
`agentkit docs` 引用的主题必须真实存在。
|
|
22
|
+
|
|
23
|
+
## 1.1.0 - 2026-09-07
|
|
24
|
+
|
|
25
|
+
- 修复从待回收 worktree 自身执行 `reclaim` 时,目录删除后误判本地分支已不存在的问题;未完成的
|
|
26
|
+
branch cleanup 现在返回非零并可幂等重试。
|
|
27
|
+
- `manage-worktrees` 持久化评审 watcher 的 pending/disabled 意图,让 `doctor` 与 `resume-all` 不再忽略未武装记录。
|
|
28
|
+
- 新增 macOS `watch-service` LaunchAgent,为已登记 watcher 提供跨 Agent 会话与重启后的自动恢复触发。
|
|
29
|
+
|
|
5
30
|
## 1.0.0 - 2026-09-04
|
|
6
31
|
|
|
7
32
|
- 将四个 Agent 工程 Skill 收敛为一个零依赖 Node.js CLI 与四个薄壳。
|
package/README.en.md
CHANGED
|
@@ -12,7 +12,7 @@ the `orchestrate-subagents` control plane, which then chooses lightweight or ful
|
|
|
12
12
|
rerouting from effective capabilities, task scale, and local model policy. Providers remain independently usable and
|
|
13
13
|
compose through frozen Artifact, Binding, and Evidence envelopes.
|
|
14
14
|
|
|
15
|
-
[Detailed collaboration contracts and safety boundaries (Chinese, v1.
|
|
15
|
+
[Detailed collaboration contracts and safety boundaries (Chinese, v1.1.0)](./docs/architecture/skill-system-architecture.md)
|
|
16
16
|
|
|
17
17
|
## Included skills
|
|
18
18
|
|
|
@@ -39,6 +39,18 @@ npm install -g @cr1992/agentkit
|
|
|
39
39
|
agentkit doctor
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
On macOS, explicitly install the per-repository user service when worktree merge watchers must recover across agent
|
|
43
|
+
sessions and restarts:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
agentkit worktree watch-service install
|
|
47
|
+
agentkit worktree watch-service status
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The npm install does not create a persistent service. The service pins the Node and agentkit paths present at install
|
|
51
|
+
time; rerun `watch-service install` after moving either installation, and inspect `program_available` in
|
|
52
|
+
`status --json`. Other platforms currently use `agentkit worktree resume-all` for manual recovery.
|
|
53
|
+
|
|
42
54
|
### Skills
|
|
43
55
|
|
|
44
56
|
Install all four skills:
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
其内部再按有效能力、任务规模、本地模型配置和验收证据选择轻量或完整运行方式及后续重路由。
|
|
12
12
|
各 provider 仍可独立使用,并通过冻结的 Artifact、Binding 和 Evidence envelope 按需组合。
|
|
13
13
|
|
|
14
|
-
[Agent Skills 协作契约与安全边界(v1.
|
|
14
|
+
[Agent Skills 协作契约与安全边界(v1.1.0)](./docs/architecture/skill-system-architecture.md)
|
|
15
15
|
|
|
16
16
|
## 包含的 Skill
|
|
17
17
|
|
|
@@ -37,6 +37,17 @@ npm install -g @cr1992/agentkit
|
|
|
37
37
|
agentkit doctor
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
macOS 上若要让 worktree 合入监听跨 Agent 会话和重启持续恢复,可按仓库显式安装用户级维护器:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
agentkit worktree watch-service install
|
|
44
|
+
agentkit worktree watch-service status
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
它不会随 npm install 自动常驻。服务固定安装时的 Node 与 agentkit 路径;升级 agentkit、切换 Node
|
|
48
|
+
安装位置后应重新执行 `watch-service install`,`status --json` 的 `program_available` 可检查原路径。
|
|
49
|
+
其他平台当前使用 `agentkit worktree resume-all` 手工恢复。
|
|
50
|
+
|
|
40
51
|
### Skill
|
|
41
52
|
|
|
42
53
|
安装全部 Skill:
|
|
@@ -6,7 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
监听绑定“冻结的 head SHA 已成为目标 ref 祖先”这一事实,不绑定 change request 载体。载体改为他人代推、
|
|
8
8
|
聚合 MR 或其他分支时不解除监听。进入 `ready_for_review` 默认武装;`submit` 只是其中一个入口。电脑重启后
|
|
9
|
-
由 `resume-all` 恢复 stale watcher;
|
|
9
|
+
由 `resume-all` 恢复 stale watcher;macOS 要让这一步无需新 Agent 会话触发,显式安装用户级维护器:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
agentkit worktree watch-service install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
维护器定期执行有限的 `resume-all`,实际 watcher 仍用 token/event CAS 与 heartbeat 裁决;它不放宽任何
|
|
16
|
+
回收前置条件。`watch-service status` 检查 plist、launchd job 与当前 Node/runtime 路径,`uninstall` 解除。
|
|
17
|
+
未安装时只有进程级自动回收,不得称作跨会话保证。change request 已关闭且明确不会合入时用 `unwatch`。
|
|
10
18
|
|
|
11
19
|
## 已推送成果
|
|
12
20
|
|
|
@@ -20,6 +28,11 @@ agentkit worktree reclaim <task-or-id> --pushed <sha-or-unique-prefix>
|
|
|
20
28
|
final_snapshot -> reclaim_ready -> git worktree remove -> branch cleanup -> reclaimed
|
|
21
29
|
```
|
|
22
30
|
|
|
31
|
+
命令可以从待回收 worktree 自身发起;runtime 在删除目录前冻结 primary worktree 作为后续仓库级 Git
|
|
32
|
+
操作的稳定 cwd。branch probe 只有在 `show-ref` 明确返回“不存在”时才记为 `absent`,cwd、权限或其他
|
|
33
|
+
执行错误一律 fail closed。目录已删除但 branch cleanup 未完成时保留 `BRANCH_PENDING`,命令返回非零,
|
|
34
|
+
从任一仍存在的 worktree 重跑同一 `reclaim` 完成对账。
|
|
35
|
+
|
|
23
36
|
终态事件将非 `abandoned` 任务收敛为 `done`,并以最终 source HEAD 闭合 ownership epoch。旧版本若留下
|
|
24
37
|
`reclaimed` 但状态或 epoch 未闭合,重复同一命令会追加 reconciliation event 后再对账。
|
|
25
38
|
|
|
@@ -88,7 +101,8 @@ agentkit worktree reclaim old-task \
|
|
|
88
101
|
|
|
89
102
|
- `abandoned` 只冻结写入,不等于已回收;`doctor` 持续报告残留树和断裂替代关系。
|
|
90
103
|
- 禁止 `rm -rf`、`git worktree remove --force` 和 `branch -D`。
|
|
91
|
-
- branch cleanup 失败时保留 `BRANCH_PENDING
|
|
104
|
+
- branch cleanup 失败时保留 `BRANCH_PENDING` 并返回非零;修复占用后从仍存在的 worktree 重跑相同
|
|
105
|
+
`reclaim`,工具重新验证 branch tip。
|
|
92
106
|
- `git worktree remove` 失败返回非零 `KEEP` 并记录原始错误。即使 Git 登记已解除,物理目录仍在也不能
|
|
93
107
|
标为 `reclaimed`;先恢复权限或登记关系,不用强删掩盖孤儿目录。
|
|
94
108
|
- 含 submodule 的树逐个验证已初始化工作区干净,再自动 deinit 并清理该树私有元数据。submodule 脏、
|
|
@@ -13,7 +13,29 @@ agentkit worktree touch <selector> --status ready_for_review
|
|
|
13
13
|
`ready_for_review` 默认按该树登记的 `base_ref` 武装监听,Profile `default_base` 只在 record 没有 base 时
|
|
14
14
|
兜底;显式 target 与既有人工 target 仍然优先。HEAD 前进时同一 event 使旧 watcher 失效,
|
|
15
15
|
再尝试冻结新 HEAD。worktree dirty、HEAD 未推送或远端主干不可刷新时 fail-soft,状态更新成功但明确
|
|
16
|
-
|
|
16
|
+
回报未武装原因,并把 `review_watch=auto/pending` 与原因写入 trace;`resume-all` 会重试。`--no-watch`
|
|
17
|
+
则持久登记 `disabled`,与启动失败严格区分。`doctor` 对 pending、缺失 intent 和显式退出分别报告
|
|
18
|
+
`AUTO_RECLAIM_NOT_ARMED`、`AUTO_RECLAIM_INTENT_MISSING` 与 `AUTO_RECLAIM_DISABLED`。
|
|
19
|
+
|
|
20
|
+
`--no-watch` 只用于两种情况:change request 确定不会合入(与 `unwatch` 同一判据);或该树的合入在结构上
|
|
21
|
+
无法被 watcher 观测且原因是永久的(例如仓库没有可刷新的远端 target),此时留在 pending 只会积一条永远清不
|
|
22
|
+
掉的 `AUTO_RECLAIM_NOT_ARMED`(error),按强制流程会挡住后续 `spawn/adopt`。除此之外一律不要关:watcher
|
|
23
|
+
活不过当前会话是 `watch-service` 要解决的问题,不是关 watcher 的理由;也不能拿它临时压后台进程数——
|
|
24
|
+
`disabled` 是持久策略,`resume-all` 不重试,没有“解除节流”这一步。dirty、未 push、网络等**可恢复**原因
|
|
25
|
+
导致的武装失败,留在 pending 等重试,不要转成 `disabled`。
|
|
26
|
+
|
|
27
|
+
进程级 watcher 只保证当前宿主允许后台进程存活期间的即时回收。需要跨 Agent 会话或机器重启:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
agentkit worktree watch-service install
|
|
31
|
+
agentkit worktree watch-service status --json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
macOS adapter 安装用户级 LaunchAgent,每 60 秒(可用 `--interval-seconds` 调整)从 primary worktree
|
|
35
|
+
运行一次 `resume-all`。安装是显式动作,不随 npm install 自动发生,不保存调用会话的环境变量或凭证。
|
|
36
|
+
服务固定安装时的 Node 与 manager script 绝对路径;升级 agentkit 或切换 Node 安装位置后重跑 install,
|
|
37
|
+
`status --json` 的 `program_available` 会检查这两个路径。卸载使用 `watch-service uninstall`。其他平台仍可
|
|
38
|
+
手工运行 `resume-all`,在相应 service adapter 落地前不宣称跨会话保证。
|
|
17
39
|
|
|
18
40
|
人工 `watch --target` 可改默认目标;已经显式武装的 target 不被静默改写,换目标先 `unwatch`。陈旧
|
|
19
41
|
watcher 不能覆盖并发 rearm 或 `merge_detected`。旧 SHA 合入不代表新 HEAD 已完成。
|
|
@@ -88,5 +110,7 @@ agentkit worktree touch <selector> \
|
|
|
88
110
|
未给 target 时使用 record 的 `base_ref`;远端 MR target 不同时先核对 provider/UI,再显式传
|
|
89
111
|
真实目标。`--mr` 只接受 HTTP(S) URL;纯文本载体使用 `--change-ref`。
|
|
90
112
|
|
|
91
|
-
watcher 只在冻结 SHA 成为目标 ref
|
|
92
|
-
|
|
113
|
+
watcher 只在冻结 SHA 成为目标 ref 祖先后回收。`resume-all` 恢复 stale watcher 和已登记的 pending
|
|
114
|
+
intent;对没有 intent 的 legacy 评审记录只报告 skipped,要求重新 `touch ready_for_review`,不猜测它
|
|
115
|
+
是否曾显式 `--no-watch`。change request 关闭且确定不会合入时 `unwatch`。详细回收见
|
|
116
|
+
[reclaim-and-watch.md](reclaim-and-watch.md)。
|
|
@@ -346,7 +346,7 @@ export function readJsonFileOrDie(path, label) {
|
|
|
346
346
|
export function parseArgs(argv) {
|
|
347
347
|
const flags = new Map();
|
|
348
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']);
|
|
349
|
+
const booleanFlags = new Set(['json', 'all', 'present', 'archived', 'verbose', 'quiet', 'recover-lock', 'no-watch', 'abort-on-conflict', 'no-rerere', 'recompose', 'scan-conflicts', 'abort', 'continue', 'pause-before-push']);
|
|
350
350
|
for (let index = 0; index < argv.length; index++) {
|
|
351
351
|
const value = argv[index];
|
|
352
352
|
if (!value.startsWith('--')) {
|
|
@@ -35,10 +35,18 @@ export function createCommands(deps) {
|
|
|
35
35
|
buildListing,
|
|
36
36
|
learningRoot,
|
|
37
37
|
isSettledWorktreeState,
|
|
38
|
+
watchServiceStatus,
|
|
38
39
|
} = deps;
|
|
39
40
|
|
|
40
41
|
function collectDoctorRecordMetadataFindings(loaded, listing, recordsById, record, findings) {
|
|
41
|
-
|
|
42
|
+
// 本收集器的每条 finding 都要求一棵活树才能收敛:commit+push、rebase --continue、
|
|
43
|
+
// refresh-review --continue/--abort、managed rebase 或 retarget、补记 base_reason。
|
|
44
|
+
// record 一旦 reclaimed,目录已经删除,这些提示既没有补救动作也永远不会消失;其中 error 级的
|
|
45
|
+
// (MANAGED_HISTORY_OPERATION_PENDING / REVIEW_REFRESH_PENDING / STACK_PARENT_*)还会按
|
|
46
|
+
// SKILL 的「任何 error 都暂停 spawn/adopt」把后续派工钉死。生命周期与 watcher 两个收集器
|
|
47
|
+
// 已经是这个口径,这里补齐;archived 更早一步在调用方 continue,所以只需排除 reclaimed。
|
|
48
|
+
if (record.worktree_state === 'reclaimed') return;
|
|
49
|
+
if (record.storage_class === 'ephemeral') findings.push({ code: 'EPHEMERAL_WORKTREE', severity: 'warning', worktree_id: record.worktree_id, path: record.path });
|
|
42
50
|
if (record.history_operation) {
|
|
43
51
|
findings.push({
|
|
44
52
|
code: 'MANAGED_HISTORY_OPERATION_PENDING',
|
|
@@ -102,7 +110,6 @@ export function createCommands(deps) {
|
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
112
|
if (
|
|
105
|
-
record.worktree_state !== 'reclaimed' &&
|
|
106
113
|
loaded.profile.default_base &&
|
|
107
114
|
record.base_ref &&
|
|
108
115
|
record.base_ref !== loaded.profile.default_base
|
|
@@ -243,10 +250,45 @@ export function createCommands(deps) {
|
|
|
243
250
|
|
|
244
251
|
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {Record<string,any>[]} findings */
|
|
245
252
|
function collectDoctorRecordWatcherFindings(loaded, record, findings) {
|
|
246
|
-
|
|
253
|
+
const reviewLifecycle = ['ready_for_review', 'integrating'].includes(record.task_status)
|
|
254
|
+
&& !['reclaimed', 'archived'].includes(record.worktree_state);
|
|
255
|
+
const activeWatch = record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)
|
|
256
|
+
? record.auto_reclaim
|
|
257
|
+
: null;
|
|
258
|
+
if (reviewLifecycle && !activeWatch) {
|
|
259
|
+
if (record.review_watch?.policy === 'disabled') {
|
|
260
|
+
findings.push({
|
|
261
|
+
code: 'AUTO_RECLAIM_DISABLED',
|
|
262
|
+
severity: 'warning',
|
|
263
|
+
worktree_id: record.worktree_id,
|
|
264
|
+
path: record.path,
|
|
265
|
+
detail: `已显式关闭跨会话回收(${record.review_watch.reason ?? 'no reason'});合入后需要人工 reclaim。`,
|
|
266
|
+
});
|
|
267
|
+
} else if (record.review_watch?.policy === 'auto') {
|
|
268
|
+
findings.push({
|
|
269
|
+
code: 'AUTO_RECLAIM_NOT_ARMED',
|
|
270
|
+
severity: 'error',
|
|
271
|
+
worktree_id: record.worktree_id,
|
|
272
|
+
path: record.path,
|
|
273
|
+
target_ref: record.review_watch.target_ref ?? null,
|
|
274
|
+
head_sha: record.review_watch.head_sha ?? null,
|
|
275
|
+
detail: record.review_watch.reason ?? '自动回收 intent 已登记,但 watcher 尚未武装;运行 resume-all 重试。',
|
|
276
|
+
});
|
|
277
|
+
} else {
|
|
278
|
+
findings.push({
|
|
279
|
+
code: 'AUTO_RECLAIM_INTENT_MISSING',
|
|
280
|
+
severity: 'error',
|
|
281
|
+
worktree_id: record.worktree_id,
|
|
282
|
+
path: record.path,
|
|
283
|
+
detail: `评审态 record 没有 watcher 或显式退出记录;运行 touch ${record.task} --status ready_for_review 重新登记。`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (activeWatch) {
|
|
247
289
|
const heartbeat = readWatcherHeartbeat(loaded.context.common_dir, record.worktree_id);
|
|
248
290
|
const health = watcherHealth(record, heartbeat);
|
|
249
|
-
const advance =
|
|
291
|
+
const advance = activeWatch.target_advance;
|
|
250
292
|
if (advance) {
|
|
251
293
|
const prediction = advance.prediction?.state ?? 'unknown';
|
|
252
294
|
findings.push({
|
|
@@ -258,7 +300,7 @@ export function createCommands(deps) {
|
|
|
258
300
|
severity: 'warning',
|
|
259
301
|
worktree_id: record.worktree_id,
|
|
260
302
|
path: record.path,
|
|
261
|
-
target_ref:
|
|
303
|
+
target_ref: activeWatch.target_ref,
|
|
262
304
|
target_sha: advance.target_sha,
|
|
263
305
|
recorded_base_sha: advance.recorded_base_sha,
|
|
264
306
|
prediction: advance.prediction,
|
|
@@ -462,6 +504,21 @@ export function createCommands(deps) {
|
|
|
462
504
|
}
|
|
463
505
|
}
|
|
464
506
|
collectDoctorRecordFindings(loaded, listing, recordsById, findings);
|
|
507
|
+
const expectsDurableWatch = listing.rows.some((row) => row.record
|
|
508
|
+
&& ['ready_for_review', 'integrating'].includes(row.record.task_status)
|
|
509
|
+
&& (row.record.review_watch?.policy === 'auto'
|
|
510
|
+
|| (row.record.auto_reclaim && !['disarmed', 'reclaimed'].includes(row.record.auto_reclaim.state))));
|
|
511
|
+
if (expectsDurableWatch) {
|
|
512
|
+
const service = watchServiceStatus(loaded);
|
|
513
|
+
if (service.supported && (!service.installed || !service.loaded || !service.program_available)) {
|
|
514
|
+
findings.push({
|
|
515
|
+
code: 'AUTO_RECLAIM_SERVICE_INACTIVE',
|
|
516
|
+
severity: 'warning',
|
|
517
|
+
path: service.plist_path ?? loaded.context.primary_worktree,
|
|
518
|
+
detail: '当前 watcher 只有进程级存活保证;运行 agentkit worktree watch-service install 启用跨会话恢复。',
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
465
522
|
collectDoctorSupersessionFindings(listing, findings);
|
|
466
523
|
collectDoctorSessionFindings(listing, findings);
|
|
467
524
|
collectDoctorRuntimeFindings(loaded, findings);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
6
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
7
|
+
import { homedir, tmpdir } from 'node:os';
|
|
8
8
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
|
|
@@ -25,6 +25,7 @@ import { createCommands as createLifecycleCommands } from './worktree-lifecycle.
|
|
|
25
25
|
import { createCommands as createReclaimCommands } from './worktree-reclaim.mjs';
|
|
26
26
|
import { createCommands as createReviewRefreshCommands } from './worktree-review-refresh.mjs';
|
|
27
27
|
import { createCommands as createReviewWatchCommands } from './worktree-review-watch.mjs';
|
|
28
|
+
import { createCommands as createWatchServiceCommands } from './worktree-watch-service.mjs';
|
|
28
29
|
|
|
29
30
|
export { runFileCapture, runFileTry } from './worktree-process.mjs';
|
|
30
31
|
export {
|
|
@@ -71,12 +72,16 @@ const dependencies = {
|
|
|
71
72
|
statSync,
|
|
72
73
|
writeFileSync,
|
|
73
74
|
tmpdir,
|
|
75
|
+
homedir,
|
|
74
76
|
basename,
|
|
75
77
|
dirname,
|
|
76
78
|
join,
|
|
77
79
|
resolve,
|
|
78
80
|
fileURLToPath,
|
|
79
81
|
managerScript,
|
|
82
|
+
processPlatform: process.platform,
|
|
83
|
+
processExecPath: process.execPath,
|
|
84
|
+
processGetuid: () => typeof process.getuid === 'function' ? process.getuid() : 0,
|
|
80
85
|
...mergePreview,
|
|
81
86
|
...profile,
|
|
82
87
|
...provider,
|
|
@@ -119,6 +124,8 @@ const artifactCommands = createArtifactCommands(dependencies);
|
|
|
119
124
|
assignNew(dependencies, artifactCommands, 'worktree-artifact');
|
|
120
125
|
const learningCommands = createLearningCommands(dependencies);
|
|
121
126
|
assignNew(dependencies, learningCommands, 'worktree-learning');
|
|
127
|
+
const watchServiceCommands = createWatchServiceCommands(dependencies);
|
|
128
|
+
assignNew(dependencies, watchServiceCommands, 'worktree-watch-service');
|
|
122
129
|
const doctorCommands = createDoctorCommands(dependencies);
|
|
123
130
|
|
|
124
131
|
export const batchFingerprint = batchCommands.batchFingerprint;
|
|
@@ -126,7 +133,7 @@ export const verifyArtifactEnvelope = artifactCommands.verifyArtifactEnvelope;
|
|
|
126
133
|
|
|
127
134
|
function cmdCapabilities(args) {
|
|
128
135
|
rejectUnknownFlags(args.flags, ['json']);
|
|
129
|
-
console.log(JSON.stringify({ skill: 'manage-worktrees', runtime_version: '1.
|
|
136
|
+
console.log(JSON.stringify({ skill: 'manage-worktrees', runtime_version: '1.4.0', contracts: { worktree_binding: [1], artifact_ref: [1], reflection_record: [1], improvement_proposal: [1], batch_result: [1] }, features: ['git-common-dir-ledger', 'ownership-epochs', 'artifact-verification', 'incident-reflection', 'proposed-only-improvement', 'batch-integrate', 'batch-conflict-scan', 'declared-post-integrate-steps', 'batch-result', 'evidence-archive-reclaim', 'durable-pushed-ref-proof', 'auto-armed-review-watch', 'persistent-review-watch-intent', 'launchd-watch-service', 'review-target-advance-prediction', 'explicit-review-refresh', 'managed-history-rewrite', 'stack-parent-attribution', 'structured-change-registration'], content_digest: worktreeSkillDigest() }, null, 2));
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
function usage() {
|
|
@@ -165,7 +172,9 @@ doctor [--json] [--verbose]
|
|
|
165
172
|
rebuild [<selector>] [--id <uuid>] [--recover-lock]
|
|
166
173
|
watch <selector> [--target <remote/ref>] [--interval-ms <ms>] [--change-ref <text>] [--notify auto|off] [--id <uuid>]
|
|
167
174
|
submit <selector> [--title <text>] [--description <text>] [--target <branch>] [--remote <name>] [--interval-ms <ms>] [--notify auto|off] [--id <uuid>]
|
|
168
|
-
resume-all [--json]
|
|
175
|
+
resume-all [--json] [--quiet]
|
|
176
|
+
watch-service install|status|uninstall [--json] [--interval-seconds <seconds>]
|
|
177
|
+
macOS 用户级 LaunchAgent 定期运行 resume-all;显式安装,不在 npm install 时自动常驻
|
|
169
178
|
unwatch <selector> [--id <uuid>]
|
|
170
179
|
reclaim <selector> --pushed <sha> [--id <uuid>]
|
|
171
180
|
reclaim <selector> --superseded-by <replacement-selector> [--discard <exact-old-head>] [--id <uuid>] [--replacement-id <uuid>]
|
|
@@ -211,6 +220,7 @@ function main(argv = process.argv.slice(2)) {
|
|
|
211
220
|
submit: reviewCommands.cmdSubmit,
|
|
212
221
|
watch: reviewCommands.cmdWatch,
|
|
213
222
|
'resume-all': reviewCommands.cmdResumeAll,
|
|
223
|
+
'watch-service': watchServiceCommands.cmdWatchService,
|
|
214
224
|
unwatch: reviewCommands.cmdUnwatch,
|
|
215
225
|
'watch-worker': reviewCommands.cmdWatchWorker,
|
|
216
226
|
reclaim: reclaimCommands.cmdReclaim,
|
|
@@ -281,19 +281,49 @@ export function createCommands(deps) {
|
|
|
281
281
|
return refs.out.split('\n').filter(Boolean).filter((ref) => ref !== ownBranch).sort();
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* 回收可以从目标 worktree 自身发起。目标目录一旦被移除,命令启动时的 current_worktree
|
|
286
|
+
* 就不再是合法 cwd;后续仓库级 Git 操作必须固定使用不会被本次回收删除的 primary worktree。
|
|
287
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} loaded
|
|
288
|
+
*/
|
|
289
|
+
function repositoryGitCwd(loaded) {
|
|
290
|
+
return loaded.context.primary_worktree ?? loaded.context.current_worktree;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `git show-ref --verify --quiet` 的退出码 1 才表示 ref 不存在。ENOENT、权限错误或其他
|
|
295
|
+
* operational failure 不能降级成 absent,否则会产生“branch=absent”假成功。
|
|
296
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} loaded
|
|
297
|
+
* @param {Record<string,any>} record
|
|
298
|
+
*/
|
|
299
|
+
function inspectLocalBranch(loaded, record) {
|
|
300
|
+
if (!record.branch) return { state: 'absent', reason: null };
|
|
301
|
+
const checked = gitTry(
|
|
302
|
+
['show-ref', '--verify', '--quiet', `refs/heads/${record.branch}`],
|
|
303
|
+
repositoryGitCwd(loaded),
|
|
304
|
+
);
|
|
305
|
+
if (checked.ok) return { state: 'exists', reason: null };
|
|
306
|
+
const status = checked.error && typeof checked.error === 'object' && 'status' in checked.error
|
|
307
|
+
? checked.error.status
|
|
308
|
+
: null;
|
|
309
|
+
if (status === 1) return { state: 'absent', reason: null };
|
|
310
|
+
return {
|
|
311
|
+
state: 'error',
|
|
312
|
+
reason: commandFailureReason(checked, `unable to inspect local branch ${record.branch}`),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
284
316
|
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record */
|
|
285
317
|
function localBranchExists(loaded, record) {
|
|
286
|
-
return
|
|
287
|
-
['show-ref', '--verify', '--quiet', `refs/heads/${record.branch}`],
|
|
288
|
-
loaded.context.current_worktree,
|
|
289
|
-
).ok;
|
|
318
|
+
return inspectLocalBranch(loaded, record).state !== 'absent';
|
|
290
319
|
}
|
|
291
320
|
|
|
292
321
|
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {string} pushed */
|
|
293
322
|
function attemptLocalBranchCleanup(loaded, record, pushed) {
|
|
294
323
|
const checkedAt = new Date().toISOString();
|
|
295
324
|
const previousAttempts = Number(record.branch_cleanup?.attempts ?? 0);
|
|
296
|
-
|
|
325
|
+
const branchState = inspectLocalBranch(loaded, record);
|
|
326
|
+
if (branchState.state === 'absent') {
|
|
297
327
|
return {
|
|
298
328
|
status: 'absent',
|
|
299
329
|
branch: record.branch ?? null,
|
|
@@ -302,7 +332,17 @@ export function createCommands(deps) {
|
|
|
302
332
|
reason: null,
|
|
303
333
|
};
|
|
304
334
|
}
|
|
305
|
-
if (
|
|
335
|
+
if (branchState.state === 'error') {
|
|
336
|
+
return {
|
|
337
|
+
status: 'failed',
|
|
338
|
+
branch: record.branch,
|
|
339
|
+
attempts: previousAttempts + 1,
|
|
340
|
+
checked_at: checkedAt,
|
|
341
|
+
reason: branchState.reason,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const cwd = repositoryGitCwd(loaded);
|
|
345
|
+
if (!gitTry(['merge-base', '--is-ancestor', record.branch, pushed], cwd).ok) {
|
|
306
346
|
return {
|
|
307
347
|
status: 'failed',
|
|
308
348
|
branch: record.branch,
|
|
@@ -311,7 +351,7 @@ export function createCommands(deps) {
|
|
|
311
351
|
reason: `local branch tip is not merged into pushed sha: ${pushed}`,
|
|
312
352
|
};
|
|
313
353
|
}
|
|
314
|
-
const removed = gitTry(['branch', '-D', '--', record.branch],
|
|
354
|
+
const removed = gitTry(['branch', '-D', '--', record.branch], cwd);
|
|
315
355
|
if (removed.ok) {
|
|
316
356
|
return {
|
|
317
357
|
status: 'deleted',
|
|
@@ -332,8 +372,8 @@ export function createCommands(deps) {
|
|
|
332
372
|
|
|
333
373
|
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {string} pushed */
|
|
334
374
|
function reconcileReclaimedBranchCleanup(loaded, record, pushed) {
|
|
335
|
-
const
|
|
336
|
-
if (['deleted', 'absent'].includes(record.branch_cleanup?.status) &&
|
|
375
|
+
const branchState = inspectLocalBranch(loaded, record);
|
|
376
|
+
if (['deleted', 'absent'].includes(record.branch_cleanup?.status) && branchState.state === 'absent') {
|
|
337
377
|
return { record, branch_cleanup: record.branch_cleanup, changed: false };
|
|
338
378
|
}
|
|
339
379
|
const cleanup = attemptLocalBranchCleanup(loaded, record, pushed);
|
|
@@ -511,7 +551,8 @@ export function createCommands(deps) {
|
|
|
511
551
|
branch_cleanup_changed: terminal.changed || reconciled.changed,
|
|
512
552
|
};
|
|
513
553
|
}
|
|
514
|
-
const
|
|
554
|
+
const repositoryCwd = repositoryGitCwd(loaded);
|
|
555
|
+
const registeredAtStart = parseWorktrees(repositoryCwd)
|
|
515
556
|
.find((worktree) => worktree.path === canonicalSelectorPath(record.path));
|
|
516
557
|
if (!registeredAtStart && existsSync(record.path)) {
|
|
517
558
|
return {
|
|
@@ -538,14 +579,14 @@ export function createCommands(deps) {
|
|
|
538
579
|
}, { pushed, evidence: options.evidence ?? null });
|
|
539
580
|
}
|
|
540
581
|
|
|
541
|
-
const live = parseWorktrees(
|
|
582
|
+
const live = parseWorktrees(repositoryCwd).find((worktree) => worktree.path === canonicalSelectorPath(record.path));
|
|
542
583
|
if (live) {
|
|
543
584
|
const submodules = reclaimSubmodules(live.path);
|
|
544
585
|
if (submodules.reason) return { reclaimed: false, reason: submodules.reason, record };
|
|
545
|
-
const removed = gitTry(['worktree', 'remove', live.path],
|
|
586
|
+
const removed = gitTry(['worktree', 'remove', live.path], repositoryCwd);
|
|
546
587
|
if (!removed.ok) {
|
|
547
588
|
const detail = commandFailureReason(removed, 'git worktree remove refused');
|
|
548
|
-
const stillRegistered = parseWorktrees(
|
|
589
|
+
const stillRegistered = parseWorktrees(repositoryCwd)
|
|
549
590
|
.some((worktree) => worktree.path === canonicalSelectorPath(record.path));
|
|
550
591
|
const residue = !stillRegistered && existsSync(record.path)
|
|
551
592
|
? '; Git registration was removed but the physical directory remains'
|
|
@@ -569,7 +610,7 @@ export function createCommands(deps) {
|
|
|
569
610
|
};
|
|
570
611
|
}
|
|
571
612
|
}
|
|
572
|
-
gitTry(['worktree', 'prune'],
|
|
613
|
+
gitTry(['worktree', 'prune'], repositoryCwd);
|
|
573
614
|
const branchCleanup = attemptLocalBranchCleanup(loaded, record, pushed);
|
|
574
615
|
record = appendReclaimEvent(loaded.context.common_dir, record, 'reclaimed', (next) => {
|
|
575
616
|
const completedAt = new Date().toISOString();
|
|
@@ -659,6 +700,7 @@ export function createCommands(deps) {
|
|
|
659
700
|
}
|
|
660
701
|
if (result.branch_cleanup?.status === 'failed') {
|
|
661
702
|
log(`目录已回收 ${result.record.worktree_id.slice(0, 8)};本地分支 ${result.record.branch} 清理待重试: ${result.branch_cleanup.reason}`);
|
|
703
|
+
process.exitCode = 1;
|
|
662
704
|
return;
|
|
663
705
|
}
|
|
664
706
|
const recovery = result.record.evidence_archive?.archive_ref
|
|
@@ -48,14 +48,103 @@ export function createCommands(deps) {
|
|
|
48
48
|
reclaimRecord,
|
|
49
49
|
} = deps;
|
|
50
50
|
|
|
51
|
+
/** @param {Record<string,any>} record */
|
|
52
|
+
function activeAutoReclaim(record) {
|
|
53
|
+
return record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)
|
|
54
|
+
? record.auto_reclaim
|
|
55
|
+
: null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* review_watch 是“应不应该自动回收”的持久意图;auto_reclaim 是某一次实际 watcher 的租约。
|
|
60
|
+
* 两者分开后,启动前置条件失败、宿主杀进程和显式 --no-watch 不再都坍缩成字段缺失。
|
|
61
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} loaded
|
|
62
|
+
* @param {Record<string,any>} record
|
|
63
|
+
* @param {{flags:Map<string,unknown>}} args
|
|
64
|
+
* @param {{head:string|null}} snapshot
|
|
65
|
+
*/
|
|
66
|
+
function reviewWatchIntent(loaded, record, args, snapshot) {
|
|
67
|
+
const previous = record.review_watch?.policy === 'auto'
|
|
68
|
+
? record.review_watch
|
|
69
|
+
: record.auto_reclaim ?? null;
|
|
70
|
+
const targetRef = aliasedFlag(args.flags, 'target', 'watch-target')
|
|
71
|
+
?? previous?.target_ref
|
|
72
|
+
?? record.base_ref
|
|
73
|
+
?? loaded.profile.default_base
|
|
74
|
+
?? null;
|
|
75
|
+
const changeRefValue = aliasedFlag(args.flags, 'change-ref', 'mr');
|
|
76
|
+
return {
|
|
77
|
+
policy: 'auto',
|
|
78
|
+
state: 'pending',
|
|
79
|
+
target_ref: targetRef,
|
|
80
|
+
head_sha: snapshot.head,
|
|
81
|
+
interval_ms: parseWatchInterval(flag(args.flags, 'interval-ms') ?? previous?.interval_ms),
|
|
82
|
+
change_ref: changeRefValue
|
|
83
|
+
? oneLine(changeRefValue, 'change-ref', 1000)
|
|
84
|
+
: previous?.change_ref ?? null,
|
|
85
|
+
notify: parseNotifyMode(flag(args.flags, 'notify') ?? previous?.notify),
|
|
86
|
+
reason: null,
|
|
87
|
+
source: 'auto_touch',
|
|
88
|
+
updated_at: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} loaded
|
|
94
|
+
* @param {Record<string,any>} record
|
|
95
|
+
* @param {Record<string,any>} intent
|
|
96
|
+
* @param {'pending'|'disabled'} state
|
|
97
|
+
* @param {string} reason
|
|
98
|
+
* @param {string|null} [disarmToken]
|
|
99
|
+
*/
|
|
100
|
+
function persistReviewWatchIntent(loaded, record, intent, state, reason, disarmToken = null) {
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
return appendTraceEvent({
|
|
103
|
+
commonDir: loaded.context.common_dir,
|
|
104
|
+
worktreeId: record.worktree_id,
|
|
105
|
+
eventType: state === 'disabled' ? 'review_watch_disabled' : 'review_watch_pending',
|
|
106
|
+
actor: record.agent,
|
|
107
|
+
details: {
|
|
108
|
+
policy: state === 'disabled' ? 'disabled' : 'auto',
|
|
109
|
+
target_ref: intent.target_ref,
|
|
110
|
+
head_sha: intent.head_sha,
|
|
111
|
+
reason,
|
|
112
|
+
},
|
|
113
|
+
mutate(current) {
|
|
114
|
+
if (current.worktree_state === 'reclaimed' || current.worktree_state === 'archived') {
|
|
115
|
+
throw new WorktreeTraceError('WATCH_INTENT_SETTLED', `已结算 record 不能更新 watch intent: ${record.worktree_id}`);
|
|
116
|
+
}
|
|
117
|
+
const next = structuredClone(current);
|
|
118
|
+
if (disarmToken) {
|
|
119
|
+
if (next.auto_reclaim?.token !== disarmToken || ['disarmed', 'reclaimed'].includes(next.auto_reclaim?.state)) {
|
|
120
|
+
throw new WorktreeTraceError('WATCHER_CHANGED', `watch token 已被并发更新或解除: ${record.worktree_id}`);
|
|
121
|
+
}
|
|
122
|
+
next.auto_reclaim.state = 'disarmed';
|
|
123
|
+
next.auto_reclaim.disarmed_at = now;
|
|
124
|
+
next.auto_reclaim.disarm_reason = 'explicit_no_watch';
|
|
125
|
+
}
|
|
126
|
+
next.review_watch = {
|
|
127
|
+
...intent,
|
|
128
|
+
policy: state === 'disabled' ? 'disabled' : 'auto',
|
|
129
|
+
state,
|
|
130
|
+
reason,
|
|
131
|
+
updated_at: now,
|
|
132
|
+
};
|
|
133
|
+
next.updated_at = now;
|
|
134
|
+
return next;
|
|
135
|
+
},
|
|
136
|
+
}).record;
|
|
137
|
+
}
|
|
138
|
+
|
|
51
139
|
function autoArmReviewWatch(loaded, record, args, snapshot) {
|
|
140
|
+
const existing = activeAutoReclaim(record);
|
|
141
|
+
const intent = reviewWatchIntent(loaded, record, args, snapshot);
|
|
52
142
|
if (args.flags.get('no-watch')) {
|
|
143
|
+
persistReviewWatchIntent(loaded, record, intent, 'disabled', 'explicit_no_watch', existing?.token ?? null);
|
|
144
|
+
if (existing?.token) removeWatcherHeartbeat(loaded.context.common_dir, record.worktree_id, existing.token);
|
|
53
145
|
log('watch 未武装:--no-watch 显式退出;合入后需要人工回收。');
|
|
54
146
|
return;
|
|
55
147
|
}
|
|
56
|
-
const existing = record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)
|
|
57
|
-
? record.auto_reclaim
|
|
58
|
-
: null;
|
|
59
148
|
// 原子失效旧 watcher 后仍沿用它的 target/interval/change-ref 默认值,避免自动重冻结
|
|
60
149
|
// 把人工显式选择的 target 静默改回该树登记的 base。
|
|
61
150
|
const previous = record.auto_reclaim ?? null;
|
|
@@ -63,8 +152,11 @@ export function createCommands(deps) {
|
|
|
63
152
|
log('watch 保持原冻结 SHA:目标分支已确认包含该 head,自动回收已进入提交阶段。');
|
|
64
153
|
return;
|
|
65
154
|
}
|
|
66
|
-
const skip = (reason) =>
|
|
67
|
-
|
|
155
|
+
const skip = (reason) => {
|
|
156
|
+
persistReviewWatchIntent(loaded, record, intent, 'pending', reason);
|
|
157
|
+
log(`watch 未武装:${reason};自动回收保持关闭,已登记 pending,resume-all/维护服务会重试。`);
|
|
158
|
+
};
|
|
159
|
+
const targetRef = intent.target_ref;
|
|
68
160
|
if (!targetRef || !targetRef.includes('/')) {
|
|
69
161
|
skip(`无法确定远端主干 target(Profile default_base=${loaded.profile.default_base ?? 'null'})`);
|
|
70
162
|
return;
|
|
@@ -102,11 +194,9 @@ export function createCommands(deps) {
|
|
|
102
194
|
targetRef,
|
|
103
195
|
targetSha: refreshedTarget.target_sha,
|
|
104
196
|
headSha: snapshot.head,
|
|
105
|
-
intervalMs:
|
|
106
|
-
changeRef:
|
|
107
|
-
|
|
108
|
-
: previous?.change_ref ?? null,
|
|
109
|
-
notifyMode: parseNotifyMode(flag(args.flags, 'notify') ?? previous?.notify),
|
|
197
|
+
intervalMs: intent.interval_ms,
|
|
198
|
+
changeRef: intent.change_ref,
|
|
199
|
+
notifyMode: intent.notify,
|
|
110
200
|
explicitConfig: flag(args.flags, 'config') ? loaded.profile_path : null,
|
|
111
201
|
previousHealth: health.reason,
|
|
112
202
|
armedBy: 'auto_touch',
|
|
@@ -197,6 +287,18 @@ export function createCommands(deps) {
|
|
|
197
287
|
disarm_reason: null,
|
|
198
288
|
pid: null,
|
|
199
289
|
};
|
|
290
|
+
next.review_watch = {
|
|
291
|
+
policy: 'auto',
|
|
292
|
+
state: 'armed',
|
|
293
|
+
target_ref: options.targetRef,
|
|
294
|
+
head_sha: options.headSha,
|
|
295
|
+
interval_ms: options.intervalMs,
|
|
296
|
+
change_ref: options.changeRef,
|
|
297
|
+
notify: options.notifyMode,
|
|
298
|
+
reason: null,
|
|
299
|
+
source: options.armedBy ?? existing?.armed_by ?? 'explicit',
|
|
300
|
+
updated_at: now,
|
|
301
|
+
};
|
|
200
302
|
next.updated_at = now;
|
|
201
303
|
return next;
|
|
202
304
|
},
|
|
@@ -465,24 +567,71 @@ export function createCommands(deps) {
|
|
|
465
567
|
}
|
|
466
568
|
|
|
467
569
|
function cmdResumeAll(args) {
|
|
468
|
-
rejectUnknownFlags(args.flags, ['json', 'config']);
|
|
570
|
+
rejectUnknownFlags(args.flags, ['json', 'quiet', 'config']);
|
|
469
571
|
if (args.positionals.length) die('resume-all 不接受 selector;它只扫描已 arm record。', 2);
|
|
470
572
|
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
471
573
|
const result = { resumed: [], healthy: [], skipped: [] };
|
|
472
|
-
const records = loadRecords(loaded.context.common_dir).filter((record) =>
|
|
473
|
-
record.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
//
|
|
477
|
-
record.
|
|
574
|
+
const records = loadRecords(loaded.context.common_dir).filter((record) => {
|
|
575
|
+
if (record.worktree_state === 'reclaimed' || record.worktree_state === 'archived') return false;
|
|
576
|
+
if (activeAutoReclaim(record)) return true;
|
|
577
|
+
if (!['ready_for_review', 'integrating'].includes(record.task_status)) return false;
|
|
578
|
+
// auto intent 要重试;没有任何 intent 的 legacy/live record 也必须进入 skipped,不能继续 0/0/0 隐身。
|
|
579
|
+
return record.review_watch?.policy === 'auto' || !record.review_watch;
|
|
580
|
+
});
|
|
478
581
|
for (const record of records) {
|
|
582
|
+
const existing = activeAutoReclaim(record);
|
|
583
|
+
if (!existing) {
|
|
584
|
+
const intent = record.review_watch;
|
|
585
|
+
if (!intent) {
|
|
586
|
+
result.skipped.push({ worktree_id: record.worktree_id, task: record.task, reason: 'review watch intent missing; touch ready_for_review again' });
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const snapshot = liveGitSnapshot(record);
|
|
590
|
+
let reason = null;
|
|
591
|
+
if (!snapshot.present) reason = 'worktree missing';
|
|
592
|
+
else if (snapshot.dirty !== false) reason = 'worktree dirty';
|
|
593
|
+
else if (!snapshot.head || snapshot.head !== intent.head_sha) reason = 'live head differs from pending intent';
|
|
594
|
+
else {
|
|
595
|
+
const upstream = gitTry(['rev-parse', '@{upstream}^{commit}'], record.path);
|
|
596
|
+
if (!upstream.ok || upstream.out !== snapshot.head) reason = 'current head not fully pushed to upstream';
|
|
597
|
+
}
|
|
598
|
+
if (!intent.target_ref || typeof intent.target_ref !== 'string') reason ??= 'target ref missing';
|
|
599
|
+
const intervalMs = Number(intent.interval_ms);
|
|
600
|
+
if (!Number.isInteger(intervalMs) || intervalMs < WATCH_MIN_INTERVAL_MS || intervalMs > WATCH_MAX_INTERVAL_MS) reason ??= 'interval invalid';
|
|
601
|
+
let refreshed = null;
|
|
602
|
+
if (!reason) {
|
|
603
|
+
refreshed = refreshTargetRef(intent.target_ref, loaded.context.current_worktree);
|
|
604
|
+
if (!refreshed.ok) reason = `target ref unavailable: ${intent.target_ref}`;
|
|
605
|
+
}
|
|
606
|
+
if (reason) {
|
|
607
|
+
result.skipped.push({ worktree_id: record.worktree_id, task: record.task, reason });
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
const started = startWatcher(loaded, record, {
|
|
612
|
+
targetRef: intent.target_ref,
|
|
613
|
+
targetSha: refreshed.target_sha,
|
|
614
|
+
headSha: intent.head_sha,
|
|
615
|
+
intervalMs,
|
|
616
|
+
changeRef: intent.change_ref ?? null,
|
|
617
|
+
notifyMode: intent.notify ?? 'auto',
|
|
618
|
+
explicitConfig: flag(args.flags, 'config') ? loaded.profile_path : null,
|
|
619
|
+
previousHealth: intent.reason ?? 'pending intent',
|
|
620
|
+
armedBy: intent.source ?? 'auto_touch',
|
|
621
|
+
});
|
|
622
|
+
result.resumed.push({ worktree_id: record.worktree_id, task: record.task, pid: started.pid, previous_health: intent.reason ?? 'pending intent', dirty: false });
|
|
623
|
+
} catch (error) {
|
|
624
|
+
result.skipped.push({ worktree_id: record.worktree_id, task: record.task, reason: error instanceof Error ? error.message : String(error) });
|
|
625
|
+
}
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
479
628
|
const heartbeat = readWatcherHeartbeat(loaded.context.common_dir, record.worktree_id);
|
|
480
629
|
const health = watcherHealth(record, heartbeat);
|
|
481
630
|
if (health.healthy) {
|
|
482
631
|
result.healthy.push({ worktree_id: record.worktree_id, task: record.task, pid: heartbeat.state.pid });
|
|
483
632
|
continue;
|
|
484
633
|
}
|
|
485
|
-
const auto =
|
|
634
|
+
const auto = existing;
|
|
486
635
|
const intervalMs = Number(auto.interval_ms);
|
|
487
636
|
const snapshot = liveGitSnapshot(record);
|
|
488
637
|
let reason = null;
|
|
@@ -512,6 +661,7 @@ export function createCommands(deps) {
|
|
|
512
661
|
result.skipped.push({ worktree_id: record.worktree_id, task: record.task, reason: error instanceof Error ? error.message : String(error) });
|
|
513
662
|
}
|
|
514
663
|
}
|
|
664
|
+
if (args.flags.get('quiet')) return;
|
|
515
665
|
if (args.flags.get('json')) {
|
|
516
666
|
console.log(JSON.stringify(result, null, 2));
|
|
517
667
|
return;
|
|
@@ -526,17 +676,45 @@ export function createCommands(deps) {
|
|
|
526
676
|
rejectUnknownFlags(args.flags, ['id', 'config']);
|
|
527
677
|
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
528
678
|
let record = selectRecord(loadRecords(loaded.context.common_dir), args.positionals[0] ?? null, flag(args.flags, 'id'));
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
log(`watcher 未 arm: ${record.worktree_id.slice(0, 8)}`);
|
|
679
|
+
if (record.worktree_state === 'reclaimed' || record.worktree_state === 'archived') {
|
|
680
|
+
log(`已结算,无需 unwatch: ${record.worktree_id.slice(0, 8)}`);
|
|
532
681
|
return;
|
|
533
682
|
}
|
|
534
|
-
|
|
683
|
+
const active = activeAutoReclaim(record);
|
|
684
|
+
const token = active?.token;
|
|
685
|
+
if (!token) {
|
|
686
|
+
if (record.review_watch?.policy === 'disabled') {
|
|
687
|
+
log(`watcher 未 arm: ${record.worktree_id.slice(0, 8)}`);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const snapshot = liveGitSnapshot(record);
|
|
691
|
+
const intent = {
|
|
692
|
+
...(record.review_watch ?? {}),
|
|
693
|
+
target_ref: record.review_watch?.target_ref ?? record.base_ref ?? null,
|
|
694
|
+
head_sha: record.review_watch?.head_sha ?? snapshot.head,
|
|
695
|
+
interval_ms: record.review_watch?.interval_ms ?? parseWatchInterval(null),
|
|
696
|
+
change_ref: record.review_watch?.change_ref ?? null,
|
|
697
|
+
notify: record.review_watch?.notify ?? 'auto',
|
|
698
|
+
source: record.review_watch?.source ?? 'explicit',
|
|
699
|
+
};
|
|
700
|
+
persistReviewWatchIntent(loaded, record, intent, 'disabled', 'explicit_unwatch');
|
|
701
|
+
log(`auto-reclaim 已明确关闭: ${record.worktree_id.slice(0, 8)}`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (active.state === 'merge_detected') {
|
|
535
705
|
die('目标分支已确认包含冻结的 MR head,自动回收已进入提交阶段,不能再 unwatch。');
|
|
536
706
|
}
|
|
537
707
|
record = appendReclaimEvent(loaded.context.common_dir, record, 'auto_reclaim_disarmed', (next) => {
|
|
538
708
|
next.auto_reclaim.state = 'disarmed';
|
|
539
709
|
next.auto_reclaim.disarmed_at = new Date().toISOString();
|
|
710
|
+
next.auto_reclaim.disarm_reason = 'explicit_unwatch';
|
|
711
|
+
next.review_watch = {
|
|
712
|
+
...(next.review_watch ?? {}),
|
|
713
|
+
policy: 'disabled',
|
|
714
|
+
state: 'disabled',
|
|
715
|
+
reason: 'explicit_unwatch',
|
|
716
|
+
updated_at: new Date().toISOString(),
|
|
717
|
+
};
|
|
540
718
|
});
|
|
541
719
|
removeWatcherHeartbeat(loaded.context.common_dir, record.worktree_id, token);
|
|
542
720
|
log(`auto-reclaim watcher 已解除: ${record.worktree_id.slice(0, 8)}`);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const DEFAULT_INTERVAL_SECONDS = 60;
|
|
4
|
+
const MIN_INTERVAL_SECONDS = 30;
|
|
5
|
+
const MAX_INTERVAL_SECONDS = 60 * 60;
|
|
6
|
+
|
|
7
|
+
/** @param {string} value */
|
|
8
|
+
function xmlEscape(value) {
|
|
9
|
+
return value
|
|
10
|
+
.replaceAll('&', '&')
|
|
11
|
+
.replaceAll('<', '<')
|
|
12
|
+
.replaceAll('>', '>');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {string} repositoryId */
|
|
16
|
+
export function launchAgentLabel(repositoryId) {
|
|
17
|
+
const suffix = String(repositoryId).toLowerCase().replaceAll(/[^a-z0-9]/gu, '').slice(0, 32);
|
|
18
|
+
if (!suffix) throw new Error('repository id 不能生成 launchd label。');
|
|
19
|
+
return `io.github.cr1992.agentkit.worktree.${suffix}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 生成固定 argv 的 LaunchAgent;不经过 shell,也不把调用会话的环境变量或凭证写进 plist。
|
|
24
|
+
* @param {{label:string,nodePath:string,managerScript:string,workingDirectory:string,intervalSeconds:number,logPath:string,configPath?:string|null}} options
|
|
25
|
+
*/
|
|
26
|
+
export function renderLaunchAgentPlist(options) {
|
|
27
|
+
const args = [options.nodePath, options.managerScript, 'resume-all', '--quiet'];
|
|
28
|
+
if (options.configPath) args.push('--config', options.configPath);
|
|
29
|
+
const argumentXml = args.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join('\n');
|
|
30
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
31
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
32
|
+
<plist version="1.0">
|
|
33
|
+
<dict>
|
|
34
|
+
<key>Label</key>
|
|
35
|
+
<string>${xmlEscape(options.label)}</string>
|
|
36
|
+
<key>ProgramArguments</key>
|
|
37
|
+
<array>
|
|
38
|
+
${argumentXml}
|
|
39
|
+
</array>
|
|
40
|
+
<key>WorkingDirectory</key>
|
|
41
|
+
<string>${xmlEscape(options.workingDirectory)}</string>
|
|
42
|
+
<key>RunAtLoad</key>
|
|
43
|
+
<true/>
|
|
44
|
+
<key>StartInterval</key>
|
|
45
|
+
<integer>${options.intervalSeconds}</integer>
|
|
46
|
+
<key>AbandonProcessGroup</key>
|
|
47
|
+
<true/>
|
|
48
|
+
<key>ProcessType</key>
|
|
49
|
+
<string>Background</string>
|
|
50
|
+
<key>StandardOutPath</key>
|
|
51
|
+
<string>/dev/null</string>
|
|
52
|
+
<key>StandardErrorPath</key>
|
|
53
|
+
<string>${xmlEscape(options.logPath)}</string>
|
|
54
|
+
</dict>
|
|
55
|
+
</plist>
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @param {unknown} raw */
|
|
60
|
+
export function parseServiceInterval(raw) {
|
|
61
|
+
const value = raw === null || raw === undefined ? DEFAULT_INTERVAL_SECONDS : Number(raw);
|
|
62
|
+
if (!Number.isInteger(value) || value < MIN_INTERVAL_SECONDS || value > MAX_INTERVAL_SECONDS) {
|
|
63
|
+
throw new Error(`--interval-seconds 必须是 ${MIN_INTERVAL_SECONDS}-${MAX_INTERVAL_SECONDS} 的整数。`);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {Record<string,any>} deps
|
|
70
|
+
*/
|
|
71
|
+
export function createCommands(deps) {
|
|
72
|
+
const {
|
|
73
|
+
processPlatform,
|
|
74
|
+
processExecPath,
|
|
75
|
+
processGetuid,
|
|
76
|
+
homedir,
|
|
77
|
+
randomUUID,
|
|
78
|
+
existsSync,
|
|
79
|
+
mkdirSync,
|
|
80
|
+
readFileSync,
|
|
81
|
+
renameSync,
|
|
82
|
+
rmSync,
|
|
83
|
+
writeFileSync,
|
|
84
|
+
dirname,
|
|
85
|
+
join,
|
|
86
|
+
managerScript,
|
|
87
|
+
runFileCapture,
|
|
88
|
+
loadRepositoryProfile,
|
|
89
|
+
readRepositoryIdentity,
|
|
90
|
+
ensureRepositoryIdentity,
|
|
91
|
+
traceLayout,
|
|
92
|
+
flag,
|
|
93
|
+
rejectUnknownFlags,
|
|
94
|
+
die,
|
|
95
|
+
log,
|
|
96
|
+
} = deps;
|
|
97
|
+
|
|
98
|
+
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {boolean} createIdentity */
|
|
99
|
+
function descriptor(loaded, createIdentity) {
|
|
100
|
+
const identity = createIdentity
|
|
101
|
+
? ensureRepositoryIdentity(loaded.context)
|
|
102
|
+
: readRepositoryIdentity(loaded.context);
|
|
103
|
+
if (!identity) return null;
|
|
104
|
+
const label = launchAgentLabel(identity.repository_id);
|
|
105
|
+
const plistPath = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
106
|
+
const logPath = join(traceLayout(loaded.context.common_dir).root, 'watch-service.log');
|
|
107
|
+
const uid = processGetuid();
|
|
108
|
+
return {
|
|
109
|
+
label,
|
|
110
|
+
plist_path: plistPath,
|
|
111
|
+
log_path: logPath,
|
|
112
|
+
domain: `gui/${uid}`,
|
|
113
|
+
service_target: `gui/${uid}/${label}`,
|
|
114
|
+
working_directory: loaded.context.primary_worktree,
|
|
115
|
+
config_path: loaded.profile_source === 'explicit' ? loaded.profile_path : null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded */
|
|
120
|
+
function watchServiceStatus(loaded) {
|
|
121
|
+
if (processPlatform !== 'darwin') {
|
|
122
|
+
return { supported: false, installed: false, loaded: false, platform: processPlatform, reason: 'macOS LaunchAgent adapter only' };
|
|
123
|
+
}
|
|
124
|
+
const value = descriptor(loaded, false);
|
|
125
|
+
if (!value) {
|
|
126
|
+
return { supported: true, installed: false, loaded: false, platform: processPlatform, reason: 'repository identity missing' };
|
|
127
|
+
}
|
|
128
|
+
const probe = runFileCapture('/bin/launchctl', ['print', value.service_target], { cwd: loaded.context.primary_worktree });
|
|
129
|
+
return {
|
|
130
|
+
supported: true,
|
|
131
|
+
installed: existsSync(value.plist_path),
|
|
132
|
+
loaded: probe.ok,
|
|
133
|
+
platform: processPlatform,
|
|
134
|
+
label: value.label,
|
|
135
|
+
plist_path: value.plist_path,
|
|
136
|
+
log_path: value.log_path,
|
|
137
|
+
node_path: processExecPath,
|
|
138
|
+
manager_script: managerScript,
|
|
139
|
+
program_available: existsSync(processExecPath) && existsSync(managerScript),
|
|
140
|
+
reason: probe.ok ? null : (probe.out || 'launchd job not loaded'),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {number} intervalSeconds */
|
|
145
|
+
function install(loaded, intervalSeconds) {
|
|
146
|
+
if (processPlatform !== 'darwin') die(`watch-service install 当前只支持 macOS;${processPlatform} 可继续手工运行 resume-all。`, 2);
|
|
147
|
+
const value = descriptor(loaded, true);
|
|
148
|
+
const plist = renderLaunchAgentPlist({
|
|
149
|
+
label: value.label,
|
|
150
|
+
nodePath: processExecPath,
|
|
151
|
+
managerScript,
|
|
152
|
+
workingDirectory: value.working_directory,
|
|
153
|
+
intervalSeconds,
|
|
154
|
+
logPath: value.log_path,
|
|
155
|
+
configPath: value.config_path,
|
|
156
|
+
});
|
|
157
|
+
mkdirSync(dirname(value.plist_path), { recursive: true });
|
|
158
|
+
mkdirSync(dirname(value.log_path), { recursive: true });
|
|
159
|
+
const previous = existsSync(value.plist_path) ? readFileSync(value.plist_path, 'utf8') : null;
|
|
160
|
+
const temporary = `${value.plist_path}.${processGetuid()}.${randomUUID()}.tmp`;
|
|
161
|
+
writeFileSync(temporary, plist, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
162
|
+
|
|
163
|
+
const wasLoaded = runFileCapture('/bin/launchctl', ['print', value.service_target], { cwd: value.working_directory }).ok;
|
|
164
|
+
if (wasLoaded) {
|
|
165
|
+
const stopped = runFileCapture('/bin/launchctl', ['bootout', value.service_target], { cwd: value.working_directory });
|
|
166
|
+
if (!stopped.ok) {
|
|
167
|
+
rmSync(temporary, { force: true });
|
|
168
|
+
die(`无法重载现有 LaunchAgent:${stopped.out || 'launchctl bootout failed'}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
renameSync(temporary, value.plist_path);
|
|
172
|
+
const started = runFileCapture('/bin/launchctl', ['bootstrap', value.domain, value.plist_path], { cwd: value.working_directory });
|
|
173
|
+
if (!started.ok) {
|
|
174
|
+
if (previous === null) rmSync(value.plist_path, { force: true });
|
|
175
|
+
else writeFileSync(value.plist_path, previous, { encoding: 'utf8', mode: 0o600 });
|
|
176
|
+
if (wasLoaded && previous !== null) runFileCapture('/bin/launchctl', ['bootstrap', value.domain, value.plist_path], { cwd: value.working_directory });
|
|
177
|
+
die(`LaunchAgent 安装失败:${started.out || 'launchctl bootstrap failed'}`);
|
|
178
|
+
}
|
|
179
|
+
const kicked = runFileCapture('/bin/launchctl', ['kickstart', '-k', value.service_target], { cwd: value.working_directory });
|
|
180
|
+
if (!kicked.ok) die(`LaunchAgent 已加载但首次执行失败:${kicked.out || 'launchctl kickstart failed'}`);
|
|
181
|
+
return { ...watchServiceStatus(loaded), interval_seconds: intervalSeconds };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded */
|
|
185
|
+
function uninstall(loaded) {
|
|
186
|
+
if (processPlatform !== 'darwin') die(`watch-service uninstall 当前只支持 macOS;当前平台 ${processPlatform}。`, 2);
|
|
187
|
+
const value = descriptor(loaded, false);
|
|
188
|
+
if (!value) return { supported: true, installed: false, loaded: false, removed: false };
|
|
189
|
+
const loadedBefore = runFileCapture('/bin/launchctl', ['print', value.service_target], { cwd: value.working_directory }).ok;
|
|
190
|
+
if (loadedBefore) {
|
|
191
|
+
const stopped = runFileCapture('/bin/launchctl', ['bootout', value.service_target], { cwd: value.working_directory });
|
|
192
|
+
if (!stopped.ok) die(`LaunchAgent 停止失败:${stopped.out || 'launchctl bootout failed'}`);
|
|
193
|
+
}
|
|
194
|
+
const installedBefore = existsSync(value.plist_path);
|
|
195
|
+
rmSync(value.plist_path, { force: true });
|
|
196
|
+
return { supported: true, installed: false, loaded: false, removed: installedBefore || loadedBefore, label: value.label, plist_path: value.plist_path };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function cmdWatchService(args) {
|
|
200
|
+
rejectUnknownFlags(args.flags, ['json', 'config', 'interval-seconds']);
|
|
201
|
+
const action = args.positionals[0] ?? 'status';
|
|
202
|
+
if (args.positionals.length > 1 || !['install', 'status', 'uninstall'].includes(action)) {
|
|
203
|
+
die('watch-service 用法:watch-service install|status|uninstall [--json] [--interval-seconds <seconds>]', 2);
|
|
204
|
+
}
|
|
205
|
+
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
206
|
+
let result;
|
|
207
|
+
if (action === 'install') {
|
|
208
|
+
let intervalSeconds;
|
|
209
|
+
try { intervalSeconds = parseServiceInterval(flag(args.flags, 'interval-seconds')); }
|
|
210
|
+
catch (error) { die(error instanceof Error ? error.message : String(error), 2); }
|
|
211
|
+
result = install(loaded, intervalSeconds);
|
|
212
|
+
} else if (action === 'uninstall') {
|
|
213
|
+
result = uninstall(loaded);
|
|
214
|
+
} else {
|
|
215
|
+
result = watchServiceStatus(loaded);
|
|
216
|
+
}
|
|
217
|
+
if (args.flags.get('json')) console.log(JSON.stringify(result, null, 2));
|
|
218
|
+
else if (action === 'install') log(`跨会话 watch-service 已安装 label=${result.label} interval=${result.interval_seconds}s`);
|
|
219
|
+
else if (action === 'uninstall') log(`跨会话 watch-service ${result.removed ? '已卸载' : '原本未安装'}`);
|
|
220
|
+
else log(`watch-service supported=${result.supported} installed=${result.installed} loaded=${result.loaded}${result.reason ? ` reason=${result.reason}` : ''}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { cmdWatchService, watchServiceStatus };
|
|
224
|
+
}
|
|
@@ -23,7 +23,7 @@ metadata:
|
|
|
23
23
|
下文多行示例采用 POSIX 续行语法;PowerShell/cmd 保留相同 argv 即可。不要把 `python3`、`bash`、
|
|
24
24
|
`~` 展开或 `/tmp` 视为跨平台常量。
|
|
25
25
|
|
|
26
|
-
不要假设目标仓库有同名脚本。仓库可以通过根目录 `.worktree-trace.json` 提供 Profile,但 portable core 不执行 Profile 中的任意 shell command。配置 schema 和项目适配边界见
|
|
26
|
+
不要假设目标仓库有同名脚本。仓库可以通过根目录 `.worktree-trace.json` 提供 Profile,但 portable core 不执行 Profile 中的任意 shell command。配置 schema 和项目适配边界见 Profile 配置(`agentkit docs worktree profile`)。
|
|
27
27
|
|
|
28
28
|
所有命令都必须从**目标 Git 仓库的任意 worktree 内**执行:manager 以当前工作目录解析 repository
|
|
29
29
|
identity、primary worktree 和 Profile。
|
|
@@ -58,14 +58,18 @@ manager 的 trace/common-dir 状态只应由受信 operator/controller 与 runti
|
|
|
58
58
|
|
|
59
59
|
| 阶段 | 最小命令面 | 硬门禁与按需文档 |
|
|
60
60
|
|---|---|---|
|
|
61
|
-
| 恢复/盘点 | `resume-all` → `list` → `doctor` | `list/doctor` 只读;`UNTRACKED` 不等于无人使用;`EPHEMERAL` 优先 commit+push;任何 error 都暂停 `spawn/adopt` |
|
|
61
|
+
| 恢复/盘点 | `watch-service status` → `resume-all` → `list` → `doctor` | 常驻服务未安装时只报告并建议 `watch-service install`,不得用 `--no-watch` 顶替;`list/doctor` 只读;`UNTRACKED` 不等于无人使用;`EPHEMERAL` 优先 commit+push;任何 error 都暂停 `spawn/adopt` |
|
|
62
62
|
| 碰撞扫描 | `scan --target <paths>` | `COLLIDE`、范围不确定或重做昂贵时隔离;`CLEAR` 仅在 owner 明确且改动小时共享;低置信后缀匹配先人工核对路径基准 |
|
|
63
|
-
| 交付身份 | 选择复用/并存/替代 | branch、MR 或目录名不同不能单独证明独立交付;同一 agent
|
|
64
|
-
| 创建/接管 | `spawn <semantic-slug>` / `adopt <path>` | 使用真实 agent-id,不猜身份;task 至少两个 lowercase 语义分词;base、root、堆叠、rebase/retarget
|
|
65
|
-
| 更新/交接 | `touch` / `handoff` / `refresh-review` | `ready_for_review` 默认武装冻结 HEAD;handoff 只接受 clean、已 push 边界,禁止 stash
|
|
63
|
+
| 交付身份 | 选择复用/并存/替代 | branch、MR 或目录名不同不能单独证明独立交付;同一 agent 有存量树或需要替代时读交付身份(`agentkit docs worktree delivery-identity`) |
|
|
64
|
+
| 创建/接管 | `spawn <semantic-slug>` / `adopt <path>` | 使用真实 agent-id,不猜身份;task 至少两个 lowercase 语义分词;base、root、堆叠、rebase/retarget 读创建与堆叠(`agentkit docs worktree spawn-and-stack`) |
|
|
65
|
+
| 更新/交接 | `touch` / `handoff` / `refresh-review` | `ready_for_review` 默认武装冻结 HEAD;handoff 只接受 clean、已 push 边界,禁止 stash 搬运;读评审生命周期(`agentkit docs worktree review-lifecycle`) |
|
|
66
66
|
| 审计/提交 | `audit` / `submit` | portable core 不决定 MR/PR 或直推策略;先服从目标仓规则,再原子登记 change request 与 watcher target |
|
|
67
|
-
| 批量集成 | `plan-batch` → `batch-integrate` → `batch-step` → `batch-result` | 同仓同 target:≥3 默认聚合,2 个仅在碰撞时;只合成冻结 SHA,不代跑门禁;合同/顺序/target
|
|
68
|
-
| 回收 | `reclaim` / `archive` | 必须有远端/其他 ref 可达证明或明确 archive evidence;dirty、stash、submodule、权限或 branch-tip 异常一律 `KEEP`;替代树默认先归档,`--discard`
|
|
67
|
+
| 批量集成 | `plan-batch` → `batch-integrate` → `batch-step` → `batch-result` | 同仓同 target:≥3 默认聚合,2 个仅在碰撞时;只合成冻结 SHA,不代跑门禁;合同/顺序/target 变化即重规划;读批量集成(`agentkit docs worktree batch-integration`) |
|
|
68
|
+
| 回收 | `reclaim` / `archive` | 必须有远端/其他 ref 可达证明或明确 archive evidence;dirty、stash、submodule、权限或 branch-tip 异常一律 `KEEP`;替代树默认先归档,`--discard` 需人工明确授权;读回收与看护(`agentkit docs worktree reclaim-and-watch`) |
|
|
69
|
+
|
|
70
|
+
`resume-all` 只提供当次恢复。跨 Agent 会话、注销或重启后仍要自动恢复 watcher,靠的是常驻维护器:
|
|
71
|
+
macOS 由用户一次性显式执行 `watch-service install`。未安装时不得把进程级 watcher 描述成跨会话保证,
|
|
72
|
+
也不得因此改用 `--no-watch`(判据见评审生命周期)——那只会把待回收的树变成无人看护。
|
|
69
73
|
|
|
70
74
|
不得按目录年龄、名称或“看起来干净”删除。禁止 `rm -rf`、`git worktree remove --force`、`branch -D`
|
|
71
75
|
和 feature owner 手工 rebase。watcher 的 `merge-tree` 结果只是目标前进后的只读成本预判,不保证逐
|
|
@@ -38,7 +38,7 @@ controller 始终拥有闭环对象、完成标准、任务图、依赖/barrier
|
|
|
38
38
|
|
|
39
39
|
派生后维护“活跃 Agent”“本批已完成”,存在并行时再维护“工作进展”;在派发、批次收口、用户询问
|
|
40
40
|
和最终答复时更新,无活跃 worker 明示为 0,不虚构百分比。字段与状态变换见
|
|
41
|
-
|
|
41
|
+
编排运行时(`agentkit docs orchestrate orchestration-runtime`)「派发台账的三张表」。台账也是回收清单:收口前
|
|
42
42
|
确认没有孤儿 worker/worktree/运行时资源。任务图与台账只写仓库外会话状态;完整档用 mechanical
|
|
43
43
|
ledger,轻量档用单一 JSON 快照,闭环后清理。
|
|
44
44
|
|
|
@@ -48,7 +48,7 @@ ledger,轻量档用单一 JSON 快照,闭环后清理。
|
|
|
48
48
|
只有 `allowed` 可用。输入已随合同完整提供、节点不依赖额外宿主能力时 requirements 可为空,
|
|
49
49
|
此时记 `not_required` 及依据即可。同机、本地或过去恒过都不构成豁免。探针只在比 controller 自做或
|
|
50
50
|
缩小节点更便宜时派发,并计入 worker 与 Token 预算。能力键前缀、binding 与复用规则、反应矩阵见
|
|
51
|
-
|
|
51
|
+
编排运行时(`agentkit docs orchestrate orchestration-runtime`)「Worker 有效能力预检」。
|
|
52
52
|
|
|
53
53
|
### 显式轻量档
|
|
54
54
|
|
|
@@ -59,7 +59,7 @@ ledger,轻量档用单一 JSON 快照,闭环后清理。
|
|
|
59
59
|
轻量档仍冻结每个节点的目标、范围、事实/假设、输出合同、验收、证据和停止条件,并在仓库外维护
|
|
60
60
|
单一 JSON 快照;它只记录事实,不伪造 journal。资格失效时停止新派发,把现状和产物收养进完整 ledger。
|
|
61
61
|
Reflection 使用 `agentkit orchestrate reflection record/propose`,通用 Skill 缺口不得写入宿主能力缓存。
|
|
62
|
-
快照 schema、收养步骤和 Reflection 约束见
|
|
62
|
+
快照 schema、收养步骤和 Reflection 约束见 编排运行时(`agentkit docs orchestrate orchestration-runtime`)。
|
|
63
63
|
|
|
64
64
|
### 完整档机械台账工具
|
|
65
65
|
|
|
@@ -68,7 +68,7 @@ review-view / diff` 固定公共 Task Contract,再以仓库外 state root 初
|
|
|
68
68
|
脚本不直接派发 Agent,controller 把宿主回执写入 `dispatch-record`。节点必须选择
|
|
69
69
|
`worker_self_check`、`controller_recheck`、`independent_evidence`,只读 critic/scout 用
|
|
70
70
|
`not_applicable`;依赖/barrier 未通过不派下游。命令、schema、`--expected-revision`、state root 信任
|
|
71
|
-
边界和批级熔断见
|
|
71
|
+
边界和批级熔断见 编排运行时(`agentkit docs orchestrate orchestration-runtime`)。
|
|
72
72
|
Reflection 只记证据化观察,Improvement Proposal 永远保持 `proposed`,不能改变当前运行。
|
|
73
73
|
|
|
74
74
|
## 1. 先过闸门
|
|
@@ -81,7 +81,7 @@ Reflection 只记证据化观察,Improvement Proposal 永远保持 `proposed`
|
|
|
81
81
|
|
|
82
82
|
规模来自真正独立且单个上下文装不下的工作块,不来自话题数;删掉不影响答案的节点。常规盘点/审计
|
|
83
83
|
一轮总 Agent 数 `≤10`。扫描与核验配比、
|
|
84
|
-
合并判据与各档规模见
|
|
84
|
+
合并判据与各档规模见 任务类型剧本(`agentkit docs orchestrate task-playbooks`)。
|
|
85
85
|
|
|
86
86
|
## 3. 派发前决定环境
|
|
87
87
|
|
|
@@ -98,7 +98,7 @@ Reflection 只记证据化观察,Improvement Proposal 永远保持 `proposed`
|
|
|
98
98
|
|
|
99
99
|
仓库 dirty、任务大或包含新文件本身都不足以要求隔离;先确认第二写入者、归属冲突或路径相交。
|
|
100
100
|
裁决隔离后使用 `manage-worktrees`,它不改变任务图、权限或验收。不可用时必须先读
|
|
101
|
-
|
|
101
|
+
无 manage-worktrees 时的隔离下限(`agentkit docs orchestrate isolation-fallback`)。
|
|
102
102
|
|
|
103
103
|
共享树中 worker 不得切换分支,只按路径 stage / commit;禁止 `git add -A`、`git commit -am`、裸 `stash`、`reset --hard`、`checkout -- .` 和并发 merge,controller 是唯一 integrator。
|
|
104
104
|
|
|
@@ -107,7 +107,7 @@ Reflection 只记证据化观察,Improvement Proposal 永远保持 `proposed`
|
|
|
107
107
|
2-3 个独立任务用宿主轻量并行;循环、条件、逐项管线或预算驱动才使用结构化编排。多 stage 默认逐条
|
|
108
108
|
pipeline,只有跨条去重、聚合、比较或早退才设 barrier。截断、采样或不重试必须披露覆盖缺口。
|
|
109
109
|
barrier、critic 面板与 loop-until-dry 的具体用法见
|
|
110
|
-
|
|
110
|
+
编排运行时(`agentkit docs orchestrate orchestration-runtime`)「编排原语选择」。具体 API 服从宿主编排工具说明。
|
|
111
111
|
|
|
112
112
|
## 5. 派发可验收的任务契约
|
|
113
113
|
|
|
@@ -118,17 +118,17 @@ stop_conditions`;写任务再冻结权限、精确仓库/workdir/branch 和 wr
|
|
|
118
118
|
controller 圆场。
|
|
119
119
|
|
|
120
120
|
派发写 worker、完整档、独立 Evidence 或失败后重派前,必须读取
|
|
121
|
-
|
|
121
|
+
节点派发合同(`agentkit docs orchestrate dispatch-contract`) 并使用完整 envelope。轻量只读节点可只使用上述最小合同。
|
|
122
122
|
|
|
123
123
|
## 6. 控制运行态与升级
|
|
124
124
|
|
|
125
125
|
用户可见台账只用 `运行中 / 阻塞 / 待验收`;`partial` 由 controller 决定继续、转阻塞或停止后判
|
|
126
126
|
未通过。只有 controller 能给出 `通过 / 未通过 / 取消`。台账术语不进用户汇报,对外说法见
|
|
127
|
-
|
|
127
|
+
面向用户的汇报词表(`agentkit docs orchestrate user-facing-reporting`)。
|
|
128
128
|
|
|
129
129
|
只在结论交付、需要输入、scope/写入冲突、预算将尽、新依赖、失败或置信不足时介入;“等待中”不是
|
|
130
130
|
状态包。能力故障、业务失败、中断与 contract gap 分开处理,不按失败次数机械升档。失败、重派、中断
|
|
131
|
-
或 controller
|
|
131
|
+
或 controller 接手时读取失败路由与恢复(`agentkit docs orchestrate failure-routing-and-recovery`)。合同失真、
|
|
132
132
|
新授权、不可逆动作、资源/结论冲突或重试耗尽必须升级,不得自行扩 scope。
|
|
133
133
|
|
|
134
134
|
## 7. 验证、收敛与停止
|
|
@@ -139,7 +139,7 @@ controller 圆场。
|
|
|
139
139
|
复核记录;`independent_evidence` 绑定唯一 Artifact 与标准 Evidence;只读 critic/scout 用
|
|
140
140
|
`not_applicable`,附一份 report 即收口。失败、不可判定、安全阻塞、
|
|
141
141
|
human gate 或错绑 Evidence 均不能通过。节点专属验收合同只能用 `agentkit contract project` 从公共
|
|
142
|
-
合同切出 acceptance 子集;完整门禁见
|
|
142
|
+
合同切出 acceptance 子集;完整门禁见 编排运行时(`agentkit docs orchestrate orchestration-runtime`)「合同投影」。
|
|
143
143
|
|
|
144
144
|
快速演进分支的迭代期只读 review 最多算 `controller_recheck`;到 RC/合入候选才冻结唯一 Artifact,
|
|
145
145
|
运行 `verify-agent-output` 生成 `independent_evidence`。
|
|
@@ -153,7 +153,7 @@ human gate 或错绑 Evidence 均不能通过。节点专属验收合同只能
|
|
|
153
153
|
独立 reviewer 是高成本资源,不与 worker 1:1 配对。对同一 Artifact 默认只允许一次 primary review;
|
|
154
154
|
第二次必须由 `undecidable`、证据冲突或协议歧义触发,使用不同 lens 且可能改变处置。smoke 未通过、
|
|
155
155
|
同 lens 重复、输入超出预算或已有 `blocked_safety` 时不派。考虑任何 critic/reviewer 时必须读取
|
|
156
|
-
|
|
156
|
+
Reviewer 数量与 Token 预算(`agentkit docs orchestrate review-budget`),冻结 `extensions.review_policy`,并在派发前运行
|
|
157
157
|
`agentkit orchestrate review-budget evaluate`。提高 reviewer 数量或输入上限属于 re-contract。
|
|
158
158
|
|
|
159
159
|
最终答复前用 `闭环项 / 标准 / 证据 / 结果` 审计:闭环对象与外部状态已重查;必要节点已验收且活跃
|
|
@@ -163,11 +163,11 @@ human gate 或错绑 Evidence 均不能通过。节点专属验收合同只能
|
|
|
163
163
|
## 8. 模型路由与宿主自适配
|
|
164
164
|
|
|
165
165
|
controller 持续掌握目标、任务图、re-plan、失败分类和最终验收;worker 不自选模型或下一次配置。
|
|
166
|
-
派发、首次建 tier
|
|
167
|
-
|
|
166
|
+
派发、首次建 tier、失败后调档或切模型时读模型路由(`agentkit docs orchestrate model-routing-config`),按任务类型
|
|
167
|
+
选档再读任务剧本(`agentkit docs orchestrate task-playbooks`):使用本地配置中最低可靠 tier,model/effort 只取
|
|
168
168
|
用户配置与实时 schema,每次保存精确配置、attempt lineage、理由和预算;扩大授权或显著增费先确认。
|
|
169
169
|
|
|
170
170
|
`agents/openai.yaml` 和缓存都不是能力证明。runtime/path/tool 要求先做有效能力预检;能力快照的创建、
|
|
171
|
-
|
|
171
|
+
刷新和复用读宿主能力缓存(`agentkit docs orchestrate host-capability-cache`)。实时 schema 优先,unknown 不能被
|
|
172
172
|
缓存补成 allowed,缓存不能扩大授权。`protocol_version`、`runtime_version`、`content_digest` 分别表示
|
|
173
173
|
协议兼容性、实现版本和精确安装内容,不得互相替代。
|
package/package.json
CHANGED
|
@@ -51,8 +51,8 @@ verification run,消费其标准 Evidence Package。Loop 不执行或复制完
|
|
|
51
51
|
- runtime 只生成 `embedded_verification_record`,不能导出、转换或冒充 Evidence Package;
|
|
52
52
|
- 无独立 reviewer 时停止,不用实现者自审产生 pass。
|
|
53
53
|
|
|
54
|
-
进入 embedded L1 前读取
|
|
55
|
-
verifier 行为语义以
|
|
54
|
+
进入 embedded L1 前读取 内嵌 review 适配器(`agentkit docs loop embedded-review-adapter`)。
|
|
55
|
+
verifier 行为语义以 验收协议(`agentkit docs verify verification-protocol`) 为唯一真源;独立安装本 Skill
|
|
56
56
|
时,adapter 只携带绑定和最低输入要求,不重定义 verdict。
|
|
57
57
|
|
|
58
58
|
## Runtime 流程
|
|
@@ -79,8 +79,8 @@ run-embedded-l0 → 新上下文 reviewer → record-embedded-review
|
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
所有状态写命令可带 `--expected-revision <n>`;revision 不匹配时拒绝写入。详细状态迁移见
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
Loop 状态机(`agentkit docs loop loop-state-machine`),恢复和熔断见
|
|
83
|
+
恢复与熔断(`agentkit docs loop recovery-and-fuses`)。只有发生复制/移动、journal 恢复、
|
|
84
84
|
reflection 或提案时才使用 `adopt-root / record-reflection / convergence-report / propose-improvement --help`。
|
|
85
85
|
|
|
86
86
|
## 决策规则
|
package/shell-manifest.json
CHANGED
|
@@ -31,8 +31,8 @@ controller 必须先提供:
|
|
|
31
31
|
4. clean workdir,`HEAD == artifact_sha`;
|
|
32
32
|
5. 新上下文 reviewer,或由用户中继的第二会话。只有当前实现者上下文时停止,不得伪造独立验收。
|
|
33
33
|
|
|
34
|
-
详细 envelope 与字段规则见
|
|
35
|
-
|
|
34
|
+
详细 envelope 与字段规则见 Evidence schema(`agentkit docs verify evidence-schema`)。进入 L1 前必须完整读取
|
|
35
|
+
验收协议(`agentkit docs verify verification-protocol`)。
|
|
36
36
|
|
|
37
37
|
## 标准流程
|
|
38
38
|
|
|
@@ -56,7 +56,7 @@ agentkit verify validate --run <run-dir>
|
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
所有子命令支持 `--help`;写命令支持 `--expected-revision`。尚未准备输入、happy path 失败或需要完整
|
|
59
|
-
诊断时,先读
|
|
59
|
+
诊断时,先读 输入准备与诊断命令(`agentkit docs verify input-preparation`)。readiness/preflight 失败属于环境
|
|
60
60
|
前提,不得记录为 Artifact verdict。
|
|
61
61
|
|
|
62
62
|
## 状态与裁决
|