@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
@@ -3,8 +3,8 @@
3
3
  * auto-emit-artifact-ready.mjs — code-enforced plan-ready signal.
4
4
  *
5
5
  * Promotes the `artifact.ready{kind:'plan'}` emit from a markdown contract
6
- * in `skills/apt-plan/adapters/conductor.md` to a framework-level
7
- * postcondition of `apt-tools commit`. This closes the Conductor v2 gap
6
+ * in `skills/apt-plan/adapters/showrunner.md` to a framework-level
7
+ * postcondition of `apt-tools commit`. This closes the Showrunner v2 gap
8
8
  * exposed during live verification 2026-05-15: the planner's QUICK /
9
9
  * SIMPLE track is hard-exempt from "new interaction discipline"
10
10
  * (Aperant Fast Path Guarantee ID-05), so the markdown emit step was
@@ -19,7 +19,7 @@
19
19
  * OpenCode, Codex) traverses via `apt-tools commit`
20
20
  *
21
21
  * Conditions for emit (ALL must hold — otherwise no-op):
22
- * 1. `APERANT_TERMINAL_ID` is set in process.env (Conductor PTY context).
22
+ * 1. `APERANT_TERMINAL_ID` is set in process.env (Showrunner PTY context).
23
23
  * 2. The committed files include a path matching
24
24
  * `.aperant/tasks/{task-id}/implementation_plan.json`.
25
25
  *
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto'
32
32
  import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'
33
33
  import { hostname } from 'node:os'
34
34
  import { join } from 'node:path'
35
- import { validateConductorEvent } from './event-schema.mjs'
35
+ import { validateShowrunnerEvent } from './event-schema.mjs'
36
36
 
37
37
  const PLAN_PATH_RE = /^(?:\.\/)?\.aperant\/tasks\/([^/]+)\/implementation_plan\.json$/
38
38
 
@@ -70,7 +70,7 @@ export function maybeAutoEmitPlanReady({ files, projectRoot }) {
70
70
  terminal_id: terminalId,
71
71
  }
72
72
 
73
- const validation = validateConductorEvent('artifact.ready', data)
73
+ const validation = validateShowrunnerEvent('artifact.ready', data)
74
74
  if (!validation.ok) {
75
75
  return { emitted: false, reason: `schema violation: ${validation.error}` }
76
76
  }
@@ -5,9 +5,11 @@
5
5
 
6
6
  export type ValidateResult = { ok: true } | { ok: false; error: string }
7
7
 
8
- export function validateConductorEvent(
8
+ export function validateShowrunnerEvent(
9
9
  op: string,
10
10
  data: Record<string, unknown> | undefined | null,
11
11
  ): ValidateResult
12
12
 
13
- export const CONDUCTOR_VALIDATED_OPS: ReadonlySet<string>
13
+ export const SHOWRUNNER_VALIDATED_OPS: ReadonlySet<string>
14
+
15
+ export const PRODUCE_VALIDATED_OPS: ReadonlySet<string>
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * @internal
3
- * event-schema.mjs — single source of truth for conductor event payload shapes.
3
+ * event-schema.mjs — single source of truth for showrunner event payload shapes.
4
4
  *
5
5
  * Why this lives in `framework`, not `core`:
6
6
  * `packages/core` depends on `@aperant/framework`. The schema is shared by
7
7
  * the CLI write path (`commands/event.mjs`), the framework lifecycle write
8
8
  * seam (`coordination/store.mjs:appendEvent`), and the core IPC write seam
9
- * (`packages/core/src/runtime/orchestration/conductor/event-log.ts`).
9
+ * (`packages/core/src/runtime/orchestration/showrunner/event-log.ts`).
10
10
  * Putting it in the lower layer keeps a single rule table that every
11
11
  * writer can import.
12
12
  *
@@ -22,11 +22,11 @@
22
22
  * - `awaiting-input` / `input-rescinded` MUST carry both terminal_id and
23
23
  * request_id so the drawer can pair cards strictly.
24
24
  * - `artifact.ready{kind:'plan'}` is the canonical plan-completion signal
25
- * consumed by the conductor's reviewer queue (Step 5's reviewPlan tool).
25
+ * consumed by the showrunner's reviewer queue (Step 5's reviewPlan tool).
26
26
  */
27
27
 
28
- /** Conductor ops the schema enforces. Other ops pass through unchanged. */
29
- const CONDUCTOR_OPS = new Set([
28
+ /** Showrunner ops the schema enforces. Other ops pass through unchanged. */
29
+ const SHOWRUNNER_OPS = new Set([
30
30
  'phase.changed',
31
31
  'artifact.ready',
32
32
  'awaiting-input',
@@ -35,34 +35,120 @@ const CONDUCTOR_OPS = new Set([
35
35
  'budget.exceeded',
36
36
  'terminal.enrolled',
37
37
  'terminal.unenrolled',
38
- 'conductor.intent',
39
- 'conductor.cross_check',
40
- 'conductor.realignment',
38
+ 'showrunner.intent',
39
+ 'showrunner.cross_check',
40
+ 'showrunner.realignment',
41
41
  // c-4 (action-capable) — gate verdict + boot-replay audit trail
42
- 'conductor.action.executed',
43
- 'conductor.action.gate_blocked',
44
- 'conductor.boot_reconciled',
42
+ 'showrunner.action.executed',
43
+ 'showrunner.action.gate_blocked',
44
+ 'showrunner.boot_reconciled',
45
45
  // AC21 — MCP-server consent decisions audited to .aperant/events/*.jsonl
46
46
  'mcp-consent.approved',
47
47
  'mcp-consent.denied',
48
48
  'mcp-consent.tofu-reprompt',
49
49
  ])
50
50
 
51
+ /**
52
+ * Produce outer-loop ops (M1) — the `/apt:produce` roadmap-mutation vocabulary.
53
+ * Kept as a PARALLEL set (not merged into SHOWRUNNER_OPS) so the two surfaces
54
+ * stay conceptually distinct; both are consulted by the same
55
+ * `validateShowrunnerEvent` entry point that `store.appendEvent` calls, so a
56
+ * malformed produce event is rejected fail-closed on the canonical event log
57
+ * (ID-04 / AC4). Every produce op carries a `run_id` tagging its run.
58
+ */
59
+ const PRODUCE_OPS = new Set([
60
+ 'produce.run.started',
61
+ 'produce.setpoint.frozen',
62
+ 'produce.roadmap.milestone_added',
63
+ 'produce.roadmap.milestone_status_changed',
64
+ 'produce.tick',
65
+ // M2 — the oracle + held-out cross-family blind-probe forensic trail. Every
66
+ // oracle/probe decision lands on the same append-only log, fail-closed
67
+ // against malformed entries (US-1 / AC1 / AC12). The probe ops are
68
+ // STRUCTURALLY blind: they carry only a hash/result/family, NEVER the probe
69
+ // content (ID-M2-03). The projection still folds `produce.roadmap.*` ONLY,
70
+ // so these never enter the roadmap the Showrunner re-reads each tick.
71
+ 'produce.milestone.implemented',
72
+ 'produce.probe.frozen',
73
+ 'produce.probe.run',
74
+ 'produce.oracle.judged',
75
+ 'produce.oracle.verdict',
76
+ 'produce.oracle.probe_gap',
77
+ // M3 — the Lyapunov acceptance-criteria ledger + scope-change forensic trail.
78
+ // Every criteria-open / criteria-close / scope-change decision lands on the
79
+ // same append-only log, fail-closed against malformed entries (US-4 / AC1). V
80
+ // (open acceptance criteria) = declared − closed, folded by ledger.mjs. The
81
+ // projection still folds `produce.roadmap.*` ONLY — criteria/scope ops are a
82
+ // SEPARATE fold, so the M1/M2 roadmap-blindness invariant is preserved.
83
+ 'produce.criteria.declared',
84
+ 'produce.criteria.closed',
85
+ 'produce.scope.changed',
86
+ // M4 — the saga + verification-TTL forensic trail. Each plan→execute→verify→
87
+ // review→ship phase transition, every FORWARD compensation, every diverse
88
+ // review verdict, and every TTL re-verify / regression lands on the same
89
+ // append-only log, fail-closed against malformed entries (US-2 / AC1). The
90
+ // saga fold is SEPARATE from the roadmap projection (which still folds
91
+ // `produce.roadmap.*` ONLY), so the M1/M2/M3 blindness invariant holds. The
92
+ // saga-phase ops enforce a closed `phase` vocabulary (ALLOWED_SAGA_PHASES),
93
+ // mirroring the phase.changed.to / artifact.ready.kind allowed-value pattern.
94
+ 'produce.saga.phase_entered',
95
+ 'produce.saga.phase_completed',
96
+ 'produce.saga.compensated',
97
+ 'produce.saga.reviewed',
98
+ 'produce.ttl.reverified',
99
+ 'produce.ttl.regression_detected',
100
+ // M5 — the WSJF/WIP scheduler's audited decision. The wake `tick` emits ONE
101
+ // `produce.schedule.computed` alongside its `produce.tick` so the scheduler's
102
+ // pick is a recorded fact on the append-only log (the event-sourced ethos). It
103
+ // uses the `produce.schedule.*` prefix — the roadmap projection folds
104
+ // `produce.roadmap.*` ONLY, so this op NEVER enters the roadmap the Showrunner
105
+ // re-reads (the M1/M2 blindness invariant holds automatically — ID-M5-06).
106
+ 'produce.schedule.computed',
107
+ // M6 — the Claim Envelope lifecycle forensic trail. A claim's CONTENT lives in
108
+ // the frozen `claims/<claim-id>.json` file, referenced by `content_hash` — it
109
+ // NEVER touches the log (ID-M6-07, mirrors produce.probe.frozen /
110
+ // produce.setpoint.frozen). `declared` seeds the claim ledger, `expired` drops
111
+ // it from the active set, `consumed` records a bounded down-projected delta
112
+ // (target_kind value-checked against the closed set). The claim ledger is a
113
+ // SEPARATE fold (projectClaims) — the roadmap projection still folds
114
+ // `produce.roadmap.*` ONLY, so these ops NEVER enter the roadmap the Showrunner
115
+ // re-reads (the M1/M2 blindness invariant holds automatically).
116
+ 'produce.claim.declared',
117
+ 'produce.claim.expired',
118
+ 'produce.claim.consumed',
119
+ ])
120
+
51
121
  /** Allowed values for `phase.changed.to` — PTY busy/idle only. */
52
122
  const ALLOWED_PHASE_VALUES = new Set(['busy', 'idle'])
53
123
 
124
+ /**
125
+ * Allowed values for the saga `phase` on `produce.saga.phase_entered` /
126
+ * `produce.saga.phase_completed` / `produce.saga.compensated` (M4). The saga
127
+ * models plan→execute→verify→review→ship; an out-of-vocabulary phase is rejected
128
+ * fail-closed, mirroring the ALLOWED_PHASE_VALUES value check for phase.changed.
129
+ */
130
+ const ALLOWED_SAGA_PHASES = new Set(['plan', 'execute', 'verify', 'review', 'ship'])
131
+
132
+ /**
133
+ * Allowed values for the claim `target_kind` on `produce.claim.consumed` (M6).
134
+ * Consumption is a DELTA down-projection onto ONE acceptance-criterion / subtask /
135
+ * proof-obligation (ID-M6-06); an out-of-vocabulary kind is rejected fail-closed,
136
+ * mirroring the ALLOWED_SAGA_PHASES value check for the saga-phase ops.
137
+ */
138
+ const ALLOWED_CLAIM_TARGET_KINDS = new Set(['acceptance_criterion', 'subtask', 'proof_obligation'])
139
+
54
140
  /** Allowed values for `artifact.ready.kind`. */
55
141
  const ALLOWED_ARTIFACT_KINDS = new Set(['plan', 'spec', 'build', 'review'])
56
142
 
57
143
  /**
58
- * Validate the data payload of a conductor event against the op's contract.
144
+ * Validate the data payload of a showrunner event against the op's contract.
59
145
  *
60
146
  * @param {string} op
61
147
  * @param {Record<string, unknown> | undefined | null} data
62
148
  * @returns {{ ok: true } | { ok: false, error: string }}
63
149
  */
64
- export function validateConductorEvent(op, data) {
65
- if (!CONDUCTOR_OPS.has(op)) return { ok: true }
150
+ export function validateShowrunnerEvent(op, data) {
151
+ if (!SHOWRUNNER_OPS.has(op) && !PRODUCE_OPS.has(op)) return { ok: true }
66
152
  const d = data && typeof data === 'object' ? data : {}
67
153
 
68
154
  switch (op) {
@@ -105,13 +191,13 @@ export function validateConductorEvent(op, data) {
105
191
  return { ok: true }
106
192
  }
107
193
 
108
- case 'conductor.intent':
194
+ case 'showrunner.intent':
109
195
  return requireFields(op, d, ['terminal_id', 'request_id', 'action', 'reasoning'])
110
196
 
111
- case 'conductor.cross_check':
197
+ case 'showrunner.cross_check':
112
198
  return requireFields(op, d, ['terminal_id', 'request_id'])
113
199
 
114
- case 'conductor.realignment': {
200
+ case 'showrunner.realignment': {
115
201
  const r = requireFields(op, d, ['terminal_id', 'request_id_orig'])
116
202
  if (!r.ok) return r
117
203
  if (d.attempt_n === undefined || d.attempt_n === null)
@@ -125,10 +211,10 @@ export function validateConductorEvent(op, data) {
125
211
  case 'budget.exceeded':
126
212
  return requireFields(op, d, ['terminal_id'])
127
213
 
128
- case 'conductor.action.executed':
214
+ case 'showrunner.action.executed':
129
215
  return requireFields(op, d, ['terminal_id', 'request_id', 'action_kind', 'idempotency_key'])
130
216
 
131
- case 'conductor.action.gate_blocked':
217
+ case 'showrunner.action.gate_blocked':
132
218
  return requireFields(op, d, [
133
219
  'terminal_id',
134
220
  'request_id',
@@ -152,7 +238,130 @@ export function validateConductorEvent(op, data) {
152
238
  'session_id',
153
239
  ])
154
240
 
155
- case 'conductor.boot_reconciled': {
241
+ // Produce outer-loop (M1) — roadmap-mutation + tick vocabulary. Every
242
+ // op requires its `run_id`; roadmap ops additionally carry the
243
+ // milestone identity / status the projection folds (ID-04 / AC4).
244
+ case 'produce.run.started':
245
+ return requireFields(op, d, ['run_id', 'setpoint_hash'])
246
+
247
+ case 'produce.setpoint.frozen':
248
+ return requireFields(op, d, ['run_id', 'setpoint_hash', 'setpoint_path'])
249
+
250
+ case 'produce.roadmap.milestone_added':
251
+ return requireFields(op, d, ['run_id', 'milestone_id', 'title'])
252
+
253
+ case 'produce.roadmap.milestone_status_changed':
254
+ return requireFields(op, d, ['run_id', 'milestone_id', 'status'])
255
+
256
+ case 'produce.tick':
257
+ return requireFields(op, d, ['run_id'])
258
+
259
+ // Produce M2 — oracle + held-out cross-family blind-probe vocabulary.
260
+ // The implementer family is the recorded disjointness anchor (ID-M2-02);
261
+ // the probe ops are content-blind (only hash/result/family — ID-M2-03).
262
+
263
+ // The Showrunner records who implemented a milestone once per milestone;
264
+ // the cross-family rule is enforced against THIS recorded fact.
265
+ case 'produce.milestone.implemented':
266
+ return requireFields(op, d, ['run_id', 'milestone_id', 'implementer_family'])
267
+
268
+ // The frozen blind-probe SET is announced by hash + author family ONLY —
269
+ // NEVER the probe content (structural probe-blindness, AC5).
270
+ case 'produce.probe.frozen':
271
+ return requireFields(op, d, ['run_id', 'milestone_id', 'probe_set_hash', 'author_family'])
272
+
273
+ // A blind-probe RUN carries only its pass/fail result, the running family,
274
+ // and the HEAD sha it ran against — no probe content.
275
+ case 'produce.probe.run':
276
+ return requireFields(op, d, ['run_id', 'milestone_id', 'result', 'probe_family', 'head_sha'])
277
+
278
+ // The independent evidence-quality oracle's verdict over verify-proof's
279
+ // captured evidence, tagged with the (cross-family) oracle family.
280
+ case 'produce.oracle.judged':
281
+ return requireFields(op, d, ['run_id', 'milestone_id', 'verdict', 'oracle_family'])
282
+
283
+ // The two-gate done verdict (visible AND blind agree) — the ONLY path to
284
+ // `done` (ID-03: the Showrunner never self-attests).
285
+ case 'produce.oracle.verdict':
286
+ return requireFields(op, d, ['run_id', 'milestone_id', 'verdict'])
287
+
288
+ // The reward-hacking canary near-miss: visible gate passed, blind gate
289
+ // failed → the milestone stays open (ID-M2-06 / AC9).
290
+ case 'produce.oracle.probe_gap':
291
+ return requireFields(op, d, ['run_id', 'milestone_id'])
292
+
293
+ // Produce M3 — the Lyapunov acceptance-criteria ledger (V = declared −
294
+ // closed) + the scope-change escape hatch (ID-M3-01 / ID-M3-04 / AC1). A
295
+ // criterion binds to a milestone; it opens on `declared` (V += 1), closes
296
+ // on `closed` (V −= 1). `produce.scope.changed` is the ONLY sanctioned
297
+ // post-descent V increase — `milestone_id` is optional (a scope change may
298
+ // not target one milestone), so only `run_id` + `reason` are required.
299
+ case 'produce.criteria.declared':
300
+ return requireFields(op, d, ['run_id', 'milestone_id', 'criterion_id', 'text'])
301
+
302
+ case 'produce.criteria.closed':
303
+ return requireFields(op, d, ['run_id', 'milestone_id', 'criterion_id'])
304
+
305
+ case 'produce.scope.changed':
306
+ return requireFields(op, d, ['run_id', 'reason'])
307
+
308
+ // Produce M4 — the saga phase model + FORWARD compensators + verification-TTL.
309
+ // The saga-phase ops (entered/completed/compensated) enforce the closed
310
+ // `phase` vocabulary (ALLOWED_SAGA_PHASES), mirroring the phase.changed.to
311
+ // value check. No op names a probe/review CONTENT field — the roadmap
312
+ // projection still folds `produce.roadmap.*` ONLY (blindness preserved).
313
+ case 'produce.saga.phase_entered':
314
+ case 'produce.saga.phase_completed': {
315
+ const r = requireFields(op, d, ['run_id', 'milestone_id', 'phase'])
316
+ if (!r.ok) return r
317
+ return checkSagaPhase(op, d)
318
+ }
319
+
320
+ case 'produce.saga.compensated': {
321
+ const r = requireFields(op, d, ['run_id', 'milestone_id', 'phase', 'compensator'])
322
+ if (!r.ok) return r
323
+ return checkSagaPhase(op, d)
324
+ }
325
+
326
+ // A diverse-review verdict over the milestone's diff, tagged with the
327
+ // (cross-family) reviewer family. Content-free — verdict + family only.
328
+ case 'produce.saga.reviewed':
329
+ return requireFields(op, d, ['run_id', 'milestone_id', 'reviewer_family', 'verdict'])
330
+
331
+ // A scheduled post-`done` TTL re-verify RUN against HEAD — result/head_sha
332
+ // only, never probe content. A regression fires the forward compensator.
333
+ case 'produce.ttl.reverified':
334
+ return requireFields(op, d, ['run_id', 'milestone_id', 'head_sha', 'result'])
335
+
336
+ case 'produce.ttl.regression_detected':
337
+ return requireFields(op, d, ['run_id', 'milestone_id', 'head_sha'])
338
+
339
+ // Produce M5 — the WSJF/WIP scheduler's recorded pick. Only `run_id` is
340
+ // required (the `picked` ids + `wip_limit` are optional context); the
341
+ // roadmap projection folds `produce.roadmap.*` ONLY, so it never sees this
342
+ // op (blindness preserved — ID-M5-06).
343
+ case 'produce.schedule.computed':
344
+ return requireFields(op, d, ['run_id'])
345
+
346
+ // Produce M6 — the Claim Envelope lifecycle. `declared` carries a
347
+ // `content_hash` ONLY (the full envelope lives in the frozen
348
+ // claims/<claim-id>.json file — ID-M6-07); `consumed.target_kind` is
349
+ // value-checked against the closed set (mirrors ALLOWED_SAGA_PHASES /
350
+ // checkSagaPhase). No op names a claim-content field — the roadmap
351
+ // projection still folds `produce.roadmap.*` ONLY (blindness preserved).
352
+ case 'produce.claim.declared':
353
+ return requireFields(op, d, ['run_id', 'profile', 'claim_id', 'content_hash'])
354
+
355
+ case 'produce.claim.expired':
356
+ return requireFields(op, d, ['run_id', 'claim_id', 'reason'])
357
+
358
+ case 'produce.claim.consumed': {
359
+ const r = requireFields(op, d, ['run_id', 'claim_id', 'target_kind'])
360
+ if (!r.ok) return r
361
+ return checkClaimTargetKind(op, d)
362
+ }
363
+
364
+ case 'showrunner.boot_reconciled': {
156
365
  // Fleet-wide event — numeric fields only. All optional but if
157
366
  // present must be finite numbers (zero allowed).
158
367
  const numFields = ['cleaned_leases', 'replayed', 'start_offset']
@@ -174,7 +383,10 @@ export function validateConductorEvent(op, data) {
174
383
  }
175
384
 
176
385
  /** Set of ops the schema validates — exported for test enumeration. */
177
- export const CONDUCTOR_VALIDATED_OPS = CONDUCTOR_OPS
386
+ export const SHOWRUNNER_VALIDATED_OPS = SHOWRUNNER_OPS
387
+
388
+ /** Produce outer-loop ops the schema validates — exported for test enumeration. */
389
+ export const PRODUCE_VALIDATED_OPS = PRODUCE_OPS
178
390
 
179
391
  /**
180
392
  * @param {string} op
@@ -191,3 +403,46 @@ function requireFields(op, d, fields) {
191
403
  if (missing.length === 0) return { ok: true }
192
404
  return { ok: false, error: `${op} missing required field(s): ${missing.join(', ')}` }
193
405
  }
406
+
407
+ /**
408
+ * Validate the saga `phase` value against ALLOWED_SAGA_PHASES (M4). Mirrors the
409
+ * inline phase.changed.to value check — fail-closed on an out-of-vocabulary
410
+ * phase so a saga event can never carry an unknown phase onto the log.
411
+ *
412
+ * @param {string} op
413
+ * @param {Record<string, unknown>} d
414
+ * @returns {{ ok: true } | { ok: false, error: string }}
415
+ */
416
+ function checkSagaPhase(op, d) {
417
+ if (!ALLOWED_SAGA_PHASES.has(String(d.phase))) {
418
+ return {
419
+ ok: false,
420
+ error: `${op}.phase must be one of [${[...ALLOWED_SAGA_PHASES].join(', ')}], got: ${String(
421
+ d.phase,
422
+ )}`,
423
+ }
424
+ }
425
+ return { ok: true }
426
+ }
427
+
428
+ /**
429
+ * Validate the claim `target_kind` value against ALLOWED_CLAIM_TARGET_KINDS (M6).
430
+ * Mirrors checkSagaPhase — fail-closed on an out-of-vocabulary target kind so a
431
+ * produce.claim.consumed event can never carry an unknown delta target onto the
432
+ * log (ID-M6-06).
433
+ *
434
+ * @param {string} op
435
+ * @param {Record<string, unknown>} d
436
+ * @returns {{ ok: true } | { ok: false, error: string }}
437
+ */
438
+ function checkClaimTargetKind(op, d) {
439
+ if (!ALLOWED_CLAIM_TARGET_KINDS.has(String(d.target_kind))) {
440
+ return {
441
+ ok: false,
442
+ error: `${op}.target_kind must be one of [${[...ALLOWED_CLAIM_TARGET_KINDS].join(
443
+ ', ',
444
+ )}], got: ${String(d.target_kind)}`,
445
+ }
446
+ }
447
+ return { ok: true }
448
+ }
@@ -32,7 +32,7 @@ import { withFileLock } from '../util/fs-lock.mjs'
32
32
  import { atomicWriteJson } from '../util/io.mjs'
33
33
  import { getAgentIdentity, resolveSharedRoot } from './agent-identity.mjs'
34
34
  import { appendLifecycleEvent } from './event-log.mjs'
35
- import { validateConductorEvent } from './event-schema.mjs'
35
+ import { validateShowrunnerEvent } from './event-schema.mjs'
36
36
  import { advanceLastSeen as lastSeenAdvance, readLastSeen as lastSeenRead } from './last-seen.mjs'
37
37
  import {
38
38
  listActive as teamStatusListActive,
@@ -131,16 +131,16 @@ export function createCoordinationStore(projectRoot) {
131
131
  * call via appendLifecycleEvent. Best-effort: filesystem errors are
132
132
  * swallowed so a missing events/ dir never breaks task lifecycle.
133
133
  *
134
- * Step 1 — schema gate: conductor ops written via the store seam are
134
+ * Step 1 — schema gate: showrunner ops written via the store seam are
135
135
  * validated against `event-schema.mjs` before they reach disk.
136
136
  * Schema violations throw (caught here and surfaced via console.warn)
137
- * because a malformed conductor event would silently break the
137
+ * because a malformed showrunner event would silently break the
138
138
  * drawer's pairing logic — we want it loud, not best-effort.
139
139
  */
140
140
  appendEvent(evt) {
141
141
  try {
142
142
  if (evt && typeof evt.op === 'string') {
143
- const validation = validateConductorEvent(evt.op, evt.data)
143
+ const validation = validateShowrunnerEvent(evt.op, evt.data)
144
144
  if (!validation.ok) {
145
145
  console.warn(`[store.appendEvent] schema violation: ${validation.error}`)
146
146
  return null
@@ -62,7 +62,7 @@ import { cmdEditCheck } from './commands/edit-check.mjs'
62
62
  import { cmdEffects } from './commands/effects.mjs'
63
63
  import { cmdEvent } from './commands/event.mjs'
64
64
  import { cmdFanOut } from './commands/fan-out.mjs'
65
- import { cmdFeaturesAudit } from './commands/features-audit.mjs'
65
+ import { cmdFeaturesReconcile } from './commands/features-reconcile.mjs'
66
66
  import { cmdFollowups } from './commands/followups.mjs'
67
67
  import { cmdGate } from './commands/gate.mjs'
68
68
  import { cmdHealthCheck } from './commands/health-check.mjs'
@@ -78,6 +78,7 @@ import { cmdPersonas } from './commands/personas.mjs'
78
78
  import { cmdPlanApproval } from './commands/plan-approval.mjs'
79
79
  import { cmdPrReview } from './commands/pr-review.mjs'
80
80
  import { cmdPrecompactHook } from './commands/precompact-hook.mjs'
81
+ import { cmdProduce } from './commands/produce.mjs'
81
82
  import { cmdProductContext } from './commands/product-context.mjs'
82
83
  import { cmdProfile } from './commands/profile.mjs'
83
84
  // cmdRecipeDiff — loaded lazily in the dispatch handler (Fast Path PRF-001)
@@ -260,7 +261,7 @@ export const DISPATCH = {
260
261
  exitCode: 1,
261
262
  }))()
262
263
  },
263
- 'features-audit': (args) => wrap(cmdFeaturesAudit)(args[1], args.slice(2)),
264
+ 'features-reconcile': (args) => wrap(cmdFeaturesReconcile)(args[1], args.slice(2)),
264
265
  'init-state': (args) => wrap(cmdInitState)(args[1], args.slice(2)),
265
266
  'risk-check': (args) => wrap(cmdRiskCheck)(args[1], args.slice(2)),
266
267
  'edit-check': (args) => wrap(cmdEditCheck)(args.slice(1)),
@@ -326,6 +327,7 @@ export const DISPATCH = {
326
327
  team: (args) => wrap(cmdTeam)(args[1], args[2], args.slice(3)),
327
328
  active: (args) => wrap(cmdActive)(args[1], args[2], args.slice(3)),
328
329
  roadmap: (args) => wrap(cmdRoadmap)(args[1], args[2], args.slice(3)),
330
+ produce: (args) => wrap(cmdProduce)(args[1], args[2], args.slice(3)),
329
331
  seed: (args) => wrap(cmdSeed)(args[1], args[2], args.slice(3)),
330
332
  workflow: (args) => wrap(cmdWorkflow)(args[1], args[2], args.slice(3)),
331
333
  'pr-review': (args) => wrap(cmdPrReview)(args[1], args[2], args.slice(3)),
@@ -0,0 +1,140 @@
1
+ /**
2
+ * area-shape.mjs — hand-rolled structural validator for feature-registry
3
+ * area files, mirroring `src/schemas/feature-registry.ts` (FeatureRegistrySchema)
4
+ * PLUS the uniqueness refinement (feature ids unique per area; sub_feature ids
5
+ * unique per feature group).
6
+ *
7
+ * WHY A TWIN VALIDATOR EXISTS (ID-07): the bare-node CI check
8
+ * (`bin/features-reconcile-check.mjs`) runs with NO node_modules, so it cannot
9
+ * import zod. This module is node:-builtins only (in fact, zero imports).
10
+ * `feature-registry-schema-parity.test.ts` validates shared fixtures against
11
+ * BOTH this validator and the zod schema so they cannot drift.
12
+ *
13
+ * Semantics mirror zod defaults:
14
+ * - unknown keys are ALLOWED (zod objects are non-strict);
15
+ * - `components` and `sub_features` may be absent (zod `.default([])`);
16
+ * - `route` stays required non-empty — non-routed areas use the literal
17
+ * "(cross-cutting)" (chrome.json precedent), never null.
18
+ *
19
+ * Returns { ok, errors: [] } — never throws.
20
+ */
21
+
22
+ const ID_RE = /^[a-z0-9-]+$/
23
+ const LAST_SCANNED_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/
24
+ const TESTABLE_VALUES = new Set(['testable', 'observable', 'blocked'])
25
+ const WIRED_VALUES = new Set([true, false, 'stubbed', 'partial'])
26
+
27
+ function isNonEmptyString(v) {
28
+ return typeof v === 'string' && v.length > 0
29
+ }
30
+
31
+ function checkOptionalString(errors, at, v) {
32
+ if (v !== undefined && !isNonEmptyString(v)) {
33
+ errors.push(`${at}: must be a non-empty string when present`)
34
+ }
35
+ }
36
+
37
+ function validateSubFeature(errors, at, sub) {
38
+ if (!sub || typeof sub !== 'object' || Array.isArray(sub)) {
39
+ errors.push(`${at}: must be an object`)
40
+ return
41
+ }
42
+ if (!isNonEmptyString(sub.id) || !ID_RE.test(sub.id)) {
43
+ errors.push(`${at}.id: must match ${ID_RE}`)
44
+ }
45
+ if (!isNonEmptyString(sub.name)) errors.push(`${at}.name: required non-empty string`)
46
+ checkOptionalString(errors, `${at}.component`, sub.component)
47
+ if (!isNonEmptyString(sub.type)) errors.push(`${at}.type: required non-empty string`)
48
+ if (!WIRED_VALUES.has(sub.wired)) {
49
+ errors.push(`${at}.wired: must be true|false|"stubbed"|"partial"`)
50
+ }
51
+ if (sub.handler !== undefined && typeof sub.handler !== 'string') {
52
+ errors.push(`${at}.handler: must be a string when present`)
53
+ }
54
+ if (sub.shortcut !== undefined && typeof sub.shortcut !== 'string') {
55
+ errors.push(`${at}.shortcut: must be a string when present`)
56
+ }
57
+ if (sub.testable !== undefined && !TESTABLE_VALUES.has(sub.testable)) {
58
+ errors.push(`${at}.testable: must be "testable"|"observable"|"blocked" when present`)
59
+ }
60
+ if (sub.test_hint !== undefined && typeof sub.test_hint !== 'string') {
61
+ errors.push(`${at}.test_hint: must be a string when present`)
62
+ }
63
+ // default / range / options are `unknown` in the zod schema — anything goes.
64
+ }
65
+
66
+ function validateFeatureGroup(errors, at, group) {
67
+ if (!group || typeof group !== 'object' || Array.isArray(group)) {
68
+ errors.push(`${at}: must be an object`)
69
+ return
70
+ }
71
+ if (!isNonEmptyString(group.id) || !ID_RE.test(group.id)) {
72
+ errors.push(`${at}.id: must match ${ID_RE}`)
73
+ }
74
+ if (!isNonEmptyString(group.name)) errors.push(`${at}.name: required non-empty string`)
75
+ checkOptionalString(errors, `${at}.component`, group.component)
76
+ if (group.sub_features !== undefined) {
77
+ if (!Array.isArray(group.sub_features)) {
78
+ errors.push(`${at}.sub_features: must be an array when present`)
79
+ } else {
80
+ const seen = new Set()
81
+ for (const [i, sub] of group.sub_features.entries()) {
82
+ validateSubFeature(errors, `${at}.sub_features[${i}]`, sub)
83
+ const id = sub?.id
84
+ if (isNonEmptyString(id)) {
85
+ if (seen.has(id)) {
86
+ errors.push(`${at}.sub_features[${i}].id: duplicate sub_feature id "${id}" in group`)
87
+ }
88
+ seen.add(id)
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Structural + uniqueness validation of an area-file value.
97
+ *
98
+ * @param {unknown} data — parsed JSON of a `.aperant/features/<area>.json`
99
+ * @returns {{ ok: boolean, errors: string[] }}
100
+ */
101
+ export function validateAreaShape(data) {
102
+ const errors = []
103
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
104
+ return { ok: false, errors: ['area file must be a JSON object'] }
105
+ }
106
+ if (!isNonEmptyString(data.area)) errors.push('area: required non-empty string')
107
+ if (!isNonEmptyString(data.route)) {
108
+ errors.push(
109
+ 'route: required non-empty string — use "(cross-cutting)" for non-routed areas, never null',
110
+ )
111
+ }
112
+ if (data.components !== undefined) {
113
+ if (!Array.isArray(data.components)) {
114
+ errors.push('components: must be an array when present')
115
+ } else {
116
+ for (const [i, c] of data.components.entries()) {
117
+ if (!isNonEmptyString(c)) errors.push(`components[${i}]: must be a non-empty string`)
118
+ }
119
+ }
120
+ }
121
+ if (!isNonEmptyString(data.last_scanned) || !LAST_SCANNED_RE.test(data.last_scanned)) {
122
+ errors.push('last_scanned: must be a YYYY-MM-DD or full ISO 8601 date string')
123
+ }
124
+ if (!Array.isArray(data.features)) {
125
+ errors.push('features: required array')
126
+ } else {
127
+ const seen = new Set()
128
+ for (const [i, group] of data.features.entries()) {
129
+ validateFeatureGroup(errors, `features[${i}]`, group)
130
+ const id = group?.id
131
+ if (isNonEmptyString(id)) {
132
+ if (seen.has(id)) {
133
+ errors.push(`features[${i}].id: duplicate feature id "${id}" in area`)
134
+ }
135
+ seen.add(id)
136
+ }
137
+ }
138
+ }
139
+ return { ok: errors.length === 0, errors }
140
+ }