@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
@@ -42,6 +42,7 @@ import { parseFlags } from '../util/args.mjs'
42
42
  import { withFileLock } from '../util/fs-lock.mjs'
43
43
  import { emitInitPrStateHint } from '../util/init-hints.mjs'
44
44
  import { err, exitWith, ok, UsageError } from '../util/result.mjs'
45
+ import { cmdDetectSpecialists } from './pr-review/detect-specialists.mjs'
45
46
  import { cmdPrReviewAuditFixer } from './pr-review-audit-fixer.mjs'
46
47
 
47
48
  // C56 B4 — helper for self-staging committable pr-review writes. The
@@ -1083,7 +1084,7 @@ function handleSetAuthorshipOverride(root, flags) {
1083
1084
  export function cmdPrReview(subcommand, projectDir, extraArgs) {
1084
1085
  if (!subcommand)
1085
1086
  return err(
1086
- 'Usage: apt-tools pr-review <init-pr|acquire-lock|release-lock|start-round|close-round|get-state|detect-authorship|record-comment|set-authorship-override|audit-fixer> <project-dir> [flags]',
1087
+ 'Usage: apt-tools pr-review <init-pr|acquire-lock|release-lock|start-round|close-round|get-state|detect-authorship|detect-specialists|record-comment|set-authorship-override|audit-fixer> <project-dir> [flags]',
1087
1088
  )
1088
1089
 
1089
1090
  // audit-fixer has its own argv shape (accepts --help in projectDir
@@ -1096,6 +1097,15 @@ export function cmdPrReview(subcommand, projectDir, extraArgs) {
1096
1097
  return cmdPrReviewAuditFixer(projectDir, extraArgs)
1097
1098
  }
1098
1099
 
1100
+ // detect-specialists runs over an ARBITRARY project dir (the PR's worktree,
1101
+ // or a unit-test fixture tree) that need NOT contain a `.aperant/` dir —
1102
+ // the detector reads `.aperant/config.json` best-effort but does not
1103
+ // require it. Delegate BEFORE the .aperant existence guard, like
1104
+ // audit-fixer.
1105
+ if (subcommand === 'detect-specialists') {
1106
+ return cmdDetectSpecialists(projectDir, extraArgs)
1107
+ }
1108
+
1099
1109
  if (!projectDir) return err(`Usage: apt-tools pr-review ${subcommand} <project-dir> [flags]`)
1100
1110
 
1101
1111
  const root = resolve(projectDir)
@@ -1124,7 +1134,7 @@ export function cmdPrReview(subcommand, projectDir, extraArgs) {
1124
1134
  return handleSetAuthorshipOverride(root, flags)
1125
1135
  default:
1126
1136
  return err(
1127
- `Unknown pr-review subcommand: ${subcommand}. Available: init-pr, acquire-lock, release-lock, start-round, close-round, get-state, detect-authorship, record-comment, set-authorship-override, audit-fixer`,
1137
+ `Unknown pr-review subcommand: ${subcommand}. Available: init-pr, acquire-lock, release-lock, start-round, close-round, get-state, detect-authorship, detect-specialists, record-comment, set-authorship-override, audit-fixer`,
1128
1138
  )
1129
1139
  }
1130
1140
  } catch (e) {
@@ -11,6 +11,7 @@ import { existsSync, readFileSync } from 'node:fs'
11
11
  import { join, resolve } from 'node:path'
12
12
  import { mergeClaudeHook } from '../install/claude-hooks.mjs'
13
13
  import { rawText } from '../util/result.mjs'
14
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
14
15
 
15
16
  /**
16
17
  * Install or merge the apt-tools PreCompact hook into
@@ -93,7 +94,10 @@ export function cmdPrecompactHook(projectDir) {
93
94
  return rawText('', 0)
94
95
  }
95
96
 
96
- const statePath = join(aperantDir, 'state.json')
97
+ // state-fork-fix: state.json + tasks/ are EPHEMERAL — resolve to the main
98
+ // board so a post-compact agent in a worktree reads the authoritative tasks.
99
+ const stateRoot = resolveMainRepoRoot(targetDir)
100
+ const statePath = join(stateRoot, '.aperant', 'state.json')
97
101
  if (!existsSync(statePath)) {
98
102
  return rawText('', 0)
99
103
  }
@@ -138,11 +142,11 @@ export function cmdPrecompactHook(projectDir) {
138
142
  lines.push(`Active tasks (${taskIds.length}):`)
139
143
 
140
144
  for (const [taskId, task] of Object.entries(activeTasks)) {
141
- // Resolve task_dir deterministically via getTaskDir() — the task object
142
- // does not store its own directory path; it's always derived from id.
143
- // Use project-relative path so the post-compact agent can Read it
144
- // regardless of cwd changes.
145
- const taskDir = join('.aperant', 'tasks', taskId)
145
+ // Resolve task_dir deterministically — the task object does not store its
146
+ // own directory path; it's always derived from id. state-fork-fix: tasks/
147
+ // is EPHEMERAL, so anchor at the main-repo state root (cwd-independent AND
148
+ // points at the real spec/plan/progress, not a worktree's empty board).
149
+ const taskDir = join(stateRoot, '.aperant', 'tasks', taskId)
146
150
  // Real schema from cmdTask create: description, lifecycle_phase,
147
151
  // progress.{subtasks_total, subtasks_completed, current_subtask}.
148
152
  // Title/status are fallbacks for legacy or hand-edited state.
@@ -30,6 +30,7 @@ import { parseFlags } from '../util/args.mjs'
30
30
  import { withFileLock } from '../util/fs-lock.mjs'
31
31
  import { atomicWriteJson } from '../util/io.mjs'
32
32
  import { err, exitWith, ok, UsageError } from '../util/result.mjs'
33
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
33
34
 
34
35
  /**
35
36
  * Sentinel thrown by inner helpers (`loadRoadmap`, `resolveScope`, etc.) so
@@ -60,6 +61,15 @@ export async function cmdRoadmap(subcommand, projectDir, extraArgs) {
60
61
 
61
62
  async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
62
63
  const targetDir = resolve(projectDir)
64
+ // state-fork-fix ID-01/ID-05/ID-06: committable + ephemeral path-builds
65
+ // INTERLEAVE in this file. `roadmapRoot` (milestones.json + ROADMAP.md +
66
+ // SEEDS.md), seeds.json, and the per-scope sinks are COMMITTABLE and MUST
67
+ // stay worktree-local on `targetDir` so they ride the PR branch. Only the
68
+ // EPHEMERAL state.json reads/writes + their withFileLock roots redirect to
69
+ // the main repo via `stateRoot` — including the deliberately behavior-CHANGING
70
+ // seeds-count read (ID-05: from a worktree the SEEDS.md counts now reflect the
71
+ // authoritative main board, not a forked worktree's zeros).
72
+ const stateRoot = resolveMainRepoRoot(projectDir)
63
73
  updateTeamMember(targetDir)
64
74
  const roadmapRoot = join(targetDir, '.aperant', 'roadmap')
65
75
  const flags = parseFlags(extraArgs)
@@ -332,7 +342,10 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
332
342
  }
333
343
  let state = null
334
344
  try {
335
- const statePath = join(targetDir, '.aperant', 'state.json')
345
+ // ID-05 behavior-CHANGING redirect: seeds-count read resolves to
346
+ // the authoritative MAIN board so renderSeedsMd reports real
347
+ // project-wide counts even when render ran from a worktree.
348
+ const statePath = join(stateRoot, '.aperant', 'state.json')
336
349
  state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf-8')) : null
337
350
  } catch {
338
351
  /* state.json malformed — render with zero task counts */
@@ -526,7 +539,7 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
526
539
  // same state.json lock so a concurrent task create can't slip a new
527
540
  // reference in between the check and the roadmap mutation.
528
541
  if (status === 'deleted') {
529
- const statePathLocal = join(targetDir, '.aperant', 'state.json')
542
+ const statePathLocal = join(stateRoot, '.aperant', 'state.json')
530
543
  let cascadedCount = 0
531
544
  let blockedAffected = null
532
545
  if (existsSync(statePathLocal)) {
@@ -563,7 +576,7 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
563
576
  cascadedCount = freshAffected.length
564
577
  }
565
578
  },
566
- { projectRoot: targetDir },
579
+ { projectRoot: stateRoot },
567
580
  )
568
581
  }
569
582
  if (blockedAffected) {
@@ -577,7 +590,7 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
577
590
  1,
578
591
  )
579
592
  }
580
- if (cascadedCount > 0) regenerateTaskIndex(targetDir)
593
+ if (cascadedCount > 0) regenerateTaskIndex(stateRoot)
581
594
  // Remove the phase entirely when status=deleted.
582
595
  milestone.phases = milestone.phases.filter((p) => p.id !== phaseId)
583
596
  saveRoadmap(scope, roadmap)
@@ -676,7 +689,7 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
676
689
 
677
690
  // Atomic orphan-block check + cascade under the state.json lock to
678
691
  // close the TOCTOU window between the check and the roadmap write.
679
- const stPath = join(targetDir, '.aperant', 'state.json')
692
+ const stPath = join(stateRoot, '.aperant', 'state.json')
680
693
  let cascadedCount = 0
681
694
  let blockedAffected = null
682
695
  if (existsSync(stPath)) {
@@ -713,7 +726,7 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
713
726
  cascadedCount = freshAffected.length
714
727
  }
715
728
  },
716
- { projectRoot: targetDir },
729
+ { projectRoot: stateRoot },
717
730
  )
718
731
  }
719
732
  if (blockedAffected) {
@@ -727,7 +740,7 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
727
740
  1,
728
741
  )
729
742
  }
730
- if (cascadedCount > 0) regenerateTaskIndex(targetDir)
743
+ if (cascadedCount > 0) regenerateTaskIndex(stateRoot)
731
744
 
732
745
  milestone.phases = milestone.phases.filter((p) => p.id !== phaseId)
733
746
  saveRoadmap(scope, roadmap)
@@ -772,7 +785,7 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
772
785
  throw new RoadmapUsageError(`Milestone "${milestoneId}" not found in scope "${scope}"`)
773
786
 
774
787
  // Atomic orphan-block check + cascade under the state.json lock.
775
- const stPath = join(targetDir, '.aperant', 'state.json')
788
+ const stPath = join(stateRoot, '.aperant', 'state.json')
776
789
  let cascadedCount = 0
777
790
  let blockedAffected = null
778
791
  if (existsSync(stPath)) {
@@ -809,7 +822,7 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
809
822
  cascadedCount = freshAffected.length
810
823
  }
811
824
  },
812
- { projectRoot: targetDir },
825
+ { projectRoot: stateRoot },
813
826
  )
814
827
  }
815
828
  if (blockedAffected) {
@@ -823,7 +836,7 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
823
836
  1,
824
837
  )
825
838
  }
826
- if (cascadedCount > 0) regenerateTaskIndex(targetDir)
839
+ if (cascadedCount > 0) regenerateTaskIndex(stateRoot)
827
840
 
828
841
  // C34c — capture the phase ids before filtering so we can unlink each
829
842
  // `.aperant/roadmap/{scope}/phases/{id}.json` and prevent the
@@ -851,7 +864,7 @@ async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
851
864
  // project root and grep for `<a id="<anchor>">`. For URL: unless
852
865
  // --offline, HEAD via curl with a 5s timeout. Emits {status, checked, broken}.
853
866
  const offline = extraArgs.includes('--offline')
854
- const statePathLocal = join(targetDir, '.aperant', 'state.json')
867
+ const statePathLocal = join(stateRoot, '.aperant', 'state.json')
855
868
  const broken = []
856
869
  let checked = 0
857
870
 
@@ -39,6 +39,7 @@ import { updateTeamMember } from '../team/team.mjs'
39
39
  import { parseFlags } from '../util/args.mjs'
40
40
  import { withFileLock } from '../util/fs-lock.mjs'
41
41
  import { err, exitWith, ok } from '../util/result.mjs'
42
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
42
43
 
43
44
  /**
44
45
  * Run validateSeeds against the current on-disk state.
@@ -65,7 +66,8 @@ function runValidation(targetDir) {
65
66
  */
66
67
  function gatherSeedTasks(targetDir, refs) {
67
68
  const out = { active: 0, completed: 0, total: 0, tasks: [] }
68
- const statePath = join(targetDir, '.aperant', 'state.json')
69
+ // state-fork-fix: ephemeral state read resolves to the main board.
70
+ const statePath = join(resolveMainRepoRoot(targetDir), '.aperant', 'state.json')
69
71
  if (!existsSync(statePath)) return out
70
72
  let state
71
73
  try {
@@ -16,6 +16,7 @@ import { parseFlags } from '../util/args.mjs'
16
16
  import { withFileLock } from '../util/fs-lock.mjs'
17
17
  import { atomicWriteJson } from '../util/io.mjs'
18
18
  import { err, exitWith, ok } from '../util/result.mjs'
19
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
19
20
 
20
21
  /**
21
22
  * Emit a human-readable preview to stderr when stdout is a TTY. Never throws;
@@ -38,7 +39,10 @@ export function cmdState(projectDir) {
38
39
  if (!projectDir) return err('Usage: apt-tools state <project-dir>')
39
40
  const targetDir = resolve(projectDir)
40
41
  const aperantDir = join(targetDir, '.aperant')
41
- const statePath = join(aperantDir, 'state.json')
42
+ // state-fork-fix: state.json is EPHEMERAL — resolve to the main board so
43
+ // `state .` from a worktree reads the authoritative board (the primary fork
44
+ // victim). config.json is COMMITTABLE — it stays worktree-local on aperantDir.
45
+ const statePath = join(resolveMainRepoRoot(projectDir), '.aperant', 'state.json')
42
46
  const configPath = join(aperantDir, 'config.json')
43
47
 
44
48
  if (!existsSync(aperantDir)) {
@@ -145,7 +149,9 @@ export function cmdStateUpdate(projectDir, args) {
145
149
  if (!projectDir)
146
150
  return err('Usage: apt-tools state-update <project-dir> --field <path> --value <json>')
147
151
  const targetDir = resolve(projectDir)
148
- const statePath = join(targetDir, '.aperant', 'state.json')
152
+ // state-fork-fix: ephemeral state-update resolves + locks against the main board.
153
+ const stateRoot = resolveMainRepoRoot(projectDir)
154
+ const statePath = join(stateRoot, '.aperant', 'state.json')
149
155
 
150
156
  if (!existsSync(statePath)) return err('No .aperant/state.json found.')
151
157
 
@@ -183,7 +189,7 @@ export function cmdStateUpdate(projectDir, args) {
183
189
 
184
190
  atomicWriteJson(statePath, state)
185
191
  },
186
- { projectRoot: targetDir },
192
+ { projectRoot: stateRoot },
187
193
  )
188
194
 
189
195
  return ok({ status: 'ok', command: 'state-update', field, value, projectDir: targetDir })
@@ -22,6 +22,7 @@ import { formatUpdateChips, readUpdateCache } from '../install/update-chips.mjs'
22
22
  import { statusLine, supportsColor } from '../output-style/index.mjs'
23
23
  import { parseFlags } from '../util/args.mjs'
24
24
  import { err, ok, rawText } from '../util/result.mjs'
25
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
25
26
 
26
27
  /**
27
28
  * Normalize `active_tasks` from state.json across the legacy (single object)
@@ -143,7 +144,10 @@ export function cmdStatusline(projectDir, extraArgs = []) {
143
144
  })
144
145
 
145
146
  const aperantDir = join(targetDir, '.aperant')
146
- const statePath = join(aperantDir, 'state.json')
147
+ // state-fork-fix: state.json is EPHEMERAL — resolve to the main board (aligns
148
+ // with the createCoordinationStore redirect below). The local aperantDir is
149
+ // kept only for the not-initialized existence check.
150
+ const statePath = join(resolveMainRepoRoot(targetDir), '.aperant', 'state.json')
147
151
 
148
152
  // Read SessionStart worker cache once — chips prepend onto every render
149
153
  // path (initialized or not).
@@ -21,6 +21,7 @@ import {
21
21
  } from '../config/load.mjs'
22
22
  import { findMostRecentEventForTask } from '../coordination/event-log.mjs'
23
23
  import { createCoordinationStore } from '../coordination/store.mjs'
24
+ import { listBlockingRowsForClose, parseFollowups } from '../followups/schema.mjs'
24
25
  import { getGitIdentity } from '../git/identity.mjs'
25
26
  import { detectWorktree } from '../git/worktree-detect.mjs'
26
27
  import {
@@ -44,6 +45,7 @@ import { parseDuration } from '../util/duration.mjs'
44
45
  import { withFileLock } from '../util/fs-lock.mjs'
45
46
  import { atomicWriteJson } from '../util/io.mjs'
46
47
  import { err, exitWith, ok } from '../util/result.mjs'
48
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
47
49
  import { cmdCiWatch } from './ci-watch.mjs'
48
50
 
49
51
  export function cmdTask(subcommand, projectDir, extraArgs) {
@@ -54,7 +56,14 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
54
56
  if (!projectDir) return err(`Usage: apt-tools task ${subcommand} <project-dir> [flags]`)
55
57
 
56
58
  const targetDir = resolve(projectDir)
57
- const statePath = join(targetDir, '.aperant', 'state.json')
59
+ // state-fork-fix ID-01/ID-04: ephemeral state (state.json + tasks/) resolves
60
+ // to the MAIN repo from any worktree cwd, so the task board never forks into
61
+ // a worktree's gitignored .aperant/. task.mjs touches ONLY ephemeral state
62
+ // (the mechanical exception in ID-01), so threading one stateRoot is correct.
63
+ // Committable roadmap path-builds in this file (the milestones.json reads)
64
+ // deliberately stay on the raw targetDir — they MUST ride the task branch.
65
+ const stateRoot = resolveMainRepoRoot(projectDir)
66
+ const statePath = join(stateRoot, '.aperant', 'state.json')
58
67
  const flags = parseFlags(extraArgs)
59
68
 
60
69
  // R8 — @internal CoordinationStore seam. V1 is a thin wrapper over the
@@ -167,8 +176,8 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
167
176
  // but allows `.` and `..` as valid characters. A lone `..` id would
168
177
  // escape via getTaskDir's join: `tasks/..` resolves to `.aperant/`.
169
178
  if (explicitIdRaw) {
170
- const tasksBase = resolve(join(targetDir, '.aperant', 'tasks'))
171
- const candidateDir = resolve(getTaskDir(targetDir, explicitIdRaw))
179
+ const tasksBase = resolve(join(stateRoot, '.aperant', 'tasks'))
180
+ const candidateDir = resolve(getTaskDir(stateRoot, explicitIdRaw))
172
181
  if (!candidateDir.startsWith(`${tasksBase}/`) && candidateDir !== tasksBase) {
173
182
  return err('[apt-tools] Invalid task id: path escapes the tasks directory', 1, {
174
183
  command: 'task-create',
@@ -213,7 +222,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
213
222
  : []
214
223
  const known = new Set([...activeIds, ...completedIds])
215
224
  const isTaken = (candidate) =>
216
- known.has(candidate) || existsSync(getTaskDir(targetDir, candidate))
225
+ known.has(candidate) || existsSync(getTaskDir(stateRoot, candidate))
217
226
  let candidate = base
218
227
  let counter = 2
219
228
  while (isTaken(candidate)) {
@@ -231,7 +240,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
231
240
 
232
241
  // Create task directory under the lock so a concurrent
233
242
  // run can't observe (and then re-pick) a half-created id.
234
- taskDir = getTaskDir(targetDir, taskId)
243
+ taskDir = getTaskDir(stateRoot, taskId)
235
244
  mkdirSync(taskDir, { recursive: true })
236
245
  for (const subdir of ['events', 'locks']) {
237
246
  mkdirSync(join(taskDir, subdir), { recursive: true })
@@ -295,6 +304,10 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
295
304
  if (isolation.from_branch && isolation.from_branch !== 'current') {
296
305
  wtArgs.push('--from', isolation.from_branch)
297
306
  }
307
+ // state-fork-fix ID-09b: forward the task's effective autonomy
308
+ // so `worktree create` can pick the 0-1 confirm vs. 2-3 log
309
+ // branch for a non-default `from_branch: current` base.
310
+ wtArgs.push('--autonomy', String(autonomy))
298
311
  // Invoke inline so we capture the structured payload
299
312
  // without shelling back out. We cheat by calling the
300
313
  // worktree creator directly as a subprocess to keep
@@ -310,6 +323,19 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
310
323
  )
311
324
  if (createRes.status === 0 && createRes.stdout) {
312
325
  const wtOut = JSON.parse(createRes.stdout)
326
+ // state-fork-fix ID-09b: a requires_input non-default-base
327
+ // return (autonomy <= 1) is NOT a created worktree — degrade
328
+ // it to a worktree_warning so task-create still SUCCEEDS
329
+ // (task created, worktree deferred) instead of recording a
330
+ // bogus undefined worktree_path. The operator re-runs
331
+ // `worktree create` with an explicit --from.
332
+ if (wtOut.status === 'requires_input' || wtOut.reason === 'non_default_base') {
333
+ state.active_tasks[taskId].worktree_warning =
334
+ `worktree deferred: non-default base — re-run worktree create with explicit --from (base would be '${wtOut.base_branch}', default '${wtOut.default_branch}')`
335
+ state.last_activity = new Date().toISOString()
336
+ atomicWriteJson(statePath, state)
337
+ return
338
+ }
313
339
  state.active_tasks[taskId].worktree_path = wtOut.worktree_path
314
340
  state.active_tasks[taskId].branch = wtOut.branch
315
341
  state.active_tasks[taskId].base_branch = wtOut.base_branch
@@ -370,12 +396,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
370
396
  state.last_activity = new Date().toISOString()
371
397
  atomicWriteJson(statePath, state)
372
398
  },
373
- { projectRoot: targetDir },
399
+ { projectRoot: stateRoot },
374
400
  )
375
401
 
376
402
  if (collisionOverflowErr) return collisionOverflowErr
377
403
 
378
- regenerateTaskIndex(targetDir)
404
+ regenerateTaskIndex(stateRoot)
379
405
 
380
406
  // Team collaboration: register user + add to active board
381
407
  updateTeamMember(targetDir)
@@ -517,7 +543,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
517
543
  atomicWriteJson(statePath, freshState)
518
544
  }
519
545
  },
520
- { projectRoot: targetDir },
546
+ { projectRoot: stateRoot },
521
547
  )
522
548
  } catch {
523
549
  /* best-effort — the in-memory `task` is already rewritten */
@@ -525,7 +551,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
525
551
  }
526
552
  }
527
553
 
528
- const taskDir = getTaskDir(targetDir, taskId)
554
+ const taskDir = getTaskDir(stateRoot, taskId)
529
555
  const hasSpec = existsSync(join(taskDir, 'spec.md'))
530
556
  const hasPlan = existsSync(join(taskDir, 'implementation_plan.json'))
531
557
  const hasProgress = existsSync(join(taskDir, 'build-progress.json'))
@@ -549,6 +575,18 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
549
575
  // runs fetch + ff-only instead.
550
576
  const prMerged = flags.has('pr-merged-remotely')
551
577
 
578
+ // G45 — followups disposition gate. `--force --reason "<text>"`
579
+ // bypasses it with an audit event (G42 Phase 1 pattern). Reject
580
+ // --force without --reason, mirroring gate.mjs:88.
581
+ const closeForce = flags.get('force') === true
582
+ const closeReasonRaw = flags.get('reason')
583
+ const closeReason = typeof closeReasonRaw === 'string' ? closeReasonRaw : null
584
+ if (closeForce && !closeReason) {
585
+ return err(
586
+ '--force requires --reason "<explanation>" (the override is logged to the audit trail)',
587
+ )
588
+ }
589
+
552
590
  if (!existsSync(statePath)) return err('No state.json found')
553
591
 
554
592
  // withFileLock callback can produce either an error envelope (early
@@ -560,14 +598,16 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
560
598
  () => {
561
599
  const state = JSON.parse(readFileSync(statePath, 'utf-8'))
562
600
 
563
- // Handle both formats
601
+ // Locate the task WITHOUT mutating yet — the G45 disposition
602
+ // gate (below) must run before any state mutation so a refused
603
+ // close leaves active_tasks intact.
564
604
  let task = null
605
+ let taskInActiveMap = false
565
606
  if (state.active_tasks?.[taskId]) {
566
607
  task = state.active_tasks[taskId]
567
- delete state.active_tasks[taskId]
608
+ taskInActiveMap = true
568
609
  } else if (state.active_task?.id === taskId) {
569
610
  task = state.active_task
570
- state.active_task = null
571
611
  }
572
612
 
573
613
  if (!task) {
@@ -575,6 +615,75 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
575
615
  return
576
616
  }
577
617
 
618
+ // G45 — followups disposition gate (ID-06 / ID-13). Runs at the
619
+ // TOP of the lock, before any state mutation, so close-merged
620
+ // (:1476 spawns `task close`) + the passive sweep inherit it.
621
+ // Skipped for verdict 'rejected' (abandoned work has moot
622
+ // follow-ups). Absent/malformed followups.json → no gating
623
+ // (byte-identical close; malformed is `followups check`'s
624
+ // problem, not close's). `--force --reason` bypasses with an
625
+ // audited task.close.followups-override event.
626
+ if (verdict !== 'rejected') {
627
+ const followupsPath = join(getTaskDir(stateRoot, taskId), 'followups.json')
628
+ let ledgerDoc = null
629
+ if (existsSync(followupsPath)) {
630
+ try {
631
+ ledgerDoc = JSON.parse(readFileSync(followupsPath, 'utf-8'))
632
+ } catch {
633
+ ledgerDoc = null // malformed → no gating
634
+ }
635
+ }
636
+ if (ledgerDoc !== null) {
637
+ const parsed = parseFollowups(ledgerDoc)
638
+ const blocking = listBlockingRowsForClose(parsed)
639
+ if (blocking.length > 0) {
640
+ const blockingSummary = blocking.map((r) => ({
641
+ id: r.id,
642
+ title: r.title ?? null,
643
+ status: r.status,
644
+ verdict: r.adjudication?.verdict ?? 'unadjudicated',
645
+ disposition_kind: r.disposition?.kind ?? null,
646
+ }))
647
+ if (!closeForce) {
648
+ closeError = err(
649
+ `Task "${taskId}" has ${blocking.length} open follow-up row(s) without a durable disposition — disposition them (\`apt-tools followups disposition\`) or re-run with --force --reason "<text>"`,
650
+ 1,
651
+ {
652
+ command: 'task-close',
653
+ task_id: taskId,
654
+ blocking_followups: blockingSummary,
655
+ },
656
+ )
657
+ return
658
+ }
659
+ // Forced bypass — audit the override. `appendEvent` is
660
+ // best-effort (it swallows I/O errors so a full disk
661
+ // can't block a lifecycle transition). For this
662
+ // accountability event specifically, surface a stderr
663
+ // warning when the write does not land so the bypass
664
+ // is never SILENTLY unaudited — without re-introducing
665
+ // a hard block on the close.
666
+ const auditLogged = store.appendEvent({
667
+ op: 'task.close.followups-override',
668
+ task: taskId,
669
+ data: { rows: blockingSummary, reason: closeReason },
670
+ })
671
+ if (!auditLogged) {
672
+ process.stderr.write(
673
+ `[task-close] WARNING: forced followups override for "${taskId}" could not be written to the audit log — the bypass proceeded UNAUDITED.\n`,
674
+ )
675
+ }
676
+ }
677
+ }
678
+ }
679
+
680
+ // Gate passed (or bypassed) — now mutate.
681
+ if (taskInActiveMap) {
682
+ delete state.active_tasks[taskId]
683
+ } else {
684
+ state.active_task = null
685
+ }
686
+
578
687
  if (!state.completed_tasks) state.completed_tasks = []
579
688
  // C28 — preserve scope + lifecycle_phase on the completed row so
580
689
  // INDEX.md can group completed tasks by scope and CompletedTaskRecord
@@ -591,7 +700,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
591
700
  state.last_activity = new Date().toISOString()
592
701
  atomicWriteJson(statePath, state)
593
702
 
594
- regenerateTaskIndex(targetDir)
703
+ regenerateTaskIndex(stateRoot)
595
704
 
596
705
  // Team collaboration: remove from active board
597
706
  updateTeamMember(targetDir)
@@ -742,7 +851,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
742
851
  }
743
852
  closeResult = ok(closeOut)
744
853
  },
745
- { projectRoot: targetDir },
854
+ { projectRoot: stateRoot },
746
855
  )
747
856
  return closeError ?? closeResult
748
857
  }
@@ -1054,7 +1163,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1054
1163
  }
1055
1164
  }
1056
1165
 
1057
- regenerateTaskIndex(targetDir)
1166
+ regenerateTaskIndex(stateRoot)
1058
1167
 
1059
1168
  // Team collaboration: sync active board with updated state
1060
1169
  updateTeamMember(targetDir)
@@ -1084,7 +1193,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1084
1193
  }
1085
1194
  updateResult = ok(updateOut)
1086
1195
  },
1087
- { projectRoot: targetDir },
1196
+ { projectRoot: stateRoot },
1088
1197
  )
1089
1198
  return updateError ?? updateResult
1090
1199
  }
@@ -1236,7 +1345,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1236
1345
  state.last_activity = new Date().toISOString()
1237
1346
  atomicWriteJson(statePath, state)
1238
1347
 
1239
- regenerateTaskIndex(targetDir)
1348
+ regenerateTaskIndex(stateRoot)
1240
1349
  updateTeamMember(targetDir)
1241
1350
  store.upsertTeamStatus(taskId, {
1242
1351
  description: task.description,
@@ -1253,7 +1362,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1253
1362
  task,
1254
1363
  })
1255
1364
  },
1256
- { projectRoot: targetDir },
1365
+ { projectRoot: stateRoot },
1257
1366
  )
1258
1367
  return moveError ?? moveResult
1259
1368
  }
@@ -1272,7 +1381,10 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1272
1381
  const notes = flags.get('notes') || null
1273
1382
  try {
1274
1383
  const result = completeSubtask({
1275
- projectDir: targetDir,
1384
+ // state-fork-fix: tasks/<id>/ (plan + build-progress.json) is
1385
+ // EPHEMERAL — resolve to the main board so a complete-subtask
1386
+ // issued from a worktree updates the one authoritative task dir.
1387
+ projectDir: stateRoot,
1276
1388
  taskId,
1277
1389
  subtaskId: subtaskIdFlag,
1278
1390
  commit,
@@ -1461,7 +1573,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1461
1573
  prUrlMutated = true
1462
1574
  }
1463
1575
  },
1464
- { projectRoot: targetDir },
1576
+ { projectRoot: stateRoot },
1465
1577
  )
1466
1578
  }
1467
1579
  } catch {
@@ -1592,7 +1704,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1592
1704
  })
1593
1705
  atomicWriteJson(statePath, fresh)
1594
1706
  },
1595
- { projectRoot: targetDir },
1707
+ { projectRoot: stateRoot },
1596
1708
  )
1597
1709
  } catch (e) {
1598
1710
  errors.push({
@@ -1647,7 +1759,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1647
1759
  }
1648
1760
  remaining = ledger.length
1649
1761
  },
1650
- { projectRoot: targetDir },
1762
+ { projectRoot: stateRoot },
1651
1763
  )
1652
1764
 
1653
1765
  return ok({
@@ -1712,7 +1824,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1712
1824
  skipped.push({ id: row.id, reason: 'worktree-active' })
1713
1825
  continue
1714
1826
  }
1715
- const taskDir = getTaskDir(targetDir, row.id)
1827
+ const taskDir = getTaskDir(stateRoot, row.id)
1716
1828
  if (!existsSync(taskDir)) {
1717
1829
  skipped.push({ id: row.id, reason: 'dir-missing' })
1718
1830
  continue