@aperant/framework 0.13.0-next.0 → 0.15.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 (241) hide show
  1. package/CHANGELOG.md +89 -3
  2. package/README.md +11 -1
  3. package/agents/apt-pr-review-i18n.md +190 -0
  4. package/agents/apt-pr-review-xplatform.md +189 -0
  5. package/dist/cli/commands/audit-branch-current.d.mts +27 -5
  6. package/dist/cli/commands/audit-branch-current.d.mts.map +1 -1
  7. package/dist/cli/commands/audit-branch-current.mjs +3 -3
  8. package/dist/cli/commands/audit-branch-current.mjs.map +1 -1
  9. package/dist/cli/commands/event.d.mts.map +1 -1
  10. package/dist/cli/commands/event.mjs +6 -4
  11. package/dist/cli/commands/event.mjs.map +1 -1
  12. package/dist/cli/commands/fan-out.d.mts.map +1 -1
  13. package/dist/cli/commands/fan-out.mjs +3 -1
  14. package/dist/cli/commands/fan-out.mjs.map +1 -1
  15. package/dist/cli/commands/features-audit.d.mts.map +1 -1
  16. package/dist/cli/commands/features-audit.mjs +6 -1
  17. package/dist/cli/commands/features-audit.mjs.map +1 -1
  18. package/dist/cli/commands/followups.d.mts +5 -0
  19. package/dist/cli/commands/followups.d.mts.map +1 -0
  20. package/dist/cli/commands/followups.mjs +266 -0
  21. package/dist/cli/commands/followups.mjs.map +1 -0
  22. package/dist/cli/commands/health-check.d.mts.map +1 -1
  23. package/dist/cli/commands/health-check.mjs +3 -1
  24. package/dist/cli/commands/health-check.mjs.map +1 -1
  25. package/dist/cli/commands/merge-detect.d.mts +29 -0
  26. package/dist/cli/commands/merge-detect.d.mts.map +1 -0
  27. package/dist/cli/commands/merge-detect.mjs +196 -0
  28. package/dist/cli/commands/merge-detect.mjs.map +1 -0
  29. package/dist/cli/commands/merge-integrate.d.mts +28 -0
  30. package/dist/cli/commands/merge-integrate.d.mts.map +1 -0
  31. package/dist/cli/commands/merge-integrate.mjs +383 -0
  32. package/dist/cli/commands/merge-integrate.mjs.map +1 -0
  33. package/dist/cli/commands/pr-review/detect-specialists.d.mts +58 -0
  34. package/dist/cli/commands/pr-review/detect-specialists.d.mts.map +1 -0
  35. package/dist/cli/commands/pr-review/detect-specialists.mjs +980 -0
  36. package/dist/cli/commands/pr-review/detect-specialists.mjs.map +1 -0
  37. package/dist/cli/commands/pr-review.d.mts.map +1 -1
  38. package/dist/cli/commands/pr-review.mjs +11 -2
  39. package/dist/cli/commands/pr-review.mjs.map +1 -1
  40. package/dist/cli/commands/precompact-hook.d.mts.map +1 -1
  41. package/dist/cli/commands/precompact-hook.mjs +10 -6
  42. package/dist/cli/commands/precompact-hook.mjs.map +1 -1
  43. package/dist/cli/commands/roadmap.d.mts.map +1 -1
  44. package/dist/cli/commands/roadmap.mjs +24 -11
  45. package/dist/cli/commands/roadmap.mjs.map +1 -1
  46. package/dist/cli/commands/seed.d.mts.map +1 -1
  47. package/dist/cli/commands/seed.mjs +3 -1
  48. package/dist/cli/commands/seed.mjs.map +1 -1
  49. package/dist/cli/commands/state.d.mts.map +1 -1
  50. package/dist/cli/commands/state.mjs +9 -3
  51. package/dist/cli/commands/state.mjs.map +1 -1
  52. package/dist/cli/commands/statusline.d.mts.map +1 -1
  53. package/dist/cli/commands/statusline.mjs +5 -1
  54. package/dist/cli/commands/statusline.mjs.map +1 -1
  55. package/dist/cli/commands/task.d.mts.map +1 -1
  56. package/dist/cli/commands/task.mjs +126 -23
  57. package/dist/cli/commands/task.mjs.map +1 -1
  58. package/dist/cli/commands/worktree.d.mts +1 -1
  59. package/dist/cli/commands/worktree.d.mts.map +1 -1
  60. package/dist/cli/commands/worktree.mjs +282 -83
  61. package/dist/cli/commands/worktree.mjs.map +1 -1
  62. package/dist/cli/config/post-merge-sweep.d.mts.map +1 -1
  63. package/dist/cli/config/post-merge-sweep.mjs +3 -2
  64. package/dist/cli/config/post-merge-sweep.mjs.map +1 -1
  65. package/dist/cli/config/share-policy.d.mts +3 -1
  66. package/dist/cli/config/share-policy.d.mts.map +1 -1
  67. package/dist/cli/config/share-policy.mjs +16 -3
  68. package/dist/cli/config/share-policy.mjs.map +1 -1
  69. package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
  70. package/dist/cli/config/upgrade-gitignore.mjs +27 -5
  71. package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
  72. package/dist/cli/consistency/parse-qa.mjs +12 -1
  73. package/dist/cli/consistency/parse-qa.mjs.map +1 -1
  74. package/dist/cli/consistency/parse-review.d.mts +23 -0
  75. package/dist/cli/consistency/parse-review.d.mts.map +1 -1
  76. package/dist/cli/consistency/parse-review.mjs +36 -0
  77. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  78. package/dist/cli/consistency/parse-spec.d.mts +16 -0
  79. package/dist/cli/consistency/parse-spec.d.mts.map +1 -1
  80. package/dist/cli/consistency/parse-spec.mjs +44 -0
  81. package/dist/cli/consistency/parse-spec.mjs.map +1 -1
  82. package/dist/cli/coordination/agent-identity.d.mts +5 -2
  83. package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
  84. package/dist/cli/coordination/agent-identity.mjs +6 -4
  85. package/dist/cli/coordination/agent-identity.mjs.map +1 -1
  86. package/dist/cli/coordination/store.d.mts +1 -1
  87. package/dist/cli/dispatch.d.mts.map +1 -1
  88. package/dist/cli/dispatch.mjs +4 -0
  89. package/dist/cli/dispatch.mjs.map +1 -1
  90. package/dist/cli/followups/derive.d.mts +63 -0
  91. package/dist/cli/followups/derive.d.mts.map +1 -0
  92. package/dist/cli/followups/derive.mjs +115 -0
  93. package/dist/cli/followups/derive.mjs.map +1 -0
  94. package/dist/cli/followups/schema.d.mts +165 -0
  95. package/dist/cli/followups/schema.d.mts.map +1 -0
  96. package/dist/cli/followups/schema.mjs +251 -0
  97. package/dist/cli/followups/schema.mjs.map +1 -0
  98. package/dist/cli/gate/context.d.mts +1 -1
  99. package/dist/cli/gate/gates/followups-adjudicated.d.mts +69 -0
  100. package/dist/cli/gate/gates/followups-adjudicated.d.mts.map +1 -0
  101. package/dist/cli/gate/gates/followups-adjudicated.mjs +141 -0
  102. package/dist/cli/gate/gates/followups-adjudicated.mjs.map +1 -0
  103. package/dist/cli/gate/gates/index.mjs +1 -0
  104. package/dist/cli/gate/gates/index.mjs.map +1 -1
  105. package/dist/cli/git/materialize-env.d.mts +39 -0
  106. package/dist/cli/git/materialize-env.d.mts.map +1 -0
  107. package/dist/cli/git/materialize-env.mjs +217 -0
  108. package/dist/cli/git/materialize-env.mjs.map +1 -0
  109. package/dist/cli/help.d.mts.map +1 -1
  110. package/dist/cli/help.mjs +9 -1
  111. package/dist/cli/help.mjs.map +1 -1
  112. package/dist/cli/install/legacy-paths.d.mts.map +1 -1
  113. package/dist/cli/install/legacy-paths.mjs +4 -0
  114. package/dist/cli/install/legacy-paths.mjs.map +1 -1
  115. package/dist/cli/roadmap/lifecycle.d.mts.map +1 -1
  116. package/dist/cli/roadmap/lifecycle.mjs +4 -1
  117. package/dist/cli/roadmap/lifecycle.mjs.map +1 -1
  118. package/dist/cli/task/ids.d.mts +9 -0
  119. package/dist/cli/task/ids.d.mts.map +1 -1
  120. package/dist/cli/task/ids.mjs +9 -0
  121. package/dist/cli/task/ids.mjs.map +1 -1
  122. package/dist/cli/task/index-md.d.mts +5 -0
  123. package/dist/cli/task/index-md.d.mts.map +1 -1
  124. package/dist/cli/task/index-md.mjs +12 -4
  125. package/dist/cli/task/index-md.mjs.map +1 -1
  126. package/dist/cli/util/io.d.mts +6 -0
  127. package/dist/cli/util/io.d.mts.map +1 -1
  128. package/dist/cli/util/io.mjs +16 -1
  129. package/dist/cli/util/io.mjs.map +1 -1
  130. package/dist/cli/util/project.d.mts +7 -2
  131. package/dist/cli/util/project.d.mts.map +1 -1
  132. package/dist/cli/util/project.mjs +11 -14
  133. package/dist/cli/util/project.mjs.map +1 -1
  134. package/dist/cli/util/state-paths.d.mts +19 -0
  135. package/dist/cli/util/state-paths.d.mts.map +1 -0
  136. package/dist/cli/util/state-paths.mjs +45 -0
  137. package/dist/cli/util/state-paths.mjs.map +1 -0
  138. package/dist/plugin/.claude-plugin/plugin.json +4 -1
  139. package/dist/plugin/agents/apt-pr-review-i18n.md +190 -0
  140. package/dist/plugin/agents/apt-pr-review-xplatform.md +189 -0
  141. package/dist/plugin/skills/apt/SKILL.md +13 -6
  142. package/dist/plugin/skills/apt-close-task/SKILL.md +48 -0
  143. package/dist/plugin/skills/apt-debug/SKILL.md +19 -9
  144. package/dist/plugin/skills/apt-execute/SKILL.md +3 -1
  145. package/dist/plugin/skills/apt-fan-out/SKILL.md +65 -1
  146. package/dist/plugin/skills/apt-merge-conflict-resolution/SKILL.md +227 -0
  147. package/dist/plugin/skills/apt-pr-review/SCHEMA.md +4 -2
  148. package/dist/plugin/skills/apt-pr-review/SKILL.md +117 -29
  149. package/dist/plugin/skills/apt-quick/SKILL.md +24 -1
  150. package/dist/plugin/skills/apt-review/SKILL.md +6 -0
  151. package/dist/plugin/skills/apt-run/SKILL.md +1 -1
  152. package/dist/plugin/skills/apt-setup/SKILL.md +21 -3
  153. package/dist/plugin/skills/apt-ship/SKILL.md +32 -11
  154. package/dist/plugin/skills/apt-terminal/SKILL.md +8 -0
  155. package/dist/plugin/skills/apt-triage/SKILL.md +4 -0
  156. package/dist/plugin/skills/apt-verify/SKILL.md +24 -0
  157. package/dist/plugin/skills/apt-watch-ci/SKILL.md +3 -1
  158. package/dist/types/config.d.ts +33 -0
  159. package/dist/types/config.d.ts.map +1 -1
  160. package/package.json +138 -138
  161. package/prompts/conductor-framework-context.md +1 -1
  162. package/prompts/github/pr_i18n_agent.md +279 -0
  163. package/prompts/github/pr_xplatform_agent.md +283 -0
  164. package/prompts/inbox-clarification.md +18 -11
  165. package/skills/apt/SKILL.md +9 -3
  166. package/skills/apt-close-task/SKILL.md +48 -0
  167. package/skills/apt-debug/SKILL.md +19 -9
  168. package/skills/apt-execute/SKILL.md +3 -1
  169. package/skills/apt-fan-out/SKILL.md +65 -1
  170. package/skills/apt-merge-conflict-resolution/SKILL.md +227 -0
  171. package/skills/apt-pr-review/SCHEMA.md +4 -2
  172. package/skills/apt-pr-review/SKILL.md +117 -29
  173. package/skills/apt-quick/SKILL.md +24 -1
  174. package/skills/apt-review/SKILL.md +6 -0
  175. package/skills/apt-run/SKILL.md +1 -1
  176. package/skills/apt-setup/SKILL.md +21 -3
  177. package/skills/apt-ship/SKILL.md +32 -11
  178. package/skills/apt-terminal/SKILL.md +8 -0
  179. package/skills/apt-triage/SKILL.md +4 -0
  180. package/skills/apt-verify/SKILL.md +24 -0
  181. package/skills/apt-watch-ci/SKILL.md +3 -1
  182. package/src/cli/commands/audit-branch-current.mjs +3 -3
  183. package/src/cli/commands/event.mjs +6 -4
  184. package/src/cli/commands/fan-out.mjs +3 -1
  185. package/src/cli/commands/features-audit.mjs +6 -1
  186. package/src/cli/commands/followups.mjs +303 -0
  187. package/src/cli/commands/health-check.mjs +3 -1
  188. package/src/cli/commands/merge-detect.mjs +203 -0
  189. package/src/cli/commands/merge-integrate.mjs +392 -0
  190. package/src/cli/commands/pr-review/__fixtures__/android-strings/app/src/main/res/values-fr/strings.xml +3 -0
  191. package/src/cli/commands/pr-review/__fixtures__/babel-js/package.json +4 -0
  192. package/src/cli/commands/pr-review/__fixtures__/c-ifdef/src/main.c +4 -0
  193. package/src/cli/commands/pr-review/__fixtures__/ci-matrix/.github/workflows/ci.yml +10 -0
  194. package/src/cli/commands/pr-review/__fixtures__/cmake-win/CMakeLists.txt +5 -0
  195. package/src/cli/commands/pr-review/__fixtures__/diffs/add-arb.patch +9 -0
  196. package/src/cli/commands/pr-review/__fixtures__/diffs/ci-single-os-dep.patch +13 -0
  197. package/src/cli/commands/pr-review/__fixtures__/diffs/ci-single-os-list-item-dep.patch +17 -0
  198. package/src/cli/commands/pr-review/__fixtures__/diffs/lone-weak-t.patch +8 -0
  199. package/src/cli/commands/pr-review/__fixtures__/diffs/monorepo-b.patch +7 -0
  200. package/src/cli/commands/pr-review/__fixtures__/diffs/two-weak-xpl.patch +12 -0
  201. package/src/cli/commands/pr-review/__fixtures__/dropped-underscore/src/app.js +2 -0
  202. package/src/cli/commands/pr-review/__fixtures__/electron-app/package.json +4 -0
  203. package/src/cli/commands/pr-review/__fixtures__/i18n-locale-file/src/locales/fr.json +1 -0
  204. package/src/cli/commands/pr-review/__fixtures__/monorepo/packages/a/locales/fr.json +1 -0
  205. package/src/cli/commands/pr-review/__fixtures__/monorepo/packages/b/src/index.ts +1 -0
  206. package/src/cli/commands/pr-review/__fixtures__/py-babel/pyproject.toml +3 -0
  207. package/src/cli/commands/pr-review/__fixtures__/qt-i18n-dir/i18n/app_fr.ts +11 -0
  208. package/src/cli/commands/pr-review/__fixtures__/rust-cli/Cargo.toml +7 -0
  209. package/src/cli/commands/pr-review/__fixtures__/rust-cli/src/main.rs +1 -0
  210. package/src/cli/commands/pr-review/__fixtures__/ts-dir/ts/foo.ts +3 -0
  211. package/src/cli/commands/pr-review/detect-specialists.mjs +1023 -0
  212. package/src/cli/commands/pr-review.mjs +12 -2
  213. package/src/cli/commands/precompact-hook.mjs +10 -6
  214. package/src/cli/commands/roadmap.mjs +24 -11
  215. package/src/cli/commands/seed.mjs +3 -1
  216. package/src/cli/commands/state.mjs +9 -3
  217. package/src/cli/commands/statusline.mjs +5 -1
  218. package/src/cli/commands/task.mjs +135 -23
  219. package/src/cli/commands/worktree.mjs +298 -81
  220. package/src/cli/config/post-merge-sweep.mjs +3 -2
  221. package/src/cli/config/share-policy.mjs +16 -3
  222. package/src/cli/config/upgrade-gitignore.mjs +27 -5
  223. package/src/cli/consistency/parse-qa.mjs +11 -1
  224. package/src/cli/consistency/parse-review.mjs +36 -0
  225. package/src/cli/consistency/parse-spec.mjs +43 -0
  226. package/src/cli/coordination/agent-identity.mjs +6 -4
  227. package/src/cli/dispatch.mjs +4 -0
  228. package/src/cli/followups/derive.mjs +123 -0
  229. package/src/cli/followups/schema.mjs +264 -0
  230. package/src/cli/gate/gates/followups-adjudicated.mjs +158 -0
  231. package/src/cli/gate/gates/index.mjs +1 -0
  232. package/src/cli/git/materialize-env.mjs +212 -0
  233. package/src/cli/help.mjs +9 -1
  234. package/src/cli/install/legacy-paths.mjs +4 -0
  235. package/src/cli/roadmap/lifecycle.mjs +4 -1
  236. package/src/cli/task/ids.mjs +9 -0
  237. package/src/cli/task/index-md.mjs +12 -4
  238. package/src/cli/util/io.mjs +15 -1
  239. package/src/cli/util/project.mjs +11 -14
  240. package/src/cli/util/state-paths.mjs +45 -0
  241. package/prompts/followup_planner.md +0 -399
@@ -199,7 +199,7 @@ AskUserQuestion([
199
199
 
200
200
  **Routing:**
201
201
  - "Verification" → jump to **Step 2** (Batch 1 + Batch 2 only), then skip to **Step 4** (mapping) and **Step 5** (write)
202
- - "Pipeline" → jump to **Step 3** (Batch 2: auto-branch, code review, TDD, PR Review Auto-Suggest, **Sparring Auto-Fire**) AND **Step 3b** (Batch 3 only, including Diagram Mode), then skip to **Step 4** and **Step 5**
202
+ - "Pipeline" → jump to **Step 3** (Batch 2: auto-branch, code review, TDD, PR Review Auto-Suggest, CI Watcher, **Sparring Auto-Fire**) AND **Step 3b** (Batch 3 only, including Diagram Mode), then skip to **Step 4** and **Step 5**
203
203
  - "Orchestration" → jump to **Step 3c** (Batch 4 only), then skip to **Step 4** and **Step 5**
204
204
  - "Team Collaboration" → jump to **Step 3d** (Batch 5 only), then skip to **Step 4** and **Step 5**
205
205
  - "Visibility" → jump to **Step 3e** (Batch 6 only), then skip to **Step 4** and **Step 5**
@@ -412,6 +412,16 @@ AskUserQuestion([
412
412
  { label: "Ask me", description: "Prompt [Y/n] each time — choose per-PR." },
413
413
  { label: "Never", description: "Silent. Run /apt:pr-review manually if needed." }
414
414
  ]
415
+ },
416
+ {
417
+ question: "After /apt:ship opens a PR, run the post-ship CI watcher? It polls checks, auto-fixes the first red check and pushes, and can auto-merge once CI is green.",
418
+ header: "CI Watcher",
419
+ multiSelect: false,
420
+ options: [
421
+ { label: "Watch & auto-fix (Recommended)", description: "Ship auto-arms the watcher: it polls CI, fixes the first red check + pushes, then stops at green for you to merge. Ticks automatically on cron-capable hosts (Claude Code/Pi); elsewhere you advance ticks manually with /apt:watch-ci --tick." },
422
+ { label: "Watch, fix & auto-merge on green", description: "Same as above, plus auto-merges the PR (squash) once CI is green, then suggests /apt:close-task. Fully hands-off run." },
423
+ { label: "Off", description: "Ship does not arm the watcher. Watch CI and merge yourself, or run /apt:watch-ci manually." }
424
+ ]
415
425
  }
416
426
  ])
417
427
  ```
@@ -728,8 +738,8 @@ AskUserQuestion([
728
738
  options: [
729
739
  { label: "Auto", description: "Best available for your CLI" },
730
740
  { label: "Agent Team", description: "Uses Claude Code agent teams (requires CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1)" },
731
- { label: "Sub-Agents", description: "Spawns 6 parallel sub-agents for review (available on Claude Code and Codex)" },
732
- { label: "Sequential", description: "Runs 6 review passes inline, one at a time — works on any CLI" }
741
+ { label: "Sub-Agents", description: "Spawns 6-8 parallel sub-agents for review (available on Claude Code and Codex)" },
742
+ { label: "Sequential", description: "Runs 6-8 review passes inline, one at a time — works on any CLI" }
733
743
  ]
734
744
  },
735
745
  {
@@ -1378,6 +1388,13 @@ Map the user's selections to config values:
1378
1388
  - "Ask me" → `pr_review.suggest_after_ship: "ask"`
1379
1389
  - "Never" → `pr_review.suggest_after_ship: "never"`
1380
1390
 
1391
+ **CI Watcher (post-ship):** maps to the SHARED `.aperant/config.json` `ci_watch` block (team policy — NOT a per-device AUDIT-001 field, so it stays in shared `config.json`):
1392
+ - "Watch & auto-fix (Recommended)" → `ci_watch.after_ship: "auto"`, `ci_watch.auto_merge_on_green: false`
1393
+ - "Watch, fix & auto-merge on green" → `ci_watch.after_ship: "auto"`, `ci_watch.auto_merge_on_green: true`
1394
+ - "Off" → `ci_watch.after_ship: "never"`, `ci_watch.auto_merge_on_green: false`
1395
+
1396
+ The existing `ci_watch.merge_method` key (default `"squash"`) is preserved untouched — this question does not change it. On non-cron hosts, `after_ship: "auto"` still creates the watcher state and ship prints manual-tick instructions; the host-capability fallback already lives in `apt-ship/SKILL.md` §7.2, so the value is safe to store as team policy regardless of the setup-runner's host.
1397
+
1381
1398
  **Sparring Auto-Fire (FRAMEWORK-RFC-001):**
1382
1399
  - Phase 1 "No" → `spar.enabled: false`, ask Phase 3 (hint sub-picker)
1383
1400
  - Phase 1 "Yes" → `spar.enabled: true`, ask Phase 2 (partner picker)
@@ -1522,6 +1539,7 @@ Display:
1522
1539
  | Auto Branch | {Yes/No} |
1523
1540
  | Code Review | {Yes/No} |
1524
1541
  | TDD Iron Law | {Yes/No} |
1542
+ | CI Watcher | {Off / Watch & fix / Watch, fix & auto-merge} |
1525
1543
  | Sparring Auto-Fire | {Off / On (codex), On (claude), On (gemini)} (hint: {Yes/No}) |
1526
1544
  | Date Format | {DD-MM-YY / MM-DD-YY / YY-MM-DD} |
1527
1545
  | Time Format | {24h / 12h} |
@@ -20,6 +20,7 @@ gates:
20
20
  - constitution-read
21
21
  - verify-approved
22
22
  - review-clean
23
+ - followups-adjudicated
23
24
  - consistency-check
24
25
  - r-persona-alignment
25
26
  - gitignore-in-sync
@@ -149,15 +150,18 @@ Capture `remote_tip_sha_pre_rebase` now — §6's lease uses it.
149
150
  ```
150
151
  Proceed to §1. §6's push will use `--force-with-lease=refs/heads/<branch>:$remote_tip_sha_pre_rebase` because history was rewritten.
151
152
 
152
- - **`behind > 0` AND `mergeable === false`** → STOP. Emit a structured error with the conflict preview verbatim:
153
- ```
154
- [APT:ship] Cannot ship task branch is behind origin/{base_branch} and the rebase would conflict.
155
- Conflicts preview:
156
- - {path1}
157
- - {path2}
158
- Resolve manually (rebase or merge in the worktree), then re-run /apt:ship.
159
- ```
160
- Exit the skill — do NOT auto-resolve, do NOT proceed to §5 or §6.
153
+ - **`behind > 0` AND `mergeable === false`** → the rebase would conflict. Default behavior is a hard STOP; AI resolution is **opt-in** via `pipeline.resolve_merge_conflicts` (default false) in `.aperant/config.json`.
154
+
155
+ - **Resolution opted in (`pipeline.resolve_merge_conflicts === true`)** hand off to **`/apt:merge-conflict-resolution`** (`--base origin/{base_branch}`). That skill drives the rebase, resolves the conflicted files inline (reading the markers via `apt-tools merge markers`), and re-probes mergeability. On success it returns here — proceed to §1 with `--force-with-lease=refs/heads/<branch>:$remote_tip_sha_pre_rebase` (history was rewritten). On its non-convergence STOP, it aborts to a clean tree and hands back; fall through to the manual-STOP message below.
156
+ - **Resolution NOT opted in (default)** → STOP. Emit a structured error with the conflict preview verbatim:
157
+ ```
158
+ [APT:ship] Cannot ship — task branch is behind origin/{base_branch} and the rebase would conflict.
159
+ Conflicts preview:
160
+ - {path1}
161
+ - {path2}
162
+ Resolve manually (rebase or merge in the worktree) or enable pipeline.resolve_merge_conflicts to use /apt:merge-conflict-resolution, then re-run /apt:ship.
163
+ ```
164
+ Exit the skill — do NOT auto-resolve, do NOT proceed to §5 or §6.
161
165
 
162
166
  - **`mergeable === null` AND `audit_warning === "git-version-too-old"`** → STOP with a friendlier message:
163
167
  ```
@@ -175,8 +179,8 @@ Capture `remote_tip_sha_pre_rebase` now — §6's lease uses it.
175
179
  **Autonomy gating.** Read the active task's autonomy level from `.aperant/state.json` (`active_tasks[{task-id}].autonomy`, falling back to `autonomy.default`):
176
180
 
177
181
  - **Autonomy 0–1 (step / guided)** — print the audit envelope to the operator and ask `Rebase now? [Y/n]` before executing the `git rebase` line. On `N`, exit the skill (user can rebase manually and re-invoke).
178
- - **Autonomy 2 (auto-chain)** — proceed silently on the clean-rebase branch; STOP on conflict regardless.
179
- - **Autonomy 3 (YOLO)** — same as 2: silent on clean, STOP on conflict. Auto-resolution of conflicts is explicitly out of scope (see spec §Out of Scope).
182
+ - **Autonomy 2 (auto-chain)** — proceed silently on the clean-rebase branch; on conflict, hand off to `/apt:merge-conflict-resolution` when `pipeline.resolve_merge_conflicts` is enabled, else STOP.
183
+ - **Autonomy 3 (YOLO)** — same as 2: silent on clean; on conflict, AI-resolve via `/apt:merge-conflict-resolution` when opted in, else STOP. Even at YOLO the resolved branch still flows through PR → review → CI — apt:ship never auto-merges.
180
184
 
181
185
  The captured `remote_tip_sha_pre_rebase` and `behind` integer are referenced verbatim in §6's push command. Treat them as read-only state for the remainder of the skill.
182
186
 
@@ -380,11 +384,21 @@ The body structure below is the full-pipeline template — unchanged from the pr
380
384
 
381
385
  {If no review: "No code review was run for this task."}
382
386
 
387
+ ## Follow-ups
388
+
389
+ {Read `{task_dir}/followups.json` and enumerate EVERY open/dispositioned row VERBATIM — never a count. One line per row:}
390
+
391
+ - [{status}] {title} — {adjudication.verdict ?? 'unadjudicated'} → {disposition.kind ?? 'pending'}
392
+
393
+ {If no followups.json: "No follow-ups recorded for this task."}
394
+
383
395
  ---
384
396
 
385
397
  Built with [Aperant Framework](https://github.com/Mikalsen-AI/aperant-cloud-desktop/tree/main/packages/framework)
386
398
  ```
387
399
 
400
+ Counts hide work; the verbatim row list is the visibility contract (ID-16). The `followups-adjudicated` gate (Section 0) has already guaranteed every open row is adjudicated and every load-bearing row is fixed before you reach this section.
401
+
388
402
  ## 4. Determine Ship Mode
389
403
 
390
404
  Read `.aperant/config.json` for `project_type`.
@@ -520,8 +534,15 @@ Review: logic={n} security={n} quality={n} architecture={n} performance={n}
520
534
  Commits: {count}
521
535
  Review findings: {count by severity or "none"}
522
536
  Decisions traced: {count}
537
+
538
+ Follow-ups:
539
+ {Enumerate EVERY open/dispositioned row from {task_dir}/followups.json VERBATIM,
540
+ one per line — `- [{status}] {title} — {verdict} → {disposition.kind ?? 'pending'}`.
541
+ Never a count. "none" only when followups.json is absent or has zero rows.}
523
542
  ```
524
543
 
544
+ The report's Follow-ups block mirrors the PR-body `## Follow-ups` section (ID-16) — rows verbatim, never counts.
545
+
525
546
  ### 7.1 Post-ship /apt:pr-review hint
526
547
 
527
548
  After printing the ship summary, conditionally emit a tip suggesting `/apt:pr-review` for a deep multi-agent review of the freshly-opened PR. The hint is controlled by `pr_review.suggest_after_ship` in `.aperant/config.json` (tri-state: `always` | `ask` | `never`; default when absent: `always`).
@@ -97,6 +97,14 @@ The response is JSON to stdout. On success:
97
97
 
98
98
  `depth` is the new terminal's level (caller_depth + 1). Record `sessionId` in your task notes.
99
99
 
100
+ The worktree the spawned terminal opens in already has the project's stored env
101
+ blobs materialized (the per-project encrypted env store, #354) — worktree-create
102
+ copies each `.env*` blob verbatim (traversal-guarded, best-effort) so the sibling
103
+ terminal's CLI runs with the real env instead of crashing in no-cloud mode. If a
104
+ long-lived worktree's stored env later drifts, re-copy on demand with
105
+ `node packages/framework/bin/apt-tools.mjs worktree sync-env <dir> --task <id>`
106
+ (the store is the source of truth — sync-env overwrites, never merges).
107
+
100
108
  ## 4. Recognise the graceful-degrade error envelopes
101
109
 
102
110
  These are **expected** error shapes — do not retry blindly. Each one tells you exactly which obligation discharged:
@@ -49,6 +49,10 @@ OUT-OF-SCOPE.md sub-files ported from
49
49
  - An inbound task / bug / feature request needs status assignment
50
50
  - Periodic review of `needs-triage` tasks
51
51
  - Re-triage when new info arrives on a `needs-info` task
52
+ - A `new-task` follow-up disposition (G45) — `/apt:close-task` files an open
53
+ follow-up as a fresh task and enters it here at `needs-triage` via
54
+ `apt-tools triage init` (the configured backend mirrors it). This is a normal
55
+ entry point; the chained-task depth marker rides `triage.json` `extra.followups_chain`.
52
56
 
53
57
  ## When NOT to Use
54
58
 
@@ -266,6 +266,30 @@ for ITERATION in 1 2 3 4:
266
266
 
267
267
  **Exhaustion:** If issues remain after 4 iterations, mark them in `qa_signoff.json` and report them. The verdict should reflect the remaining issues.
268
268
 
269
+ ## 6.5 Follow-ups Adjudication (mandatory — G45)
270
+
271
+ This pass is **mandatory** on every full-pipeline verify. It is the owner of follow-up adjudication: `apt:verify` always runs in the pipeline (`apt:run` = plan → execute → verify → review), whereas `apt:verify-proof` is opt-in and therefore cannot be the sole owner. (When `verify-proof` runs later it MAY overwrite an adjudication with stronger evidence, `by: 'verify-proof'`.)
272
+
273
+ Run the deterministic check first:
274
+
275
+ ```bash
276
+ node packages/framework/bin/apt-tools.mjs followups check . --task-id {task-id}
277
+ ```
278
+
279
+ The envelope returns `missing_expected[]` — the derived expected universe (parsed `## Out of Scope` bullets ∪ raw review rows ∪ qa findings, `status !== 'fixed'`) that the ledger does not yet cover. Create/sync `{task_dir}/followups.json` rows **mechanically** from those payloads:
280
+
281
+ - Append one row per `missing_expected[]` entry: `{ id, title, origin, class_proposed: <class_hint>, files: [], outcome_refs: [], status: 'open' }`. `class_proposed` is only a PROPOSAL (ID-03) — the verdict below is the authority; never let the class self-certify a deferral.
282
+ - Voluntary rows (origin `discuss | execute | ci`) may also be present; they are adjudicated the same way.
283
+
284
+ Then **adjudicate every open row**. For each, answer the single question:
285
+
286
+ > "Would the feature satisfy its stated `## User Outcomes` right now WITHOUT this follow-up?"
287
+
288
+ - If NO → `adjudication: { verdict: 'load-bearing', by: 'verify', outcome_ref: '<O-n>' }`. A load-bearing row blocks ship until it is fixed (flip `status: 'fixed'` once the work lands in this PR's fix loop) or bypassed via `--force --reason`.
289
+ - If YES → `adjudication: { verdict: 'deferrable', by: 'verify', outcome_ref: '<O-n> | null' }`. `outcome_ref` is the `O<n>` id (from `parseUserOutcomes`) the row relates to; it is nullable for refactor-only specs that declare no outcomes.
290
+
291
+ Re-run `followups check` until it is **derivation-clean** (exit 0 — no `missing_expected`, no `invalid_outcome_refs`, no `schema_errors`) BEFORE writing `qa_signoff.json`. The `followups-adjudicated` ship gate will block the PR otherwise.
292
+
269
293
  ## 7. Write QA Sign-off
270
294
 
271
295
  Create `qa_signoff.json` using the `ScoredQASignoff` format (see `packages/framework/src/types/qa-scoring.ts`):
@@ -125,7 +125,9 @@ When `action === 'fix'` (or `diverged-rebased`/`diverged-bailed`):
125
125
  5. **Emit event.** `apt-tools event append . --type ci-watch.fix-pushed --data '{pr, attempts, commit_sha}'`.
126
126
  6. **Reschedule** per §3 step 4.
127
127
 
128
- For `diverged-rebased`: the cmd already returned the verb; the orchestrator runs `git -C {worktree} fetch && git rebase origin/{base}` BEFORE the fix step. On conflict the action becomes `diverged-bailed` and the watcher exits with a PR comment summarizing the conflict.
128
+ For `diverged-rebased`: the cmd already returned the verb; the orchestrator runs `git -C {worktree} fetch && git rebase origin/{base}` BEFORE the fix step. On conflict the action becomes `diverged-bailed`.
129
+
130
+ For `diverged-bailed`: default behavior is to exit with a PR comment summarizing the conflict. Self-heal is **opt-in** via `ci_watch.resolve_merge_conflicts` (default false) in `.aperant/config.json`. When enabled, instead of bailing, hand off to **`/apt:merge-conflict-resolution`** (`--base origin/{base}`) for the worktree; it resolves the rebase conflict inline and re-probes mergeability. On success, continue to the §5 fix-and-push step (`git push origin {branch}`) and reschedule per §3 — the watcher keeps running. On its non-convergence STOP (it aborts the rebase to a clean tree first), fall back to the bail-with-PR-comment path. The resolved branch still goes through CI; the watcher never auto-merges except via the existing config-gated `gh pr merge --auto`.
129
131
 
130
132
  ---
131
133
 
@@ -42,7 +42,7 @@ import { err, ok } from '../util/result.mjs'
42
42
  * @param {string} cwd
43
43
  * @param {string[]} args
44
44
  */
45
- function gitTry(cwd, args) {
45
+ export function gitTry(cwd, args) {
46
46
  try {
47
47
  const stdout = execFileSync('git', args, {
48
48
  cwd,
@@ -94,7 +94,7 @@ function parseBaseFlag(extraArgs) {
94
94
  * @param {string} stdout
95
95
  * @returns {string[]}
96
96
  */
97
- function parseConflictPaths(stdout) {
97
+ export function parseConflictPaths(stdout) {
98
98
  const paths = new Set()
99
99
  for (const rawLine of stdout.split('\n')) {
100
100
  const line = rawLine.replace(/\0/g, '')
@@ -125,7 +125,7 @@ function parseConflictPaths(stdout) {
125
125
  * @param {string} stderr
126
126
  * @returns {boolean}
127
127
  */
128
- function isGitVersionTooOld(stderr) {
128
+ export function isGitVersionTooOld(stderr) {
129
129
  return /unknown option|did you mean|usage: git merge-tree/i.test(stderr)
130
130
  }
131
131
 
@@ -8,9 +8,9 @@ import { join, resolve } from 'node:path'
8
8
  import { readLifecycleEventsForDay } from '../coordination/event-log.mjs'
9
9
  import { validateConductorEvent } from '../coordination/event-schema.mjs'
10
10
  import { createCoordinationStore } from '../coordination/store.mjs'
11
- import { detectWorktree } from '../git/worktree-detect.mjs'
12
11
  import { parseFlags } from '../util/args.mjs'
13
12
  import { err, ok } from '../util/result.mjs'
13
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
14
14
 
15
15
  // QUA-003 / PRF-002 — single source of truth for valid event types.
16
16
  // VALID_EVENT_TYPES is the authoritative list; CI_WATCH_EVENT_TYPES is the O(1)
@@ -88,9 +88,11 @@ export function cmdEvent(subcommand, projectDir, extraArgs) {
88
88
  if (!projectDir) return err('Usage: apt-tools event ' + subcommand + ' <project-dir> [flags]')
89
89
 
90
90
  const targetDir = resolve(projectDir)
91
- // Use shared event dir (coordinates across worktrees via git common dir, same as locks)
92
- const wt = detectWorktree(targetDir)
93
- const eventsBase = wt.is_worktree && wt.main_repo_root ? wt.main_repo_root : targetDir
91
+ // Use shared event dir (coordinates across worktrees, same as locks). This is
92
+ // a SEPARATE policy wrapper over the shared resolveMainRepoRoot mechanism
93
+ // (state-fork-fix ID-03) events do NOT follow resolveStateDir's Phase-2
94
+ // home relocation, so the resolution stays distinct from resolveStateDir.
95
+ const eventsBase = resolveMainRepoRoot(targetDir)
94
96
  const eventsDir = join(eventsBase, '.aperant', 'events')
95
97
  mkdirSync(eventsDir, { recursive: true })
96
98
 
@@ -14,6 +14,7 @@ import { existsSync, readFileSync } from 'node:fs'
14
14
  import { join, resolve } from 'node:path'
15
15
  import { parseFlags } from '../util/args.mjs'
16
16
  import { err, ok } from '../util/result.mjs'
17
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
17
18
 
18
19
  const ID_PATTERN = /^[a-z0-9_-]+$/i
19
20
 
@@ -35,7 +36,8 @@ function parseIdList(raw) {
35
36
  }
36
37
 
37
38
  function readState(targetDir) {
38
- const statePath = join(targetDir, '.aperant', 'state.json')
39
+ // state-fork-fix: ephemeral state read resolves to the main board.
40
+ const statePath = join(resolveMainRepoRoot(targetDir), '.aperant', 'state.json')
39
41
  if (!existsSync(statePath)) return null
40
42
  try {
41
43
  return JSON.parse(readFileSync(statePath, 'utf-8'))
@@ -30,6 +30,7 @@ import { loadDocsPolicy, matchSurfaceToPolicy } from '../docs-audit/policy.mjs'
30
30
  import { applyStubPatches, auditFeatureRegistry } from '../features/registry-audit.mjs'
31
31
  import { parseFlags } from '../util/args.mjs'
32
32
  import { err, ok } from '../util/result.mjs'
33
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
33
34
 
34
35
  const TERMINAL_PHASES = new Set(['completed', 'cancelled', 'closed', 'shipped', 'merged'])
35
36
 
@@ -138,7 +139,11 @@ function isWorktreeRoot(projectDir) {
138
139
  }
139
140
 
140
141
  function readActiveTasks(projectDir) {
141
- const statePath = join(projectDir, '.aperant', 'state.json')
142
+ // state-fork-fix: read the AUTHORITATIVE main board — readActiveTasks finds
143
+ // each task's worktree_path for the BUG-025b feature-write redirect, so it
144
+ // MUST read main or the redirect-target lookup itself forks. (The feature
145
+ // WRITE path / resolveWriteRoot stays untouched.)
146
+ const statePath = join(resolveMainRepoRoot(projectDir), '.aperant', 'state.json')
142
147
  if (!existsSync(statePath)) return {}
143
148
  try {
144
149
  const parsed = JSON.parse(readFileSync(statePath, 'utf-8'))
@@ -0,0 +1,303 @@
1
+ /**
2
+ * commands/followups.mjs — `apt-tools followups` (G45).
3
+ *
4
+ * The deterministic surface for the per-task follow-ups ledger
5
+ * ({task_dir}/followups.json). Three subcommands:
6
+ *
7
+ * apt-tools followups check <project-dir> --task-id <id>
8
+ * Derive the expected candidate universe (Out of Scope ∪ raw review rows ∪
9
+ * qa findings) and report what the ledger is missing / unadjudicated /
10
+ * unfixed / undispositioned. Exits 1 when the story is dishonest.
11
+ *
12
+ * apt-tools followups disposition <project-dir> --task-id <id> --row <row-id>
13
+ * --kind <filed_task|executed_commit|parked_note|dropped_by_user> --ref <ref>
14
+ * [--reason <text>]
15
+ * Flip a row to `dispositioned` with the validated disposition record
16
+ * (atomic write). `dropped_by_user` additionally requires --reason.
17
+ *
18
+ * apt-tools followups policy <project-dir> --task-id <id>
19
+ * Return the effective disposition policy (ask | file | chain +
20
+ * max_chain_depth) from config.followups when set, else autonomy-derived.
21
+ *
22
+ * Shared-root resolution + taskDir + taskRecord + config all come from
23
+ * buildGateContext so a worktree-resident invocation reads/writes the same
24
+ * followups.json the gates read (the L5 worktree concern, plan-approval.mjs).
25
+ */
26
+
27
+ import { existsSync, readFileSync } from 'node:fs'
28
+ import { join, resolve } from 'node:path'
29
+
30
+ import { parseUserOutcomes } from '../coverage-check/user-outcomes.mjs'
31
+ import {
32
+ computeMissingExpected,
33
+ deriveExpectedCandidates,
34
+ validateOutcomeRefs,
35
+ } from '../followups/derive.mjs'
36
+ import {
37
+ DISPOSITION_KINDS,
38
+ listLoadBearingUnfixed,
39
+ listUnadjudicatedOpen,
40
+ listUndispositioned,
41
+ parseFollowups,
42
+ } from '../followups/schema.mjs'
43
+ import { buildGateContext } from '../gate/context.mjs'
44
+ import { parseFlags } from '../util/args.mjs'
45
+ import { atomicWriteJson, readJsonIfExists } from '../util/io.mjs'
46
+ import { err, exitWith, ok } from '../util/result.mjs'
47
+
48
+ const SUBCOMMANDS = new Set(['check', 'disposition', 'policy'])
49
+
50
+ function readTextIfExists(path) {
51
+ if (!existsSync(path)) return null
52
+ try {
53
+ return readFileSync(path, 'utf-8')
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Entry: `apt-tools followups <subcommand> <project-dir> [flags]`.
61
+ */
62
+ export function cmdFollowups(subcommand, projectDir, extraArgs) {
63
+ if (!subcommand || !SUBCOMMANDS.has(subcommand)) {
64
+ return err(
65
+ 'Usage: apt-tools followups <check|disposition|policy> <project-dir> --task-id <id> [...]',
66
+ 1,
67
+ { command: 'followups' },
68
+ )
69
+ }
70
+ if (!projectDir) {
71
+ return err('project-dir is required', 1, { command: `followups-${subcommand}` })
72
+ }
73
+ const absProjectDir = resolve(projectDir)
74
+ if (!existsSync(absProjectDir)) {
75
+ return err(`project directory not found: ${absProjectDir}`, 1, {
76
+ command: `followups-${subcommand}`,
77
+ })
78
+ }
79
+
80
+ const flags = parseFlags(extraArgs ?? [])
81
+ const taskId = flags.get('task-id')
82
+ if (typeof taskId !== 'string' || taskId.trim() === '') {
83
+ return err('--task-id <id> is required', 1, { command: `followups-${subcommand}` })
84
+ }
85
+
86
+ let ctx
87
+ try {
88
+ ctx = buildGateContext(absProjectDir, taskId)
89
+ } catch (e) {
90
+ return err(`task not found: ${taskId} (${e?.message ?? String(e)})`, 1, {
91
+ command: `followups-${subcommand}`,
92
+ })
93
+ }
94
+
95
+ if (subcommand === 'check') return doCheck(ctx)
96
+ if (subcommand === 'disposition') return doDisposition(ctx, flags)
97
+ return doPolicy(ctx)
98
+ }
99
+
100
+ /**
101
+ * `followups check` — the deterministic adjudication-readiness envelope (AC5).
102
+ */
103
+ function doCheck(ctx) {
104
+ const followupsPath = join(ctx.taskDir, 'followups.json')
105
+ const specPath = join(ctx.taskDir, 'spec.md')
106
+ const reviewPath = join(ctx.taskDir, 'review.json')
107
+ const qaPath = join(ctx.taskDir, 'qa_signoff.json')
108
+
109
+ const specContent = readTextIfExists(specPath)
110
+ const review = readJsonIfExists(reviewPath)
111
+ const qa = readJsonIfExists(qaPath)
112
+
113
+ const candidates = deriveExpectedCandidates({ specContent, review, qa })
114
+ const ledgerDoc = readJsonIfExists(followupsPath)
115
+ const ledger_present = ledgerDoc !== null
116
+
117
+ const parsed = parseFollowups(ledgerDoc ?? { rows: [] })
118
+ const parsedOutcomes = parseUserOutcomes(specContent ?? '')
119
+
120
+ const missing_expected = computeMissingExpected(candidates, parsed.rows)
121
+ const invalid_outcome_refs = validateOutcomeRefs(parsed.rows, parsedOutcomes)
122
+ const unadjudicated_open = listUnadjudicatedOpen(parsed)
123
+ const load_bearing_unfixed = listLoadBearingUnfixed(parsed)
124
+ const undispositioned = listUndispositioned(parsed)
125
+
126
+ // Exit 1 when the ledger is absent while candidates exist (adoption forcing
127
+ // function), or when derivation/schema/outcome-ref validation fails. The
128
+ // unadjudicated / load-bearing / undispositioned lists are reported but do
129
+ // NOT fail `check` directly — those are gate concerns (ship gate / close
130
+ // gate) so an in-progress verify pass can iterate without `check` flapping.
131
+ const derivationFailure =
132
+ (!ledger_present && candidates.length > 0) ||
133
+ missing_expected.length > 0 ||
134
+ invalid_outcome_refs.length > 0 ||
135
+ parsed.schema_errors.length > 0
136
+
137
+ const envelope = {
138
+ status: derivationFailure ? 'fail' : 'ok',
139
+ command: 'followups-check',
140
+ task_id: ctx.taskId,
141
+ ledger_present,
142
+ expected_total: candidates.length,
143
+ ledger_rows: parsed.rows.length,
144
+ schema_errors: parsed.schema_errors,
145
+ missing_expected,
146
+ invalid_outcome_refs,
147
+ unadjudicated_open: unadjudicated_open.map(rowSummary),
148
+ load_bearing_unfixed: load_bearing_unfixed.map(rowSummary),
149
+ undispositioned: undispositioned.map(rowSummary),
150
+ }
151
+ return exitWith(envelope, derivationFailure ? 1 : 0)
152
+ }
153
+
154
+ /**
155
+ * `followups disposition` — flip a row to dispositioned (AC7).
156
+ */
157
+ function doDisposition(ctx, flags) {
158
+ const rowId = flags.get('row')
159
+ if (typeof rowId !== 'string' || rowId.trim() === '') {
160
+ return err('--row <row-id> is required', 1, { command: 'followups-disposition' })
161
+ }
162
+ const kind = flags.get('kind')
163
+ if (!DISPOSITION_KINDS.has(kind)) {
164
+ return err(`--kind must be one of: ${[...DISPOSITION_KINDS].join(', ')}`, 1, {
165
+ command: 'followups-disposition',
166
+ })
167
+ }
168
+ const ref = flags.get('ref')
169
+ if (typeof ref !== 'string' || ref.trim() === '') {
170
+ return err('--ref <ref> is required (non-empty)', 1, { command: 'followups-disposition' })
171
+ }
172
+ const reasonRaw = flags.get('reason')
173
+ const reason = typeof reasonRaw === 'string' ? reasonRaw : null
174
+ if (kind === 'dropped_by_user' && !reason) {
175
+ return err('--kind dropped_by_user requires --reason "<text>"', 1, {
176
+ command: 'followups-disposition',
177
+ })
178
+ }
179
+
180
+ const followupsPath = join(ctx.taskDir, 'followups.json')
181
+ const ledgerDoc = readJsonIfExists(followupsPath)
182
+ if (ledgerDoc === null) {
183
+ return err(`followups.json not found for ${ctx.taskId}`, 1, {
184
+ command: 'followups-disposition',
185
+ })
186
+ }
187
+ const parsed = parseFollowups(ledgerDoc)
188
+ // Fail closed on schema errors: the write-back below re-serializes only the
189
+ // validated `parsed.rows`, so a single malformed row elsewhere in the ledger
190
+ // would be silently dropped when dispositioning a different row. Refuse and
191
+ // surface the errors so the operator fixes them first (no silent data loss).
192
+ if (parsed.schema_errors.length > 0) {
193
+ return err(
194
+ `followups.json has ${parsed.schema_errors.length} malformed row(s) — fix them before dispositioning (a write-back would drop them)`,
195
+ 1,
196
+ { command: 'followups-disposition', schema_errors: parsed.schema_errors },
197
+ )
198
+ }
199
+ const target = parsed.rows.find((r) => r.id === rowId)
200
+ if (!target) {
201
+ return err(`unknown row id: ${rowId}`, 1, {
202
+ command: 'followups-disposition',
203
+ known_rows: parsed.rows.map((r) => r.id),
204
+ })
205
+ }
206
+
207
+ // Atomic flip: re-serialize the full validated row set with the target's
208
+ // status + disposition updated. The on-disk shape is `{ rows: [...] }`.
209
+ const disposition = { kind, ref, ...(reason ? { reason } : {}) }
210
+ const nextRows = parsed.rows.map((r) =>
211
+ r.id === rowId ? { ...r, status: 'dispositioned', disposition } : r,
212
+ )
213
+ atomicWriteJson(followupsPath, { rows: nextRows })
214
+
215
+ return ok({
216
+ status: 'ok',
217
+ command: 'followups-disposition',
218
+ task_id: ctx.taskId,
219
+ row: rowId,
220
+ disposition,
221
+ })
222
+ }
223
+
224
+ /**
225
+ * `followups policy` — effective disposition policy (AC8 / ID-11).
226
+ */
227
+ function doPolicy(ctx) {
228
+ const followupsCfg = ctx.config?.followups ?? {}
229
+ const explicit = followupsCfg.after_close
230
+
231
+ // Resolve the autonomy level: task record first, then config default, else 1.
232
+ const autonomy =
233
+ typeof ctx.taskRecord?.autonomy === 'number'
234
+ ? ctx.taskRecord.autonomy
235
+ : typeof ctx.config?.autonomy?.default === 'number'
236
+ ? ctx.config.autonomy.default
237
+ : 1
238
+
239
+ let after_close
240
+ let source
241
+ if (explicit === 'ask' || explicit === 'file' || explicit === 'chain') {
242
+ after_close = explicit
243
+ source = 'config'
244
+ } else {
245
+ after_close = autonomy <= 1 ? 'ask' : autonomy === 2 ? 'file' : 'chain'
246
+ source = 'autonomy'
247
+ }
248
+
249
+ // max_chain_depth: default 1; clamp values > 1 to 1 with a warning (v1).
250
+ let max_chain_depth = 1
251
+ let clamp_warning = null
252
+ const rawDepth = followupsCfg.max_chain_depth
253
+ if (typeof rawDepth === 'number' && Number.isFinite(rawDepth)) {
254
+ if (rawDepth > 1) {
255
+ clamp_warning = `max_chain_depth ${rawDepth} clamped to 1 (v1 supports depth-1 only)`
256
+ } else if (rawDepth >= 1) {
257
+ max_chain_depth = 1
258
+ } else {
259
+ // depth < 1 means no chaining; degrade chain → file.
260
+ max_chain_depth = 0
261
+ }
262
+ }
263
+
264
+ // Anti-perpetual-motion: degrade chain → file when the CURRENT task's
265
+ // triage.json marks it as already a chained descendant at/over the cap.
266
+ const triagePath = join(ctx.taskDir, 'triage.json')
267
+ const triage = readJsonIfExists(triagePath)
268
+ const chainDepth = triage?.extra?.followups_chain?.depth
269
+ let degraded = false
270
+ if (after_close === 'chain' && typeof chainDepth === 'number' && chainDepth >= max_chain_depth) {
271
+ after_close = 'file'
272
+ degraded = true
273
+ }
274
+ // A 0 max_chain_depth (depth < 1 config) also degrades chain → file.
275
+ if (after_close === 'chain' && max_chain_depth < 1) {
276
+ after_close = 'file'
277
+ degraded = true
278
+ }
279
+
280
+ const envelope = {
281
+ status: 'ok',
282
+ command: 'followups-policy',
283
+ task_id: ctx.taskId,
284
+ after_close,
285
+ max_chain_depth,
286
+ source,
287
+ autonomy,
288
+ }
289
+ if (clamp_warning) envelope.warning = clamp_warning
290
+ if (degraded) envelope.degraded_from_chain = true
291
+ return ok(envelope)
292
+ }
293
+
294
+ function rowSummary(r) {
295
+ return {
296
+ id: r.id,
297
+ title: r.title,
298
+ status: r.status,
299
+ class_proposed: r.class_proposed,
300
+ verdict: r.adjudication?.verdict ?? null,
301
+ disposition_kind: r.disposition?.kind ?? null,
302
+ }
303
+ }
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'
9
9
  import { getSharedLockDir } from '../util/project.mjs'
10
10
  import { err, ok } from '../util/result.mjs'
11
11
  import { readSkillInstallState } from '../util/skill-installs.mjs'
12
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
12
13
 
13
14
  const __filename = fileURLToPath(import.meta.url)
14
15
  const __dirname = dirname(__filename)
@@ -70,7 +71,8 @@ export function cmdHealthCheck(projectDir) {
70
71
  }
71
72
 
72
73
  // 2. Check for stale active_task in state.json
73
- const statePath = join(aperantDir, 'state.json')
74
+ // state-fork-fix: state.json is EPHEMERAL — resolve to the main board.
75
+ const statePath = join(resolveMainRepoRoot(targetDir), '.aperant', 'state.json')
74
76
  if (existsSync(statePath)) {
75
77
  try {
76
78
  const state = JSON.parse(readFileSync(statePath, 'utf-8'))