@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
@@ -71,13 +71,15 @@ export function buildAperantGitignoreTemplate(policy = DEFAULT_SHARE_POLICY) {
71
71
  '# framework scaffolding that ships with the repo. Runtime workflow run',
72
72
  '# files (workflows/*.json + workflows/archive/) are ephemeral.',
73
73
  '# shared-source (committed iff matching share.* toggle is on):',
74
- '# team.json, roadmap/**, decisions/*.md, digests/*.md, tasks/*/** —',
75
- '# the allow rules below are emitted only when the policy opts in.',
74
+ '# team.json, roadmap/**, decisions/*.md, digests/*.md, tasks/*/**,',
75
+ '# quick/*/** (quick shares the tasks toggle) the allow rules below',
76
+ '# are emitted only when the policy opts in.',
76
77
  '# ephemeral/coordination (always ignored): state.json, active.json (legacy, removed in C34 — rule kept as defense in depth), events/,',
77
78
  '# locks/, team-status/, paused/, pending-effects.jsonl — per-machine',
78
79
  '# lifecycle data; committing leaks PIDs + hostnames.',
79
- '# derived/regenerated (always ignored): tasks/INDEX.md, roadmap/**/ROADMAP.md,',
80
- '# decisions/INDEX.md — humans read the Markdown, machines write the JSON.',
80
+ '# derived/regenerated (always ignored): tasks/INDEX.md, quick/INDEX.md,',
81
+ '# roadmap/**/ROADMAP.md, decisions/INDEX.md — humans read the Markdown,',
82
+ '# machines write the JSON.',
81
83
  '',
82
84
  '# Ignore everything by default',
83
85
  '*',
@@ -139,6 +141,15 @@ export function buildAperantGitignoreTemplate(policy = DEFAULT_SHARE_POLICY) {
139
141
  lines.push('!tasks/*/ # class: shared-source')
140
142
  lines.push('!tasks/*/** # class: shared-source')
141
143
  lines.push('')
144
+ // QUICK-task records share the tasks toggle (quick is a task track).
145
+ // Same shape as tasks/: per-record dirs allowed, derived INDEX denied.
146
+ lines.push('# Quick-task records (the per-task directory content, not the INDEX)')
147
+ lines.push('!quick/')
148
+ lines.push('quick/*')
149
+ lines.push('# Re-allow per-quick-task directories; INDEX.md is derived and stays ignored.')
150
+ lines.push('!quick/*/ # class: shared-source')
151
+ lines.push('!quick/*/** # class: shared-source')
152
+ lines.push('')
142
153
  }
143
154
 
144
155
  // ── Shared-source: roadmap (share.roadmap) ──────────────────────────────
@@ -193,6 +204,10 @@ export function buildAperantGitignoreTemplate(policy = DEFAULT_SHARE_POLICY) {
193
204
  lines.push('')
194
205
  lines.push('# ── Derived markdown indexes (regenerated from JSON) ──────────────')
195
206
  lines.push('tasks/INDEX.md')
207
+ // quick/INDEX.md is the QUICK-task analogue of tasks/INDEX.md — a derived
208
+ // markdown index regenerated from the per-task records. Always denied,
209
+ // regardless of share.tasks, so it never lingers as a tracked dirty file.
210
+ lines.push('quick/INDEX.md')
196
211
  lines.push('')
197
212
 
198
213
  return lines.join('\n')
@@ -298,7 +313,14 @@ function loadPolicyFromConfig(aperantDir) {
298
313
  }
299
314
 
300
315
  function findTrackedFilesThatWillBeIgnored(projectRoot) {
301
- const candidates = ['.aperant/state.json', '.aperant/active.json', '.aperant/tasks/INDEX.md']
316
+ const candidates = [
317
+ '.aperant/state.json',
318
+ '.aperant/active.json',
319
+ '.aperant/tasks/INDEX.md',
320
+ // Derived QUICK index — always ignored now; surface the historical
321
+ // orphan (committed before quick/ entered the taxonomy) for untracking.
322
+ '.aperant/quick/INDEX.md',
323
+ ]
302
324
  // roadmap/**/ROADMAP.md — enumerate via git ls-files below.
303
325
  const tracked = new Set()
304
326
  try {
@@ -48,7 +48,7 @@ export function parseQaSignoff(qa) {
48
48
  findings.push({
49
49
  severity: sev,
50
50
  title: typeof f.title === 'string' ? f.title : typeof f.message === 'string' ? f.message : '',
51
- status: typeof f.status === 'string' ? f.status.toLowerCase() : 'open',
51
+ status: normalizeFindingStatus(f),
52
52
  file: typeof f.file === 'string' ? f.file : typeof f.path === 'string' ? f.path : '',
53
53
  })
54
54
  }
@@ -100,3 +100,13 @@ function normalizeSeverity(sev) {
100
100
  const key = sev.toLowerCase().trim()
101
101
  return SEVERITY_MAP[key] ?? null
102
102
  }
103
+
104
+ // Mirror parse-review's `normalizeStatus` shorthand: a finding marked with the
105
+ // `fixed: true` / `resolved: true` boolean (no string `status`) is `fixed`.
106
+ // Without this, derive.mjs would treat such a finding as a still-open candidate
107
+ // and the ledger would have to cover a phantom expected row.
108
+ function normalizeFindingStatus(f) {
109
+ if (f.fixed === true) return 'fixed'
110
+ if (f.resolved === true) return 'fixed'
111
+ return typeof f.status === 'string' ? f.status.toLowerCase() : 'open'
112
+ }
@@ -61,6 +61,42 @@ export function parseReview(review) {
61
61
  return { findings }
62
62
  }
63
63
 
64
+ /**
65
+ * Like {@link parseReview} but RETAINS the sub-critical findings that
66
+ * `DROPPED_SEVERITIES` discards (suggestion/nitpick/note/info/minor). The
67
+ * severity is the raw lowercased string (no SEVERITY_MAP collapse), so a
68
+ * `warning` stays `warning` and a `minor` stays `minor`. Status uses the
69
+ * identical `normalizeStatus` rules as parseReview (`fixed:true`,
70
+ * `resolved:true`, `deferred-*` collapse). The id-synthesis (`rf-<i>`) and
71
+ * `{id, severity, status, title, file}` shape match parseReview.
72
+ *
73
+ * Additive export — parseReview's output is byte-stable.
74
+ *
75
+ * @param {object | null | undefined} review
76
+ * @returns {{findings: Array<{id: string, severity: string, status: string, title: string, file: string}>}}
77
+ */
78
+ export function parseReviewRaw(review) {
79
+ if (!review || typeof review !== 'object') {
80
+ return { findings: [] }
81
+ }
82
+
83
+ const raw = collectRaw(review)
84
+ const findings = []
85
+ for (let i = 0; i < raw.length; i++) {
86
+ const f = raw[i]
87
+ if (!f || typeof f !== 'object') continue
88
+ if (typeof f.severity !== 'string') continue
89
+ findings.push({
90
+ id: typeof f.id === 'string' ? f.id : typeof f.id === 'number' ? String(f.id) : `rf-${i + 1}`,
91
+ severity: f.severity.toLowerCase().trim(),
92
+ status: normalizeStatus(f),
93
+ title: typeof f.title === 'string' ? f.title : typeof f.message === 'string' ? f.message : '',
94
+ file: typeof f.file === 'string' ? f.file : typeof f.path === 'string' ? f.path : '',
95
+ })
96
+ }
97
+ return { findings }
98
+ }
99
+
64
100
  function collectRaw(review) {
65
101
  const out = []
66
102
  const findings = review.findings
@@ -43,6 +43,49 @@ export function parseSpec(md) {
43
43
  return { acs, dimensions_claimed }
44
44
  }
45
45
 
46
+ /**
47
+ * Extract `## Out of Scope` bullets from a spec.md string.
48
+ *
49
+ * Heading match is case-insensitive at any level >= 2. Bullets may be
50
+ * `-`/`*`/numbered; the section body ends at the next heading of
51
+ * equal-or-shallower depth. Returns `[]` on absent section or non-string
52
+ * input. Does NOT touch the filesystem (additive sibling to parseSpec — the
53
+ * existing parseSpec return shape is unchanged).
54
+ *
55
+ * @param {string | null | undefined} md
56
+ * @returns {Array<{text: string, line: number}>}
57
+ */
58
+ export function parseOutOfScope(md) {
59
+ if (typeof md !== 'string' || md.length === 0) return []
60
+
61
+ const lines = md.split(/\r?\n/)
62
+ let headingIdx = -1
63
+ let headingLevel = 0
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const m = lines[i].match(/^(#{2,})\s*out\s+of\s+scope\s*$/i)
66
+ if (m) {
67
+ headingIdx = i
68
+ headingLevel = m[1].length
69
+ break
70
+ }
71
+ }
72
+ if (headingIdx === -1) return []
73
+
74
+ const out = []
75
+ for (let i = headingIdx + 1; i < lines.length; i++) {
76
+ const line = lines[i]
77
+ const h = line.match(/^(#{1,6})\s+/)
78
+ if (h && h[1].length <= headingLevel) break
79
+
80
+ const bulletMatch = line.match(/^\s*(?:[-*]|\d+\.)\s+(.*)$/)
81
+ if (!bulletMatch) continue
82
+ const text = bulletMatch[1].trim()
83
+ if (text.length === 0) continue
84
+ out.push({ text, line: i + 1 })
85
+ }
86
+ return out
87
+ }
88
+
46
89
  function collectAcs(lines) {
47
90
  // Find the first "## Acceptance Criteria" heading (level >= 2).
48
91
  let headingIdx = -1
@@ -18,20 +18,22 @@
18
18
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
19
19
  import { hostname as osHostname } from 'node:os'
20
20
  import { join } from 'node:path'
21
- import { detectWorktree } from '../git/worktree-detect.mjs'
22
21
  import { withFileLock } from '../util/fs-lock.mjs'
23
22
  import { atomicWriteJson } from '../util/io.mjs'
23
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
24
24
 
25
25
  // Cached identity per project root (rooted at the shared .aperant/ dir).
26
26
  const cache = new Map()
27
27
 
28
28
  /**
29
29
  * Resolve the shared coordination root — always the main repo's root, even
30
- * when called from a worktree. Mirrors commands/event.mjs.
30
+ * when called from a worktree. Body re-pointed at the shared resolveMainRepoRoot
31
+ * mechanism (state-fork-fix ID-07) to inherit the null-guard; the name and the
32
+ * @internal CoordinationStore seam ownership stay as-is (it is NOT merged with
33
+ * resolveStateDir — it is a SEPARATE policy wrapper over the same mechanism).
31
34
  */
32
35
  export function resolveSharedRoot(projectDir) {
33
- const wt = detectWorktree(projectDir)
34
- return wt.is_worktree && wt.main_repo_root ? wt.main_repo_root : projectDir
36
+ return resolveMainRepoRoot(projectDir)
35
37
  }
36
38
 
37
39
  /**
@@ -63,6 +63,7 @@ import { cmdEffects } from './commands/effects.mjs'
63
63
  import { cmdEvent } from './commands/event.mjs'
64
64
  import { cmdFanOut } from './commands/fan-out.mjs'
65
65
  import { cmdFeaturesAudit } from './commands/features-audit.mjs'
66
+ import { cmdFollowups } from './commands/followups.mjs'
66
67
  import { cmdGate } from './commands/gate.mjs'
67
68
  import { cmdHealthCheck } from './commands/health-check.mjs'
68
69
  import { cmdHostDetect, cmdReconcile } from './commands/host-detect.mjs'
@@ -70,6 +71,7 @@ import { cmdInit } from './commands/init.mjs'
70
71
  import { cmdInitState } from './commands/init-state.mjs'
71
72
  import { cmdInstallFromSource } from './commands/install-from-source.mjs'
72
73
  import { cmdLock } from './commands/lock.mjs'
74
+ import { cmdMerge } from './commands/merge-detect.mjs'
73
75
  import { cmdMigratePrReviews } from './commands/migrate-pr-reviews.mjs'
74
76
  import { cmdMobilePrepare } from './commands/mobile-prepare.mjs'
75
77
  import { cmdPersonas } from './commands/personas.mjs'
@@ -317,6 +319,7 @@ export const DISPATCH = {
317
319
  'variant-pick': (args) => wrap(cmdVariantPick)(args[1], args[2], args.slice(3)),
318
320
  worktree: (args) => wrap(cmdWorktree)(args[1], args[2], args.slice(3)),
319
321
  audit: (args) => wrap(cmdAudit)(args[1], args[2], args.slice(3)),
322
+ merge: (args) => wrap(cmdMerge)(args[1], args[2], args.slice(3)),
320
323
  'release-notes': (args) => wrap(cmdReleaseNotes)(args[1], args[2], args.slice(3)),
321
324
  team: (args) => wrap(cmdTeam)(args[1], args[2], args.slice(3)),
322
325
  active: (args) => wrap(cmdActive)(args[1], args[2], args.slice(3)),
@@ -336,6 +339,7 @@ export const DISPATCH = {
336
339
  context: (args) => wrap(cmdContext)(args[1], args[2], args.slice(3)),
337
340
  adr: (args) => wrap(cmdAdr)(args[1], args[2], args.slice(3)),
338
341
  triage: (args) => wrap(cmdTriage)(args[1], args[2], args.slice(3)),
342
+ followups: (args) => wrap(cmdFollowups)(args[1], args[2], args.slice(3)),
339
343
  modes: async (args) => {
340
344
  const { cmdModes } = await import('./commands/modes.mjs')
341
345
  return wrap(cmdModes)(args[1], args[2], args.slice(3))
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @internal
3
+ * followups/derive.mjs — deterministic expected-candidate derivation (G45, ID-04).
4
+ *
5
+ * Pure: takes already-parsed inputs (no fs), returns the expected row
6
+ * universe + outcome-ref validation. The expected universe is what makes the
7
+ * ledger DERIVED, not voluntary — a writer cannot self-certify completeness.
8
+ *
9
+ * Expected universe =
10
+ * parseOutOfScope(spec.md) bullets → origin 'plan', id `oos-<n>`
11
+ * ∪ parseReviewRaw(review.json) rows status !== 'fixed' → origin 'review', id `rev-<finding.id>`
12
+ * ∪ parseQaSignoff(qa_signoff.json) findings status!=='fixed' → origin 'verify', id `qa-<n>`
13
+ *
14
+ * Each candidate carries a full payload `{id, title, origin, class_hint}` so
15
+ * the adjudicating agent appends ledger rows mechanically. `class_hint` is a
16
+ * non-authoritative suggestion (ID-03): plan/verify default to 'new-task',
17
+ * review findings default to 'in-pr'.
18
+ */
19
+
20
+ import { parseQaSignoff } from '../consistency/parse-qa.mjs'
21
+ import { parseReviewRaw } from '../consistency/parse-review.mjs'
22
+ import { parseOutOfScope } from '../consistency/parse-spec.mjs'
23
+
24
+ /**
25
+ * @param {{specContent?: string, review?: object, qa?: object}} inputs
26
+ * @returns {Array<{id: string, title: string, origin: string, class_hint: string}>}
27
+ */
28
+ export function deriveExpectedCandidates({ specContent, review, qa } = {}) {
29
+ /** @type {Array<{id: string, title: string, origin: string, class_hint: string}>} */
30
+ const candidates = []
31
+
32
+ // (1) Out of Scope bullets — origin plan, 1-based ordinal id.
33
+ const oos = parseOutOfScope(specContent)
34
+ for (let i = 0; i < oos.length; i++) {
35
+ candidates.push({
36
+ id: `oos-${i + 1}`,
37
+ title: oos[i].text,
38
+ origin: 'plan',
39
+ class_hint: 'new-task',
40
+ })
41
+ }
42
+
43
+ // (2) Review raw rows that are not fixed — origin review, id rev-<finding.id>.
44
+ const rawReview = parseReviewRaw(review)
45
+ for (const f of rawReview.findings) {
46
+ if (f.status === 'fixed') continue
47
+ candidates.push({
48
+ id: `rev-${f.id}`,
49
+ title: f.title || f.id,
50
+ origin: 'review',
51
+ class_hint: 'in-pr',
52
+ })
53
+ }
54
+
55
+ // (3) QA sign-off findings that are not fixed — origin verify, 1-based
56
+ // ordinal by ABSOLUTE position in the findings list. The id MUST be stable
57
+ // as findings get fixed over the task's life: a counter over the filtered
58
+ // (non-fixed) subset would renumber qa-2 → qa-1 once an earlier finding
59
+ // flips to fixed, so an already-adjudicated ledger row (id qa-2) would
60
+ // resurface as a "missing expected" candidate — the exact evaporation this
61
+ // gate exists to prevent. Position-based ids match the oos path (absolute
62
+ // index) and the rev path (stable finding id).
63
+ const qaParsed = parseQaSignoff(qa)
64
+ for (let i = 0; i < qaParsed.findings.length; i++) {
65
+ const f = qaParsed.findings[i]
66
+ if (f.status === 'fixed') continue
67
+ candidates.push({
68
+ id: `qa-${i + 1}`,
69
+ title: f.title || `qa finding ${i + 1}`,
70
+ origin: 'verify',
71
+ class_hint: 'new-task',
72
+ })
73
+ }
74
+
75
+ return candidates
76
+ }
77
+
78
+ /**
79
+ * Validate every `outcome_refs[]` entry + `adjudication.outcome_ref` on the
80
+ * ledger rows against the set of `O<n>` ids from parseUserOutcomes. Unknown
81
+ * refs are returned for `invalid_outcome_refs[]` (never a new id namespace,
82
+ * ID-01). A null/omitted `adjudication.outcome_ref` is valid (refactor-only
83
+ * specs have no outcomes).
84
+ *
85
+ * @param {Array<{id: string, outcome_refs?: string[], adjudication?: {outcome_ref?: string|null}}>} rows — validated ledger rows (only id / outcome_refs / adjudication.outcome_ref are read)
86
+ * @param {{outcomes?: Array<{id: string}>}} parsedOutcomes — parseUserOutcomes(spec.md)
87
+ * @returns {Array<{row_id: string, ref: string, field: string}>}
88
+ */
89
+ export function validateOutcomeRefs(rows, parsedOutcomes) {
90
+ const valid = new Set(
91
+ (parsedOutcomes && Array.isArray(parsedOutcomes.outcomes) ? parsedOutcomes.outcomes : []).map(
92
+ (o) => o.id,
93
+ ),
94
+ )
95
+ /** @type {Array<{row_id: string, ref: string, field: string}>} */
96
+ const invalid = []
97
+ const list = Array.isArray(rows) ? rows : []
98
+ for (const r of list) {
99
+ const refs = Array.isArray(r.outcome_refs) ? r.outcome_refs : []
100
+ for (const ref of refs) {
101
+ if (!valid.has(ref)) invalid.push({ row_id: r.id, ref, field: 'outcome_refs' })
102
+ }
103
+ const adjRef = r.adjudication?.outcome_ref
104
+ if (typeof adjRef === 'string' && adjRef.length > 0 && !valid.has(adjRef)) {
105
+ invalid.push({ row_id: r.id, ref: adjRef, field: 'adjudication.outcome_ref' })
106
+ }
107
+ }
108
+ return invalid
109
+ }
110
+
111
+ /**
112
+ * Compute the set of expected candidate ids missing from the ledger rows.
113
+ * Returns the full candidate payloads for each missing id (so the agent can
114
+ * append them mechanically — ID-04).
115
+ *
116
+ * @param {Array<{id: string, title: string, origin: string, class_hint: string}>} candidates
117
+ * @param {Array<{id: string}>} rows — only the `id` is read
118
+ * @returns {Array<{id: string, title: string, origin: string, class_hint: string}>}
119
+ */
120
+ export function computeMissingExpected(candidates, rows) {
121
+ const present = new Set((Array.isArray(rows) ? rows : []).map((r) => r.id))
122
+ return (Array.isArray(candidates) ? candidates : []).filter((c) => !present.has(c.id))
123
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * @internal
3
+ * followups/schema.mjs — validate the per-task followups.json ledger (G45).
4
+ *
5
+ * Pure: takes a parsed JSON document, returns a normalized shape with
6
+ * structured `schema_errors[]`. Never throws, never touches the filesystem
7
+ * (mirrors the consistency-parser style).
8
+ *
9
+ * Row shape:
10
+ * {
11
+ * id: string, // free id (e.g. oos-1, rev-rf-3, qa-2, or a voluntary id)
12
+ * title: string,
13
+ * class_proposed: 'in-pr' | 'new-task' | 'polish', // a PROPOSAL — adjudication is authority (ID-03)
14
+ * origin: 'plan' | 'discuss' | 'execute' | 'verify' | 'review' | 'ci',
15
+ * files: string[],
16
+ * outcome_refs: string[], // 'O<n>' ids validated elsewhere (derive.validateOutcomeRefs)
17
+ * status: 'open' | 'fixed' | 'dispositioned',
18
+ * adjudication?: {
19
+ * verdict: 'load-bearing' | 'deferrable',
20
+ * by: 'verify' | 'verify-proof',
21
+ * outcome_ref?: string | null // nullable — refactor-only specs have no outcomes (ID-01)
22
+ * },
23
+ * disposition?: {
24
+ * kind: 'filed_task' | 'executed_commit' | 'parked_note' | 'dropped_by_user',
25
+ * ref: string,
26
+ * reason?: string // required for dropped_by_user (enforced at the CLI seam)
27
+ * }
28
+ * }
29
+ *
30
+ * Output:
31
+ * { rows: ValidRow[], schema_errors: [{row_index, id?, field, message}] }
32
+ *
33
+ * A row that fails validation is reported in schema_errors and EXCLUDED from
34
+ * rows[] (so downstream gate logic never reasons over a malformed row).
35
+ */
36
+
37
+ export const CLASS_PROPOSED = new Set(['in-pr', 'new-task', 'polish'])
38
+ export const ORIGINS = new Set(['plan', 'discuss', 'execute', 'verify', 'review', 'ci'])
39
+ export const STATUSES = new Set(['open', 'fixed', 'dispositioned'])
40
+ export const VERDICTS = new Set(['load-bearing', 'deferrable'])
41
+ export const ADJUDICATORS = new Set(['verify', 'verify-proof'])
42
+ export const DISPOSITION_KINDS = new Set([
43
+ 'filed_task',
44
+ 'executed_commit',
45
+ 'parked_note',
46
+ 'dropped_by_user',
47
+ ])
48
+
49
+ /**
50
+ * @typedef {Object} FollowupAdjudication
51
+ * @property {'load-bearing'|'deferrable'} verdict
52
+ * @property {'verify'|'verify-proof'} by
53
+ * @property {string|null} [outcome_ref]
54
+ *
55
+ * @typedef {Object} FollowupDisposition
56
+ * @property {'filed_task'|'executed_commit'|'parked_note'|'dropped_by_user'} kind
57
+ * @property {string} ref
58
+ * @property {string} [reason]
59
+ *
60
+ * @typedef {Object} FollowupRow
61
+ * @property {string} id
62
+ * @property {string} title
63
+ * @property {'in-pr'|'new-task'|'polish'} class_proposed
64
+ * @property {'plan'|'discuss'|'execute'|'verify'|'review'|'ci'} origin
65
+ * @property {string[]} files
66
+ * @property {string[]} outcome_refs
67
+ * @property {'open'|'fixed'|'dispositioned'} status
68
+ * @property {FollowupAdjudication} [adjudication]
69
+ * @property {FollowupDisposition} [disposition]
70
+ *
71
+ * @typedef {Object} ParsedFollowups
72
+ * @property {FollowupRow[]} rows
73
+ * @property {Array<{row_index: number, id?: string, field: string, message: string}>} schema_errors
74
+ *
75
+ * The selector helpers below read only `status` / `adjudication` /
76
+ * `disposition`, so they accept a structural row shape (any caller-built row
77
+ * with those fields fits) and return the same element type they were given.
78
+ * @typedef {{status?: string, adjudication?: {verdict?: string}, disposition?: {kind?: string, ref?: string}}} SelectorRow
79
+ */
80
+
81
+ /**
82
+ * @param {unknown} doc — a parsed followups.json document. Accepts either
83
+ * `{ rows: [...] }` or a bare `[...]` array of rows.
84
+ * @returns {ParsedFollowups}
85
+ */
86
+ export function parseFollowups(doc) {
87
+ /** @type {Array<{row_index: number, id?: string, field: string, message: string}>} */
88
+ const schema_errors = []
89
+ /** @type {Array<object>} */
90
+ const rows = []
91
+
92
+ const rawRows = extractRows(doc)
93
+ if (rawRows === null) {
94
+ schema_errors.push({
95
+ row_index: -1,
96
+ field: 'rows',
97
+ message: 'followups.json must be an array of rows or an object with a `rows` array',
98
+ })
99
+ return { rows, schema_errors }
100
+ }
101
+
102
+ for (let i = 0; i < rawRows.length; i++) {
103
+ const r = rawRows[i]
104
+ const errsBefore = schema_errors.length
105
+ if (!r || typeof r !== 'object' || Array.isArray(r)) {
106
+ schema_errors.push({ row_index: i, field: 'row', message: 'row must be an object' })
107
+ continue
108
+ }
109
+ const id = typeof r.id === 'string' && r.id.trim().length > 0 ? r.id : undefined
110
+ const push = (field, message) => schema_errors.push({ row_index: i, id, field, message })
111
+
112
+ if (!id) push('id', 'missing or empty `id`')
113
+ if (typeof r.title !== 'string' || r.title.trim().length === 0)
114
+ push('title', 'missing or empty `title`')
115
+ if (!CLASS_PROPOSED.has(r.class_proposed))
116
+ push('class_proposed', `must be one of: ${[...CLASS_PROPOSED].join(', ')}`)
117
+ if (!ORIGINS.has(r.origin)) push('origin', `must be one of: ${[...ORIGINS].join(', ')}`)
118
+ if (!STATUSES.has(r.status)) push('status', `must be one of: ${[...STATUSES].join(', ')}`)
119
+ if (r.files !== undefined && !isStringArray(r.files)) push('files', 'must be a string array')
120
+ if (r.outcome_refs !== undefined && !isStringArray(r.outcome_refs))
121
+ push('outcome_refs', 'must be a string array of O<n> ids')
122
+
123
+ if (r.adjudication !== undefined) {
124
+ const a = r.adjudication
125
+ if (!a || typeof a !== 'object' || Array.isArray(a)) {
126
+ push('adjudication', 'must be an object when present')
127
+ } else {
128
+ if (!VERDICTS.has(a.verdict))
129
+ push('adjudication.verdict', `must be one of: ${[...VERDICTS].join(', ')}`)
130
+ if (!ADJUDICATORS.has(a.by))
131
+ push('adjudication.by', `must be one of: ${[...ADJUDICATORS].join(', ')}`)
132
+ if (
133
+ a.outcome_ref !== undefined &&
134
+ a.outcome_ref !== null &&
135
+ typeof a.outcome_ref !== 'string'
136
+ )
137
+ push('adjudication.outcome_ref', 'must be a string, null, or omitted')
138
+ }
139
+ }
140
+
141
+ if (r.disposition !== undefined) {
142
+ const d = r.disposition
143
+ if (!d || typeof d !== 'object' || Array.isArray(d)) {
144
+ push('disposition', 'must be an object when present')
145
+ } else {
146
+ if (!DISPOSITION_KINDS.has(d.kind))
147
+ push('disposition.kind', `must be one of: ${[...DISPOSITION_KINDS].join(', ')}`)
148
+ if (typeof d.ref !== 'string' || d.ref.trim().length === 0)
149
+ push('disposition.ref', 'missing or empty `ref`')
150
+ }
151
+ }
152
+
153
+ if (schema_errors.length === errsBefore) {
154
+ rows.push({
155
+ id,
156
+ title: r.title,
157
+ class_proposed: r.class_proposed,
158
+ origin: r.origin,
159
+ files: Array.isArray(r.files) ? r.files : [],
160
+ outcome_refs: Array.isArray(r.outcome_refs) ? r.outcome_refs : [],
161
+ status: r.status,
162
+ ...(r.adjudication !== undefined
163
+ ? { adjudication: normalizeAdjudication(r.adjudication) }
164
+ : {}),
165
+ ...(r.disposition !== undefined
166
+ ? { disposition: normalizeDisposition(r.disposition) }
167
+ : {}),
168
+ })
169
+ }
170
+ }
171
+
172
+ return { rows, schema_errors }
173
+ }
174
+
175
+ /**
176
+ * Rows that block `task close`: any `open` row, or any `dispositioned` row
177
+ * without a valid `disposition.kind` + `ref`. (A `fixed` row is settled.)
178
+ *
179
+ * @template {SelectorRow} T
180
+ * @param {{rows: T[]}} parsed
181
+ * @returns {T[]}
182
+ */
183
+ export function listBlockingRowsForClose(parsed) {
184
+ const rows = parsed && Array.isArray(parsed.rows) ? parsed.rows : []
185
+ return rows.filter((r) => {
186
+ if (r.status === 'open') return true
187
+ if (r.status === 'dispositioned') {
188
+ const d = r.disposition
189
+ return (
190
+ !d ||
191
+ !DISPOSITION_KINDS.has(d.kind) ||
192
+ typeof d.ref !== 'string' ||
193
+ d.ref.trim().length === 0
194
+ )
195
+ }
196
+ return false
197
+ })
198
+ }
199
+
200
+ /**
201
+ * Open rows that have no `adjudication` (ship-gate input).
202
+ *
203
+ * @template {SelectorRow} T
204
+ * @param {{rows: T[]}} parsed
205
+ * @returns {T[]}
206
+ */
207
+ export function listUnadjudicatedOpen(parsed) {
208
+ const rows = parsed && Array.isArray(parsed.rows) ? parsed.rows : []
209
+ return rows.filter((r) => r.status === 'open' && !r.adjudication)
210
+ }
211
+
212
+ /**
213
+ * Load-bearing rows that are not yet fixed (ship-gate input).
214
+ *
215
+ * @template {SelectorRow} T
216
+ * @param {{rows: T[]}} parsed
217
+ * @returns {T[]}
218
+ */
219
+ export function listLoadBearingUnfixed(parsed) {
220
+ const rows = parsed && Array.isArray(parsed.rows) ? parsed.rows : []
221
+ return rows.filter(
222
+ (r) => r.adjudication && r.adjudication.verdict === 'load-bearing' && r.status !== 'fixed',
223
+ )
224
+ }
225
+
226
+ /**
227
+ * Rows whose `status === 'dispositioned'` lack a valid disposition record.
228
+ *
229
+ * @template {SelectorRow} T
230
+ * @param {{rows: T[]}} parsed
231
+ * @returns {T[]}
232
+ */
233
+ export function listUndispositioned(parsed) {
234
+ const rows = parsed && Array.isArray(parsed.rows) ? parsed.rows : []
235
+ return rows.filter((r) => {
236
+ if (r.status !== 'dispositioned') return false
237
+ const d = r.disposition
238
+ return (
239
+ !d || !DISPOSITION_KINDS.has(d.kind) || typeof d.ref !== 'string' || d.ref.trim().length === 0
240
+ )
241
+ })
242
+ }
243
+
244
+ function extractRows(doc) {
245
+ if (Array.isArray(doc)) return doc
246
+ if (doc && typeof doc === 'object' && Array.isArray(doc.rows)) return doc.rows
247
+ return null
248
+ }
249
+
250
+ function isStringArray(v) {
251
+ return Array.isArray(v) && v.every((x) => typeof x === 'string')
252
+ }
253
+
254
+ function normalizeAdjudication(a) {
255
+ const out = { verdict: a.verdict, by: a.by }
256
+ if (a.outcome_ref !== undefined) out.outcome_ref = a.outcome_ref
257
+ return out
258
+ }
259
+
260
+ function normalizeDisposition(d) {
261
+ const out = { kind: d.kind, ref: d.ref }
262
+ if (typeof d.reason === 'string') out.reason = d.reason
263
+ return out
264
+ }