@cr1992/agentkit 1.0.0 → 1.1.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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  本项目遵循 [Semantic Versioning](https://semver.org/)。
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 1.1.0 - 2026-09-07
8
+
9
+ - 修复从待回收 worktree 自身执行 `reclaim` 时,目录删除后误判本地分支已不存在的问题;未完成的
10
+ branch cleanup 现在返回非零并可幂等重试。
11
+ - `manage-worktrees` 持久化评审 watcher 的 pending/disabled 意图,让 `doctor` 与 `resume-all` 不再忽略未武装记录。
12
+ - 新增 macOS `watch-service` LaunchAgent,为已登记 watcher 提供跨 Agent 会话与重启后的自动恢复触发。
13
+
5
14
  ## 1.0.0 - 2026-09-04
6
15
 
7
16
  - 将四个 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.0.0)](./docs/architecture/skill-system-architecture.md)
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.0.0)](./docs/architecture/skill-system-architecture.md)
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;change request 已关闭且明确不会合入时用 `unwatch`。
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`;修复占用后重跑相同 `reclaim`,工具重新验证 branch tip。
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,22 @@ 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
- 回报未武装原因。补齐前提后重跑,或用 `--no-watch` 明确接受人工回收。
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
+ 进程级 watcher 只保证当前宿主允许后台进程存活期间的即时回收。需要跨 Agent 会话或机器重启:
21
+
22
+ ```bash
23
+ agentkit worktree watch-service install
24
+ agentkit worktree watch-service status --json
25
+ ```
26
+
27
+ macOS adapter 安装用户级 LaunchAgent,每 60 秒(可用 `--interval-seconds` 调整)从 primary worktree
28
+ 运行一次 `resume-all`。安装是显式动作,不随 npm install 自动发生,不保存调用会话的环境变量或凭证。
29
+ 服务固定安装时的 Node 与 manager script 绝对路径;升级 agentkit 或切换 Node 安装位置后重跑 install,
30
+ `status --json` 的 `program_available` 会检查这两个路径。卸载使用 `watch-service uninstall`。其他平台仍可
31
+ 手工运行 `resume-all`,在相应 service adapter 落地前不宣称跨会话保证。
17
32
 
18
33
  人工 `watch --target` 可改默认目标;已经显式武装的 target 不被静默改写,换目标先 `unwatch`。陈旧
19
34
  watcher 不能覆盖并发 rearm 或 `merge_detected`。旧 SHA 合入不代表新 HEAD 已完成。
@@ -88,5 +103,7 @@ agentkit worktree touch <selector> \
88
103
  未给 target 时使用 record 的 `base_ref`;远端 MR target 不同时先核对 provider/UI,再显式传
89
104
  真实目标。`--mr` 只接受 HTTP(S) URL;纯文本载体使用 `--change-ref`。
90
105
 
91
- watcher 只在冻结 SHA 成为目标 ref 祖先后回收。重启后 `resume-all` 恢复 stale watcher;change request
92
- 关闭且确定不会合入时 `unwatch`。详细回收见 [reclaim-and-watch.md](reclaim-and-watch.md)。
106
+ watcher 只在冻结 SHA 成为目标 ref 祖先后回收。`resume-all` 恢复 stale watcher 和已登记的 pending
107
+ intent;对没有 intent 的 legacy 评审记录只报告 skipped,要求重新 `touch ready_for_review`,不猜测它
108
+ 是否曾显式 `--no-watch`。change request 关闭且确定不会合入时 `unwatch`。详细回收见
109
+ [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,6 +35,7 @@ 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) {
@@ -243,10 +244,45 @@ export function createCommands(deps) {
243
244
 
244
245
  /** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {Record<string,any>[]} findings */
245
246
  function collectDoctorRecordWatcherFindings(loaded, record, findings) {
246
- if (record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state) && record.worktree_state !== 'reclaimed') {
247
+ const reviewLifecycle = ['ready_for_review', 'integrating'].includes(record.task_status)
248
+ && !['reclaimed', 'archived'].includes(record.worktree_state);
249
+ const activeWatch = record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)
250
+ ? record.auto_reclaim
251
+ : null;
252
+ if (reviewLifecycle && !activeWatch) {
253
+ if (record.review_watch?.policy === 'disabled') {
254
+ findings.push({
255
+ code: 'AUTO_RECLAIM_DISABLED',
256
+ severity: 'warning',
257
+ worktree_id: record.worktree_id,
258
+ path: record.path,
259
+ detail: `已显式关闭跨会话回收(${record.review_watch.reason ?? 'no reason'});合入后需要人工 reclaim。`,
260
+ });
261
+ } else if (record.review_watch?.policy === 'auto') {
262
+ findings.push({
263
+ code: 'AUTO_RECLAIM_NOT_ARMED',
264
+ severity: 'error',
265
+ worktree_id: record.worktree_id,
266
+ path: record.path,
267
+ target_ref: record.review_watch.target_ref ?? null,
268
+ head_sha: record.review_watch.head_sha ?? null,
269
+ detail: record.review_watch.reason ?? '自动回收 intent 已登记,但 watcher 尚未武装;运行 resume-all 重试。',
270
+ });
271
+ } else {
272
+ findings.push({
273
+ code: 'AUTO_RECLAIM_INTENT_MISSING',
274
+ severity: 'error',
275
+ worktree_id: record.worktree_id,
276
+ path: record.path,
277
+ detail: `评审态 record 没有 watcher 或显式退出记录;运行 touch ${record.task} --status ready_for_review 重新登记。`,
278
+ });
279
+ }
280
+ return;
281
+ }
282
+ if (activeWatch) {
247
283
  const heartbeat = readWatcherHeartbeat(loaded.context.common_dir, record.worktree_id);
248
284
  const health = watcherHealth(record, heartbeat);
249
- const advance = record.auto_reclaim.target_advance;
285
+ const advance = activeWatch.target_advance;
250
286
  if (advance) {
251
287
  const prediction = advance.prediction?.state ?? 'unknown';
252
288
  findings.push({
@@ -258,7 +294,7 @@ export function createCommands(deps) {
258
294
  severity: 'warning',
259
295
  worktree_id: record.worktree_id,
260
296
  path: record.path,
261
- target_ref: record.auto_reclaim.target_ref,
297
+ target_ref: activeWatch.target_ref,
262
298
  target_sha: advance.target_sha,
263
299
  recorded_base_sha: advance.recorded_base_sha,
264
300
  prediction: advance.prediction,
@@ -462,6 +498,21 @@ export function createCommands(deps) {
462
498
  }
463
499
  }
464
500
  collectDoctorRecordFindings(loaded, listing, recordsById, findings);
501
+ const expectsDurableWatch = listing.rows.some((row) => row.record
502
+ && ['ready_for_review', 'integrating'].includes(row.record.task_status)
503
+ && (row.record.review_watch?.policy === 'auto'
504
+ || (row.record.auto_reclaim && !['disarmed', 'reclaimed'].includes(row.record.auto_reclaim.state))));
505
+ if (expectsDurableWatch) {
506
+ const service = watchServiceStatus(loaded);
507
+ if (service.supported && (!service.installed || !service.loaded || !service.program_available)) {
508
+ findings.push({
509
+ code: 'AUTO_RECLAIM_SERVICE_INACTIVE',
510
+ severity: 'warning',
511
+ path: service.plist_path ?? loaded.context.primary_worktree,
512
+ detail: '当前 watcher 只有进程级存活保证;运行 agentkit worktree watch-service install 启用跨会话恢复。',
513
+ });
514
+ }
515
+ }
465
516
  collectDoctorSupersessionFindings(listing, findings);
466
517
  collectDoctorSessionFindings(listing, findings);
467
518
  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.3.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', 'review-target-advance-prediction', 'explicit-review-refresh', 'managed-history-rewrite', 'stack-parent-attribution', 'structured-change-registration'], content_digest: worktreeSkillDigest() }, null, 2));
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 Boolean(record.branch) && gitTry(
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
- if (!record.branch || !localBranchExists(loaded, record)) {
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 (!gitTry(['merge-base', '--is-ancestor', record.branch, pushed], loaded.context.current_worktree).ok) {
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], loaded.context.current_worktree);
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 branchExists = localBranchExists(loaded, record);
336
- if (['deleted', 'absent'].includes(record.branch_cleanup?.status) && !branchExists) {
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 registeredAtStart = parseWorktrees(loaded.context.current_worktree)
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(loaded.context.current_worktree).find((worktree) => worktree.path === canonicalSelectorPath(record.path));
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], loaded.context.current_worktree);
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(loaded.context.current_worktree)
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'], loaded.context.current_worktree);
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) => log(`watch 未武装:${reason};自动回收保持关闭,可补齐前提后重新 touch。`);
67
- const targetRef = aliasedFlag(args.flags, 'target', 'watch-target') ?? previous?.target_ref ?? record.base_ref ?? loaded.profile.default_base;
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: parseWatchInterval(flag(args.flags, 'interval-ms') ?? previous?.interval_ms),
106
- changeRef: aliasedFlag(args.flags, 'change-ref', 'mr')
107
- ? oneLine(aliasedFlag(args.flags, 'change-ref', 'mr'), 'change-ref', 1000)
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.auto_reclaim &&
474
- !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state) &&
475
- // archive 的前置条件本身已经要求 watcher 先 disarm,正常路径不会走到这里;这里保留
476
- // 一道防线,避免归档记录被任何遗留/异常状态误当作待恢复 watcher。
477
- record.worktree_state !== 'reclaimed' && record.worktree_state !== 'archived');
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 = record.auto_reclaim;
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
- const token = record.auto_reclaim?.token;
530
- if (!token || ['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)) {
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
- if (record.auto_reclaim.state === 'merge_detected') {
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('&', '&amp;')
11
+ .replaceAll('<', '&lt;')
12
+ .replaceAll('>', '&gt;');
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
+ }
@@ -67,6 +67,10 @@ manager 的 trace/common-dir 状态只应由受信 operator/controller 与 runti
67
67
  | 批量集成 | `plan-batch` → `batch-integrate` → `batch-step` → `batch-result` | 同仓同 target:≥3 默认聚合,2 个仅在碰撞时;只合成冻结 SHA,不代跑门禁;合同/顺序/target 变化即重规划;读[批量集成](../docs/worktree/batch-integration.md) |
68
68
  | 回收 | `reclaim` / `archive` | 必须有远端/其他 ref 可达证明或明确 archive evidence;dirty、stash、submodule、权限或 branch-tip 异常一律 `KEEP`;替代树默认先归档,`--discard` 需人工明确授权;读[回收与看护](../docs/worktree/reclaim-and-watch.md) |
69
69
 
70
+ `resume-all` 只提供当次恢复。需要跨 Agent 会话、注销或重启后仍自动恢复 watcher 时,先检查
71
+ `agentkit worktree watch-service status`;macOS 可由用户一次性显式执行 `watch-service install`。
72
+ 未安装常驻维护器时不得把进程级 watcher 描述成跨会话保证。
73
+
70
74
  不得按目录年龄、名称或“看起来干净”删除。禁止 `rm -rf`、`git worktree remove --force`、`branch -D`
71
75
  和 feature owner 手工 rebase。watcher 的 `merge-tree` 结果只是目标前进后的只读成本预判,不保证逐
72
76
  commit rebase 成功;`batch-result` 只冻结 controller 已完成的验收结论,不替代验证。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cr1992/agentkit",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Agent engineering toolkit: orchestration, git worktree isolation, independent verification and bounded verify loops.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "package_name": "@cr1992/agentkit",
4
- "package_version": "1.0.0",
4
+ "package_version": "1.1.0",
5
5
  "cli": {
6
6
  "command": "agentkit",
7
7
  "entry": "bin/agentkit.mjs"