@event4u/agent-config 11.0.0 → 12.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.
Files changed (312) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +173 -229
  3. package/agents/templates/.ai-council.yml.example +46 -3
  4. package/dist/agent-src/commands/agent-handoff.md +0 -1
  5. package/dist/agent-src/commands/agent-status.md +0 -1
  6. package/dist/agent-src/commands/agents/audit.md +0 -1
  7. package/dist/agent-src/commands/agents/init.md +0 -1
  8. package/dist/agent-src/commands/agents/optimize.md +0 -1
  9. package/dist/agent-src/commands/agents/user/accept.md +0 -1
  10. package/dist/agent-src/commands/agents/user/delete.md +0 -1
  11. package/dist/agent-src/commands/agents/user/init.md +0 -1
  12. package/dist/agent-src/commands/agents/user/review.md +0 -1
  13. package/dist/agent-src/commands/agents/user/show.md +0 -1
  14. package/dist/agent-src/commands/agents/user/update.md +0 -1
  15. package/dist/agent-src/commands/agents/user.md +0 -1
  16. package/dist/agent-src/commands/agents.md +0 -1
  17. package/dist/agent-src/commands/analytics/prune.md +0 -1
  18. package/dist/agent-src/commands/analytics/show.md +0 -1
  19. package/dist/agent-src/commands/analytics.md +0 -1
  20. package/dist/agent-src/commands/analyze/conformance.md +0 -1
  21. package/dist/agent-src/commands/analyze/decision.md +0 -1
  22. package/dist/agent-src/commands/analyze/inbox.md +0 -1
  23. package/dist/agent-src/commands/analyze/incident.md +0 -1
  24. package/dist/agent-src/commands/analyze/near-miss.md +0 -1
  25. package/dist/agent-src/commands/analyze/postmortem.md +0 -1
  26. package/dist/agent-src/commands/analyze/premortem.md +0 -1
  27. package/dist/agent-src/commands/analyze/reference-repo.md +0 -1
  28. package/dist/agent-src/commands/analyze.md +0 -1
  29. package/dist/agent-src/commands/brand/identity.md +0 -1
  30. package/dist/agent-src/commands/brand/review.md +0 -1
  31. package/dist/agent-src/commands/brand/strategy.md +0 -1
  32. package/dist/agent-src/commands/brand/tokens.md +0 -1
  33. package/dist/agent-src/commands/brand/voice.md +0 -1
  34. package/dist/agent-src/commands/brand.md +0 -1
  35. package/dist/agent-src/commands/bug/fix.md +0 -1
  36. package/dist/agent-src/commands/bug/investigate.md +0 -1
  37. package/dist/agent-src/commands/bug.md +0 -1
  38. package/dist/agent-src/commands/challenge-me/vision.md +0 -1
  39. package/dist/agent-src/commands/challenge-me/with-docs.md +0 -1
  40. package/dist/agent-src/commands/challenge-me.md +0 -1
  41. package/dist/agent-src/commands/chat-history/import.md +0 -1
  42. package/dist/agent-src/commands/chat-history.md +0 -1
  43. package/dist/agent-src/commands/check-current-md.md +0 -1
  44. package/dist/agent-src/commands/commit/in-chunks.md +0 -1
  45. package/dist/agent-src/commands/commit.md +0 -1
  46. package/dist/agent-src/commands/condense.md +0 -1
  47. package/dist/agent-src/commands/context/create.md +0 -1
  48. package/dist/agent-src/commands/context/refactor.md +0 -1
  49. package/dist/agent-src/commands/context.md +0 -1
  50. package/dist/agent-src/commands/contribution-precheck.md +0 -1
  51. package/dist/agent-src/commands/cost/profile.md +0 -1
  52. package/dist/agent-src/commands/cost/report.md +0 -1
  53. package/dist/agent-src/commands/cost.md +0 -1
  54. package/dist/agent-src/commands/council/analysis.md +0 -1
  55. package/dist/agent-src/commands/council/debate.md +0 -1
  56. package/dist/agent-src/commands/council/default.md +0 -1
  57. package/dist/agent-src/commands/council/design.md +0 -1
  58. package/dist/agent-src/commands/council/optimize.md +0 -1
  59. package/dist/agent-src/commands/council/pr.md +0 -1
  60. package/dist/agent-src/commands/council.md +0 -1
  61. package/dist/agent-src/commands/design-system/capture.md +0 -1
  62. package/dist/agent-src/commands/design-system/generate.md +0 -1
  63. package/dist/agent-src/commands/design-system/import.md +0 -1
  64. package/dist/agent-src/commands/design-system.md +0 -1
  65. package/dist/agent-src/commands/estimate-ticket.md +0 -1
  66. package/dist/agent-src/commands/explain-run.md +0 -1
  67. package/dist/agent-src/commands/feature/dev.md +0 -1
  68. package/dist/agent-src/commands/feature/explore.md +0 -1
  69. package/dist/agent-src/commands/feature/plan.md +0 -1
  70. package/dist/agent-src/commands/feature/refactor.md +0 -1
  71. package/dist/agent-src/commands/feature/roadmap.md +0 -1
  72. package/dist/agent-src/commands/feature.md +0 -1
  73. package/dist/agent-src/commands/fix/ci.md +0 -1
  74. package/dist/agent-src/commands/fix/comments.md +0 -1
  75. package/dist/agent-src/commands/fix/portability.md +0 -1
  76. package/dist/agent-src/commands/fix/pr-comments-loop.md +0 -1
  77. package/dist/agent-src/commands/fix/pr-comments.md +0 -1
  78. package/dist/agent-src/commands/fix/quality.md +0 -1
  79. package/dist/agent-src/commands/fix/refs.md +0 -1
  80. package/dist/agent-src/commands/fix/route.md +0 -1
  81. package/dist/agent-src/commands/fix/seeder.md +0 -1
  82. package/dist/agent-src/commands/fix.md +0 -1
  83. package/dist/agent-src/commands/ghostwriter/delete.md +0 -1
  84. package/dist/agent-src/commands/ghostwriter/fetch.md +0 -1
  85. package/dist/agent-src/commands/ghostwriter/list.md +0 -1
  86. package/dist/agent-src/commands/ghostwriter/show.md +0 -1
  87. package/dist/agent-src/commands/ghostwriter/write.md +0 -1
  88. package/dist/agent-src/commands/ghostwriter.md +0 -1
  89. package/dist/agent-src/commands/grill-me.md +0 -1
  90. package/dist/agent-src/commands/humanize.md +0 -1
  91. package/dist/agent-src/commands/image/analyse.md +0 -1
  92. package/dist/agent-src/commands/image/create.md +0 -1
  93. package/dist/agent-src/commands/image/verify.md +0 -1
  94. package/dist/agent-src/commands/image.md +0 -1
  95. package/dist/agent-src/commands/implement-ticket.md +0 -1
  96. package/dist/agent-src/commands/jira-ticket.md +0 -1
  97. package/dist/agent-src/commands/judge/on-diff.md +0 -1
  98. package/dist/agent-src/commands/judge/solo.md +0 -1
  99. package/dist/agent-src/commands/judge/steps.md +0 -1
  100. package/dist/agent-src/commands/judge.md +0 -1
  101. package/dist/agent-src/commands/knowledge/cross-repo.md +0 -1
  102. package/dist/agent-src/commands/knowledge/forget.md +0 -1
  103. package/dist/agent-src/commands/knowledge/ingest.md +0 -1
  104. package/dist/agent-src/commands/knowledge/list.md +0 -1
  105. package/dist/agent-src/commands/knowledge.md +0 -1
  106. package/dist/agent-src/commands/memory/add.md +0 -1
  107. package/dist/agent-src/commands/memory/learn-low-impact.md +0 -1
  108. package/dist/agent-src/commands/memory/load.md +0 -1
  109. package/dist/agent-src/commands/memory/mine-session.md +0 -1
  110. package/dist/agent-src/commands/memory/promote.md +0 -1
  111. package/dist/agent-src/commands/memory/propose.md +0 -1
  112. package/dist/agent-src/commands/memory.md +0 -1
  113. package/dist/agent-src/commands/mission/upgrade.md +0 -1
  114. package/dist/agent-src/commands/mode.md +0 -1
  115. package/dist/agent-src/commands/module/create.md +0 -1
  116. package/dist/agent-src/commands/module/explore.md +0 -1
  117. package/dist/agent-src/commands/module.md +0 -1
  118. package/dist/agent-src/commands/optimize/agents-dir.md +0 -1
  119. package/dist/agent-src/commands/optimize/augmentignore.md +0 -1
  120. package/dist/agent-src/commands/optimize/deep.md +0 -1
  121. package/dist/agent-src/commands/optimize/project.md +0 -1
  122. package/dist/agent-src/commands/optimize/prompt.md +0 -1
  123. package/dist/agent-src/commands/optimize/rtk.md +0 -1
  124. package/dist/agent-src/commands/optimize/skills.md +0 -1
  125. package/dist/agent-src/commands/optimize.md +0 -1
  126. package/dist/agent-src/commands/orchestrate.md +0 -1
  127. package/dist/agent-src/commands/override/create.md +0 -1
  128. package/dist/agent-src/commands/override/manage.md +0 -1
  129. package/dist/agent-src/commands/override.md +0 -1
  130. package/dist/agent-src/commands/package/reset.md +0 -1
  131. package/dist/agent-src/commands/package/test.md +0 -1
  132. package/dist/agent-src/commands/package.md +0 -1
  133. package/dist/agent-src/commands/post-as/ghostwriter.md +0 -1
  134. package/dist/agent-src/commands/post-as/me.md +0 -1
  135. package/dist/agent-src/commands/post-as.md +0 -1
  136. package/dist/agent-src/commands/pr/create/description-only.md +0 -1
  137. package/dist/agent-src/commands/pr/create.md +23 -9
  138. package/dist/agent-src/commands/prediction-pool.md +0 -1
  139. package/dist/agent-src/commands/prepare-for-review.md +0 -1
  140. package/dist/agent-src/commands/profile/activate.md +0 -1
  141. package/dist/agent-src/commands/profile/deactivate.md +0 -1
  142. package/dist/agent-src/commands/profile/show.md +0 -1
  143. package/dist/agent-src/commands/profile.md +0 -1
  144. package/dist/agent-src/commands/project/analyze.md +0 -1
  145. package/dist/agent-src/commands/project/health.md +0 -1
  146. package/dist/agent-src/commands/project.md +0 -1
  147. package/dist/agent-src/commands/refine-ticket.md +0 -1
  148. package/dist/agent-src/commands/research/deep.md +0 -1
  149. package/dist/agent-src/commands/research/report.md +0 -1
  150. package/dist/agent-src/commands/research.md +0 -1
  151. package/dist/agent-src/commands/review/changes.md +0 -1
  152. package/dist/agent-src/commands/review/routing.md +0 -1
  153. package/dist/agent-src/commands/review.md +0 -1
  154. package/dist/agent-src/commands/roadmap/ai-council.md +0 -1
  155. package/dist/agent-src/commands/roadmap/create.md +0 -1
  156. package/dist/agent-src/commands/roadmap/materialize.md +0 -1
  157. package/dist/agent-src/commands/roadmap/next.md +0 -1
  158. package/dist/agent-src/commands/roadmap/process-full.md +0 -1
  159. package/dist/agent-src/commands/roadmap/process-phase.md +0 -1
  160. package/dist/agent-src/commands/roadmap/process-step.md +0 -1
  161. package/dist/agent-src/commands/roadmap.md +0 -1
  162. package/dist/agent-src/commands/rule-compliance-audit.md +0 -1
  163. package/dist/agent-src/commands/security-audit-config.md +0 -1
  164. package/dist/agent-src/commands/skill/preview.md +0 -1
  165. package/dist/agent-src/commands/skill.md +0 -1
  166. package/dist/agent-src/commands/skills/discover.md +0 -1
  167. package/dist/agent-src/commands/skills.md +0 -1
  168. package/dist/agent-src/commands/sync/agent-settings.md +0 -1
  169. package/dist/agent-src/commands/sync/gitignore/fix.md +0 -1
  170. package/dist/agent-src/commands/sync/gitignore.md +0 -1
  171. package/dist/agent-src/commands/sync.md +0 -1
  172. package/dist/agent-src/commands/tdd/green.md +0 -1
  173. package/dist/agent-src/commands/tdd/red.md +0 -1
  174. package/dist/agent-src/commands/tdd/refactor.md +0 -1
  175. package/dist/agent-src/commands/tdd.md +0 -1
  176. package/dist/agent-src/commands/team/adversarial.md +0 -1
  177. package/dist/agent-src/commands/team/delegate.md +0 -1
  178. package/dist/agent-src/commands/team/review.md +0 -1
  179. package/dist/agent-src/commands/team/status.md +0 -1
  180. package/dist/agent-src/commands/team-knowledge/bootstrap.md +0 -1
  181. package/dist/agent-src/commands/team-knowledge/consolidate.md +0 -1
  182. package/dist/agent-src/commands/team-knowledge.md +0 -1
  183. package/dist/agent-src/commands/team.md +0 -1
  184. package/dist/agent-src/commands/tests/create.md +0 -1
  185. package/dist/agent-src/commands/tests/e2e-heal.md +0 -1
  186. package/dist/agent-src/commands/tests/e2e-plan.md +0 -1
  187. package/dist/agent-src/commands/tests/execute.md +0 -1
  188. package/dist/agent-src/commands/tests.md +0 -1
  189. package/dist/agent-src/commands/threat-model.md +0 -1
  190. package/dist/agent-src/commands/update-form-request-messages.md +0 -1
  191. package/dist/agent-src/commands/upstream-contribute.md +0 -1
  192. package/dist/agent-src/commands/video/from-script.md +0 -1
  193. package/dist/agent-src/commands/video/from-song.md +0 -1
  194. package/dist/agent-src/commands/video/scene.md +0 -1
  195. package/dist/agent-src/commands/video/stitch.md +0 -1
  196. package/dist/agent-src/commands/video/storyboard.md +0 -1
  197. package/dist/agent-src/commands/video.md +0 -1
  198. package/dist/agent-src/commands/work.md +0 -1
  199. package/dist/agent-src/commands/worktree/cleanup.md +0 -1
  200. package/dist/agent-src/commands/worktree/create.md +0 -1
  201. package/dist/agent-src/commands/worktree/status.md +0 -1
  202. package/dist/agent-src/commands/worktree/verify.md +0 -1
  203. package/dist/agent-src/commands/worktree.md +0 -1
  204. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  205. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  206. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  207. package/dist/agent-src/scripts/update_roadmap_progress.ts +32 -1
  208. package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -0
  209. package/dist/agent-src/skills/git-workflow/SKILL.md +9 -1
  210. package/dist/agent-src/skills/pdf-tools/SKILL.md +1 -0
  211. package/dist/agent-src/skills/roadmap-writing/SKILL.md +7 -7
  212. package/dist/agent-src/skills/skill-writing/SKILL.md +35 -0
  213. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  214. package/dist/agent-src/templates/roadmaps.md +41 -3
  215. package/dist/cli/commands/commands.candidates.test.js +1 -1
  216. package/dist/cli/commands/commands.candidates.test.js.map +1 -1
  217. package/dist/cli/commands/commands.js +9 -15
  218. package/dist/cli/commands/commands.js.map +1 -1
  219. package/dist/cli/commands/profiles.test.js +7 -7
  220. package/dist/cli/commands/profiles.test.js.map +1 -1
  221. package/dist/cli/discovery/loadManifest.js.map +1 -1
  222. package/dist/cli/main.js +2 -2
  223. package/dist/cli/main.js.map +1 -1
  224. package/dist/cli/registry.js +1 -0
  225. package/dist/cli/registry.js.map +1 -1
  226. package/dist/cli-delegate/chunk-K34QTSD5.js +3895 -0
  227. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-LKZ5DOYW.js} +557 -297
  228. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-O52B4F5S.js} +123 -2338
  229. package/dist/cli-delegate/cmd_conformance.js +18 -6
  230. package/dist/cli-delegate/cmd_doctor.js +2 -2
  231. package/dist/cli-delegate/cmd_export.js +1 -1
  232. package/dist/cli-delegate/cmd_handoff.js +1 -1
  233. package/dist/cli-delegate/cmd_prune.js +1 -1
  234. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  235. package/dist/cli-delegate/cmd_sync.js +1 -1
  236. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  237. package/dist/cli-delegate/cmd_validate.js +1 -1
  238. package/dist/discovery/deprecation-report.md +1 -1
  239. package/dist/discovery/discovery-manifest.json +213 -412
  240. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  241. package/dist/discovery/discovery-manifest.summary.md +2 -2
  242. package/dist/discovery/dormancy-report.md +1 -1
  243. package/dist/discovery/orphan-report.md +1 -1
  244. package/dist/discovery/packs.json +3 -3
  245. package/dist/discovery/trust-report.md +1 -1
  246. package/dist/discovery/workspaces.json +3 -3
  247. package/dist/hooks/dispatch.js +2588 -1082
  248. package/dist/install/install.mjs +744 -482
  249. package/dist/mcp/registry-manifest.json +3 -3
  250. package/dist/mcp/server.json +2 -2
  251. package/dist/mcp/server.mjs +159 -15
  252. package/dist/router.json +1 -1
  253. package/dist/scripts/_lib/model_tier.js +9 -0
  254. package/dist/scripts/_lib/model_tier.js.map +1 -1
  255. package/dist/scripts/condense.js +50 -0
  256. package/dist/scripts/condense.js.map +1 -1
  257. package/docs/MIGRATION.md +21 -1
  258. package/docs/contracts/settings-classes.md +41 -0
  259. package/package.json +1 -1
  260. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  261. package/src/agent-src/scripts/update_roadmap_progress.ts +32 -1
  262. package/src/config/agent-settings.template.yml +34 -0
  263. package/src/config/evaluator-budgets.json +4 -4
  264. package/src/config/gate-coverage.yml +39 -9
  265. package/src/config/gate-violation-baselines.json +9 -3
  266. package/src/config/hook-token-budget.json +7 -1
  267. package/src/config/preamble-payload-budget.json +28 -0
  268. package/src/scripts/_dispatch.bash +52 -5
  269. package/src/scripts/_lib/git_common_dir.ts +173 -0
  270. package/src/scripts/_lib/model_tier.ts +9 -0
  271. package/src/scripts/_lib/release_highlights.ts +108 -7
  272. package/src/scripts/_lib/skill_catalogue.ts +862 -0
  273. package/src/scripts/ai_council/clients.ts +182 -3
  274. package/src/scripts/ai_council/config.ts +16 -1
  275. package/src/scripts/ai_council/low_impact.ts +14 -1
  276. package/src/scripts/ai_council/orchestrator.ts +14 -1
  277. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  278. package/src/scripts/audit_command_surface.ts +21 -15
  279. package/src/scripts/build_catalog_index.ts +1 -1
  280. package/src/scripts/build_discovery_manifest.ts +30 -34
  281. package/src/scripts/capture_skill_catalogue.ts +185 -328
  282. package/src/scripts/check_branch_freshness.ts +217 -62
  283. package/src/scripts/check_release_published.ts +6 -2
  284. package/src/scripts/check_release_trunk_sync.ts +18 -2
  285. package/src/scripts/condense.ts +50 -1
  286. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  287. package/src/scripts/evidence_report.ts +15 -4
  288. package/src/scripts/gen_discovery_baseline.ts +3 -3
  289. package/src/scripts/hook_manifest.yaml +67 -13
  290. package/src/scripts/hooks/concern_registry.ts +2 -0
  291. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  292. package/src/scripts/hooks/subagent_ledger_hook.ts +17 -4
  293. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  294. package/src/scripts/hot_context_hook.ts +13 -1
  295. package/src/scripts/install.ts +129 -0
  296. package/src/scripts/janitor.ts +77 -2
  297. package/src/scripts/lint_command_tiers.ts +29 -75
  298. package/src/scripts/lint_originality.ts +101 -14
  299. package/src/scripts/lint_plan_risk_register.ts +22 -9
  300. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  301. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  302. package/src/scripts/migration_status.ts +17 -1
  303. package/src/scripts/probe_review_binding_drift.ts +731 -0
  304. package/src/scripts/release.ts +95 -1
  305. package/src/scripts/schemas/command.schema.json +6 -11
  306. package/src/scripts/schemas/skill.schema.json +12 -2
  307. package/src/scripts/schemas/subagent.schema.json +2 -2
  308. package/src/scripts/session_eol_report.ts +81 -0
  309. package/src/scripts/skill_linter.ts +82 -2
  310. package/src/scripts/workspace_doctor.ts +464 -0
  311. package/src/scripts/worktree_cleanup_check.ts +113 -8
  312. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
@@ -0,0 +1,464 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * workspace:doctor — read-only answer to "where am I", with provenance.
4
+ *
5
+ * Phase 3 of `road-to-inbox-harvest-2026-08-c-workspace-identity`. Two reads,
6
+ * one command, and **no action**: the roadmap's Non-goals put worktree deletion
7
+ * out of scope, and its Risk 3 names "the pressure read becomes a disposal path
8
+ * by accident" as the failure to design against. There is therefore no `--fix`,
9
+ * no `--prune`, and no removal plan here — `worktree_cleanup_check.ts` owns
10
+ * that question behind its own approval.
11
+ *
12
+ * 1. **Identity** — the five fields of `workspaceIdentity()` plus the session
13
+ * facts a location question actually needs: the roadmap claim, foreign live
14
+ * records, expired-but-unpruned records, and whether this checkout is
15
+ * contained in the main worktree. Every field prints its provenance, in the
16
+ * shape `routing:doctor` uses, because a value whose source is invisible is
17
+ * indistinguishable from a guess.
18
+ *
19
+ * 2. **Worktree pressure** — how large the estate is and how much of it is
20
+ * merged. The buckets **partition** the registered set, so they sum to the
21
+ * `git worktree list` total exactly. The live-session count is deliberately
22
+ * NOT one of them: it overlaps every bucket, and printing it inside the
23
+ * partition would invite a reader to add it in.
24
+ *
25
+ * Exit code is 0 whenever the command could read the repository. It is a
26
+ * report, not a gate: nothing here has a pass/fail opinion, so a non-zero exit
27
+ * would have to invent one.
28
+ *
29
+ * `--strict` exits 1 when **any** identity field is unresolved. Note what that
30
+ * means in a repo with no `refs/remotes/origin/HEAD` on disk: `prBase` is
31
+ * unresolved *correctly*, so `--strict` is red there by design. It is a probe
32
+ * for "is every field answerable here", not a health check — and this file
33
+ * previously claimed a per-field selection that does not exist.
34
+ */
35
+
36
+ import { execFileSync } from 'node:child_process';
37
+ import * as fs from 'node:fs';
38
+ import * as path from 'node:path';
39
+ import { fileURLToPath, pathToFileURL } from 'node:url';
40
+
41
+ import {
42
+ type IdentityField,
43
+ type WorkspaceIdentity,
44
+ workspaceIdentity,
45
+ } from './_lib/git_common_dir.js';
46
+ import { gitEnv } from './_lib/git_env.js';
47
+ import {
48
+ read_live_records,
49
+ register_dir,
50
+ type SessionRecord,
51
+ } from './_lib/session_register.js';
52
+ import { listWorktrees, resolveTrunk } from './worktree_cleanup_check.js';
53
+
54
+ const IDENTITY_FIELDS = ['repoRoot', 'mainWorktree', 'currentWorktree', 'branch', 'prBase'] as const;
55
+ type IdentityFieldName = (typeof IDENTITY_FIELDS)[number];
56
+
57
+ interface SessionView {
58
+ /** Roadmap slug this session claims, or `null`; `source` says where from. */
59
+ readonly claim: string | null;
60
+ readonly claim_source: string;
61
+ /** Live foreign records, i.e. every live record that is not this session's. */
62
+ readonly live_records: number;
63
+ /** Live records whose `worktree` is this checkout — a real collision. */
64
+ readonly conflicting: number;
65
+ /** Record files on disk that `read_live_records` judged expired. */
66
+ readonly stale: number;
67
+ /** Where the register lives, or why it could not be located. */
68
+ readonly register: string;
69
+ }
70
+
71
+ interface ContainmentView {
72
+ /** Is the current checkout inside the main worktree's directory tree? */
73
+ readonly contained: boolean | null;
74
+ readonly reason: string;
75
+ }
76
+
77
+ interface PressureView {
78
+ readonly trunk: string | null;
79
+ /** Where `trunk` came from — a candidate search, not a recorded default. */
80
+ readonly trunk_provenance: string;
81
+ readonly registered: number;
82
+ /** Same count from a DIFFERENT command; a mismatch is the real signal. */
83
+ readonly independent_registered: number | null;
84
+ /** Branch appears in `for-each-ref --merged <trunk>`. */
85
+ readonly merged: number;
86
+ /** Branch exists and is NOT merged into the trunk. */
87
+ readonly unmerged: number;
88
+ /** No branch to ask about (detached), or the merged probe was unavailable. */
89
+ readonly unclassifiable: number;
90
+ /** Overlaps the three buckets above on purpose; never summed with them. */
91
+ readonly with_live_session: number;
92
+ /** `merged + unmerged + unclassifiable`, printed so the sum is auditable. */
93
+ readonly partition_total: number;
94
+ readonly note: string | null;
95
+ }
96
+
97
+ export interface WorkspaceDoctorReport {
98
+ readonly identity: Record<IdentityFieldName, IdentityField>;
99
+ readonly session: SessionView;
100
+ readonly containment: ContainmentView;
101
+ readonly pressure: PressureView;
102
+ }
103
+
104
+ function _git(cwd: string, args: readonly string[]): string | null {
105
+ try {
106
+ return execFileSync('git', [...args], {
107
+ cwd,
108
+ encoding: 'utf-8',
109
+ stdio: ['ignore', 'pipe', 'ignore'],
110
+ timeout: 15_000,
111
+ // cwd decides, never an inherited GIT_DIR (hook environments).
112
+ env: gitEnv(),
113
+ }).trim();
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Independent count of registered worktrees, parsed from the **plain**
121
+ * `git worktree list` rather than the porcelain form `listWorktrees` reads.
122
+ *
123
+ * This exists so the partition assertion below is falsifiable. Comparing
124
+ * `merged + unmerged + unclassifiable` against `rows.length` — where the
125
+ * buckets are incremented once per row — is a tautology that holds by
126
+ * construction and cannot detect a dropped entry. Two different commands with
127
+ * two different output formats can disagree; that disagreement is the signal.
128
+ */
129
+ export function independentRegisteredCount(repoPath: string): number | null {
130
+ const out = _git(repoPath, ['worktree', 'list']);
131
+ if (out === null) return null;
132
+ return out.split('\n').filter((l) => l.trim() !== '').length;
133
+ }
134
+
135
+ /**
136
+ * Short branch names merged into `trunk`, in **one** subprocess.
137
+ *
138
+ * Replaces one `merge-base --is-ancestor` spawn per registered worktree —
139
+ * measured at 307 spawns and 15.2 s wall for a read-only report. It also
140
+ * removes a silent misclassification: `_git` returns `null` on any failure
141
+ * (bad ref, timeout, git absent), and mapping that onto "not an ancestor"
142
+ * reported a failed probe as `unmerged`. Here a failed call is `null` and the
143
+ * caller sends every row to `unclassifiable` instead of guessing a side.
144
+ */
145
+ function mergedBranches(repoPath: string, trunk: string): Set<string> | null {
146
+ const out = _git(repoPath, ['for-each-ref', '--merged', trunk, '--format=%(refname:short)', 'refs/heads/']);
147
+ if (out === null) return null;
148
+ return new Set(out.split('\n').map((l) => l.trim()).filter((l) => l !== ''));
149
+ }
150
+
151
+ function realpathOrSelf(p: string): string {
152
+ try {
153
+ return fs.realpathSync(p);
154
+ } catch {
155
+ return p;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Is `child` inside `parent`?
161
+ *
162
+ * Both sides are realpath-normalised first, and the comparison is
163
+ * separator-anchored: a plain `startsWith` would report `/a/repo-backup` as
164
+ * contained in `/a/repo`.
165
+ */
166
+ export function isContained(parent: string, child: string): boolean {
167
+ const p = realpathOrSelf(parent);
168
+ const c = realpathOrSelf(child);
169
+ if (p === c) return false; // the main worktree is not "inside" itself
170
+ return c.startsWith(p.endsWith(path.sep) ? p : p + path.sep);
171
+ }
172
+
173
+ function collectSession(identity: WorkspaceIdentity, sessionId: string | null): SessionView {
174
+ const main = identity.mainWorktree;
175
+ const here = identity.currentWorktree;
176
+ if (!main.resolved) {
177
+ return {
178
+ claim: null,
179
+ claim_source: 'unavailable — no main worktree to anchor the register on',
180
+ live_records: 0,
181
+ conflicting: 0,
182
+ stale: 0,
183
+ register: `unresolvable: ${main.reason}`,
184
+ };
185
+ }
186
+ const dir = register_dir(main.value);
187
+ if (dir === null) {
188
+ return {
189
+ claim: null,
190
+ claim_source: 'unavailable — register_dir declined to resolve',
191
+ live_records: 0,
192
+ conflicting: 0,
193
+ stale: 0,
194
+ register: 'unresolvable',
195
+ };
196
+ }
197
+
198
+ // `read_live_records` filters expired ones out; the difference against the
199
+ // files on disk is the stale count. Never prune here — this is a report.
200
+ const live = read_live_records(dir);
201
+ let onDisk = 0;
202
+ try {
203
+ onDisk = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).length;
204
+ } catch {
205
+ onDisk = 0;
206
+ }
207
+
208
+ const mine = sessionId === null ? null : live.find((r) => r.session_id === sessionId) ?? null;
209
+ const foreign: SessionRecord[] = live.filter((r) => r.session_id !== sessionId);
210
+ const hereValue = here.resolved ? realpathOrSelf(here.value) : null;
211
+ const conflicting =
212
+ hereValue === null ? 0 : foreign.filter((r) => realpathOrSelf(r.worktree) === hereValue).length;
213
+
214
+ let claim: string | null = null;
215
+ let claimSource: string;
216
+ if (mine?.roadmap_slug != null && mine.roadmap_slug !== '') {
217
+ claim = mine.roadmap_slug;
218
+ claimSource = 'this session\'s register record';
219
+ } else if (sessionId === null) {
220
+ claimSource = 'no session id in the environment — the host exports none, or this is not an agent session';
221
+ } else {
222
+ claimSource = 'no claim in this session\'s record (none picked, or the heartbeat has not lifted it yet)';
223
+ }
224
+
225
+ return {
226
+ claim,
227
+ claim_source: claimSource,
228
+ live_records: foreign.length,
229
+ conflicting,
230
+ stale: Math.max(0, onDisk - live.length),
231
+ register: dir,
232
+ };
233
+ }
234
+
235
+ function collectContainment(identity: WorkspaceIdentity): ContainmentView {
236
+ const main = identity.mainWorktree;
237
+ const here = identity.currentWorktree;
238
+ if (!main.resolved) return { contained: null, reason: `main worktree unresolved: ${main.reason}` };
239
+ if (!here.resolved) return { contained: null, reason: `current worktree unresolved: ${here.reason}` };
240
+ if (realpathOrSelf(main.value) === realpathOrSelf(here.value)) {
241
+ // `null`, not `false`: the question does not apply, and rendering it as
242
+ // the word "outside" beside the reason "this IS the main worktree" was
243
+ // a self-contradicting line.
244
+ return { contained: null, reason: 'this IS the main worktree — containment does not apply' };
245
+ }
246
+ const contained = isContained(main.value, here.value);
247
+ return {
248
+ contained,
249
+ reason: contained
250
+ ? 'this checkout lives inside the main worktree'
251
+ : 'this checkout lives outside the main worktree — a sibling or unrelated directory',
252
+ };
253
+ }
254
+
255
+ function collectPressure(identity: WorkspaceIdentity, live: SessionRecord[]): PressureView {
256
+ const main = identity.mainWorktree;
257
+ if (!main.resolved) {
258
+ return {
259
+ trunk: null,
260
+ trunk_provenance: 'not attempted — no main worktree to resolve from',
261
+ registered: 0,
262
+ independent_registered: null,
263
+ merged: 0,
264
+ unmerged: 0,
265
+ unclassifiable: 0,
266
+ with_live_session: 0,
267
+ partition_total: 0,
268
+ note: `no main worktree to enumerate from: ${main.reason}`,
269
+ };
270
+ }
271
+ // Both helpers are the EXPORTED ones from worktree_cleanup_check. Re-deriving
272
+ // them here would have been a fourth `worktree list --porcelain` parser and a
273
+ // second `resolveTrunk` beside the two this roadmap's own census counts — the
274
+ // duplication the whole change exists to remove.
275
+ const rows = listWorktrees(main.value);
276
+ // `resolveTrunk` returns the literal 'HEAD' when no trunk candidate resolves.
277
+ const trunkRef = resolveTrunk(main.value);
278
+ const trunk = trunkRef === 'HEAD' ? null : trunkRef;
279
+ const merged_set = trunk === null ? null : mergedBranches(main.value, trunk);
280
+ const livePaths = new Set(live.map((r) => realpathOrSelf(r.worktree)));
281
+
282
+ let merged = 0;
283
+ let unmerged = 0;
284
+ let unclassifiable = 0;
285
+ let withLive = 0;
286
+ for (const row of rows) {
287
+ if (livePaths.has(realpathOrSelf(row.path))) withLive += 1;
288
+ if (row.branch === null || merged_set === null) {
289
+ unclassifiable += 1;
290
+ } else if (merged_set.has(row.branch)) {
291
+ merged += 1;
292
+ } else {
293
+ unmerged += 1;
294
+ }
295
+ }
296
+
297
+ const independent = independentRegisteredCount(main.value);
298
+ const notes: string[] = [];
299
+ if (trunk === null) {
300
+ notes.push(
301
+ 'no trunk ref resolved — every entry is unclassifiable rather than assumed merged',
302
+ );
303
+ } else if (merged_set === null) {
304
+ notes.push(
305
+ `the merged-branch probe against ${trunk} failed — every entry is unclassifiable rather than assumed unmerged`,
306
+ );
307
+ }
308
+ if (independent !== null && independent !== rows.length) {
309
+ notes.push(
310
+ `COUNT DISAGREEMENT: the porcelain parse sees ${rows.length}, the plain listing ${independent}`,
311
+ );
312
+ }
313
+
314
+ return {
315
+ trunk,
316
+ trunk_provenance:
317
+ trunk === null
318
+ ? 'unresolved — no candidate ref verified'
319
+ : 'candidate-ref search (first of origin/main, origin/master, main, master that resolves) — NOT a recorded remote default, which is what identity.prBase reports',
320
+ registered: rows.length,
321
+ independent_registered: independent,
322
+ merged,
323
+ unmerged,
324
+ unclassifiable,
325
+ with_live_session: withLive,
326
+ partition_total: merged + unmerged + unclassifiable,
327
+ note: notes.length === 0 ? null : notes.join(' · '),
328
+ };
329
+ }
330
+
331
+ export function collectReport(start: string, sessionId: string | null): WorkspaceDoctorReport {
332
+ const identity = workspaceIdentity(start);
333
+ const session = collectSession(identity, sessionId);
334
+ const main = identity.mainWorktree;
335
+ const dir = main.resolved ? register_dir(main.value) : null;
336
+ const live = dir === null ? [] : read_live_records(dir);
337
+ return {
338
+ identity: {
339
+ repoRoot: identity.repoRoot,
340
+ mainWorktree: identity.mainWorktree,
341
+ currentWorktree: identity.currentWorktree,
342
+ branch: identity.branch,
343
+ prBase: identity.prBase,
344
+ },
345
+ session,
346
+ containment: collectContainment(identity),
347
+ pressure: collectPressure(identity, live),
348
+ };
349
+ }
350
+
351
+ function renderField(name: string, f: IdentityField): string {
352
+ const marker = f.resolved ? '✅' : '⚠️';
353
+ const body = f.resolved ? `${f.value} [${f.provenance}]` : `UNRESOLVED — ${f.reason}`;
354
+ return ` ${marker} ${name.padEnd(16)} ${body}`;
355
+ }
356
+
357
+ export function render(report: WorkspaceDoctorReport): string {
358
+ const lines: string[] = [];
359
+ lines.push('workspace:doctor · read-only · no disposal path by design');
360
+ lines.push('');
361
+ lines.push('Identity (every field with its provenance):');
362
+ for (const name of IDENTITY_FIELDS) {
363
+ lines.push(renderField(name, report.identity[name]));
364
+ }
365
+ lines.push('');
366
+
367
+ const s = report.session;
368
+ lines.push('Session:');
369
+ lines.push(` claim: ${s.claim ?? 'none'} [${s.claim_source}]`);
370
+ lines.push(` register: ${s.register}`);
371
+ lines.push(
372
+ ` foreign live records: ${s.live_records} · conflicting on THIS checkout: ${s.conflicting} · expired-not-pruned: ${s.stale}`,
373
+ );
374
+ lines.push(
375
+ ' advisory, never a lock — two sessions can claim in the same millisecond, and an idle session leaves the register while its user returns',
376
+ );
377
+ lines.push('');
378
+
379
+ const c = report.containment;
380
+ lines.push(`Path containment: ${c.contained === null ? 'n/a' : c.contained ? 'inside' : 'outside'} — ${c.reason}`);
381
+ lines.push('');
382
+
383
+ const p = report.pressure;
384
+ lines.push(`Worktree pressure (trunk = ${p.trunk ?? 'unresolved'}):`);
385
+ lines.push(` trunk provenance: ${p.trunk_provenance}`);
386
+ lines.push(` registered: ${p.registered}`);
387
+ lines.push(` merged into trunk: ${p.merged}`);
388
+ lines.push(` carrying unmerged commits:${String(p.unmerged).padStart(4)}`);
389
+ lines.push(` unclassifiable: ${String(p.unclassifiable).padStart(4)} (detached, or no merged probe)`);
390
+ const independentOk = p.independent_registered === null || p.independent_registered === p.registered;
391
+ lines.push(
392
+ ` ── partition sums to ${p.partition_total} of ${p.registered}` +
393
+ `, cross-checked against an independent count of ${p.independent_registered ?? 'n/a'}` +
394
+ `${p.partition_total === p.registered && independentOk ? ' ✅' : ' ❌ MISMATCH'}`,
395
+ );
396
+ lines.push(
397
+ ` with a live session record: ${p.with_live_session} (overlaps the buckets above — not part of the partition)`,
398
+ );
399
+ if (p.note !== null) lines.push(` note: ${p.note}`);
400
+ lines.push('');
401
+ lines.push('Disposal is out of scope here (roadmap Non-goals; Risk 3). `worktree:cleanup` owns it, behind its own approval.');
402
+ return lines.join('\n');
403
+ }
404
+
405
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
406
+ let start = process.cwd();
407
+ let json = false;
408
+ let strict = false;
409
+ for (let i = 0; i < argv.length; i += 1) {
410
+ const a = argv[i];
411
+ if (a === '--from' && argv[i + 1] !== undefined) {
412
+ start = path.resolve(String(argv[(i += 1)]));
413
+ } else if (a === '--json') {
414
+ json = true;
415
+ } else if (a === '--strict') {
416
+ strict = true;
417
+ } else if (a === '--help' || a === '-h') {
418
+ process.stdout.write(
419
+ 'workspace:doctor [--from <dir>] [--json] [--strict]\n' +
420
+ ' Read-only workspace identity + worktree pressure report.\n' +
421
+ ' --strict exits 1 when any identity field is unresolved.\n',
422
+ );
423
+ return 0;
424
+ }
425
+ }
426
+
427
+ const sessionId = process.env['CLAUDE_CODE_SESSION_ID'] ?? process.env['AGENT_SESSION_ID'] ?? null;
428
+ const report = collectReport(start, sessionId);
429
+ process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : `${render(report)}\n`);
430
+
431
+ if (strict) {
432
+ const unresolved = IDENTITY_FIELDS.filter((n) => !report.identity[n].resolved);
433
+ if (unresolved.length > 0) {
434
+ process.stderr.write(`unresolved identity field(s): ${unresolved.join(', ')}\n`);
435
+ return 1;
436
+ }
437
+ }
438
+ return 0;
439
+ }
440
+
441
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
442
+ function _isCliEntry(): boolean {
443
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
444
+ return false;
445
+ }
446
+ if (process.argv[1] === undefined) {
447
+ return false;
448
+ }
449
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
450
+ if (import.meta.url === argvUrl) {
451
+ return true;
452
+ }
453
+ try {
454
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
455
+ const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
456
+ return here === argv1;
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+
462
+ if (_isCliEntry()) {
463
+ process.exit(main());
464
+ }
@@ -34,6 +34,7 @@ import { execFileSync } from 'node:child_process';
34
34
  import * as fs from 'node:fs';
35
35
  import * as path from 'node:path';
36
36
  import { fileURLToPath, pathToFileURL } from 'node:url';
37
+ import { read_live_records, register_dir } from './_lib/session_register.js';
37
38
 
38
39
  export interface CheckResult {
39
40
  allowed: boolean;
@@ -121,8 +122,9 @@ export function checkWorktree(worktreePath: string): CheckResult {
121
122
  return { allowed: reasons.length === 0, reasons };
122
123
  }
123
124
 
124
- interface WorktreeEntry {
125
+ export interface WorktreeEntry {
125
126
  path: string;
127
+ /** Short branch name (`refs/heads/` stripped), or `null` when detached. */
126
128
  branch: string | null;
127
129
  }
128
130
 
@@ -304,8 +306,20 @@ function isAncestor(repoPath: string, branch: string, trunk: string): boolean {
304
306
  }
305
307
  }
306
308
 
307
- export function isStandardLocation(repoPath: string, worktreePath: string): boolean {
308
- const rel = path.relative(canonical(repoPath), canonical(worktreePath));
309
+ /**
310
+ * `basePath` MUST be the main worktree, never the invoking repo path.
311
+ *
312
+ * Measured 2026-08-13, and the failure is total rather than partial: called with
313
+ * a linked worktree as the base, every sibling worktree resolves to `..` and the
314
+ * safe set collapses to empty. Same binary, same repo, same minute — from inside
315
+ * `.claude/worktrees/<branch>`: 304 registered, safe 0, 278 "non-standard"; from
316
+ * the main checkout: safe 181, 81 non-standard. A maintainer who ran the
317
+ * inventory from a worktree would read "nothing to clean" and conclude the
318
+ * estate was fine, which is the worst shape a cleanup tool can fail in — it
319
+ * fails silently, and toward inaction.
320
+ */
321
+ export function isStandardLocation(basePath: string, worktreePath: string): boolean {
322
+ const rel = path.relative(canonical(basePath), canonical(worktreePath));
309
323
  if (rel.startsWith('..') || path.isAbsolute(rel)) return false;
310
324
  return STANDARD_WORKTREE_DIRS.some(
311
325
  (dir) => rel === dir || rel.startsWith(`${dir}${path.sep}`),
@@ -348,16 +362,70 @@ function lastGitActivity(worktreePath: string): Date | null {
348
362
  * Classify every worktree. `now` is injectable so the live-window boundary is
349
363
  * testable rather than wall-clock dependent.
350
364
  */
351
- export function buildInventory(repoPath: string, now: Date = new Date()): Inventory {
365
+ /**
366
+ * Two conditions the `safe` predicate does NOT carry by default, both off unless
367
+ * asked for. They exist because an approval can be stricter than the tool, and
368
+ * silently removing under the looser predicate is the substitution such an
369
+ * approval is written to prevent.
370
+ *
371
+ * `minAgeDays` is a floor on git inactivity, layered ON TOP of the 48-hour live
372
+ * window rather than replacing it: 48h answers "is someone in it right now", a
373
+ * 60-day floor answers "is this abandoned". Different questions.
374
+ *
375
+ * `excludeLiveSessions` reads the session register — real records with a real
376
+ * `worktree` path and a real TTL — instead of inferring occupancy from file
377
+ * mtime, which this module's own header already rules out as a liveness signal.
378
+ */
379
+ export interface InventoryOptions {
380
+ now?: Date;
381
+ minAgeDays?: number | null;
382
+ excludeLiveSessions?: boolean;
383
+ }
384
+
385
+ export function buildInventory(
386
+ repoPath: string,
387
+ nowOrOptions: Date | InventoryOptions = new Date(),
388
+ ): Inventory {
389
+ const opts: InventoryOptions =
390
+ nowOrOptions instanceof Date ? { now: nowOrOptions } : nowOrOptions;
391
+ const now = opts.now ?? new Date();
392
+ const minAgeDays = opts.minAgeDays ?? null;
393
+ const ageCutoff = minAgeDays === null ? null : now.getTime() - minAgeDays * 86400 * 1000;
394
+ return buildInventoryInner(repoPath, now, ageCutoff, minAgeDays, opts.excludeLiveSessions === true);
395
+ }
396
+
397
+ function buildInventoryInner(
398
+ repoPath: string,
399
+ nowArg: Date,
400
+ ageCutoff: number | null,
401
+ minAgeDays: number | null,
402
+ excludeLiveSessions: boolean,
403
+ ): Inventory {
404
+ const now = nowArg;
352
405
  const trunk = resolveTrunk(repoPath);
353
406
  const entries = listWorktrees(repoPath);
354
407
  const mainPath = canonical(entries.length > 0 ? entries[0]!.path : repoPath);
355
408
  const liveCutoff = now.getTime() - LIVE_WINDOW_HOURS * 3600 * 1000;
356
409
 
410
+ // Registered session worktrees, canonicalised so the comparison survives a
411
+ // symlinked checkout root the same way every other path here does. Empty set
412
+ // when the option is off, when no register directory exists (the normal
413
+ // state before any session registers), or when every record has expired.
414
+ const sessionPaths = new Set<string>();
415
+ if (excludeLiveSessions) {
416
+ const dir = register_dir(mainPath);
417
+ if (dir !== null) {
418
+ for (const rec of read_live_records(dir, { now })) {
419
+ sessionPaths.add(canonical(rec.worktree));
420
+ }
421
+ }
422
+ }
423
+
357
424
  const rows: InventoryRow[] = entries.map((e) => {
358
425
  const resolved = canonical(e.path);
359
426
  const isMain = resolved === mainPath;
360
- const standardLocation = isMain ? true : isStandardLocation(repoPath, resolved);
427
+ // `mainPath`, NOT `repoPath` — see isStandardLocation's contract.
428
+ const standardLocation = isMain ? true : isStandardLocation(mainPath, resolved);
361
429
  const reasons: string[] = [];
362
430
 
363
431
  if (!fs.existsSync(resolved)) {
@@ -389,6 +457,20 @@ export function buildInventory(repoPath: string, now: Date = new Date()): Invent
389
457
  if (e.branch === null) {
390
458
  reasons.push('detached HEAD — no branch to judge reachability for');
391
459
  }
460
+ if (sessionPaths.has(resolved)) {
461
+ reasons.push('a live agent session is registered in this worktree');
462
+ }
463
+ if (ageCutoff !== null && (activity === null || activity.getTime() >= ageCutoff)) {
464
+ // `activity === null` disqualifies rather than passes: an unreadable
465
+ // git-dir mtime is absence of evidence, and under an explicit
466
+ // minimum-age floor that has to read as "cannot show it is old
467
+ // enough", never as "old enough".
468
+ reasons.push(
469
+ activity === null
470
+ ? `git activity unreadable — cannot prove ${minAgeDays}+ days of inactivity`
471
+ : `git activity within the last ${minAgeDays} days`,
472
+ );
473
+ }
392
474
  if (!standardLocation) {
393
475
  reasons.push('non-standard location — outside the conventional worktree roots');
394
476
  }
@@ -465,7 +547,7 @@ export function removalPlan(inv: Inventory): string[] {
465
547
  function usage(): never {
466
548
  process.stderr.write(
467
549
  'usage: worktree_cleanup_check check <worktree-path> | scope-overlap [repo-path] | ' +
468
- 'inventory [repo-path] [--json|--plan]\n',
550
+ 'inventory [repo-path] [--json|--plan] [--min-age-days=N] [--exclude-live-sessions]\n',
469
551
  );
470
552
  process.exit(2);
471
553
  }
@@ -509,9 +591,32 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
509
591
  const rest = argv.slice(1);
510
592
  const flags = rest.filter((a) => a.startsWith('--'));
511
593
  const positional = rest.filter((a) => !a.startsWith('--'));
512
- if (positional.length > 1 || flags.some((f) => f !== '--json' && f !== '--plan')) usage();
594
+ const KNOWN = ['--json', '--plan', '--exclude-live-sessions'];
595
+ const ageFlag = flags.find((f) => f.startsWith('--min-age-days='));
596
+ if (
597
+ positional.length > 1 ||
598
+ flags.some((f) => !KNOWN.includes(f) && !f.startsWith('--min-age-days='))
599
+ ) {
600
+ usage();
601
+ }
602
+ let minAgeDays: number | null = null;
603
+ if (ageFlag !== undefined) {
604
+ const raw = ageFlag.slice('--min-age-days='.length);
605
+ const n = Number(raw);
606
+ // Refuse rather than silently fall back to null: a typo'd floor that
607
+ // reads as "no floor" would widen the safe set at exactly the moment
608
+ // the caller asked to narrow it.
609
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
610
+ process.stderr.write(`--min-age-days needs a non-negative integer, got '${raw}'\n`);
611
+ return 2;
612
+ }
613
+ minAgeDays = n;
614
+ }
513
615
  const repo = path.resolve(positional[0] ?? '.');
514
- const inv = buildInventory(repo);
616
+ const inv = buildInventory(repo, {
617
+ minAgeDays,
618
+ excludeLiveSessions: flags.includes('--exclude-live-sessions'),
619
+ });
515
620
 
516
621
  if (flags.includes('--json')) {
517
622
  process.stdout.write(`${JSON.stringify(inv, null, 2)}\n`);