@aperant/framework 0.17.0 → 0.20.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 (242) hide show
  1. package/CHANGELOG.md +261 -0
  2. package/agents/apt-executor.md +6 -0
  3. package/agents/apt-planner.md +3 -2
  4. package/bin/features-reconcile-check.mjs +77 -0
  5. package/dist/cli/artifacts/self-stage.d.mts.map +1 -1
  6. package/dist/cli/artifacts/self-stage.mjs +2 -1
  7. package/dist/cli/artifacts/self-stage.mjs.map +1 -1
  8. package/dist/cli/cli-wrappers/features-reconcile.d.mts +2 -0
  9. package/dist/cli/cli-wrappers/features-reconcile.d.mts.map +1 -0
  10. package/dist/cli/cli-wrappers/features-reconcile.mjs +9 -0
  11. package/dist/cli/cli-wrappers/features-reconcile.mjs.map +1 -0
  12. package/dist/cli/commands/ci-watch.mjs +49 -2
  13. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  14. package/dist/cli/commands/commit.mjs +3 -3
  15. package/dist/cli/commands/commit.mjs.map +1 -1
  16. package/dist/cli/commands/event.mjs +16 -16
  17. package/dist/cli/commands/event.mjs.map +1 -1
  18. package/dist/cli/commands/features-reconcile.d.mts +2 -0
  19. package/dist/cli/commands/features-reconcile.d.mts.map +1 -0
  20. package/dist/cli/commands/features-reconcile.mjs +143 -0
  21. package/dist/cli/commands/features-reconcile.mjs.map +1 -0
  22. package/dist/cli/commands/health-check.d.mts +2 -2
  23. package/dist/cli/commands/health-check.mjs +2 -2
  24. package/dist/cli/commands/merge-integrate.mjs +3 -3
  25. package/dist/cli/commands/merge-integrate.mjs.map +1 -1
  26. package/dist/cli/commands/produce.d.mts +9 -0
  27. package/dist/cli/commands/produce.d.mts.map +1 -0
  28. package/dist/cli/commands/produce.mjs +1345 -0
  29. package/dist/cli/commands/produce.mjs.map +1 -0
  30. package/dist/cli/commands/task.d.mts +16 -1
  31. package/dist/cli/commands/task.d.mts.map +1 -1
  32. package/dist/cli/commands/task.mjs +434 -266
  33. package/dist/cli/commands/task.mjs.map +1 -1
  34. package/dist/cli/coordination/auto-emit-artifact-ready.mjs +5 -5
  35. package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -1
  36. package/dist/cli/coordination/event-schema.d.mts +5 -3
  37. package/dist/cli/coordination/event-schema.d.mts.map +1 -1
  38. package/dist/cli/coordination/event-schema.mjs +245 -21
  39. package/dist/cli/coordination/event-schema.mjs.map +1 -1
  40. package/dist/cli/coordination/store.d.mts +2 -2
  41. package/dist/cli/coordination/store.mjs +4 -4
  42. package/dist/cli/coordination/store.mjs.map +1 -1
  43. package/dist/cli/dispatch.d.mts.map +1 -1
  44. package/dist/cli/dispatch.mjs +4 -2
  45. package/dist/cli/dispatch.mjs.map +1 -1
  46. package/dist/cli/features/area-shape.d.mts +11 -0
  47. package/dist/cli/features/area-shape.d.mts.map +1 -0
  48. package/dist/cli/features/area-shape.mjs +141 -0
  49. package/dist/cli/features/area-shape.mjs.map +1 -0
  50. package/dist/cli/features/delta.d.mts +78 -0
  51. package/dist/cli/features/delta.d.mts.map +1 -0
  52. package/dist/cli/features/delta.mjs +591 -0
  53. package/dist/cli/features/delta.mjs.map +1 -0
  54. package/dist/cli/features/reconcile.d.mts +236 -0
  55. package/dist/cli/features/reconcile.d.mts.map +1 -0
  56. package/dist/cli/features/reconcile.mjs +1126 -0
  57. package/dist/cli/features/reconcile.mjs.map +1 -0
  58. package/dist/cli/features/surface-map.d.mts +63 -0
  59. package/dist/cli/features/surface-map.d.mts.map +1 -0
  60. package/dist/cli/features/surface-map.mjs +191 -0
  61. package/dist/cli/features/surface-map.mjs.map +1 -0
  62. package/dist/cli/{commands/features-audit.d.mts → features/write-root.d.mts} +4 -3
  63. package/dist/cli/features/write-root.d.mts.map +1 -0
  64. package/dist/cli/features/write-root.mjs +137 -0
  65. package/dist/cli/features/write-root.mjs.map +1 -0
  66. package/dist/cli/gate/gates/features-reconciled.d.mts +36 -0
  67. package/dist/cli/gate/gates/features-reconciled.d.mts.map +1 -0
  68. package/dist/cli/gate/gates/features-reconciled.mjs +67 -0
  69. package/dist/cli/gate/gates/features-reconciled.mjs.map +1 -0
  70. package/dist/cli/gate/gates/index.mjs +1 -0
  71. package/dist/cli/gate/gates/index.mjs.map +1 -1
  72. package/dist/cli/help.d.mts.map +1 -1
  73. package/dist/cli/help.mjs +31 -1
  74. package/dist/cli/help.mjs.map +1 -1
  75. package/dist/cli/install/legacy-paths.d.mts.map +1 -1
  76. package/dist/cli/install/legacy-paths.mjs +2 -0
  77. package/dist/cli/install/legacy-paths.mjs.map +1 -1
  78. package/dist/cli/produce/blind-probe.d.mts +85 -0
  79. package/dist/cli/produce/blind-probe.d.mts.map +1 -0
  80. package/dist/cli/produce/blind-probe.mjs +217 -0
  81. package/dist/cli/produce/blind-probe.mjs.map +1 -0
  82. package/dist/cli/produce/claim.d.mts +188 -0
  83. package/dist/cli/produce/claim.d.mts.map +1 -0
  84. package/dist/cli/produce/claim.mjs +518 -0
  85. package/dist/cli/produce/claim.mjs.map +1 -0
  86. package/dist/cli/produce/done-gate.d.mts +87 -0
  87. package/dist/cli/produce/done-gate.d.mts.map +1 -0
  88. package/dist/cli/produce/done-gate.mjs +200 -0
  89. package/dist/cli/produce/done-gate.mjs.map +1 -0
  90. package/dist/cli/produce/events.d.mts +77 -0
  91. package/dist/cli/produce/events.d.mts.map +1 -0
  92. package/dist/cli/produce/events.mjs +126 -0
  93. package/dist/cli/produce/events.mjs.map +1 -0
  94. package/dist/cli/produce/evidence-oracle.d.mts +63 -0
  95. package/dist/cli/produce/evidence-oracle.d.mts.map +1 -0
  96. package/dist/cli/produce/evidence-oracle.mjs +122 -0
  97. package/dist/cli/produce/evidence-oracle.mjs.map +1 -0
  98. package/dist/cli/produce/ledger.d.mts +140 -0
  99. package/dist/cli/produce/ledger.d.mts.map +1 -0
  100. package/dist/cli/produce/ledger.mjs +272 -0
  101. package/dist/cli/produce/ledger.mjs.map +1 -0
  102. package/dist/cli/produce/probe-family.d.mts +53 -0
  103. package/dist/cli/produce/probe-family.d.mts.map +1 -0
  104. package/dist/cli/produce/probe-family.mjs +160 -0
  105. package/dist/cli/produce/probe-family.mjs.map +1 -0
  106. package/dist/cli/produce/projection.d.mts +55 -0
  107. package/dist/cli/produce/projection.d.mts.map +1 -0
  108. package/dist/cli/produce/projection.mjs +97 -0
  109. package/dist/cli/produce/projection.mjs.map +1 -0
  110. package/dist/cli/produce/run-id.d.mts +42 -0
  111. package/dist/cli/produce/run-id.d.mts.map +1 -0
  112. package/dist/cli/produce/run-id.mjs +79 -0
  113. package/dist/cli/produce/run-id.mjs.map +1 -0
  114. package/dist/cli/produce/saga.d.mts +180 -0
  115. package/dist/cli/produce/saga.d.mts.map +1 -0
  116. package/dist/cli/produce/saga.mjs +290 -0
  117. package/dist/cli/produce/saga.mjs.map +1 -0
  118. package/dist/cli/produce/scheduler.d.mts +165 -0
  119. package/dist/cli/produce/scheduler.d.mts.map +1 -0
  120. package/dist/cli/produce/scheduler.mjs +399 -0
  121. package/dist/cli/produce/scheduler.mjs.map +1 -0
  122. package/dist/cli/produce/setpoint.d.mts +52 -0
  123. package/dist/cli/produce/setpoint.d.mts.map +1 -0
  124. package/dist/cli/produce/setpoint.mjs +113 -0
  125. package/dist/cli/produce/setpoint.mjs.map +1 -0
  126. package/dist/cli/produce/verification-ttl.d.mts +75 -0
  127. package/dist/cli/produce/verification-ttl.d.mts.map +1 -0
  128. package/dist/cli/produce/verification-ttl.mjs +169 -0
  129. package/dist/cli/produce/verification-ttl.mjs.map +1 -0
  130. package/dist/cli/release-notes/ship-autodraft.mjs +2 -2
  131. package/dist/cli/roadmap/{conductor-view.d.mts → showrunner-view.d.mts} +3 -3
  132. package/dist/cli/roadmap/showrunner-view.d.mts.map +1 -0
  133. package/dist/cli/roadmap/{conductor-view.mjs → showrunner-view.mjs} +7 -7
  134. package/dist/cli/roadmap/showrunner-view.mjs.map +1 -0
  135. package/dist/cli/variant/gallery.d.mts.map +1 -1
  136. package/dist/cli/variant/gallery.mjs +7 -3
  137. package/dist/cli/variant/gallery.mjs.map +1 -1
  138. package/dist/plugin/.claude-plugin/plugin.json +2 -1
  139. package/dist/plugin/agents/apt-executor.md +6 -0
  140. package/dist/plugin/agents/apt-planner.md +3 -2
  141. package/dist/plugin/skills/apt/SKILL.md +112 -38
  142. package/dist/plugin/skills/apt-close-task/SKILL.md +2 -2
  143. package/dist/plugin/skills/apt-debug/SKILL.md +22 -24
  144. package/dist/plugin/skills/apt-execute/SKILL.md +35 -26
  145. package/dist/plugin/skills/apt-fan-out/SKILL.md +4 -4
  146. package/dist/plugin/skills/apt-handoff/SKILL.md +1 -1
  147. package/dist/plugin/skills/apt-mockup/SKILL.md +1 -1
  148. package/dist/plugin/skills/apt-plan/SKILL.md +5 -5
  149. package/dist/plugin/skills/apt-plan/adapters/{conductor.md → showrunner.md} +16 -16
  150. package/dist/plugin/skills/apt-produce/SKILL.md +606 -0
  151. package/dist/plugin/skills/apt-quick/SKILL.md +27 -33
  152. package/dist/plugin/skills/apt-review/SKILL.md +14 -11
  153. package/dist/plugin/skills/apt-run/SKILL.md +132 -3
  154. package/dist/plugin/skills/apt-ship/SKILL.md +29 -5
  155. package/dist/plugin/skills/apt-spar/SKILL.md +5 -3
  156. package/dist/plugin/skills/apt-watch-ci/SKILL.md +6 -4
  157. package/dist/schemas/feature-registry.d.ts.map +1 -1
  158. package/dist/schemas/feature-registry.js +34 -3
  159. package/dist/schemas/feature-registry.js.map +1 -1
  160. package/dist/types/config.d.ts +8 -5
  161. package/dist/types/config.d.ts.map +1 -1
  162. package/package.json +4 -4
  163. package/prompts/{conductor-framework-context.md → showrunner-framework-context.md} +1 -1
  164. package/prompts/{conductor-status-check.md → showrunner-status-check.md} +1 -1
  165. package/prompts/{conductor-sub-agent.md → showrunner-sub-agent.md} +6 -6
  166. package/prompts/{conductor-system.md → showrunner-system.md} +8 -8
  167. package/skills/apt/SKILL.md +112 -38
  168. package/skills/apt-close-task/SKILL.md +2 -2
  169. package/skills/apt-debug/SKILL.md +22 -24
  170. package/skills/apt-execute/SKILL.md +35 -26
  171. package/skills/apt-fan-out/SKILL.md +4 -4
  172. package/skills/apt-handoff/SKILL.md +1 -1
  173. package/skills/apt-mockup/SKILL.md +1 -1
  174. package/skills/apt-plan/SKILL.md +5 -5
  175. package/skills/apt-plan/adapters/{conductor.md → showrunner.md} +16 -16
  176. package/skills/apt-produce/SKILL.md +606 -0
  177. package/skills/apt-quick/SKILL.md +27 -33
  178. package/skills/apt-review/SKILL.md +14 -11
  179. package/skills/apt-run/SKILL.md +132 -3
  180. package/skills/apt-ship/SKILL.md +29 -5
  181. package/skills/apt-spar/SKILL.md +5 -3
  182. package/skills/apt-watch-ci/SKILL.md +6 -4
  183. package/src/cli/artifacts/self-stage.mjs +2 -1
  184. package/src/cli/cli-wrappers/features-reconcile.mjs +9 -0
  185. package/src/cli/commands/ci-watch.mjs +51 -2
  186. package/src/cli/commands/commit.mjs +3 -3
  187. package/src/cli/commands/event.mjs +16 -16
  188. package/src/cli/commands/features-reconcile.mjs +157 -0
  189. package/src/cli/commands/health-check.mjs +2 -2
  190. package/src/cli/commands/merge-integrate.mjs +3 -3
  191. package/src/cli/commands/produce.mjs +1466 -0
  192. package/src/cli/commands/task.mjs +482 -285
  193. package/src/cli/coordination/auto-emit-artifact-ready.mjs +5 -5
  194. package/src/cli/coordination/event-schema.d.ts +4 -2
  195. package/src/cli/coordination/event-schema.mjs +276 -21
  196. package/src/cli/coordination/store.mjs +4 -4
  197. package/src/cli/dispatch.mjs +4 -2
  198. package/src/cli/features/area-shape.mjs +140 -0
  199. package/src/cli/features/delta.mjs +625 -0
  200. package/src/cli/features/reconcile.mjs +1169 -0
  201. package/src/cli/features/surface-map.mjs +192 -0
  202. package/src/cli/features/write-root.mjs +140 -0
  203. package/src/cli/gate/gates/features-reconciled.mjs +70 -0
  204. package/src/cli/gate/gates/index.mjs +1 -0
  205. package/src/cli/help.mjs +31 -1
  206. package/src/cli/install/legacy-paths.mjs +2 -0
  207. package/src/cli/produce/blind-probe.mjs +245 -0
  208. package/src/cli/produce/claim.mjs +543 -0
  209. package/src/cli/produce/done-gate.mjs +238 -0
  210. package/src/cli/produce/events.mjs +131 -0
  211. package/src/cli/produce/evidence-oracle.mjs +133 -0
  212. package/src/cli/produce/ledger.mjs +284 -0
  213. package/src/cli/produce/probe-family.mjs +168 -0
  214. package/src/cli/produce/projection.mjs +105 -0
  215. package/src/cli/produce/run-id.mjs +84 -0
  216. package/src/cli/produce/saga.mjs +303 -0
  217. package/src/cli/produce/scheduler.mjs +423 -0
  218. package/src/cli/produce/setpoint.mjs +122 -0
  219. package/src/cli/produce/verification-ttl.mjs +191 -0
  220. package/src/cli/release-notes/ship-autodraft.mjs +2 -2
  221. package/src/cli/roadmap/showrunner-view.d.ts +10 -0
  222. package/src/cli/roadmap/{conductor-view.mjs → showrunner-view.mjs} +6 -6
  223. package/src/cli/variant/gallery.mjs +7 -3
  224. package/templates/aperant-claude-md-appendix.md +2 -2
  225. package/workflows/scan-features.md +17 -11
  226. package/dist/cli/cli-wrappers/features-audit.d.mts +0 -2
  227. package/dist/cli/cli-wrappers/features-audit.d.mts.map +0 -1
  228. package/dist/cli/cli-wrappers/features-audit.mjs +0 -8
  229. package/dist/cli/cli-wrappers/features-audit.mjs.map +0 -1
  230. package/dist/cli/commands/features-audit.d.mts.map +0 -1
  231. package/dist/cli/commands/features-audit.mjs +0 -293
  232. package/dist/cli/commands/features-audit.mjs.map +0 -1
  233. package/dist/cli/features/registry-audit.d.mts +0 -56
  234. package/dist/cli/features/registry-audit.d.mts.map +0 -1
  235. package/dist/cli/features/registry-audit.mjs +0 -264
  236. package/dist/cli/features/registry-audit.mjs.map +0 -1
  237. package/dist/cli/roadmap/conductor-view.d.mts.map +0 -1
  238. package/dist/cli/roadmap/conductor-view.mjs.map +0 -1
  239. package/src/cli/cli-wrappers/features-audit.mjs +0 -8
  240. package/src/cli/commands/features-audit.mjs +0 -302
  241. package/src/cli/features/registry-audit.mjs +0 -254
  242. package/src/cli/roadmap/conductor-view.d.ts +0 -10
@@ -0,0 +1,1169 @@
1
+ /**
2
+ * reconcile.mjs — the features-reconcile engine: diff-driven delta drafting,
3
+ * the 7-step ordered idempotent materializer, the 4-clause read-only checker,
4
+ * and squash-safe receipt GC.
5
+ *
6
+ * DEPENDENCY-FREE (ID-08): this module's import closure is exclusively
7
+ * node:-builtins + the sibling pure feature modules + docs-audit/policy.mjs
8
+ * (node:fs/node:path only). It must NEVER import util/io.mjs or
9
+ * util/atomic-write.mjs (their closure pulls proper-lockfile) — the bare-node
10
+ * CI entry `bin/features-reconcile-check.mjs` runs with NO node_modules.
11
+ * A closure-walker test locks this purity.
12
+ *
13
+ * TRUST MODEL (ID-04): receipt trust for --check is IN-TREE PRESENCE plus
14
+ * hash matching — NEVER head-sha ancestry. This repo squash-merges, so branch
15
+ * heads are never ancestors of main. GC trusts `git cat-file -e
16
+ * origin/main:<receipt>` presence + a >30d created_at age.
17
+ */
18
+
19
+ import { execFileSync } from 'node:child_process'
20
+ import {
21
+ closeSync,
22
+ existsSync,
23
+ fsyncSync,
24
+ mkdirSync,
25
+ openSync,
26
+ readdirSync,
27
+ readFileSync,
28
+ renameSync,
29
+ unlinkSync,
30
+ writeSync,
31
+ } from 'node:fs'
32
+ import { basename, dirname, join, resolve } from 'node:path'
33
+ import { loadDocsPolicy, matchSurfaceToPolicy } from '../docs-audit/policy.mjs'
34
+ import { validateAreaShape } from './area-shape.mjs'
35
+ import {
36
+ CHANGE_KINDS,
37
+ canonicalJson,
38
+ DRAFT_ONLY_DISPOSITION,
39
+ isValidIsoDatetime,
40
+ mergeDeltas,
41
+ parseDeltaFile,
42
+ sha256Bytes,
43
+ validateDelta,
44
+ } from './delta.mjs'
45
+ import { qualifiesAsFeatureSurface } from './surface-map.mjs'
46
+
47
+ export const FEATURES_DIR = '.aperant/features'
48
+ export const DELTAS_DIR = `${FEATURES_DIR}/deltas`
49
+ export const RECEIPTS_DIR = `${FEATURES_DIR}/receipts`
50
+ export const MATERIALIZER_VERSION = 1
51
+
52
+ /** The exact repair command surfaced in every coverage failure. */
53
+ export function draftRepairCommand(taskId) {
54
+ return `node packages/framework/bin/apt-tools.mjs features-reconcile . --draft --task-id ${taskId || '<task-id>'}`
55
+ }
56
+ const WRITE_REPAIR = 'node packages/framework/bin/apt-tools.mjs features-reconcile . --write'
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // small pure helpers (no npm deps — see module header)
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** tmp+rename atomic JSON write, tab-indented + trailing LF (deterministic bytes). */
63
+ function atomicWriteJsonPure(targetPath, data) {
64
+ atomicWriteTextPure(targetPath, `${JSON.stringify(data, null, '\t')}\n`)
65
+ }
66
+
67
+ function atomicWriteTextPure(targetPath, text) {
68
+ mkdirSync(dirname(targetPath), { recursive: true })
69
+ const tmpPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`
70
+ let fd = null
71
+ try {
72
+ fd = openSync(tmpPath, 'wx')
73
+ writeSync(fd, text, 0, 'utf-8')
74
+ fsyncSync(fd)
75
+ closeSync(fd)
76
+ fd = null
77
+ renameSync(tmpPath, targetPath)
78
+ } catch (err) {
79
+ if (fd !== null) {
80
+ try {
81
+ closeSync(fd)
82
+ } catch {
83
+ /* already closed */
84
+ }
85
+ }
86
+ try {
87
+ unlinkSync(tmpPath)
88
+ } catch {
89
+ /* tmp may not exist */
90
+ }
91
+ throw err
92
+ }
93
+ }
94
+
95
+ function git(root, args) {
96
+ try {
97
+ return execFileSync('git', args, {
98
+ cwd: root,
99
+ encoding: 'utf-8',
100
+ stdio: ['ignore', 'pipe', 'ignore'],
101
+ }).trim()
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Resolve the diff base COMMIT. An explicit ref is normalized through
109
+ * `git merge-base HEAD <ref>` — NOT used verbatim — for two reasons:
110
+ * (1) the ship gate / apt-ship pass a base BRANCH (`base_branch: 'main'`);
111
+ * once main advances past the fork point, `git diff main..HEAD`
112
+ * compares SNAPSHOTS and reports main-side changes reversed (phantom
113
+ * modifications/deletions the task never made) → false-red coverage
114
+ * findings on every stale branch. merge-base pins the fork point.
115
+ * (2) a garbage/unresolvable ref previously flowed into diffNameStatus,
116
+ * whose catch returned [] — an EMPTY diff → clause (a) vacuously green
117
+ * (fail-open). merge-base fails → null → callers fail CLOSED.
118
+ * merge-base(HEAD, X) === X whenever X is already an ancestor of HEAD, so the
119
+ * CI workflow's precomputed merge-base and sha-based callers are byte-identical.
120
+ * No explicit ref → merge-base HEAD origin/main (local main fallback).
121
+ */
122
+ export function resolveBase(root, baseFlag) {
123
+ if (baseFlag && typeof baseFlag === 'string') {
124
+ return git(root, ['merge-base', 'HEAD', baseFlag])
125
+ }
126
+ const originMain = git(root, ['merge-base', 'HEAD', 'origin/main'])
127
+ if (originMain) return originMain
128
+ const localMain = git(root, ['merge-base', 'HEAD', 'main'])
129
+ if (localMain) return localMain
130
+ return null
131
+ }
132
+
133
+ /** Blob fingerprint of a path at a rev (`HEAD` or the base). null when absent. */
134
+ function blobAt(root, rev, path) {
135
+ return git(root, ['rev-parse', `${rev}:${path}`])
136
+ }
137
+
138
+ /** Parse `git diff --name-status -z base..HEAD` preserving rename pairs. */
139
+ export function diffNameStatus(root, base) {
140
+ let out
141
+ try {
142
+ out = execFileSync('git', ['diff', '--name-status', '-z', `${base}..HEAD`], {
143
+ cwd: root,
144
+ encoding: 'utf-8',
145
+ stdio: ['ignore', 'pipe', 'ignore'],
146
+ })
147
+ } catch {
148
+ return []
149
+ }
150
+ const parts = out.split('\0').filter((p) => p.length > 0)
151
+ const entries = []
152
+ for (let i = 0; i < parts.length; ) {
153
+ const status = parts[i]
154
+ if (status.startsWith('R') || status.startsWith('C')) {
155
+ entries.push({ status: status[0], from: parts[i + 1], to: parts[i + 2] })
156
+ i += 3
157
+ } else {
158
+ entries.push({ status: status[0], path: parts[i + 1] })
159
+ i += 2
160
+ }
161
+ }
162
+ return entries
163
+ }
164
+
165
+ function listTopLevelAreaFiles(root) {
166
+ const dir = join(root, FEATURES_DIR)
167
+ if (!existsSync(dir)) return []
168
+ return readdirSync(dir, { withFileTypes: true })
169
+ .filter((d) => d.isFile() && d.name.endsWith('.json'))
170
+ .map((d) => join(dir, d.name))
171
+ .sort()
172
+ }
173
+
174
+ function listDeltaFiles(root) {
175
+ const dir = join(root, DELTAS_DIR)
176
+ if (!existsSync(dir)) return []
177
+ return readdirSync(dir, { withFileTypes: true })
178
+ .filter((d) => d.isFile() && d.name.endsWith('.json'))
179
+ .map((d) => join(dir, d.name))
180
+ .sort()
181
+ }
182
+
183
+ function listReceiptFiles(root) {
184
+ const dir = join(root, RECEIPTS_DIR)
185
+ if (!existsSync(dir)) return []
186
+ return readdirSync(dir, { withFileTypes: true })
187
+ .filter((d) => d.isFile() && d.name.endsWith('.json'))
188
+ .map((d) => join(dir, d.name))
189
+ .sort()
190
+ }
191
+
192
+ function readJsonSafe(path) {
193
+ try {
194
+ return JSON.parse(readFileSync(path, 'utf-8'))
195
+ } catch {
196
+ return null
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Structural receipt validator (N1): clause (a) coverage and clause (c)
202
+ * integrity TRUST receipt contents, so a malformed receipt must be EXCLUDED
203
+ * from trust rather than silently consumed.
204
+ */
205
+ export function validateReceiptShape(data) {
206
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return false
207
+ if (typeof data.head !== 'string' || !data.head) return false
208
+ if (typeof data.materializer_version !== 'number') return false
209
+ if (!Array.isArray(data.consumed_delta_hashes)) return false
210
+ for (const c of data.consumed_delta_hashes) {
211
+ // A [null] (or path/sha-less) entry would crash the clause-(d) orphan
212
+ // scan (`c.sha256`) — entries must be {path, sha256} of non-empty strings.
213
+ if (!c || typeof c !== 'object') return false
214
+ if (typeof c.path !== 'string' || !c.path) return false
215
+ if (typeof c.sha256 !== 'string' || !c.sha256) return false
216
+ }
217
+ if (!Array.isArray(data.files)) return false
218
+ for (const f of data.files) {
219
+ if (!f || typeof f !== 'object') return false
220
+ if (typeof f.path !== 'string' || !f.path) return false
221
+ if (!CHANGE_KINDS.includes(f.change)) return false
222
+ if (typeof f.blob_sha !== 'string' || !f.blob_sha) return false
223
+ }
224
+ if (
225
+ !data.area_hashes ||
226
+ typeof data.area_hashes !== 'object' ||
227
+ Array.isArray(data.area_hashes)
228
+ ) {
229
+ return false
230
+ }
231
+ for (const v of Object.values(data.area_hashes)) {
232
+ if (typeof v !== 'string' || !v) return false
233
+ }
234
+ return true
235
+ }
236
+
237
+ /**
238
+ * Read every in-tree receipt (newest last by created_at, filename tiebreak).
239
+ * Malformed receipts are EXCLUDED from the trusted set and surfaced in
240
+ * `malformed` so --check can fail loudly instead of trusting them (N1).
241
+ */
242
+ function readReceipts(root) {
243
+ const receipts = []
244
+ const malformed = []
245
+ for (const path of listReceiptFiles(root)) {
246
+ const data = readJsonSafe(path)
247
+ if (validateReceiptShape(data)) receipts.push({ path, data })
248
+ else malformed.push({ path })
249
+ }
250
+ receipts.sort((a, b) => {
251
+ const ca = String(a.data.created_at ?? '')
252
+ const cb = String(b.data.created_at ?? '')
253
+ if (ca !== cb) return ca < cb ? -1 : 1
254
+ return a.path < b.path ? -1 : 1
255
+ })
256
+ return { receipts, malformed }
257
+ }
258
+
259
+ function knownAreaNames(root) {
260
+ return listTopLevelAreaFiles(root).map((p) => basename(p, '.json'))
261
+ }
262
+
263
+ function slugify(name) {
264
+ const slug = name
265
+ .replace(/\.[a-z]+$/i, '')
266
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
267
+ .toLowerCase()
268
+ .replace(/[^a-z0-9]+/g, '-')
269
+ .replace(/^-+|-+$/g, '')
270
+ return slug || 'feature'
271
+ }
272
+
273
+ /** Relative path of an absolute file under root (posix separators). */
274
+ function relTo(root, absPath) {
275
+ return absPath.slice(root.length + 1).replace(/\\/g, '/')
276
+ }
277
+
278
+ /** docs_policy consult (ID-13): are area-JSON writes allowed? */
279
+ function areaPolicyOptOut(root) {
280
+ const policy = loadDocsPolicy(root)
281
+ const probe = matchSurfaceToPolicy(`${FEATURES_DIR}/probe.json`, policy)
282
+ return probe !== 'auto-update' ? (probe ?? 'unmatched') : null
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // --draft
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Scaffold (or idempotently re-scaffold) a per-writer delta from
291
+ * `git diff --name-status <base>..HEAD`.
292
+ *
293
+ * - renames preserved (`change: "renamed"` + previous_path/previous_blob_sha)
294
+ * - deletions pre-filled `remove` with the BASE blob fingerprint
295
+ * - additions pre-filled `upsert` + a minimal upsert operation
296
+ * - modifications marked `needs-disposition` (draft-only sentinel)
297
+ * - re-runs refresh fingerprints but PRESERVE agent-authored dispositions,
298
+ * areas, reasons, and all operations[].
299
+ *
300
+ * @param {{root: string, base?: string, taskId?: string, subtaskId?: string, dryRun?: boolean}} opts
301
+ * @returns {{ ok: boolean, deltaPath?: string, delta?: Record<string, any>, wrote?: boolean,
302
+ * qualifying?: number, base?: string, error?: string }}
303
+ */
304
+ export function draftDelta({ root: rootIn, base: baseFlag, taskId, subtaskId, dryRun = false }) {
305
+ const root = resolve(rootIn)
306
+ if (!taskId || typeof taskId !== 'string') {
307
+ return { ok: false, error: 'draftDelta requires --task-id (the delta path is per-writer)' }
308
+ }
309
+ const base = resolveBase(root, baseFlag)
310
+ if (!base) {
311
+ return {
312
+ ok: false,
313
+ error:
314
+ 'cannot resolve diff base (unresolvable --base ref, or no origin/main / main); pass --base <ref>',
315
+ }
316
+ }
317
+ const known = knownAreaNames(root)
318
+ const entries = diffNameStatus(root, base)
319
+ const files = []
320
+ const autoOps = []
321
+ for (const entry of entries) {
322
+ if (entry.status === 'D') {
323
+ const q = qualifiesAsFeatureSurface(entry.path, known)
324
+ if (!q) continue
325
+ files.push({
326
+ path: entry.path,
327
+ change: 'deleted',
328
+ blob_sha: blobAt(root, base, entry.path) ?? '',
329
+ area: q.suggestedArea,
330
+ disposition: 'remove',
331
+ })
332
+ continue
333
+ }
334
+ if (entry.status === 'R' || entry.status === 'C') {
335
+ const qNew = qualifiesAsFeatureSurface(entry.to, known)
336
+ if (qNew) {
337
+ files.push({
338
+ path: entry.to,
339
+ change: 'renamed',
340
+ previous_path: entry.from,
341
+ previous_blob_sha: blobAt(root, base, entry.from) ?? '',
342
+ blob_sha: blobAt(root, 'HEAD', entry.to) ?? '',
343
+ area: qNew.suggestedArea,
344
+ disposition: DRAFT_ONLY_DISPOSITION,
345
+ })
346
+ continue
347
+ }
348
+ // Renamed OUT of every qualifying family — the old surface is gone;
349
+ // treat like a deletion of the previous path.
350
+ const qOld = qualifiesAsFeatureSurface(entry.from, known)
351
+ if (qOld) {
352
+ files.push({
353
+ path: entry.from,
354
+ change: 'deleted',
355
+ blob_sha: blobAt(root, base, entry.from) ?? '',
356
+ area: qOld.suggestedArea,
357
+ disposition: 'remove',
358
+ })
359
+ }
360
+ continue
361
+ }
362
+ // A / M (and anything else git reports on a surviving path).
363
+ const q = qualifiesAsFeatureSurface(entry.path, known)
364
+ if (!q) continue
365
+ const added = entry.status === 'A'
366
+ files.push({
367
+ path: entry.path,
368
+ change: added ? 'added' : 'modified',
369
+ blob_sha: blobAt(root, 'HEAD', entry.path) ?? '',
370
+ area: q.suggestedArea,
371
+ disposition: added ? 'upsert' : DRAFT_ONLY_DISPOSITION,
372
+ })
373
+ if (added && q.suggestedArea) {
374
+ const featureId = slugify(basename(entry.path))
375
+ autoOps.push({
376
+ op: 'upsert',
377
+ area: q.suggestedArea,
378
+ feature_id: featureId,
379
+ value: {
380
+ id: featureId,
381
+ name: basename(entry.path),
382
+ component: entry.path,
383
+ sub_features: [],
384
+ },
385
+ })
386
+ }
387
+ }
388
+
389
+ const deltaName = subtaskId ? `${taskId}-${subtaskId}.json` : `${taskId}.json`
390
+ const deltaPath = join(root, DELTAS_DIR, deltaName)
391
+ let delta = {
392
+ task_id: taskId,
393
+ ...(subtaskId ? { subtask_id: subtaskId } : {}),
394
+ base,
395
+ created_at: new Date().toISOString(),
396
+ files,
397
+ operations: autoOps,
398
+ }
399
+
400
+ // Idempotent re-draft: refresh fingerprints, preserve agent enrichment.
401
+ if (existsSync(deltaPath)) {
402
+ const prior = readJsonSafe(deltaPath)
403
+ if (prior && typeof prior === 'object') {
404
+ const priorFiles = new Map((prior.files ?? []).map((f) => [f.path, f]))
405
+ const mergedFiles = files.map((fresh) => {
406
+ const old = priorFiles.get(fresh.path)
407
+ if (!old) return fresh
408
+ return {
409
+ ...fresh,
410
+ // Agent-authored fields survive the re-draft; the draft only
411
+ // refreshes fingerprints + change kind.
412
+ area: old.area ?? fresh.area,
413
+ disposition:
414
+ old.disposition && old.disposition !== DRAFT_ONLY_DISPOSITION
415
+ ? old.disposition
416
+ : fresh.disposition,
417
+ ...(old.reason !== undefined ? { reason: old.reason } : {}),
418
+ }
419
+ })
420
+ const priorOps = Array.isArray(prior.operations) ? prior.operations : []
421
+ const priorOpKeys = new Set(
422
+ priorOps.map((o) => `${o.op} ${o.area} ${o.feature_id} ${o.sub_feature_id ?? ''}`),
423
+ )
424
+ const keptAutoOps = autoOps.filter(
425
+ (o) => !priorOpKeys.has(`${o.op} ${o.area} ${o.feature_id} ${o.sub_feature_id ?? ''}`),
426
+ )
427
+ delta = {
428
+ ...prior,
429
+ task_id: taskId,
430
+ ...(subtaskId ? { subtask_id: subtaskId } : {}),
431
+ base,
432
+ files: mergedFiles,
433
+ operations: [...priorOps, ...keptAutoOps],
434
+ }
435
+ }
436
+ }
437
+
438
+ const validation = validateDelta(delta, { mode: 'draft' })
439
+ if (!validation.ok) {
440
+ return { ok: false, error: `draft failed validation: ${validation.errors.join('; ')}` }
441
+ }
442
+ if (!dryRun) {
443
+ if (files.length === 0 && !existsSync(deltaPath)) {
444
+ // Nothing qualifying and no prior delta — do not write an empty file.
445
+ return {
446
+ ok: true,
447
+ deltaPath: relTo(root, deltaPath),
448
+ delta,
449
+ wrote: false,
450
+ qualifying: 0,
451
+ base,
452
+ }
453
+ }
454
+ atomicWriteJsonPure(deltaPath, delta)
455
+ }
456
+ return {
457
+ ok: true,
458
+ deltaPath: relTo(root, deltaPath),
459
+ delta,
460
+ wrote: !dryRun && (files.length > 0 || existsSync(deltaPath)),
461
+ qualifying: files.length,
462
+ base,
463
+ }
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // --write (the ID-03 7-step ordered idempotent materializer)
468
+ // ---------------------------------------------------------------------------
469
+
470
+ function receiptTimestamp(now) {
471
+ return now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '')
472
+ }
473
+
474
+ /**
475
+ * Apply merged operations + files[] bookkeeping to in-memory post-images.
476
+ * `stampDate` (YYYY-MM-DD) is derived DETERMINISTICALLY from the consumed
477
+ * deltas' `created_at` (FIX 2 / AC5) — never from wall-clock `now` — so a
478
+ * crash-before-receipt replay on a LATER day still writes byte-identical
479
+ * area files.
480
+ */
481
+ function computePostImages(root, mergedOps, mergedFiles, stampDate) {
482
+ const areasDir = join(root, FEATURES_DIR)
483
+ const images = new Map() // area name → { data, touched }
484
+ const conflicts = []
485
+ const load = (area) => {
486
+ if (images.has(area)) return images.get(area)
487
+ const path = join(areasDir, `${area}.json`)
488
+ const existing = existsSync(path) ? readJsonSafe(path) : null
489
+ const data = existing ?? {
490
+ area,
491
+ route: '(cross-cutting)',
492
+ components: [],
493
+ last_scanned: stampDate,
494
+ features: [],
495
+ }
496
+ if (!Array.isArray(data.features)) data.features = []
497
+ if (!Array.isArray(data.components)) data.components = []
498
+ const image = { data, touched: false }
499
+ images.set(area, image)
500
+ return image
501
+ }
502
+
503
+ for (const op of mergedOps) {
504
+ const image = load(op.area)
505
+ const features = image.data.features
506
+ const idx = features.findIndex((f) => f?.id === op.feature_id)
507
+ if (op.op === 'upsert') {
508
+ if (idx >= 0) features[idx] = op.value
509
+ else features.push(op.value)
510
+ image.touched = true
511
+ } else if (op.op === 'add-group') {
512
+ if (idx >= 0) {
513
+ if (canonicalJson(features[idx]) !== canonicalJson(op.value)) {
514
+ conflicts.push({
515
+ kind: 'add-group-collision',
516
+ message: `${op.source ?? ''}: add-group collides with existing divergent group ${op.area}/${op.feature_id}`,
517
+ sources: [op.source].filter(Boolean),
518
+ })
519
+ }
520
+ // byte-equal existing group → idempotent no-op
521
+ } else {
522
+ features.push(op.value)
523
+ image.touched = true
524
+ }
525
+ } else if (op.op === 'patch-sub-feature') {
526
+ const group = idx >= 0 ? features[idx] : null
527
+ const sub = group?.sub_features?.find((s) => s?.id === op.sub_feature_id)
528
+ if (!sub) {
529
+ conflicts.push({
530
+ kind: 'patch-missing-entity',
531
+ message: `${op.source ?? ''}: patch-sub-feature targets missing ${op.area}/${op.feature_id}/${op.sub_feature_id} — ids are never auto-generated (ID-02 g)`,
532
+ sources: [op.source].filter(Boolean),
533
+ })
534
+ continue
535
+ }
536
+ Object.assign(sub, op.patch)
537
+ image.touched = true
538
+ } else if (op.op === 'remove') {
539
+ if (op.sub_feature_id) {
540
+ const group = idx >= 0 ? features[idx] : null
541
+ if (group?.sub_features) {
542
+ const before = group.sub_features.length
543
+ group.sub_features = group.sub_features.filter((s) => s?.id !== op.sub_feature_id)
544
+ if (group.sub_features.length !== before) image.touched = true
545
+ }
546
+ } else if (idx >= 0) {
547
+ features.splice(idx, 1)
548
+ image.touched = true
549
+ }
550
+ // removing an already-missing entity is an idempotent no-op (replay safety)
551
+ }
552
+ }
553
+
554
+ // files[] bookkeeping — keep each area's components list current.
555
+ for (const f of mergedFiles) {
556
+ if (!f.area) continue
557
+ const image = load(f.area)
558
+ const components = image.data.components
559
+ const ensure = (p) => {
560
+ if (!components.includes(p)) {
561
+ components.push(p)
562
+ image.touched = true
563
+ }
564
+ }
565
+ const drop = (p) => {
566
+ const i = components.indexOf(p)
567
+ if (i >= 0) {
568
+ components.splice(i, 1)
569
+ image.touched = true
570
+ }
571
+ }
572
+ if (f.change === 'deleted' || f.disposition === 'remove') drop(f.path)
573
+ else if (f.change === 'renamed') {
574
+ drop(f.previous_path)
575
+ if (f.disposition !== 'no-semantic-change') ensure(f.path)
576
+ } else if (f.disposition === 'upsert' || f.disposition === 'wiring-transition') ensure(f.path)
577
+ }
578
+
579
+ for (const [, image] of images) {
580
+ if (image.touched) image.data.last_scanned = stampDate
581
+ }
582
+ return { images, conflicts }
583
+ }
584
+
585
+ /**
586
+ * materialize — the single ordered idempotent writer (ID-03 / AC5):
587
+ * (1) parse all deltas → (2) post-images in memory → (3) validate every
588
+ * post-image → (4) atomic tmp+rename area replace → (5) write receipt →
589
+ * (6) delete ONLY consumed deltas → (7) regenerate REGISTRY.md wholly from
590
+ * the JSONs.
591
+ *
592
+ * Crash-after-receipt recovery: a re-run detects deltas whose sha256 appears
593
+ * in an in-tree receipt's consumed_delta_hashes with matching area_hashes and
594
+ * completes the cleanup without rewriting.
595
+ *
596
+ * @returns envelope-ready object; never throws for expected failure classes.
597
+ */
598
+ export function materialize({ root: rootIn, taskId, now = new Date() }) {
599
+ const root = resolve(rootIn)
600
+
601
+ // ID-13/AC20 — docs_policy opt-out wins before anything is touched.
602
+ const optOut = areaPolicyOptOut(root)
603
+ if (optOut) {
604
+ return { status: 'skip', reason: 'policy-opt-out', policy: optOut }
605
+ }
606
+
607
+ // Crash-after-receipt cleanup (step 5→6 tail): consume-marked deltas whose
608
+ // receipt's area_hashes still match the on-disk areas are deleted here.
609
+ const { receipts } = readReceipts(root)
610
+ const cleanedUp = []
611
+ let deltaFiles = listDeltaFiles(root)
612
+ for (const deltaPath of deltaFiles) {
613
+ let raw
614
+ try {
615
+ raw = readFileSync(deltaPath)
616
+ } catch {
617
+ continue
618
+ }
619
+ const sha = sha256Bytes(raw)
620
+ const consumingReceipt = receipts.find((r) =>
621
+ (r.data.consumed_delta_hashes ?? []).some((c) => c.sha256 === sha),
622
+ )
623
+ if (!consumingReceipt) continue
624
+ const areaHashes = consumingReceipt.data.area_hashes ?? {}
625
+ const allMatch = Object.entries(areaHashes).every(([area, hash]) => {
626
+ const areaPath = join(root, FEATURES_DIR, `${area}.json`)
627
+ if (!existsSync(areaPath)) return false
628
+ return sha256Bytes(readFileSync(areaPath)) === hash
629
+ })
630
+ if (allMatch) {
631
+ unlinkSync(deltaPath)
632
+ cleanedUp.push(relTo(root, deltaPath))
633
+ }
634
+ }
635
+ deltaFiles = listDeltaFiles(root)
636
+
637
+ // Step 1 — parse all deltas fail-closed (strict mode).
638
+ const parsed = []
639
+ const invalid = []
640
+ for (const path of deltaFiles) {
641
+ const res = parseDeltaFile(path, { mode: 'strict' })
642
+ if (res.ok) parsed.push({ source: relTo(root, path), path, delta: res.delta })
643
+ else invalid.push({ path: relTo(root, path), errors: res.errors })
644
+ }
645
+ if (invalid.length > 0) {
646
+ return {
647
+ status: 'error',
648
+ kind: 'invalid-deltas',
649
+ invalid,
650
+ repair: draftRepairCommand(taskId),
651
+ }
652
+ }
653
+
654
+ if (parsed.length === 0) {
655
+ // Zero-delta mode — regenerate REGISTRY.md only; NO receipt. N2:
656
+ // changed_paths reflects ACTUAL byte changes (a content-identical
657
+ // regeneration is skipped) so apt-ship §0.0 never attempts an empty
658
+ // commit.
659
+ const reg = regenerateRegistryMd(root, now)
660
+ return {
661
+ status: 'ok',
662
+ materialized: 0,
663
+ receipt: null,
664
+ consumed_deltas: [],
665
+ cleaned_up: cleanedUp,
666
+ registry_regenerated: true,
667
+ changed_paths: [...(reg.changed ? [relTo(root, reg.path)] : []), ...cleanedUp],
668
+ }
669
+ }
670
+
671
+ // Step 2a — merge deltas per the ID-02 conflict matrix against the pre-image.
672
+ const areasDir = join(root, FEATURES_DIR)
673
+ const lookup = {
674
+ getFeature: (area, fid) => {
675
+ const data = readJsonSafe(join(areasDir, `${area}.json`))
676
+ return data?.features?.find?.((f) => f?.id === fid) ?? null
677
+ },
678
+ getSubFeature: (area, fid, sid) => {
679
+ const data = readJsonSafe(join(areasDir, `${area}.json`))
680
+ return (
681
+ data?.features?.find?.((f) => f?.id === fid)?.sub_features?.find?.((s) => s?.id === sid) ??
682
+ null
683
+ )
684
+ },
685
+ }
686
+ const mergedRes = mergeDeltas(
687
+ parsed.map(({ source, delta }) => ({ source, delta })),
688
+ lookup,
689
+ )
690
+ if (!mergedRes.ok) {
691
+ return {
692
+ status: 'error',
693
+ kind: 'hard-conflict',
694
+ conflicts: mergedRes.conflicts,
695
+ repair: draftRepairCommand(taskId),
696
+ }
697
+ }
698
+
699
+ // Step 2b — post-images in memory. The last_scanned stamp derives from the
700
+ // consumed deltas' created_at (max, sliced to YYYY-MM-DD) — deterministic
701
+ // under replay (FIX 2 / AC5). Wall-clock `now` is the documented fallback
702
+ // ONLY when no consumed delta carries created_at (hand-written deltas).
703
+ const createdAts = parsed
704
+ .map(({ delta }) => delta.created_at)
705
+ // Strict validation already enforces real ISO 8601; this SEMANTIC filter
706
+ // is the belt so a garbage date on a legacy/hand-written delta that
707
+ // slipped past validation (e.g. 2026-99-99T99:99:99Z — shape-valid,
708
+ // calendar-invalid) can never max-sort above real dates (ST11).
709
+ .filter((v) => isValidIsoDatetime(v))
710
+ .sort()
711
+ const stampDate =
712
+ createdAts.length > 0
713
+ ? createdAts[createdAts.length - 1].slice(0, 10)
714
+ : now.toISOString().slice(0, 10)
715
+ const { images, conflicts } = computePostImages(
716
+ root,
717
+ mergedRes.operations,
718
+ mergedRes.files,
719
+ stampDate,
720
+ )
721
+ if (conflicts.length > 0) {
722
+ return { status: 'error', kind: 'hard-conflict', conflicts, repair: draftRepairCommand(taskId) }
723
+ }
724
+
725
+ // Step 3 — validate EVERY post-image before ANY write.
726
+ const invalidImages = []
727
+ for (const [area, image] of images) {
728
+ const res = validateAreaShape(image.data)
729
+ if (!res.ok) invalidImages.push({ area, errors: res.errors })
730
+ }
731
+ if (invalidImages.length > 0) {
732
+ return { status: 'error', kind: 'invalid-post-image', invalid: invalidImages }
733
+ }
734
+
735
+ // Step 4 — atomic tmp+rename replace of touched area files.
736
+ const touchedAreas = [...images.entries()].filter(([, image]) => image.touched)
737
+ for (const [area, image] of touchedAreas) {
738
+ atomicWriteJsonPure(join(areasDir, `${area}.json`), image.data)
739
+ }
740
+
741
+ // Step 5 — write the receipt (ID-04 shape; fingerprints carried VERBATIM).
742
+ const areaHashes = {}
743
+ for (const [area] of touchedAreas) {
744
+ areaHashes[area] = sha256Bytes(readFileSync(join(areasDir, `${area}.json`)))
745
+ }
746
+ const receiptTaskId = taskId || parsed[0].delta.task_id || 'unknown'
747
+ const receipt = {
748
+ head: git(root, ['rev-parse', 'HEAD']),
749
+ materializer_version: MATERIALIZER_VERSION,
750
+ task_id: receiptTaskId,
751
+ created_at: now.toISOString(),
752
+ consumed_delta_hashes: parsed.map(({ source, path }) => ({
753
+ path: source,
754
+ sha256: sha256Bytes(readFileSync(path)),
755
+ })),
756
+ files: mergedRes.files.map((f) => ({
757
+ path: f.path,
758
+ change: f.change,
759
+ area: f.area,
760
+ disposition: f.disposition,
761
+ blob_sha: f.blob_sha,
762
+ ...(f.previous_path !== undefined ? { previous_path: f.previous_path } : {}),
763
+ ...(f.previous_blob_sha !== undefined ? { previous_blob_sha: f.previous_blob_sha } : {}),
764
+ })),
765
+ area_hashes: areaHashes,
766
+ }
767
+ const receiptName = `${receiptTimestamp(now)}-${receiptTaskId}.json`
768
+ const receiptPath = join(root, RECEIPTS_DIR, receiptName)
769
+ atomicWriteJsonPure(receiptPath, receipt)
770
+
771
+ // Step 6 — delete ONLY the deltas named in the receipt.
772
+ for (const { path } of parsed) {
773
+ unlinkSync(path)
774
+ }
775
+
776
+ // Step 7 — regenerate REGISTRY.md wholly from the JSONs.
777
+ const reg = regenerateRegistryMd(root, now)
778
+
779
+ return {
780
+ status: 'ok',
781
+ materialized: touchedAreas.length,
782
+ areas: touchedAreas.map(([area]) => area),
783
+ receipt: relTo(root, receiptPath),
784
+ consumed_deltas: parsed.map(({ source }) => source),
785
+ cleaned_up: cleanedUp,
786
+ registry_regenerated: true,
787
+ changed_paths: [
788
+ ...touchedAreas.map(([area]) => `${FEATURES_DIR}/${area}.json`),
789
+ relTo(root, receiptPath),
790
+ ...(reg.changed ? [relTo(root, reg.path)] : []),
791
+ ...parsed.map(({ source }) => source),
792
+ ...cleanedUp,
793
+ ],
794
+ }
795
+ }
796
+
797
+ /** Wired-state buckets for the REGISTRY.md table. */
798
+ function tallyArea(data) {
799
+ let groups = 0
800
+ let subs = 0
801
+ let wired = 0
802
+ let stubbed = 0
803
+ let unwired = 0
804
+ for (const group of data.features ?? []) {
805
+ groups += 1
806
+ for (const sub of group?.sub_features ?? []) {
807
+ subs += 1
808
+ if (sub?.wired === true) wired += 1
809
+ else if (sub?.wired === 'stubbed' || sub?.wired === 'partial') stubbed += 1
810
+ else unwired += 1
811
+ }
812
+ }
813
+ return { groups, subs, wired, stubbed, unwired }
814
+ }
815
+
816
+ /**
817
+ * Regenerate REGISTRY.md WHOLLY from the area JSONs (never patched in place).
818
+ * Non-conforming files are listed loudly — never silently skipped.
819
+ *
820
+ * @returns {{ path: string, changed: boolean }} — `changed:false` when the
821
+ * regeneration was content-identical (timestamp line ignored) and the
822
+ * existing file was kept byte-for-byte (N2).
823
+ */
824
+ export function regenerateRegistryMd(root, now = new Date()) {
825
+ const rows = []
826
+ const nonConforming = []
827
+ const totals = { groups: 0, subs: 0, wired: 0, stubbed: 0, unwired: 0 }
828
+ for (const path of listTopLevelAreaFiles(root)) {
829
+ const name = basename(path, '.json')
830
+ const data = readJsonSafe(path)
831
+ const valid = data ? validateAreaShape(data) : { ok: false, errors: ['unparseable JSON'] }
832
+ if (!valid.ok) {
833
+ nonConforming.push({ name, errors: valid.errors })
834
+ continue
835
+ }
836
+ const t = tallyArea(data)
837
+ totals.groups += t.groups
838
+ totals.subs += t.subs
839
+ totals.wired += t.wired
840
+ totals.stubbed += t.stubbed
841
+ totals.unwired += t.unwired
842
+ rows.push({ name, route: data.route, last_scanned: data.last_scanned, ...t })
843
+ }
844
+ rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
845
+ const pct = (n, d) => (d > 0 ? `${Math.round((n / d) * 100)}%` : '—')
846
+ const lines = [
847
+ '# Feature Registry',
848
+ '',
849
+ `Last reconciled: ${now.toISOString()}`,
850
+ 'Regenerated wholly from `.aperant/features/*.json` by `apt-tools features-reconcile --write`.',
851
+ 'Schema: `@aperant/framework/schemas` → `FeatureRegistrySchema` (zod-validated; twin: `area-shape.mjs`)',
852
+ '',
853
+ '## Areas',
854
+ '',
855
+ '| Area | Route | Groups | Sub-features | Wired | Stubbed | Unwired | Wired % |',
856
+ '|------|-------|-------:|-------------:|------:|--------:|--------:|--------:|',
857
+ ...rows.map(
858
+ (r) =>
859
+ `| ${r.name} | \`${r.route}\` | ${r.groups} | ${r.subs} | ${r.wired} | ${r.stubbed} | ${r.unwired} | ${pct(r.wired, r.subs)} |`,
860
+ ),
861
+ `| **TOTAL** | | **${totals.groups}** | **${totals.subs}** | **${totals.wired}** | **${totals.stubbed}** | **${totals.unwired}** | **${pct(totals.wired, totals.subs)}** |`,
862
+ '',
863
+ '## Coverage Summary',
864
+ '',
865
+ `- **Total feature areas:** ${rows.length}`,
866
+ `- **Total feature groups:** ${totals.groups}`,
867
+ `- **Total sub-features:** ${totals.subs}`,
868
+ `- **Wired (connected to backend):** ${totals.wired} / ${totals.subs}`,
869
+ `- **Stubbed / partial:** ${totals.stubbed} / ${totals.subs}`,
870
+ `- **Unwired (pure mock/display-only):** ${totals.unwired} / ${totals.subs}`,
871
+ ]
872
+ if (nonConforming.length > 0) {
873
+ lines.push('', '## Non-conforming files (must fix)', '')
874
+ for (const nc of nonConforming) {
875
+ lines.push(
876
+ `- \`${nc.name}.json\` — ${nc.errors[0]}${nc.errors.length > 1 ? ` (+${nc.errors.length - 1} more)` : ''}`,
877
+ )
878
+ }
879
+ }
880
+ lines.push(
881
+ '',
882
+ '## Files',
883
+ '',
884
+ ...rows.map((r) => `- \`${r.name}.json\` — last_scanned ${r.last_scanned}`),
885
+ '',
886
+ )
887
+ const registryPath = join(root, FEATURES_DIR, 'REGISTRY.md')
888
+ const next = lines.join('\n')
889
+ // N2: skip a content-identical regeneration (ignoring only the
890
+ // `Last reconciled:` timestamp line) so callers can report REAL byte
891
+ // changes and apt-ship §0.0 never attempts an empty commit.
892
+ const TS_LINE = /^Last reconciled: .*$/m
893
+ if (existsSync(registryPath)) {
894
+ const prior = readFileSync(registryPath, 'utf-8')
895
+ if (prior.replace(TS_LINE, '') === next.replace(TS_LINE, '')) {
896
+ return { path: registryPath, changed: false }
897
+ }
898
+ }
899
+ atomicWriteTextPure(registryPath, next)
900
+ return { path: registryPath, changed: true }
901
+ }
902
+
903
+ // ---------------------------------------------------------------------------
904
+ // --check (the ID-05 read-only 4-clause verifier)
905
+ // ---------------------------------------------------------------------------
906
+
907
+ /**
908
+ * checkReconciled — four read-only clauses (AC7/AC8). Judges ONLY base..HEAD
909
+ * deltas — never absolute registry completeness. Trust = in-tree presence +
910
+ * blob-fingerprint matching (squash-safe; NO git ancestry calls anywhere).
911
+ *
912
+ * @returns {Record<string, any>} envelope-ready result: status ('ok'|'fail'|
913
+ * 'warn'|'skip'), findings[], coverage_ok, schema_valid, integrity_ok,
914
+ * orphans_ok, base, qualifying_files, pending_deltas, receipts.
915
+ */
916
+ export function checkReconciled({ root: rootIn, base: baseFlag, warnOnly = false, taskId }) {
917
+ const root = resolve(rootIn)
918
+
919
+ const optOut = areaPolicyOptOut(root)
920
+ if (optOut) {
921
+ return { status: 'skip', reason: 'policy-opt-out', policy: optOut, findings: [] }
922
+ }
923
+
924
+ const findings = []
925
+ const base = resolveBase(root, baseFlag)
926
+
927
+ // Pending deltas: strict-parse. An invalid pending delta (incl. the
928
+ // needs-disposition sentinel) is itself a failure — it can never cover.
929
+ const deltaFiles = listDeltaFiles(root)
930
+ const pendingDeltas = []
931
+ for (const path of deltaFiles) {
932
+ const res = parseDeltaFile(path, { mode: 'strict' })
933
+ if (res.ok) {
934
+ pendingDeltas.push({ path, delta: res.delta })
935
+ } else {
936
+ findings.push({
937
+ clause: 'coverage',
938
+ file: relTo(root, path),
939
+ message: `invalid pending delta: ${res.errors.join('; ')}`,
940
+ repair: draftRepairCommand(taskId),
941
+ })
942
+ }
943
+ }
944
+ const { receipts, malformed } = readReceipts(root)
945
+ // N1: a malformed receipt is EXCLUDED from coverage/integrity/orphan trust
946
+ // and surfaced as a finding — never silently consumed.
947
+ for (const m of malformed) {
948
+ findings.push({
949
+ clause: 'receipts',
950
+ file: relTo(root, m.path),
951
+ message:
952
+ 'malformed receipt (missing head/materializer_version/files fingerprints/area_hashes/consumed_delta_hashes) — excluded from coverage and integrity',
953
+ repair: `re-materialize via ${WRITE_REPAIR} or remove the malformed receipt`,
954
+ })
955
+ }
956
+
957
+ // Clause (a) — disposition coverage over base..HEAD, fingerprint-matched.
958
+ const listings = []
959
+ for (const { path, delta } of pendingDeltas) {
960
+ for (const f of delta.files ?? []) listings.push({ ...f, from: relTo(root, path) })
961
+ }
962
+ for (const r of receipts) {
963
+ for (const f of r.data.files ?? []) listings.push({ ...f, from: relTo(root, r.path) })
964
+ }
965
+ const known = knownAreaNames(root)
966
+ const qualifying = []
967
+ if (base) {
968
+ for (const entry of diffNameStatus(root, base)) {
969
+ if (entry.status === 'D') {
970
+ if (qualifiesAsFeatureSurface(entry.path, known)) {
971
+ qualifying.push({
972
+ path: entry.path,
973
+ requiredBlob: blobAt(root, base, entry.path),
974
+ kind: 'deleted',
975
+ })
976
+ }
977
+ } else if (entry.status === 'R' || entry.status === 'C') {
978
+ if (qualifiesAsFeatureSurface(entry.to, known)) {
979
+ qualifying.push({
980
+ path: entry.to,
981
+ requiredBlob: blobAt(root, 'HEAD', entry.to),
982
+ kind: 'current',
983
+ })
984
+ } else if (qualifiesAsFeatureSurface(entry.from, known)) {
985
+ qualifying.push({
986
+ path: entry.from,
987
+ requiredBlob: blobAt(root, base, entry.from),
988
+ kind: 'deleted',
989
+ })
990
+ }
991
+ } else if (qualifiesAsFeatureSurface(entry.path, known)) {
992
+ qualifying.push({
993
+ path: entry.path,
994
+ requiredBlob: blobAt(root, 'HEAD', entry.path),
995
+ kind: 'current',
996
+ })
997
+ }
998
+ }
999
+ } else {
1000
+ findings.push({
1001
+ clause: 'coverage',
1002
+ file: '(repo)',
1003
+ message:
1004
+ 'cannot resolve diff base (unresolvable --base ref, or no origin/main / main); pass --base <ref>',
1005
+ repair: 'features-reconcile . --check --base <ref>',
1006
+ })
1007
+ }
1008
+ for (const q of qualifying) {
1009
+ // FIX 1: the covering listing must be KIND-COMPATIBLE, never just
1010
+ // path+blob. A prior receipt's HEAD-blob listing would otherwise cover
1011
+ // a DELETION in the next PR (that receipt's HEAD blob === this PR's
1012
+ // base blob). Rules:
1013
+ // - kind 'deleted' → only a deletion listing (change 'deleted' or
1014
+ // disposition 'remove') with blob_sha === the BASE blob covers;
1015
+ // - kind 'current' → only a NON-deleted listing (added/modified/
1016
+ // renamed — the qualifying builder emits renames as 'current' on
1017
+ // the NEW path, mirrored here) with blob_sha === the HEAD blob.
1018
+ const covered = listings.some((l) => {
1019
+ if (l.path !== q.path || !q.requiredBlob || l.blob_sha !== q.requiredBlob) return false
1020
+ if (q.kind === 'deleted') return l.change === 'deleted' || l.disposition === 'remove'
1021
+ // 'current': any NON-deleted listing (a remove-DISPOSITION on a
1022
+ // still-existing file is a deregistration, not a deletion — its
1023
+ // HEAD-anchored blob legitimately covers the edit).
1024
+ return l.change !== 'deleted'
1025
+ })
1026
+ if (!covered) {
1027
+ findings.push({
1028
+ clause: 'coverage',
1029
+ file: q.path,
1030
+ message: `feature-qualifying change has no covering delta or receipt with a matching blob fingerprint (${q.kind === 'deleted' ? 'base blob' : 'HEAD blob'})`,
1031
+ repair: draftRepairCommand(taskId),
1032
+ })
1033
+ }
1034
+ }
1035
+
1036
+ // Clause (b) — schema validity of every top-level area JSON.
1037
+ let schemaValid = true
1038
+ for (const path of listTopLevelAreaFiles(root)) {
1039
+ const data = readJsonSafe(path)
1040
+ const res = data ? validateAreaShape(data) : { ok: false, errors: ['unparseable JSON'] }
1041
+ if (!res.ok) {
1042
+ schemaValid = false
1043
+ findings.push({
1044
+ clause: 'schema',
1045
+ file: relTo(root, path),
1046
+ message:
1047
+ res.errors.slice(0, 3).join('; ') +
1048
+ (res.errors.length > 3 ? ` (+${res.errors.length - 3} more)` : ''),
1049
+ repair: 'fix the area file so it passes FeatureRegistrySchema (see area-shape.mjs)',
1050
+ })
1051
+ }
1052
+ }
1053
+
1054
+ // Clause (c) — per-area LATEST-receipt integrity, only when NO deltas pending.
1055
+ let integrityOk = true
1056
+ if (pendingDeltas.length === 0 && deltaFiles.length === 0 && receipts.length > 0) {
1057
+ for (const path of listTopLevelAreaFiles(root)) {
1058
+ const area = basename(path, '.json')
1059
+ // The latest receipt whose area_hashes names THIS area — validating
1060
+ // only the globally newest receipt would leave other areas unchecked.
1061
+ const latest = [...receipts]
1062
+ .reverse()
1063
+ .find((r) => r.data.area_hashes && area in r.data.area_hashes)
1064
+ if (!latest) continue // pre-receipt legacy area — exempt
1065
+ const recorded = latest.data.area_hashes[area]
1066
+ const current = sha256Bytes(readFileSync(path))
1067
+ if (recorded !== current) {
1068
+ integrityOk = false
1069
+ findings.push({
1070
+ clause: 'integrity',
1071
+ file: relTo(root, path),
1072
+ message: `area file bytes differ from its latest receipt (${basename(latest.path)}) — manual edit after materialization?`,
1073
+ repair: `${draftRepairCommand(taskId)} then ${WRITE_REPAIR}`,
1074
+ })
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ // Clause (d) — no orphan consumed deltas (crash-tail state).
1080
+ let orphansOk = true
1081
+ for (const path of deltaFiles) {
1082
+ let sha
1083
+ try {
1084
+ sha = sha256Bytes(readFileSync(path))
1085
+ } catch {
1086
+ continue
1087
+ }
1088
+ const consumed = receipts.some((r) =>
1089
+ (r.data.consumed_delta_hashes ?? []).some((c) => c.sha256 === sha),
1090
+ )
1091
+ if (consumed) {
1092
+ orphansOk = false
1093
+ findings.push({
1094
+ clause: 'orphans',
1095
+ file: relTo(root, path),
1096
+ message: 'delta already consumed by an in-tree receipt but still on disk (crash tail)',
1097
+ repair: WRITE_REPAIR,
1098
+ })
1099
+ }
1100
+ }
1101
+
1102
+ const coverageFindings = findings.filter((f) => f.clause === 'coverage')
1103
+ const ok = findings.length === 0
1104
+ return {
1105
+ status: ok ? 'ok' : warnOnly ? 'warn' : 'fail',
1106
+ base,
1107
+ qualifying_files: qualifying.map((q) => q.path),
1108
+ pending_deltas: pendingDeltas.length,
1109
+ receipts: receipts.length,
1110
+ coverage_ok: coverageFindings.length === 0,
1111
+ schema_valid: schemaValid,
1112
+ integrity_ok: integrityOk,
1113
+ orphans_ok: orphansOk,
1114
+ findings,
1115
+ }
1116
+ }
1117
+
1118
+ // ---------------------------------------------------------------------------
1119
+ // --gc (squash-safe receipt GC)
1120
+ // ---------------------------------------------------------------------------
1121
+
1122
+ /**
1123
+ * gcReceipts — prune ONLY receipts that (1) exist on origin/main AND (2) are
1124
+ * older than maxAgeDays by created_at. A fetch failure degrades to a warned
1125
+ * NO-OP — never a deletion.
1126
+ */
1127
+ export function gcReceipts({ root: rootIn, maxAgeDays = 30, now = new Date() }) {
1128
+ const root = resolve(rootIn)
1129
+ try {
1130
+ execFileSync('git', ['fetch', 'origin', 'main'], {
1131
+ cwd: root,
1132
+ encoding: 'utf-8',
1133
+ stdio: ['ignore', 'pipe', 'pipe'],
1134
+ })
1135
+ } catch {
1136
+ return {
1137
+ status: 'ok',
1138
+ pruned: [],
1139
+ kept: listReceiptFiles(root).length,
1140
+ warning: 'git fetch origin main failed — gc is a no-op',
1141
+ }
1142
+ }
1143
+ const cutoffMs = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000
1144
+ const pruned = []
1145
+ let kept = 0
1146
+ for (const path of listReceiptFiles(root)) {
1147
+ const rel = relTo(root, path)
1148
+ const data = readJsonSafe(path)
1149
+ const createdAt = data?.created_at ? Date.parse(data.created_at) : Number.NaN
1150
+ const oldEnough = Number.isFinite(createdAt) && createdAt < cutoffMs
1151
+ let onMain = false
1152
+ try {
1153
+ execFileSync('git', ['cat-file', '-e', `origin/main:${rel}`], {
1154
+ cwd: root,
1155
+ stdio: ['ignore', 'ignore', 'ignore'],
1156
+ })
1157
+ onMain = true
1158
+ } catch {
1159
+ onMain = false
1160
+ }
1161
+ if (oldEnough && onMain) {
1162
+ unlinkSync(path)
1163
+ pruned.push(rel)
1164
+ } else {
1165
+ kept += 1
1166
+ }
1167
+ }
1168
+ return { status: 'ok', pruned, kept }
1169
+ }