@aperant/framework 0.17.0 → 0.19.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 (152) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/dist/cli/commands/ci-watch.mjs +49 -2
  3. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  4. package/dist/cli/commands/commit.mjs +3 -3
  5. package/dist/cli/commands/commit.mjs.map +1 -1
  6. package/dist/cli/commands/event.mjs +16 -16
  7. package/dist/cli/commands/event.mjs.map +1 -1
  8. package/dist/cli/commands/merge-integrate.mjs +3 -3
  9. package/dist/cli/commands/merge-integrate.mjs.map +1 -1
  10. package/dist/cli/commands/produce.d.mts +9 -0
  11. package/dist/cli/commands/produce.d.mts.map +1 -0
  12. package/dist/cli/commands/produce.mjs +1345 -0
  13. package/dist/cli/commands/produce.mjs.map +1 -0
  14. package/dist/cli/commands/task.d.mts +16 -1
  15. package/dist/cli/commands/task.d.mts.map +1 -1
  16. package/dist/cli/commands/task.mjs +434 -266
  17. package/dist/cli/commands/task.mjs.map +1 -1
  18. package/dist/cli/coordination/auto-emit-artifact-ready.mjs +5 -5
  19. package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -1
  20. package/dist/cli/coordination/event-schema.d.mts +5 -3
  21. package/dist/cli/coordination/event-schema.d.mts.map +1 -1
  22. package/dist/cli/coordination/event-schema.mjs +245 -21
  23. package/dist/cli/coordination/event-schema.mjs.map +1 -1
  24. package/dist/cli/coordination/store.d.mts +2 -2
  25. package/dist/cli/coordination/store.mjs +4 -4
  26. package/dist/cli/coordination/store.mjs.map +1 -1
  27. package/dist/cli/dispatch.d.mts.map +1 -1
  28. package/dist/cli/dispatch.mjs +2 -0
  29. package/dist/cli/dispatch.mjs.map +1 -1
  30. package/dist/cli/help.d.mts.map +1 -1
  31. package/dist/cli/help.mjs +30 -0
  32. package/dist/cli/help.mjs.map +1 -1
  33. package/dist/cli/install/legacy-paths.d.mts.map +1 -1
  34. package/dist/cli/install/legacy-paths.mjs +2 -0
  35. package/dist/cli/install/legacy-paths.mjs.map +1 -1
  36. package/dist/cli/produce/blind-probe.d.mts +85 -0
  37. package/dist/cli/produce/blind-probe.d.mts.map +1 -0
  38. package/dist/cli/produce/blind-probe.mjs +217 -0
  39. package/dist/cli/produce/blind-probe.mjs.map +1 -0
  40. package/dist/cli/produce/claim.d.mts +188 -0
  41. package/dist/cli/produce/claim.d.mts.map +1 -0
  42. package/dist/cli/produce/claim.mjs +518 -0
  43. package/dist/cli/produce/claim.mjs.map +1 -0
  44. package/dist/cli/produce/done-gate.d.mts +87 -0
  45. package/dist/cli/produce/done-gate.d.mts.map +1 -0
  46. package/dist/cli/produce/done-gate.mjs +200 -0
  47. package/dist/cli/produce/done-gate.mjs.map +1 -0
  48. package/dist/cli/produce/events.d.mts +77 -0
  49. package/dist/cli/produce/events.d.mts.map +1 -0
  50. package/dist/cli/produce/events.mjs +126 -0
  51. package/dist/cli/produce/events.mjs.map +1 -0
  52. package/dist/cli/produce/evidence-oracle.d.mts +63 -0
  53. package/dist/cli/produce/evidence-oracle.d.mts.map +1 -0
  54. package/dist/cli/produce/evidence-oracle.mjs +122 -0
  55. package/dist/cli/produce/evidence-oracle.mjs.map +1 -0
  56. package/dist/cli/produce/ledger.d.mts +140 -0
  57. package/dist/cli/produce/ledger.d.mts.map +1 -0
  58. package/dist/cli/produce/ledger.mjs +272 -0
  59. package/dist/cli/produce/ledger.mjs.map +1 -0
  60. package/dist/cli/produce/probe-family.d.mts +53 -0
  61. package/dist/cli/produce/probe-family.d.mts.map +1 -0
  62. package/dist/cli/produce/probe-family.mjs +160 -0
  63. package/dist/cli/produce/probe-family.mjs.map +1 -0
  64. package/dist/cli/produce/projection.d.mts +55 -0
  65. package/dist/cli/produce/projection.d.mts.map +1 -0
  66. package/dist/cli/produce/projection.mjs +97 -0
  67. package/dist/cli/produce/projection.mjs.map +1 -0
  68. package/dist/cli/produce/run-id.d.mts +42 -0
  69. package/dist/cli/produce/run-id.d.mts.map +1 -0
  70. package/dist/cli/produce/run-id.mjs +79 -0
  71. package/dist/cli/produce/run-id.mjs.map +1 -0
  72. package/dist/cli/produce/saga.d.mts +180 -0
  73. package/dist/cli/produce/saga.d.mts.map +1 -0
  74. package/dist/cli/produce/saga.mjs +290 -0
  75. package/dist/cli/produce/saga.mjs.map +1 -0
  76. package/dist/cli/produce/scheduler.d.mts +165 -0
  77. package/dist/cli/produce/scheduler.d.mts.map +1 -0
  78. package/dist/cli/produce/scheduler.mjs +399 -0
  79. package/dist/cli/produce/scheduler.mjs.map +1 -0
  80. package/dist/cli/produce/setpoint.d.mts +52 -0
  81. package/dist/cli/produce/setpoint.d.mts.map +1 -0
  82. package/dist/cli/produce/setpoint.mjs +113 -0
  83. package/dist/cli/produce/setpoint.mjs.map +1 -0
  84. package/dist/cli/produce/verification-ttl.d.mts +75 -0
  85. package/dist/cli/produce/verification-ttl.d.mts.map +1 -0
  86. package/dist/cli/produce/verification-ttl.mjs +169 -0
  87. package/dist/cli/produce/verification-ttl.mjs.map +1 -0
  88. package/dist/cli/roadmap/{conductor-view.d.mts → showrunner-view.d.mts} +3 -3
  89. package/dist/cli/roadmap/showrunner-view.d.mts.map +1 -0
  90. package/dist/cli/roadmap/{conductor-view.mjs → showrunner-view.mjs} +7 -7
  91. package/dist/cli/roadmap/showrunner-view.mjs.map +1 -0
  92. package/dist/plugin/.claude-plugin/plugin.json +2 -1
  93. package/dist/plugin/skills/apt/SKILL.md +112 -38
  94. package/dist/plugin/skills/apt-debug/SKILL.md +14 -24
  95. package/dist/plugin/skills/apt-fan-out/SKILL.md +4 -4
  96. package/dist/plugin/skills/apt-handoff/SKILL.md +1 -1
  97. package/dist/plugin/skills/apt-plan/SKILL.md +5 -5
  98. package/dist/plugin/skills/apt-plan/adapters/{conductor.md → showrunner.md} +16 -16
  99. package/dist/plugin/skills/apt-produce/SKILL.md +606 -0
  100. package/dist/plugin/skills/apt-quick/SKILL.md +14 -22
  101. package/dist/plugin/skills/apt-run/SKILL.md +126 -3
  102. package/dist/plugin/skills/apt-ship/SKILL.md +2 -0
  103. package/dist/plugin/skills/apt-spar/SKILL.md +5 -3
  104. package/dist/plugin/skills/apt-watch-ci/SKILL.md +4 -4
  105. package/package.json +4 -4
  106. package/prompts/{conductor-framework-context.md → showrunner-framework-context.md} +1 -1
  107. package/prompts/{conductor-status-check.md → showrunner-status-check.md} +1 -1
  108. package/prompts/{conductor-sub-agent.md → showrunner-sub-agent.md} +6 -6
  109. package/prompts/{conductor-system.md → showrunner-system.md} +8 -8
  110. package/skills/apt/SKILL.md +112 -38
  111. package/skills/apt-debug/SKILL.md +14 -24
  112. package/skills/apt-fan-out/SKILL.md +4 -4
  113. package/skills/apt-handoff/SKILL.md +1 -1
  114. package/skills/apt-plan/SKILL.md +5 -5
  115. package/skills/apt-plan/adapters/{conductor.md → showrunner.md} +16 -16
  116. package/skills/apt-produce/SKILL.md +606 -0
  117. package/skills/apt-quick/SKILL.md +14 -22
  118. package/skills/apt-run/SKILL.md +126 -3
  119. package/skills/apt-ship/SKILL.md +2 -0
  120. package/skills/apt-spar/SKILL.md +5 -3
  121. package/skills/apt-watch-ci/SKILL.md +4 -4
  122. package/src/cli/commands/ci-watch.mjs +51 -2
  123. package/src/cli/commands/commit.mjs +3 -3
  124. package/src/cli/commands/event.mjs +16 -16
  125. package/src/cli/commands/merge-integrate.mjs +3 -3
  126. package/src/cli/commands/produce.mjs +1466 -0
  127. package/src/cli/commands/task.mjs +482 -285
  128. package/src/cli/coordination/auto-emit-artifact-ready.mjs +5 -5
  129. package/src/cli/coordination/event-schema.d.ts +4 -2
  130. package/src/cli/coordination/event-schema.mjs +276 -21
  131. package/src/cli/coordination/store.mjs +4 -4
  132. package/src/cli/dispatch.mjs +2 -0
  133. package/src/cli/help.mjs +30 -0
  134. package/src/cli/install/legacy-paths.mjs +2 -0
  135. package/src/cli/produce/blind-probe.mjs +245 -0
  136. package/src/cli/produce/claim.mjs +543 -0
  137. package/src/cli/produce/done-gate.mjs +238 -0
  138. package/src/cli/produce/events.mjs +131 -0
  139. package/src/cli/produce/evidence-oracle.mjs +133 -0
  140. package/src/cli/produce/ledger.mjs +284 -0
  141. package/src/cli/produce/probe-family.mjs +168 -0
  142. package/src/cli/produce/projection.mjs +105 -0
  143. package/src/cli/produce/run-id.mjs +84 -0
  144. package/src/cli/produce/saga.mjs +303 -0
  145. package/src/cli/produce/scheduler.mjs +423 -0
  146. package/src/cli/produce/setpoint.mjs +122 -0
  147. package/src/cli/produce/verification-ttl.mjs +191 -0
  148. package/src/cli/roadmap/showrunner-view.d.ts +10 -0
  149. package/src/cli/roadmap/{conductor-view.mjs → showrunner-view.mjs} +6 -6
  150. package/dist/cli/roadmap/conductor-view.d.mts.map +0 -1
  151. package/dist/cli/roadmap/conductor-view.mjs.map +0 -1
  152. package/src/cli/roadmap/conductor-view.d.ts +0 -10
@@ -0,0 +1,284 @@
1
+ /**
2
+ * @internal
3
+ * produce/ledger.mjs — the acceptance-criteria ledger + Lyapunov math (M3).
4
+ *
5
+ * V (the Lyapunov function) = open acceptance criteria = declared − closed,
6
+ * folded from the append-only log on every read (ID-M3-01 / ID-M3-02). Each
7
+ * criterion is a `produce.criteria.declared` event bound to a milestone; it
8
+ * closes via `produce.criteria.closed`. `produce.scope.changed` is the ONLY
9
+ * sanctioned way V may rise after the run has begun closing criteria (ID-M3-04).
10
+ *
11
+ * This module is a SEPARATE fold parallel to `projection.mjs` — the roadmap
12
+ * projection still folds `produce.roadmap.*` ONLY, so criteria/scope events
13
+ * never enter the roadmap the Showrunner re-reads (the M1/M2 blindness invariant
14
+ * is preserved). It reads ONLY the event log through `events.mjs` (no `node:`
15
+ * beyond what events.mjs already uses) and holds NO model id (it is pure math).
16
+ */
17
+
18
+ import { appendProduceEvent, readProduceRunEventsMatching } from './events.mjs'
19
+ import { slugFromOutcome } from './run-id.mjs'
20
+
21
+ /** The op-match for the criteria + scope ledger fold (never `produce.roadmap.*`). */
22
+ function isLedgerOp(op) {
23
+ return op.startsWith('produce.criteria.') || op === 'produce.scope.changed'
24
+ }
25
+
26
+ /**
27
+ * Fold the run's acceptance-criteria ledger from the append-only log. A
28
+ * `produce.criteria.declared` seeds an OPEN criterion (in first-seen order); a
29
+ * `produce.criteria.closed` advances it to `closed`; `produce.scope.changed` is
30
+ * recorded on the scope-change trail. Deterministic — two folds of the same log
31
+ * yield the identical ledger; `updated_at` is the last folded event's timestamp.
32
+ *
33
+ * @param {string} projectDir
34
+ * @param {string} runId
35
+ * @returns {{
36
+ * run_id: string,
37
+ * criteria: Array<{ criterion_id: string, milestone_id: string | null, text: string, status: 'open' | 'closed' }>,
38
+ * open_count: number,
39
+ * closed_count: number,
40
+ * scope_changes: Array<{ reason: string, milestone_id: string | null, t: string | null }>,
41
+ * updated_at: string | null,
42
+ * }}
43
+ */
44
+ export function projectLedger(projectDir, runId) {
45
+ const events = readProduceRunEventsMatching(projectDir, runId, isLedgerOp)
46
+ const byId = new Map()
47
+ const order = []
48
+ const scopeChanges = []
49
+ let updatedAt = null
50
+
51
+ for (const e of events) {
52
+ const d = e.data || {}
53
+ if (e.op === 'produce.criteria.declared') {
54
+ const cid = typeof d.criterion_id === 'string' ? d.criterion_id : null
55
+ if (cid && !byId.has(cid)) {
56
+ byId.set(cid, {
57
+ criterion_id: cid,
58
+ milestone_id: typeof d.milestone_id === 'string' ? d.milestone_id : null,
59
+ text: typeof d.text === 'string' ? d.text : '',
60
+ status: 'open',
61
+ })
62
+ order.push(cid)
63
+ }
64
+ } else if (e.op === 'produce.criteria.closed') {
65
+ const cid = typeof d.criterion_id === 'string' ? d.criterion_id : null
66
+ if (cid && byId.has(cid)) byId.get(cid).status = 'closed'
67
+ } else if (e.op === 'produce.scope.changed') {
68
+ scopeChanges.push({
69
+ reason: typeof d.reason === 'string' ? d.reason : '',
70
+ milestone_id: typeof d.milestone_id === 'string' ? d.milestone_id : null,
71
+ t: typeof e.t === 'string' ? e.t : null,
72
+ })
73
+ }
74
+ if (typeof e.t === 'string') updatedAt = e.t
75
+ }
76
+
77
+ const criteria = order.map((cid) => byId.get(cid))
78
+ return {
79
+ run_id: runId,
80
+ criteria,
81
+ open_count: criteria.filter((c) => c.status === 'open').length,
82
+ closed_count: criteria.filter((c) => c.status === 'closed').length,
83
+ scope_changes: scopeChanges,
84
+ updated_at: updatedAt,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * V = open acceptance criteria = declared − closed, folded from the log.
90
+ *
91
+ * @param {string} projectDir
92
+ * @param {string} runId
93
+ * @returns {number}
94
+ */
95
+ export function computeOpenCriteria(projectDir, runId) {
96
+ return projectLedger(projectDir, runId).open_count
97
+ }
98
+
99
+ /**
100
+ * The write-time anti-windup guard (ID-M3-03). `declare-criteria` raises V; it is
101
+ * REJECTED when the run is IN DESCENT (≥1 `produce.criteria.closed` already
102
+ * logged) AND no scope-change is supplied. Before descent, declaration is FREE
103
+ * (the baseline scope-definition window). This is the ONLY hard Lyapunov
104
+ * enforcement and it is fail-closed.
105
+ *
106
+ * @param {string} projectDir
107
+ * @param {string} runId
108
+ * @param {{ hasScopeChange?: boolean }} [opts]
109
+ * @returns {{ allowed: boolean, reason: string, in_descent: boolean }}
110
+ */
111
+ export function evaluateDeclareGuard(projectDir, runId, { hasScopeChange = false } = {}) {
112
+ const closes = readProduceRunEventsMatching(
113
+ projectDir,
114
+ runId,
115
+ (op) => op === 'produce.criteria.closed',
116
+ )
117
+ const inDescent = closes.length > 0
118
+ const allowed = !inDescent || !!hasScopeChange
119
+ let reason
120
+ if (!allowed) reason = 'in_descent_no_scope_change'
121
+ else if (!inDescent) reason = 'baseline_window'
122
+ else reason = 'scope_change'
123
+ return { allowed, reason, in_descent: inDescent }
124
+ }
125
+
126
+ /**
127
+ * Compute the monotone-progress report (ID-M3-02 / AC6). Folds ALL of
128
+ * `produce.criteria.*` + `produce.scope.changed` +
129
+ * `produce.roadmap.milestone_status_changed` in append order, maintaining the
130
+ * running V. Every `milestone_status_changed{status:'done'}` is a checkpoint: it
131
+ * captures `v_before`, then consumes the contiguous run of `criteria.closed`
132
+ * events that ship triggered (appended immediately after the done event) to
133
+ * capture `v_after`. `delta = v_after − v_before` (≤ 0). The series is STRICTLY
134
+ * DECREASING iff every ship checkpoint had `delta < 0`; a flat ship (delta === 0)
135
+ * is a soft STALL, not a hard violation.
136
+ *
137
+ * Read-time defense (ID-M3-03): a `produce.criteria.declared` that raised V
138
+ * AFTER descent WITHOUT an accompanying (immediately-preceding)
139
+ * `produce.scope.changed` is a HARD `lyapunov_violation`. `progress` FLAGS — it
140
+ * never rejects (the rejection is the write-time guard).
141
+ *
142
+ * @param {string} projectDir
143
+ * @param {string} runId
144
+ * @returns {{
145
+ * run_id: string,
146
+ * v_open_now: number,
147
+ * v_series: Array<{ milestone_id: string | null, v_before: number, v_after: number, delta: number, decreased: boolean }>,
148
+ * strictly_decreasing: boolean,
149
+ * lyapunov_ok: boolean,
150
+ * lyapunov_violations: Array<{ criterion_id: string | null, milestone_id: string | null, reason: string, t: string | null }>,
151
+ * scope_changes: Array<{ reason: string, milestone_id: string | null, t: string | null }>,
152
+ * }}
153
+ */
154
+ export function computeProgress(projectDir, runId) {
155
+ const events = readProduceRunEventsMatching(
156
+ projectDir,
157
+ runId,
158
+ (op) =>
159
+ op.startsWith('produce.criteria.') ||
160
+ op === 'produce.scope.changed' ||
161
+ op === 'produce.roadmap.milestone_status_changed',
162
+ )
163
+
164
+ const status = new Map()
165
+ let v = 0
166
+ let inDescent = false
167
+ const vSeries = []
168
+ const violations = []
169
+ const scopeChanges = []
170
+
171
+ /** Apply a `criteria.closed` payload to V (idempotent; sets inDescent). */
172
+ const applyClose = (d) => {
173
+ const cid = typeof d.criterion_id === 'string' ? d.criterion_id : null
174
+ if (cid && status.get(cid) === 'open') {
175
+ status.set(cid, 'closed')
176
+ v -= 1
177
+ } else if (cid && !status.has(cid)) {
178
+ // Close of a never-declared criterion — record the state, no V change.
179
+ status.set(cid, 'closed')
180
+ }
181
+ inDescent = true
182
+ }
183
+
184
+ for (let i = 0; i < events.length; i++) {
185
+ const e = events[i]
186
+ const d = e.data || {}
187
+
188
+ if (e.op === 'produce.criteria.declared') {
189
+ if (inDescent) {
190
+ const prev = i > 0 ? events[i - 1] : null
191
+ const accompanied = !!prev && prev.op === 'produce.scope.changed'
192
+ if (!accompanied) {
193
+ violations.push({
194
+ criterion_id: typeof d.criterion_id === 'string' ? d.criterion_id : null,
195
+ milestone_id: typeof d.milestone_id === 'string' ? d.milestone_id : null,
196
+ reason: 'declared_after_descent_without_scope_change',
197
+ t: typeof e.t === 'string' ? e.t : null,
198
+ })
199
+ }
200
+ }
201
+ const cid = typeof d.criterion_id === 'string' ? d.criterion_id : null
202
+ if (cid && !status.has(cid)) {
203
+ status.set(cid, 'open')
204
+ v += 1
205
+ }
206
+ } else if (e.op === 'produce.criteria.closed') {
207
+ applyClose(d)
208
+ } else if (e.op === 'produce.scope.changed') {
209
+ scopeChanges.push({
210
+ reason: typeof d.reason === 'string' ? d.reason : '',
211
+ milestone_id: typeof d.milestone_id === 'string' ? d.milestone_id : null,
212
+ t: typeof e.t === 'string' ? e.t : null,
213
+ })
214
+ } else if (e.op === 'produce.roadmap.milestone_status_changed' && d.status === 'done') {
215
+ const vBefore = v
216
+ // Consume the contiguous run of criteria.closed this ship triggered
217
+ // (the real flow appends them immediately after the done event).
218
+ let j = i + 1
219
+ while (j < events.length && events[j].op === 'produce.criteria.closed') {
220
+ applyClose(events[j].data || {})
221
+ j++
222
+ }
223
+ i = j - 1
224
+ const vAfter = v
225
+ const delta = vAfter - vBefore
226
+ vSeries.push({
227
+ milestone_id: typeof d.milestone_id === 'string' ? d.milestone_id : null,
228
+ v_before: vBefore,
229
+ v_after: vAfter,
230
+ delta,
231
+ decreased: delta < 0,
232
+ })
233
+ }
234
+ }
235
+
236
+ return {
237
+ run_id: runId,
238
+ v_open_now: v,
239
+ v_series: vSeries,
240
+ strictly_decreasing: vSeries.every((s) => s.delta < 0),
241
+ lyapunov_ok: violations.length === 0,
242
+ lyapunov_violations: violations,
243
+ scope_changes: scopeChanges,
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Close a shipped milestone's still-open acceptance criteria — the CONSEQUENCE
249
+ * of a milestone flipping to `done` (ID-M3-05, wired additively in
250
+ * `cmdSetMilestoneDone`). Folds the ledger, finds the milestone's OPEN criteria,
251
+ * and appends one `produce.criteria.closed` per criterion so V strictly
252
+ * decreases. Idempotent — an already-closed (or criteria-less) milestone appends
253
+ * nothing (`closed_count: 0`), so a criteria-less ship is byte-identical to M2.
254
+ *
255
+ * @param {string} projectDir
256
+ * @param {string} runId
257
+ * @param {string} milestoneId
258
+ * @returns {{ closed_count: number, criterion_ids: string[] }}
259
+ */
260
+ export function closeCriteriaForShippedMilestone(projectDir, runId, milestoneId) {
261
+ const ledger = projectLedger(projectDir, runId)
262
+ const open = ledger.criteria.filter((c) => c.milestone_id === milestoneId && c.status === 'open')
263
+ const criterionIds = []
264
+ for (const c of open) {
265
+ appendProduceEvent(projectDir, 'produce.criteria.closed', {
266
+ run_id: runId,
267
+ milestone_id: milestoneId,
268
+ criterion_id: c.criterion_id,
269
+ })
270
+ criterionIds.push(c.criterion_id)
271
+ }
272
+ return { closed_count: criterionIds.length, criterion_ids: criterionIds }
273
+ }
274
+
275
+ /**
276
+ * Mint a criterion id: `<slug-of-text>-<rand>`. Reuses `slugFromOutcome` and a
277
+ * short random suffix so two criteria minted from the same text never collide.
278
+ *
279
+ * @param {string} text
280
+ * @returns {string}
281
+ */
282
+ export function mintCriterionId(text) {
283
+ return `${slugFromOutcome(text)}-${Math.random().toString(36).slice(2, 8)}`
284
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @internal
3
+ * produce/probe-family.mjs — resolve a probe/oracle provider family DISJOINT
4
+ * from the implementer's, reusing the apt-spar cross-family mechanism (M2,
5
+ * ID-M2-01).
6
+ *
7
+ * The framework already selects + dispatches a different provider family in
8
+ * `apt:spar`: `host-detect` returns the current family, a partner-map picks a
9
+ * different one, and `spar-resolve-partner` resolves the partner's model LIVE
10
+ * from the user's own CLI config with ZERO baked default. M2 does NOT invent a
11
+ * second cross-family path — this module consumes the SAME `resolveCodexSparModel`
12
+ * primitive + the same partner-map shape and adds exactly ONE new rule: the
13
+ * resolved probe/oracle family must be DISJOINT from the *implementer* family
14
+ * (not merely "≠ host").
15
+ *
16
+ * Provider is an OUTPUT of selection; Aperant NEVER holds a model id (CLAUDE.md
17
+ * resolver rule / ID-M2-07). For codex the live model comes from
18
+ * `~/.codex/config.toml`; claude / gemini self-resolve (`model: null`). A
19
+ * setup where no disjoint family exists (host = implementer = only CLI) returns
20
+ * `available: false` fail-closed — a single-family setup cannot satisfy the
21
+ * cross-family rule by design.
22
+ */
23
+
24
+ import { existsSync, readFileSync } from 'node:fs'
25
+ import { homedir } from 'node:os'
26
+ import { resolveCodexSparModel } from '../host/codex-config.mjs'
27
+
28
+ /**
29
+ * The provider families M2 selects across — the same three apt:spar supports.
30
+ * Ordered for a deterministic tiebreak when the partner-map default is not in
31
+ * the available pool.
32
+ */
33
+ export const KNOWN_FAMILIES = ['claude', 'codex', 'gemini']
34
+
35
+ /**
36
+ * The apt-spar §3 partner-map shape: each family's natural cross-family partner.
37
+ * Consulted as a PREFERENCE only — the disjoint + available + non-host rules
38
+ * decide the final pick; this just orders ties the way spar does.
39
+ */
40
+ const PARTNER_MAP = {
41
+ claude: 'codex',
42
+ codex: 'claude',
43
+ gemini: 'codex',
44
+ }
45
+
46
+ /**
47
+ * Normalize a provider/CLI family alias to one of KNOWN_FAMILIES. Returns null
48
+ * for an unrecognized family so it is excluded from selection (fail-closed).
49
+ *
50
+ * @param {unknown} family
51
+ * @returns {string | null}
52
+ */
53
+ export function normalizeFamily(family) {
54
+ if (typeof family !== 'string') return null
55
+ const f = family.trim().toLowerCase()
56
+ if (f === 'claude' || f === 'claude-code' || f === 'anthropic') return 'claude'
57
+ if (f === 'codex' || f === 'openai' || f === 'gpt') return 'codex'
58
+ if (f === 'gemini' || f === 'google') return 'gemini'
59
+ return null
60
+ }
61
+
62
+ /**
63
+ * Pick the probe/oracle family: disjoint from the implementer (the ONE new
64
+ * rule), preferring a family that is ALSO not the host (a genuine third party),
65
+ * then honoring the apt-spar partner-map default, then a deterministic order.
66
+ *
67
+ * @param {string} implementerFamily — normalized implementer family.
68
+ * @param {string | null} hostFamily — normalized host family (or null).
69
+ * @param {string[]} normalizedAvailable — normalized available families.
70
+ * @returns {{ family: string | null, reason: string | null }}
71
+ */
72
+ function pickProbeFamily(implementerFamily, hostFamily, normalizedAvailable) {
73
+ // The ONE new constraint over apt-spar: DISJOINT from the implementer.
74
+ const disjoint = normalizedAvailable.filter((f) => f !== implementerFamily)
75
+ if (disjoint.length === 0) return { family: null, reason: 'no_disjoint_family' }
76
+ // Prefer a disjoint family that is ALSO not the host — a genuine third-party
77
+ // reviewer. Fall back to a disjoint-but-host family only if that is all
78
+ // that's available (still satisfies the cross-family rule, which is about the
79
+ // implementer, not the host).
80
+ const nonHost = disjoint.filter((f) => f !== hostFamily)
81
+ const pool = nonHost.length > 0 ? nonHost : disjoint
82
+ // Honor the partner-map default when it is in the pool (apt-spar §3 shape).
83
+ const preferred = PARTNER_MAP[implementerFamily]
84
+ if (preferred && pool.includes(preferred)) return { family: preferred, reason: null }
85
+ // Deterministic tiebreak: first in KNOWN_FAMILIES order present in the pool.
86
+ const ordered = KNOWN_FAMILIES.filter((f) => pool.includes(f))
87
+ return { family: ordered[0] ?? pool[0], reason: null }
88
+ }
89
+
90
+ /**
91
+ * Resolve the probe/oracle family + LIVE model for a produce run.
92
+ *
93
+ * @param {{
94
+ * implementerFamily: string,
95
+ * hostFamily?: string | null,
96
+ * availableFamilies?: string[],
97
+ * codexDeps?: { home: string, existsSync: (p: string) => boolean, readFileSync: (p: string, enc: string) => string },
98
+ * }} args
99
+ * @returns {{
100
+ * available: boolean,
101
+ * probe_family: string | null,
102
+ * transport_hint: { prefer_self_resolving: boolean, recommended_walk: string[] } | null,
103
+ * model: string | null,
104
+ * source: string | null,
105
+ * reason: string | null,
106
+ * }}
107
+ */
108
+ export function resolveProbeFamily({
109
+ implementerFamily,
110
+ hostFamily = null,
111
+ availableFamilies = KNOWN_FAMILIES,
112
+ codexDeps,
113
+ } = {}) {
114
+ const implementer = normalizeFamily(implementerFamily)
115
+ if (!implementer) {
116
+ return {
117
+ available: false,
118
+ probe_family: null,
119
+ transport_hint: null,
120
+ model: null,
121
+ source: null,
122
+ reason: 'unknown_implementer_family',
123
+ }
124
+ }
125
+
126
+ const host = normalizeFamily(hostFamily)
127
+ const normalizedAvailable = [...new Set(availableFamilies.map(normalizeFamily).filter(Boolean))]
128
+
129
+ const { family, reason } = pickProbeFamily(implementer, host, normalizedAvailable)
130
+ if (!family) {
131
+ return {
132
+ available: false,
133
+ probe_family: null,
134
+ transport_hint: null,
135
+ model: null,
136
+ source: null,
137
+ reason: reason ?? 'no_disjoint_family',
138
+ }
139
+ }
140
+
141
+ // Resolve the model LIVE via the reused spar primitive (provider-is-output,
142
+ // zero baked id). codex reads ~/.codex/config.toml; claude/gemini self-resolve.
143
+ if (family === 'codex') {
144
+ const deps = codexDeps ?? { home: homedir(), existsSync, readFileSync }
145
+ const res = resolveCodexSparModel(deps)
146
+ return {
147
+ available: true,
148
+ probe_family: 'codex',
149
+ transport_hint: { prefer_self_resolving: true, recommended_walk: ['plugin', 'cli', 'mcp'] },
150
+ model: res.model,
151
+ source: res.source,
152
+ // A codex model that fails to resolve from config does NOT make the
153
+ // probe family unavailable — the self-resolving plugin/CLI rungs still
154
+ // run without a named model. Surface the model-resolution reason.
155
+ reason: res.reason,
156
+ }
157
+ }
158
+
159
+ // claude / gemini MCP rungs take only {prompt} — no model, no staleness vector.
160
+ return {
161
+ available: true,
162
+ probe_family: family,
163
+ transport_hint: { prefer_self_resolving: false, recommended_walk: ['mcp', 'plugin', 'cli'] },
164
+ model: null,
165
+ source: null,
166
+ reason: 'self_resolving',
167
+ }
168
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @internal
3
+ * produce/projection.mjs — event-sourced roadmap projection (M1, ID-01).
4
+ *
5
+ * The roadmap is DERIVED: `projectRoadmap` folds the run's `produce.roadmap.*`
6
+ * events into `{ run_id, milestones:[{id,title,status}], updated_at }` on every
7
+ * call. It is NEVER read as the source of truth — a persisted `roadmap.json`
8
+ * cache MAY be written for convenience but is always regenerable from the log,
9
+ * so the shared-mutable-blob lost-update bug is structurally impossible.
10
+ *
11
+ * `writeProjectionCache` is a SINGLE-WRITER-PER-RECORD write under withFileLock
12
+ * (a torn cache is recoverable by re-folding). The event log is the authority;
13
+ * the cache is disposable.
14
+ */
15
+
16
+ import { join } from 'node:path'
17
+ import { withFileLock } from '../util/fs-lock.mjs'
18
+ import { atomicWriteJson } from '../util/io.mjs'
19
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
20
+ import { readProduceEventsForRun } from './events.mjs'
21
+ import { resolveRunDir } from './run-id.mjs'
22
+
23
+ /** M1 milestone lifecycle — `done` is external-oracle-gated (M2, ID-03). */
24
+ const SEED_STATUS = 'planned'
25
+
26
+ /**
27
+ * Recompute the roadmap projection for a run by folding its append-only
28
+ * `produce.roadmap.*` events. `milestone_added` seeds a planned milestone (in
29
+ * first-seen order); `milestone_status_changed` advances its status. Idempotent
30
+ * re-adds keep the first seed (never clobber an advanced status). `updated_at`
31
+ * is derived from the last folded event's timestamp so the projection is
32
+ * DETERMINISTIC — two folds of the same log yield the identical roadmap.
33
+ *
34
+ * @param {string} projectDir
35
+ * @param {string} runId
36
+ * @returns {{ run_id: string, milestones: Array<{ id: string, title: string, status: string }>, updated_at: string | null }}
37
+ */
38
+ export function projectRoadmap(projectDir, runId) {
39
+ const events = readProduceEventsForRun(projectDir, runId)
40
+ const byId = new Map()
41
+ const order = []
42
+ let updatedAt = null
43
+
44
+ for (const e of events) {
45
+ const d = e.data || {}
46
+ const id = typeof d.milestone_id === 'string' ? d.milestone_id : null
47
+ if (e.op === 'produce.roadmap.milestone_added') {
48
+ if (id && !byId.has(id)) {
49
+ byId.set(id, { id, title: typeof d.title === 'string' ? d.title : id, status: SEED_STATUS })
50
+ order.push(id)
51
+ }
52
+ } else if (e.op === 'produce.roadmap.milestone_status_changed') {
53
+ if (id && byId.has(id) && typeof d.status === 'string') {
54
+ byId.get(id).status = d.status
55
+ }
56
+ }
57
+ if (typeof e.t === 'string') updatedAt = e.t
58
+ }
59
+
60
+ return {
61
+ run_id: runId,
62
+ milestones: order.map((id) => byId.get(id)),
63
+ updated_at: updatedAt,
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The derived roadmap.json cache path for a run.
69
+ * @param {string} projectDir
70
+ * @param {string} runId
71
+ * @returns {string}
72
+ */
73
+ export function roadmapCachePath(projectDir, runId) {
74
+ return join(resolveRunDir(projectDir, runId), 'roadmap.json')
75
+ }
76
+
77
+ /**
78
+ * Refresh the derived roadmap.json cache single-writer under withFileLock, and
79
+ * return the freshly-folded roadmap. Convenience only — the log is the
80
+ * authority; a corrupt cache is repaired by re-folding (ID-01).
81
+ *
82
+ * The fold happens INSIDE the lock (not precomputed then written): the write is
83
+ * a full idempotent regenerate from the authoritative log, never a
84
+ * read-modify-write of a shared blob. This guarantees the last writer to
85
+ * acquire the lock persists the COMPLETE set — a precompute-then-write pattern
86
+ * could persist a STALE fold if a concurrent O_APPEND landed between the
87
+ * precompute and the write, silently dropping that milestone from the cache
88
+ * (parent TD-06 / subtask 4). The event log stays lossless regardless.
89
+ *
90
+ * @param {string} projectDir
91
+ * @param {string} runId
92
+ * @returns {{ run_id: string, milestones: Array<{ id: string, title: string, status: string }>, updated_at: string | null }}
93
+ */
94
+ export function writeProjectionCache(projectDir, runId) {
95
+ const path = roadmapCachePath(projectDir, runId)
96
+ return withFileLock(
97
+ path,
98
+ () => {
99
+ const roadmap = projectRoadmap(projectDir, runId)
100
+ atomicWriteJson(path, roadmap)
101
+ return roadmap
102
+ },
103
+ { projectRoot: resolveMainRepoRoot(projectDir) },
104
+ )
105
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @internal
3
+ * produce/run-id.mjs — mint + resolve produce run identifiers and run dirs (M1).
4
+ *
5
+ * A produce run is scoped to one frozen acceptance setpoint. Its run-id is a
6
+ * human-readable slug derived from the outcome plus a short timestamp and a
7
+ * random suffix (so two `start`s in the same second never collide). The run
8
+ * dir lives under the SHARED main-repo root at `.aperant/produce/<run-id>/`
9
+ * (resolveMainRepoRoot) so every worktree agent reads/writes one board
10
+ * (parent AC8 path / ID-06).
11
+ */
12
+
13
+ import { join } from 'node:path'
14
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
15
+
16
+ /**
17
+ * Slugify an outcome string into a short kebab identifier. Strips punctuation,
18
+ * lowercases, keeps the first `maxWords` words. Falls back to 'run' when empty.
19
+ *
20
+ * @param {string} outcome
21
+ * @param {number} [maxWords=6]
22
+ * @returns {string}
23
+ */
24
+ export function slugFromOutcome(outcome, maxWords = 6) {
25
+ const words = String(outcome || '')
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9\s-]/g, ' ')
28
+ .trim()
29
+ .split(/\s+/)
30
+ .filter(Boolean)
31
+ .slice(0, maxWords)
32
+ const slug = words.join('-').replace(/-+/g, '-')
33
+ return slug || 'run'
34
+ }
35
+
36
+ /**
37
+ * Mint a fresh run-id: `<slug>-<YYYYMMDD-HHMMSS>-<rand>`. The random suffix
38
+ * guarantees uniqueness even for identical outcomes minted in the same second.
39
+ *
40
+ * @param {string} outcome
41
+ * @param {Date} [now]
42
+ * @returns {string}
43
+ */
44
+ export function mintRunId(outcome, now = new Date()) {
45
+ const stamp = now
46
+ .toISOString()
47
+ .replace(/\.\d+Z$/, '')
48
+ .replace(/[-:]/g, '')
49
+ .replace('T', '-')
50
+ const rand = Math.random().toString(36).slice(2, 6)
51
+ return `${slugFromOutcome(outcome)}-${stamp}-${rand}`
52
+ }
53
+
54
+ /**
55
+ * The `.aperant/produce/` root, resolved to the shared main repo from any cwd.
56
+ *
57
+ * @param {string} projectDir
58
+ * @returns {string}
59
+ */
60
+ export function resolveProduceRoot(projectDir) {
61
+ return join(resolveMainRepoRoot(projectDir), '.aperant', 'produce')
62
+ }
63
+
64
+ /**
65
+ * The run dir `.aperant/produce/<run-id>/`, resolved to the shared main repo.
66
+ *
67
+ * @param {string} projectDir
68
+ * @param {string} runId
69
+ * @returns {string}
70
+ */
71
+ export function resolveRunDir(projectDir, runId) {
72
+ return join(resolveProduceRoot(projectDir), runId)
73
+ }
74
+
75
+ /**
76
+ * Repo-relative setpoint path for a run — used as the `setpoint_path` event
77
+ * field. Forward-slash separated for portability across the event log.
78
+ *
79
+ * @param {string} runId
80
+ * @returns {string}
81
+ */
82
+ export function relativeSetpointPath(runId) {
83
+ return ['.aperant', 'produce', runId, 'setpoint.json'].join('/')
84
+ }