@aperant/framework 0.13.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 +87 -0
  2. package/README.md +1 -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 +9 -3
  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 +1 -1
  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
@@ -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'))
@@ -0,0 +1,203 @@
1
+ /**
2
+ * commands/merge-detect.mjs — conflict envelope + on-disk marker reader for
3
+ * apt:merge-conflict-resolution.
4
+ *
5
+ * Two read-only subcommands, both pure git inspection — NO history rewrite,
6
+ * NO `git merge`, NO `git rebase`, NO index mutation. The SKILL.md prose owns
7
+ * every state change; this primitive only OBSERVES.
8
+ *
9
+ * apt-tools merge detect <project-dir> [--base <ref>] [--head <ref>]
10
+ * Trial-merge probe via `git merge-tree --write-tree --no-messages`.
11
+ * Reuses the EXACT detection from audit-branch-current.mjs (`gitTry`,
12
+ * `parseConflictPaths`, `isGitVersionTooOld`) — no reinvented detection.
13
+ * --base defaults to `origin/main`; --head defaults to `HEAD`. Returns
14
+ * { mergeable, conflicts_preview?, audit_warning? }. `mergeable: null`
15
+ * on an unprobeable git (too old / invocation error) so the caller can
16
+ * fall back gracefully rather than asserting clean.
17
+ *
18
+ * apt-tools merge markers <project-dir>
19
+ * Lists files that carry on-disk conflict markers (the state AFTER a
20
+ * real `git merge`/`git rebase` left the worktree conflicted) and reads
21
+ * each file's full content so the executing agent can resolve the
22
+ * markers inline. Source of truth for "is the merge conflicted" is
23
+ * `git diff --name-only --diff-filter=U` (unmerged paths). Returns
24
+ * { conflicted, files: [{ path, content }] }. Empty `conflicted` means
25
+ * a clean tree — nothing to resolve.
26
+ *
27
+ * Why agent-inline (not a programmatic resolver): the framework CLI is
28
+ * self-contained ESM and MUST NOT import @aperant/core (it declares no such
29
+ * dep — see runtime-capabilities.mjs:5). The LLM driving the CLI reads the
30
+ * markers this command surfaces and resolves them, then re-probes via
31
+ * `detect`. See GitHub issue #390 for the architecture decision.
32
+ *
33
+ * @internal
34
+ */
35
+
36
+ import { readFileSync } from 'node:fs'
37
+ import { join, resolve } from 'node:path'
38
+ import { err, ok } from '../util/result.mjs'
39
+ import { gitTry, isGitVersionTooOld, parseConflictPaths } from './audit-branch-current.mjs'
40
+ import { mergeIntegrate } from './merge-integrate.mjs'
41
+
42
+ /**
43
+ * Parse `--<name> <value>` (or `--<name>=<value>`) from extraArgs.
44
+ *
45
+ * @param {string[]} extraArgs
46
+ * @param {string} name
47
+ * @param {string} fallback
48
+ * @returns {string}
49
+ */
50
+ function parseFlag(extraArgs, name, fallback) {
51
+ const long = `--${name}`
52
+ const eq = `--${name}=`
53
+ for (let i = 0; i < extraArgs.length; i++) {
54
+ const a = extraArgs[i]
55
+ if (a === long && i + 1 < extraArgs.length) return extraArgs[i + 1]
56
+ if (a.startsWith(eq)) return a.slice(eq.length)
57
+ }
58
+ return fallback
59
+ }
60
+
61
+ /**
62
+ * `apt-tools merge detect` — trial-merge mergeability probe.
63
+ *
64
+ * @param {string} targetDir
65
+ * @param {string[]} extraArgs
66
+ * @returns {import('../util/result.mjs').CommandResult}
67
+ */
68
+ function mergeDetect(targetDir, extraArgs) {
69
+ const base = parseFlag(extraArgs, 'base', 'origin/main')
70
+ const head = parseFlag(extraArgs, 'head', 'HEAD')
71
+
72
+ const probe = gitTry(targetDir, ['merge-tree', '--write-tree', '--no-messages', head, base])
73
+
74
+ /** @type {boolean | null} */
75
+ let mergeable
76
+ /** @type {string[] | undefined} */
77
+ let conflictsPreview
78
+ /** @type {string | undefined} */
79
+ let auditWarning
80
+
81
+ if (probe.ok) {
82
+ // Documented merge-tree exit-code semantics: 0 = clean.
83
+ mergeable = true
84
+ } else if (probe.code === 1 && probe.stdout.trim().length > 0) {
85
+ // 1 = conflict, BUT only when stdout carried the conflict report
86
+ // (the tree-OID line + per-stage paths). A bad ref also exits 1 with
87
+ // EMPTY stdout and the error on stderr (e.g. "<ref> - not something we
88
+ // can merge") — that is a probe failure, not a clean-merge conflict, so
89
+ // we fall through to the merge-tree-probe-failed branch below.
90
+ mergeable = false
91
+ conflictsPreview = parseConflictPaths(probe.stdout)
92
+ } else if (isGitVersionTooOld(probe.stderr)) {
93
+ mergeable = null
94
+ auditWarning = 'git-version-too-old'
95
+ } else {
96
+ // Other = invocation error (bad ref, not a repo, etc.).
97
+ mergeable = null
98
+ auditWarning = 'merge-tree-probe-failed'
99
+ }
100
+
101
+ const envelope = {
102
+ status: 'ok',
103
+ command: 'merge-detect',
104
+ projectDir: targetDir,
105
+ base,
106
+ head,
107
+ mergeable,
108
+ }
109
+ if (conflictsPreview && conflictsPreview.length > 0) {
110
+ envelope.conflicts_preview = conflictsPreview
111
+ }
112
+ if (auditWarning) {
113
+ envelope.audit_warning = auditWarning
114
+ if (probe.stderr.trim()) {
115
+ envelope.warnings = [probe.stderr.trim().slice(0, 200)]
116
+ }
117
+ }
118
+ return ok(envelope)
119
+ }
120
+
121
+ /**
122
+ * `apt-tools merge markers` — read on-disk conflict markers from the unmerged
123
+ * worktree so the agent can resolve them inline.
124
+ *
125
+ * @param {string} targetDir
126
+ * @returns {import('../util/result.mjs').CommandResult}
127
+ */
128
+ function mergeMarkers(targetDir) {
129
+ // Unmerged paths (stage > 0) are the authoritative "conflicted file" set.
130
+ const unmerged = gitTry(targetDir, ['diff', '--name-only', '--diff-filter=U'])
131
+ if (!unmerged.ok) {
132
+ return ok({
133
+ status: 'ok',
134
+ command: 'merge-markers',
135
+ projectDir: targetDir,
136
+ conflicted: [],
137
+ files: [],
138
+ warnings: [`git diff failed: ${unmerged.stderr.trim().slice(0, 200) || 'unknown error'}`],
139
+ })
140
+ }
141
+
142
+ const conflicted = unmerged.stdout
143
+ .split('\n')
144
+ .map((l) => l.trim())
145
+ .filter((l) => l.length > 0)
146
+
147
+ const files = []
148
+ const warnings = []
149
+ for (const path of conflicted) {
150
+ try {
151
+ const content = readFileSync(join(targetDir, path), 'utf-8')
152
+ files.push({ path, content })
153
+ } catch (e) {
154
+ warnings.push(`could not read ${path}: ${e?.message ?? String(e)}`)
155
+ }
156
+ }
157
+
158
+ const envelope = {
159
+ status: 'ok',
160
+ command: 'merge-markers',
161
+ projectDir: targetDir,
162
+ conflicted,
163
+ files,
164
+ }
165
+ if (warnings.length > 0) envelope.warnings = warnings
166
+ return ok(envelope)
167
+ }
168
+
169
+ /**
170
+ * Dispatch an `apt-tools merge <subcommand>` call.
171
+ *
172
+ * @param {string} subcommand
173
+ * @param {string} projectDir
174
+ * @param {string[]} [extraArgs]
175
+ * @returns {import('../util/result.mjs').CommandResult}
176
+ *
177
+ * @internal
178
+ */
179
+ export function cmdMerge(subcommand, projectDir, extraArgs = []) {
180
+ if (!subcommand) {
181
+ return err('Usage: apt-tools merge <detect|markers|integrate> <project-dir> [flags]')
182
+ }
183
+ if (!projectDir) {
184
+ return err(`Usage: apt-tools merge ${subcommand} <project-dir> [flags]`)
185
+ }
186
+ const targetDir = resolve(projectDir)
187
+ switch (subcommand) {
188
+ case 'detect':
189
+ return mergeDetect(targetDir, extraArgs)
190
+ case 'markers':
191
+ return mergeMarkers(targetDir)
192
+ case 'integrate':
193
+ return mergeIntegrate(targetDir, extraArgs)
194
+ default:
195
+ return err(
196
+ `Unknown merge subcommand: ${subcommand}. Expected 'detect', 'markers', or 'integrate'.`,
197
+ )
198
+ }
199
+ }
200
+
201
+ // Test-only exports for unit-level coverage (not part of the public CLI
202
+ // contract).
203
+ export { mergeDetect as __mergeDetectForTests, mergeMarkers as __mergeMarkersForTests }