@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,423 @@
1
+ /**
2
+ * @internal
3
+ * produce/scheduler.mjs — the deterministic WSJF/WIP "what-next-under-budget"
4
+ * brain (M5, ID-M5-01/ID-M5-02).
5
+ *
6
+ * A NEW CONSUMER that FOLDS the existing M1/M3/M4 projections into a WSJF
7
+ * (Weighted Shortest Job First) ranking of the READY milestones + a WIP-limited
8
+ * pick. It reads `projectRoadmap` (M1 status/order/title), `projectLedger` (M3
9
+ * open acceptance criteria per milestone), `listRegressions` + the run's latest
10
+ * `produce.ttl.reverified` result (M4 unresolved regression), and its OWN small
11
+ * meta-fold over `produce.roadmap.milestone_added` (the additive depends_on /
12
+ * priority / size fields). It NEVER writes to those modules — projection.mjs /
13
+ * ledger.mjs / saga.mjs / verification-ttl.mjs stay BYTE-UNTOUCHED (ID-M5-02).
14
+ *
15
+ * The math is pure integer arithmetic over recorded folds — NO LLM, NO model id
16
+ * (scheduler.mjs is in the produce-no-hardcoded-models.guard scan list, AC11),
17
+ * NO daemon, NO loop. `computeSchedule` emits NO event (a pure read); the wake
18
+ * `tick` is the ONLY writer of `produce.schedule.computed` (ID-M5-06).
19
+ *
20
+ * WSJF(m) = CostOfDelay(m) / JobSize(m)
21
+ * CostOfDelay(m) = W_REGRESS·regression_flag(m) // 5·{0|1}
22
+ * + W_BLOCK ·blocking_count(m) // 3·(# milestones depending on m)
23
+ * + W_VALUE ·open_criteria(m) // 2·(M3 open criteria bound to m)
24
+ * + priority(m) // + optional declared priority (default 0)
25
+ * JobSize(m) = declared_size(m) > 0 ? declared_size(m) : max(open_criteria(m), 1)
26
+ *
27
+ * open_criteria deliberately appears in BOTH the CoD value term AND (as the
28
+ * fallback) in JobSize — standard WSJF (prefer small, high-value jobs); a
29
+ * declared --size decouples them (ID-M5-01). ready_queue is sorted WSJF
30
+ * descending → roadmap first-seen order → milestone_id, a total stable order so
31
+ * two folds of the same log are byte-identical.
32
+ */
33
+
34
+ import { existsSync, readFileSync } from 'node:fs'
35
+ import { join } from 'node:path'
36
+ import { readProduceRunEventsMatching } from './events.mjs'
37
+ import { projectLedger } from './ledger.mjs'
38
+ import { projectRoadmap } from './projection.mjs'
39
+ import { listRegressions } from './verification-ttl.mjs'
40
+
41
+ /**
42
+ * WSJF cost-of-delay weights (ID-M5-01). Fixed integer constants — the ordering
43
+ * is intentional: a shipped-but-regressed milestone is user-facing breakage
44
+ * (most urgent) > a milestone blocking many downstream ones > a milestone with
45
+ * more open value locked behind it > a human-declared priority nudge.
46
+ */
47
+ export const W_REGRESS = 5
48
+ export const W_BLOCK = 3
49
+ export const W_VALUE = 2
50
+
51
+ /** The default WIP cap when neither a flag nor config supplies one (ID-M5-05). */
52
+ export const DEFAULT_WIP_LIMIT = 1
53
+
54
+ /**
55
+ * Load `.aperant/config.json` defensively (mirrors roadmap/backend-adapter.mjs).
56
+ * Returns `{}` on any read/parse failure so the WIP resolver never throws.
57
+ *
58
+ * @param {string} projectDir
59
+ * @returns {Record<string, unknown>}
60
+ */
61
+ function loadConfig(projectDir) {
62
+ const p = join(projectDir, '.aperant', 'config.json')
63
+ if (!existsSync(p)) return {}
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(p, 'utf-8'))
66
+ return parsed && typeof parsed === 'object' ? parsed : {}
67
+ } catch {
68
+ return {}
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Resolve the WIP cap deterministically (ID-M5-05):
74
+ * `--wip <n>` flag → `.aperant/config.json :: produce.wip` → default 1. A
75
+ * non-numeric / negative / bare `--wip` value is ignored (falls through). The
76
+ * cap is the max number of CONCURRENT in_progress milestones the loop will run.
77
+ *
78
+ * @param {string} projectDir
79
+ * @param {Map<string, string | true>} flags
80
+ * @returns {number}
81
+ */
82
+ export function resolveWipLimit(projectDir, flags) {
83
+ const flagVal = flags && typeof flags.get === 'function' ? flags.get('wip') : undefined
84
+ if (typeof flagVal === 'string') {
85
+ const n = Number.parseInt(flagVal, 10)
86
+ if (Number.isFinite(n) && n >= 0) return n
87
+ }
88
+ const cfg = loadConfig(projectDir)
89
+ const produceCfg =
90
+ cfg && typeof cfg.produce === 'object' && cfg.produce !== null ? cfg.produce : null
91
+ if (produceCfg && Number.isFinite(produceCfg.wip) && Number(produceCfg.wip) >= 0) {
92
+ return Math.trunc(Number(produceCfg.wip))
93
+ }
94
+ return DEFAULT_WIP_LIMIT
95
+ }
96
+
97
+ /**
98
+ * Fold the run's `produce.roadmap.milestone_added` events into the per-milestone
99
+ * scheduling metadata carried as ADDITIVE optional fields on that event
100
+ * (ID-M5-03): `depends_on` (string[]), `priority` (int, default 0), `size`
101
+ * (positive int | null). An M1-style milestone (added with no new flags) folds
102
+ * to `{ depends_on: [], priority: 0, size: null }` — byte-identical behavior.
103
+ * First-seen wins (idempotent re-adds never clobber).
104
+ *
105
+ * @param {string} projectDir
106
+ * @param {string} runId
107
+ * @returns {Map<string, { depends_on: string[], priority: number, size: number | null }>}
108
+ */
109
+ export function readMilestoneMeta(projectDir, runId) {
110
+ const events = readProduceRunEventsMatching(
111
+ projectDir,
112
+ runId,
113
+ (op) => op === 'produce.roadmap.milestone_added',
114
+ )
115
+ const meta = new Map()
116
+ for (const e of events) {
117
+ const d = e.data || {}
118
+ const id = typeof d.milestone_id === 'string' ? d.milestone_id : null
119
+ if (!id || meta.has(id)) continue
120
+ const dependsOn = Array.isArray(d.depends_on)
121
+ ? d.depends_on.filter((x) => typeof x === 'string' && x.length > 0)
122
+ : []
123
+ const priority = Number.isFinite(d.priority) ? Math.trunc(Number(d.priority)) : 0
124
+ const size = Number.isFinite(d.size) && Number(d.size) > 0 ? Math.trunc(Number(d.size)) : null
125
+ meta.set(id, { depends_on: dependsOn, priority, size })
126
+ }
127
+ return meta
128
+ }
129
+
130
+ /**
131
+ * CostOfDelay = 5·regression + 3·blocking + 2·open_criteria + priority (all
132
+ * integer). Absent fields default to 0.
133
+ *
134
+ * @param {{ regressionFlag?: number, blockingCount?: number, openCriteria?: number, priority?: number }} args
135
+ * @returns {number}
136
+ */
137
+ export function computeCostOfDelay({
138
+ regressionFlag = 0,
139
+ blockingCount = 0,
140
+ openCriteria = 0,
141
+ priority = 0,
142
+ } = {}) {
143
+ return (
144
+ W_REGRESS * (regressionFlag ? 1 : 0) +
145
+ W_BLOCK * blockingCount +
146
+ W_VALUE * openCriteria +
147
+ priority
148
+ )
149
+ }
150
+
151
+ /**
152
+ * JobSize = declared_size when positive, else the open-criteria count floored at
153
+ * 1 (never 0 — a criteria-less, size-less milestone is the smallest job = 1, so
154
+ * WSJF never divides by zero). ID-M5-01.
155
+ *
156
+ * @param {{ declaredSize?: number | null, openCriteria?: number }} args
157
+ * @returns {number}
158
+ */
159
+ export function computeJobSize({ declaredSize = null, openCriteria = 0 } = {}) {
160
+ if (Number.isFinite(declaredSize) && Number(declaredSize) > 0)
161
+ return Math.trunc(Number(declaredSize))
162
+ return Math.max(openCriteria, 1)
163
+ }
164
+
165
+ /**
166
+ * WSJF = CostOfDelay / JobSize. JobSize is guaranteed ≥ 1 (computeJobSize) so
167
+ * this never divides by zero.
168
+ *
169
+ * @param {number} costOfDelay
170
+ * @param {number} jobSize
171
+ * @returns {number}
172
+ */
173
+ export function computeWsjf(costOfDelay, jobSize) {
174
+ return costOfDelay / (jobSize > 0 ? jobSize : 1)
175
+ }
176
+
177
+ /**
178
+ * True iff milestone `milestoneId` has an UNRESOLVED M4 verification-TTL
179
+ * regression — it appears in `listRegressions` AND its latest recorded
180
+ * `produce.ttl.reverified` result is not `pass` (ID-M5-07). A subsequent
181
+ * `--result pass` re-verify resolves it (latest result becomes `pass`).
182
+ *
183
+ * @param {string} projectDir
184
+ * @param {string} runId
185
+ * @param {string} milestoneId
186
+ * @returns {boolean}
187
+ */
188
+ export function hasUnresolvedRegression(projectDir, runId, milestoneId) {
189
+ const regressed = listRegressions(projectDir, runId).some((r) => r.milestone_id === milestoneId)
190
+ if (!regressed) return false
191
+ const reverifies = readProduceRunEventsMatching(
192
+ projectDir,
193
+ runId,
194
+ (op) => op === 'produce.ttl.reverified',
195
+ )
196
+ let latestResult = null
197
+ for (const e of reverifies) {
198
+ const d = e.data || {}
199
+ if (d.milestone_id === milestoneId && typeof d.result === 'string') latestResult = d.result
200
+ }
201
+ return latestResult !== 'pass'
202
+ }
203
+
204
+ /**
205
+ * A ranked ready-queue entry with its WSJF terms exposed.
206
+ *
207
+ * @typedef {Object} ScheduleEntry
208
+ * @property {string} milestone_id
209
+ * @property {string} title
210
+ * @property {string} status
211
+ * @property {number} wsjf
212
+ * @property {number} cost_of_delay
213
+ * @property {number} job_size
214
+ * @property {number} open_criteria
215
+ * @property {number} blocking_count
216
+ * @property {number} priority
217
+ * @property {boolean} regressed
218
+ */
219
+
220
+ /**
221
+ * The pure schedule combiner (ID-M5-02). Folds the existing projections + the
222
+ * meta-fold + regressions into a WSJF-ranked `ready_queue`, a `blocked` list
223
+ * (with `blocked_by` = the not-done deps), the `in_progress` accounting, and a
224
+ * WIP-limited `pick` of the top `wip_available` ready milestones. Emits NO
225
+ * event and mutates no module (a pure read).
226
+ *
227
+ * Ready-set membership: a `planned` milestone is READY iff every `depends_on`
228
+ * id resolves to a roadmap milestone with status `done` (an unknown dep fails
229
+ * closed = unmet); otherwise it goes to `blocked`. A `done` milestone with an
230
+ * unresolved M4 regression re-enters the ready set tagged regressed:true.
231
+ *
232
+ * @param {string} projectDir
233
+ * @param {string} runId
234
+ * @param {{ wipLimit?: number }} [opts]
235
+ * @returns {{
236
+ * run_id: string,
237
+ * wip_limit: number,
238
+ * wip_in_use: number,
239
+ * wip_available: number,
240
+ * ready_queue: ScheduleEntry[],
241
+ * blocked: Array<{ milestone_id: string, title: string, blocked_by: string[] }>,
242
+ * in_progress: Array<{ milestone_id: string, title: string }>,
243
+ * pick: ScheduleEntry[],
244
+ * reason: string,
245
+ * cycle: string[] | null,
246
+ * }}
247
+ */
248
+ export function computeSchedule(projectDir, runId, { wipLimit = 1 } = {}) {
249
+ const roadmap = projectRoadmap(projectDir, runId)
250
+ const ledger = projectLedger(projectDir, runId)
251
+ const meta = readMilestoneMeta(projectDir, runId)
252
+
253
+ // M3 open acceptance criteria per milestone.
254
+ const openByMilestone = new Map()
255
+ for (const c of ledger.criteria) {
256
+ if (c.status === 'open' && typeof c.milestone_id === 'string') {
257
+ openByMilestone.set(c.milestone_id, (openByMilestone.get(c.milestone_id) || 0) + 1)
258
+ }
259
+ }
260
+
261
+ // blocking_count(m) = # of milestones whose depends_on includes m.
262
+ const blockingCount = new Map()
263
+ for (const m of roadmap.milestones) {
264
+ const dm = meta.get(m.id)
265
+ for (const dep of dm ? dm.depends_on : []) {
266
+ blockingCount.set(dep, (blockingCount.get(dep) || 0) + 1)
267
+ }
268
+ }
269
+
270
+ const statusById = new Map(roadmap.milestones.map((m) => [m.id, m.status]))
271
+ const firstSeenOrder = new Map(roadmap.milestones.map((m, i) => [m.id, i]))
272
+
273
+ const inProgress = roadmap.milestones
274
+ .filter((m) => m.status === 'in_progress')
275
+ .map((m) => ({ milestone_id: m.id, title: m.title }))
276
+ const wipInUse = inProgress.length
277
+ const wipAvailable = Math.max(0, wipLimit - wipInUse)
278
+
279
+ /** Build a ranked ready-queue entry with its WSJF terms exposed. */
280
+ const buildEntry = (m, regressed) => {
281
+ const dm = meta.get(m.id) || { depends_on: [], priority: 0, size: null }
282
+ const openCriteria = openByMilestone.get(m.id) || 0
283
+ const blocking = blockingCount.get(m.id) || 0
284
+ const costOfDelay = computeCostOfDelay({
285
+ regressionFlag: regressed ? 1 : 0,
286
+ blockingCount: blocking,
287
+ openCriteria,
288
+ priority: dm.priority,
289
+ })
290
+ const jobSize = computeJobSize({ declaredSize: dm.size, openCriteria })
291
+ return {
292
+ milestone_id: m.id,
293
+ title: m.title,
294
+ status: m.status,
295
+ wsjf: computeWsjf(costOfDelay, jobSize),
296
+ cost_of_delay: costOfDelay,
297
+ job_size: jobSize,
298
+ open_criteria: openCriteria,
299
+ blocking_count: blocking,
300
+ priority: dm.priority,
301
+ regressed,
302
+ }
303
+ }
304
+
305
+ // Ready set = { planned with all deps done } ∪ { done with an unresolved M4
306
+ // regression } (ID-M5-07). A regressed `done` milestone re-enters ready_queue
307
+ // tagged regressed:true; its regression_flag gives it the W_REGRESS=5 CoD
308
+ // weight, so it ranks toward the top and competes for a WIP slot — the loop
309
+ // defends what it shipped. WIP still counts roadmap `in_progress` ONLY (a
310
+ // regressed done milestone is a candidate, not counted as in_progress).
311
+ const readyQueue = []
312
+ const blocked = []
313
+ for (const m of roadmap.milestones) {
314
+ if (m.status === 'planned') {
315
+ const dm = meta.get(m.id) || { depends_on: [], priority: 0, size: null }
316
+ // An unknown dep fails closed = unmet (ID-M5-04).
317
+ const notDone = dm.depends_on.filter((dep) => statusById.get(dep) !== 'done')
318
+ if (notDone.length === 0) {
319
+ readyQueue.push(buildEntry(m, false))
320
+ } else {
321
+ blocked.push({ milestone_id: m.id, title: m.title, blocked_by: notDone })
322
+ }
323
+ } else if (m.status === 'done' && hasUnresolvedRegression(projectDir, runId, m.id)) {
324
+ readyQueue.push(buildEntry(m, true))
325
+ }
326
+ }
327
+
328
+ sortReadyQueue(readyQueue, firstSeenOrder)
329
+
330
+ const pick = readyQueue.slice(0, wipAvailable)
331
+
332
+ // A dependency cycle among not-done milestones means none can EVER become
333
+ // ready — surface it as a diagnostic rather than a misleading silent idle
334
+ // (ID-M5-04). The cyclic milestones simply never enter ready_queue.
335
+ const cycle = detectDependencyCycle(roadmap, meta)
336
+
337
+ let reason
338
+ if (wipAvailable === 0) reason = 'wip_saturated'
339
+ else if (readyQueue.length === 0)
340
+ reason = cycle ? 'dependency_cycle_detected' : 'no_ready_milestones'
341
+ else reason = 'scheduled'
342
+
343
+ return {
344
+ run_id: runId,
345
+ wip_limit: wipLimit,
346
+ wip_in_use: wipInUse,
347
+ wip_available: wipAvailable,
348
+ ready_queue: readyQueue,
349
+ blocked,
350
+ in_progress: inProgress,
351
+ pick,
352
+ reason,
353
+ cycle,
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Minimal inline DFS over the NOT-DONE dependency sub-graph (ID-M5-04). An edge
359
+ * m → dep exists when m depends on dep AND dep is itself a not-done roadmap
360
+ * milestone (a `done` dep is satisfied; an UNKNOWN dep is a leaf — it can't form
361
+ * a cycle). Returns the offending cycle's milestone ids (the recursion-stack
362
+ * slice from the re-entered gray node), or `null` when the sub-graph is acyclic.
363
+ * Deterministic — iterates in roadmap first-seen order.
364
+ *
365
+ * @param {{ milestones: Array<{ id: string, status: string }> }} roadmap
366
+ * @param {Map<string, { depends_on: string[] }>} meta
367
+ * @returns {string[] | null}
368
+ */
369
+ function detectDependencyCycle(roadmap, meta) {
370
+ const nodes = roadmap.milestones.filter((m) => m.status !== 'done').map((m) => m.id)
371
+ const nodeSet = new Set(nodes)
372
+ const depsOf = (id) => (meta.get(id)?.depends_on ?? []).filter((dep) => nodeSet.has(dep))
373
+
374
+ const WHITE = 0
375
+ const GRAY = 1
376
+ const BLACK = 2
377
+ const color = new Map(nodes.map((n) => [n, WHITE]))
378
+ const stack = []
379
+ let found = null
380
+
381
+ const visit = (n) => {
382
+ if (found) return
383
+ color.set(n, GRAY)
384
+ stack.push(n)
385
+ for (const dep of depsOf(n)) {
386
+ if (found) return
387
+ const c = color.get(dep)
388
+ if (c === GRAY) {
389
+ found = stack.slice(stack.indexOf(dep))
390
+ return
391
+ }
392
+ if (c === WHITE) visit(dep)
393
+ }
394
+ stack.pop()
395
+ color.set(n, BLACK)
396
+ }
397
+
398
+ for (const n of nodes) {
399
+ if (found) break
400
+ if (color.get(n) === WHITE) visit(n)
401
+ }
402
+ return found
403
+ }
404
+
405
+ /**
406
+ * Sort a ready-queue in place: WSJF descending → roadmap first-seen order →
407
+ * milestone_id lexicographic. A total, stable order so two folds of the same
408
+ * log are byte-identical (ID-M5-01 tie-break).
409
+ *
410
+ * @param {Array<{ wsjf: number, milestone_id: string }>} queue
411
+ * @param {Map<string, number>} firstSeenOrder
412
+ */
413
+ function sortReadyQueue(queue, firstSeenOrder) {
414
+ queue.sort((a, b) => {
415
+ if (b.wsjf !== a.wsjf) return b.wsjf - a.wsjf
416
+ const ao = firstSeenOrder.get(a.milestone_id) ?? 0
417
+ const bo = firstSeenOrder.get(b.milestone_id) ?? 0
418
+ if (ao !== bo) return ao - bo
419
+ if (a.milestone_id < b.milestone_id) return -1
420
+ if (a.milestone_id > b.milestone_id) return 1
421
+ return 0
422
+ })
423
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @internal
3
+ * produce/setpoint.mjs — the FROZEN acceptance setpoint (M1, ID-02).
4
+ *
5
+ * The setpoint is the fixed external reference the parent spar requires
6
+ * instead of the Showrunner's moving opinion. It is written ONCE via
7
+ * `openSync(path, 'wx')` (exclusive create) — a second write throws EEXIST, so
8
+ * the reference cannot drift. `produce start` on a run whose setpoint already
9
+ * exists is idempotent-reuse: it returns the byte-identical stored setpoint,
10
+ * NEVER an overwrite.
11
+ *
12
+ * File shape: `{ run_id, outcome, frozen_at, setpoint_hash }` where
13
+ * `setpoint_hash = sha256(canonical JSON of { run_id, outcome, frozen_at })`.
14
+ */
15
+
16
+ import { createHash } from 'node:crypto'
17
+ import {
18
+ closeSync,
19
+ existsSync,
20
+ fsyncSync,
21
+ mkdirSync,
22
+ openSync,
23
+ readFileSync,
24
+ writeSync,
25
+ } from 'node:fs'
26
+ import { dirname, join } from 'node:path'
27
+
28
+ /** Stable-key-order JSON of the hashable setpoint core. */
29
+ function canonicalSetpointJson(core) {
30
+ return JSON.stringify({
31
+ run_id: core.run_id,
32
+ outcome: core.outcome,
33
+ frozen_at: core.frozen_at,
34
+ })
35
+ }
36
+
37
+ /**
38
+ * sha256 of the canonical JSON of the setpoint's immutable core.
39
+ *
40
+ * @param {{ run_id: string, outcome: string, frozen_at: string }} core
41
+ * @returns {string}
42
+ */
43
+ export function computeSetpointHash(core) {
44
+ return createHash('sha256').update(canonicalSetpointJson(core)).digest('hex')
45
+ }
46
+
47
+ /**
48
+ * The setpoint.json path for a given run dir.
49
+ * @param {string} runDir
50
+ * @returns {string}
51
+ */
52
+ export function setpointPathFor(runDir) {
53
+ return join(runDir, 'setpoint.json')
54
+ }
55
+
56
+ /**
57
+ * Exclusive-create write of the setpoint. Uses `openSync(path, 'wx')` on the
58
+ * FINAL path (NOT a tmp+rename) so a SECOND write throws EEXIST — the setpoint
59
+ * is immutable. Any attempt to overwrite an existing setpoint throws.
60
+ *
61
+ * @param {string} setpointPath
62
+ * @param {object} setpoint
63
+ */
64
+ export function writeSetpointExclusive(setpointPath, setpoint) {
65
+ mkdirSync(dirname(setpointPath), { recursive: true })
66
+ const payload = `${JSON.stringify(setpoint, null, 2)}\n`
67
+ const fd = openSync(setpointPath, 'wx') // throws EEXIST when already frozen
68
+ try {
69
+ writeSync(fd, payload, 0, 'utf-8')
70
+ fsyncSync(fd)
71
+ } finally {
72
+ closeSync(fd)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Read + parse the frozen setpoint for a run dir. Returns null when absent or
78
+ * malformed (never throws).
79
+ *
80
+ * @param {string} runDir
81
+ * @returns {object | null}
82
+ */
83
+ export function readSetpoint(runDir) {
84
+ const p = setpointPathFor(runDir)
85
+ if (!existsSync(p)) return null
86
+ try {
87
+ return JSON.parse(readFileSync(p, 'utf-8'))
88
+ } catch {
89
+ return null
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Freeze the setpoint for a run, idempotently. If already frozen, returns the
95
+ * byte-identical stored setpoint with `reused: true`. Otherwise writes it once
96
+ * via exclusive create and returns `reused: false`.
97
+ *
98
+ * @param {string} runDir
99
+ * @param {{ run_id: string, outcome: string }} args
100
+ * @param {Date} [now]
101
+ * @returns {{ reused: boolean, setpoint: object }}
102
+ */
103
+ export function freezeSetpoint(runDir, { run_id, outcome }, now = new Date()) {
104
+ const existing = readSetpoint(runDir)
105
+ if (existing) return { reused: true, setpoint: existing }
106
+
107
+ const frozen_at = now.toISOString()
108
+ const core = { run_id, outcome, frozen_at }
109
+ const setpoint = { ...core, setpoint_hash: computeSetpointHash(core) }
110
+ try {
111
+ writeSetpointExclusive(setpointPathFor(runDir), setpoint)
112
+ } catch (e) {
113
+ // Lost a concurrent race — the winner's file is authoritative; re-read
114
+ // it byte-identically rather than overwriting (ID-02).
115
+ if (e && e.code === 'EEXIST') {
116
+ const winner = readSetpoint(runDir)
117
+ if (winner) return { reused: true, setpoint: winner }
118
+ }
119
+ throw e
120
+ }
121
+ return { reused: false, setpoint }
122
+ }