@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,543 @@
1
+ /**
2
+ * @internal
3
+ * produce/claim.mjs — the Claim Envelope: a provider-neutral interop SHAPE +
4
+ * a data-driven profile registry + an event-sourced claim ledger (M6).
5
+ *
6
+ * A Claim Envelope captures a durable, bounded, expiring claim (the `lesson`
7
+ * profile is the only one M6 ships). It is NOT a 4th persistence store (parent
8
+ * D-02/D-03, ID-M6-01): the ledger reuses the EXACT substrate M1/M2 already use —
9
+ * a per-record immutable content file at `.aperant/produce/<run-id>/claims/<id>.json`
10
+ * written ONCE via `openSync(path, 'wx')` (mirrors setpoint.mjs / blind-probe.mjs),
11
+ * referenced by `content_hash` from an append-only `produce.claim.declared` event,
12
+ * and PROJECTED by a `projectClaims` fold parallel to `ledger.mjs::projectLedger`.
13
+ * The roadmap projection still folds `produce.roadmap.*` ONLY, so claim ops never
14
+ * enter the roadmap the Showrunner re-reads (blindness preserved).
15
+ *
16
+ * Generalization is REGISTRY DATA, never a code fork (ID-M6-02): `CLAIM_PROFILES`
17
+ * maps a profile id → `{ requiredFields, validate }`, and `validateClaimEnvelope`
18
+ * dispatches on `envelope.profile` via the registry — there is NO hardcoded
19
+ * `if (profile === 'lesson')` branch, so a 2nd profile is one map entry and an
20
+ * unknown profile fails closed.
21
+ *
22
+ * Deterministic, node-only, ZERO model id — it is pure fold/validation math over
23
+ * the log + per-record files, like ledger.mjs / scheduler.mjs (ID-M6-08).
24
+ */
25
+
26
+ import { createHash } from 'node:crypto'
27
+ import {
28
+ closeSync,
29
+ existsSync,
30
+ fsyncSync,
31
+ mkdirSync,
32
+ openSync,
33
+ readFileSync,
34
+ writeSync,
35
+ } from 'node:fs'
36
+ import { dirname, join } from 'node:path'
37
+ import { appendProduceEvent, readProduceRunEventsMatching } from './events.mjs'
38
+ import { resolveRunDir, slugFromOutcome } from './run-id.mjs'
39
+
40
+ /** Plain (non-array, non-null) object guard. */
41
+ function isPlainObject(v) {
42
+ return v != null && typeof v === 'object' && !Array.isArray(v)
43
+ }
44
+
45
+ /** Plain object carrying at least one own key. */
46
+ function isNonEmptyObject(v) {
47
+ return isPlainObject(v) && Object.keys(v).length > 0
48
+ }
49
+
50
+ /** A bound is a non-empty string / non-empty array / non-empty object. */
51
+ function isNonEmptyBound(v) {
52
+ if (typeof v === 'string') return v.trim().length > 0
53
+ if (Array.isArray(v)) return v.length > 0
54
+ if (isPlainObject(v)) return Object.keys(v).length > 0
55
+ return false
56
+ }
57
+
58
+ /**
59
+ * A scope is BOUNDED iff it is a plain object, `global !== true`, and carries at
60
+ * least one non-`global` key with a non-empty bound (anti-windup, ID-M6-05). A
61
+ * `{ global: true }` scope or a scope with no actual bound is REJECTED.
62
+ */
63
+ function isBoundedScope(scope) {
64
+ if (!isPlainObject(scope)) return false
65
+ if (scope.global === true) return false
66
+ return Object.keys(scope).some((k) => k !== 'global' && isNonEmptyBound(scope[k]))
67
+ }
68
+
69
+ /**
70
+ * An expiry object carries a decay path: `expires_at` (non-empty string) OR
71
+ * `revalidate_against_head` (any truthy value). A lesson with no expiry/decay
72
+ * path is REJECTED (anti-corrosion, ID-M6-04).
73
+ */
74
+ function hasExpiry(expiry) {
75
+ if (!isPlainObject(expiry)) return false
76
+ const hasExpiresAt = typeof expiry.expires_at === 'string' && expiry.expires_at.trim().length > 0
77
+ const hasRevalidate =
78
+ expiry.revalidate_against_head != null && expiry.revalidate_against_head !== false
79
+ return hasExpiresAt || hasRevalidate
80
+ }
81
+
82
+ /** The verifiable plan requirement — a non-empty string / array / object. */
83
+ function isNonEmptyVerifier(rv) {
84
+ if (typeof rv === 'string') return rv.trim().length > 0
85
+ if (Array.isArray(rv)) return rv.length > 0
86
+ if (isPlainObject(rv)) return Object.keys(rv).length > 0
87
+ return false
88
+ }
89
+
90
+ /**
91
+ * The `lesson` profile validator (ID-M6-03). REQUIRES all 6 AC15 fields and
92
+ * REJECTS fail-closed (never throws): missing provenance; empty preconditions
93
+ * (unconditional rule — bounded-critic clamp); global/unbounded scope
94
+ * (anti-windup); missing expiry (anti-corrosion); missing negative_evidence
95
+ * field; empty resolution_verifier. Returns `{ ok:false, reason }`.
96
+ *
97
+ * @param {Record<string, unknown>} env
98
+ * @returns {{ ok: true } | { ok: false, reason: string }}
99
+ */
100
+ function validateLessonEnvelope(env) {
101
+ if (!isNonEmptyObject(env.provenance)) return { ok: false, reason: 'missing_provenance' }
102
+ if (!Array.isArray(env.preconditions) || env.preconditions.length === 0)
103
+ return { ok: false, reason: 'empty_preconditions' }
104
+ if (!isBoundedScope(env.scope)) return { ok: false, reason: 'unbounded_scope' }
105
+ if (!hasExpiry(env.expiry)) return { ok: false, reason: 'missing_expiry' }
106
+ if (!Array.isArray(env.negative_evidence))
107
+ return { ok: false, reason: 'missing_negative_evidence' }
108
+ if (!isNonEmptyVerifier(env.resolution_verifier))
109
+ return { ok: false, reason: 'empty_resolution_verifier' }
110
+ return { ok: true }
111
+ }
112
+
113
+ /**
114
+ * The data-driven profile registry (ID-M6-02). A profile is one map entry
115
+ * (`{ requiredFields, validate }`) — adding a 2nd is data, not a code fork. M6
116
+ * SHIPS only `lesson`; the generalization is PROVEN by a test that injects a
117
+ * trivial second profile via `validateClaimEnvelope`'s `profiles` arg.
118
+ */
119
+ export const CLAIM_PROFILES = {
120
+ lesson: {
121
+ requiredFields: [
122
+ 'provenance',
123
+ 'preconditions',
124
+ 'scope',
125
+ 'expiry',
126
+ 'negative_evidence',
127
+ 'resolution_verifier',
128
+ ],
129
+ validate: validateLessonEnvelope,
130
+ },
131
+ }
132
+
133
+ /**
134
+ * Validate a Claim Envelope by dispatching on `envelope.profile` through the
135
+ * REGISTRY (never a hardcoded profile branch — ID-M6-02). An unknown/absent
136
+ * profile fails closed (`{ ok:false, reason:'unknown_profile' }`). Never throws.
137
+ *
138
+ * @param {Record<string, unknown>} envelope
139
+ * @param {{ profiles?: Record<string, { validate: (env: Record<string, unknown>) => { ok: boolean, reason?: string } }> }} [opts]
140
+ * @returns {{ ok: true } | { ok: false, reason: string }}
141
+ */
142
+ export function validateClaimEnvelope(envelope, { profiles = CLAIM_PROFILES } = {}) {
143
+ const env = isPlainObject(envelope) ? envelope : {}
144
+ const profileId = typeof env.profile === 'string' ? env.profile : ''
145
+ const spec = profiles[profileId]
146
+ if (!spec || typeof spec.validate !== 'function') return { ok: false, reason: 'unknown_profile' }
147
+ return spec.validate(env)
148
+ }
149
+
150
+ /**
151
+ * Mint a claim id: `<slug-of-text>-<rand>`. Reuses `slugFromOutcome` + a short
152
+ * random suffix (mirrors ledger.mjs::mintCriterionId) so two claims minted from
153
+ * the same text never collide. Falls back to the profile when text is empty.
154
+ *
155
+ * @param {string} profile
156
+ * @param {string} text
157
+ * @returns {string}
158
+ */
159
+ export function mintClaimId(profile, text) {
160
+ const slug = slugFromOutcome(text || profile || 'claim')
161
+ return `${slug}-${Math.random().toString(36).slice(2, 8)}`
162
+ }
163
+
164
+ /** sha256 of the canonical JSON of the claim content (the immutable hash stamp). */
165
+ function computeClaimContentHash(content) {
166
+ return createHash('sha256')
167
+ .update(JSON.stringify(content ?? null))
168
+ .digest('hex')
169
+ }
170
+
171
+ /** The claims/<claim-id>.json path for a run + claim. */
172
+ export function claimPathFor(projectDir, runId, claimId) {
173
+ return join(resolveRunDir(projectDir, runId), 'claims', `${claimId}.json`)
174
+ }
175
+
176
+ /** Read + parse a frozen claim record. Returns null when absent or malformed. */
177
+ export function readClaim(projectDir, runId, claimId) {
178
+ const p = claimPathFor(projectDir, runId, claimId)
179
+ if (!existsSync(p)) return null
180
+ try {
181
+ return JSON.parse(readFileSync(p, 'utf-8'))
182
+ } catch {
183
+ return null
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Exclusive-create write of the frozen claim record on the FINAL path (NOT
189
+ * tmp+rename), so a SECOND write throws EEXIST — the claim content is immutable
190
+ * (mirrors setpoint.mjs::writeSetpointExclusive / blind-probe.mjs).
191
+ */
192
+ function writeClaimExclusive(claimPath, record) {
193
+ mkdirSync(dirname(claimPath), { recursive: true })
194
+ const payload = `${JSON.stringify(record, null, 2)}\n`
195
+ const fd = openSync(claimPath, 'wx') // throws EEXIST when already frozen
196
+ try {
197
+ writeSync(fd, payload, 0, 'utf-8')
198
+ fsyncSync(fd)
199
+ } finally {
200
+ closeSync(fd)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Freeze a Claim Envelope, idempotently. VALIDATES first (refuses to freeze an
206
+ * invalid envelope — fail-closed), computes `content_hash = sha256(envelope)`,
207
+ * writes `claims/<claim-id>.json` exclusive-create, and on a FRESH freeze appends
208
+ * `produce.claim.declared {run_id, profile, claim_id, content_hash}` (content_hash
209
+ * only — the full envelope lives in the frozen file, ID-M6-07). A second freeze
210
+ * of the same claim id returns the byte-identical stored record (`reused: true`)
211
+ * and never re-logs. The resolved `profile` (explicit arg or `envelope.profile`)
212
+ * is authoritative for validation dispatch + the stored/logged profile.
213
+ *
214
+ * @param {string} projectDir
215
+ * @param {string} runId
216
+ * @param {{ profile?: string, envelope: Record<string, unknown>, claimId?: string }} args
217
+ * @param {{ profiles?: object, now?: Date }} [opts]
218
+ * @returns {{ ok: true, reused: boolean, claim_id: string, profile: string | null, content_hash: string }
219
+ * | { ok: false, reason: string }}
220
+ */
221
+ export function freezeClaim(
222
+ projectDir,
223
+ runId,
224
+ { profile, envelope, claimId } = {},
225
+ { profiles = CLAIM_PROFILES, now = new Date() } = {},
226
+ ) {
227
+ const env = isPlainObject(envelope) ? envelope : {}
228
+ const resolvedProfile =
229
+ typeof profile === 'string' && profile
230
+ ? profile
231
+ : typeof env.profile === 'string'
232
+ ? env.profile
233
+ : null
234
+ // The resolved profile is authoritative — validate + store + log agree.
235
+ const envelopeForValidation = resolvedProfile ? { ...env, profile: resolvedProfile } : env
236
+
237
+ const verdict = validateClaimEnvelope(envelopeForValidation, { profiles })
238
+ if (!verdict.ok) return { ok: false, reason: verdict.reason }
239
+
240
+ const resolvedClaimId =
241
+ typeof claimId === 'string' && claimId
242
+ ? claimId
243
+ : typeof env.id === 'string' && env.id
244
+ ? env.id
245
+ : mintClaimId(
246
+ resolvedProfile ?? 'claim',
247
+ typeof env.resolution_verifier === 'string' ? env.resolution_verifier : resolvedProfile,
248
+ )
249
+
250
+ const existing = readClaim(projectDir, runId, resolvedClaimId)
251
+ if (existing)
252
+ return {
253
+ ok: true,
254
+ reused: true,
255
+ claim_id: resolvedClaimId,
256
+ profile: existing.profile ?? resolvedProfile,
257
+ content_hash: existing.content_hash,
258
+ }
259
+
260
+ const contentHash = computeClaimContentHash(envelopeForValidation)
261
+ const record = {
262
+ run_id: runId,
263
+ claim_id: resolvedClaimId,
264
+ profile: resolvedProfile,
265
+ frozen_at: now.toISOString(),
266
+ content_hash: contentHash,
267
+ // The full claim content lives HERE — blind to the event log (ID-M6-07).
268
+ envelope: envelopeForValidation,
269
+ }
270
+
271
+ try {
272
+ writeClaimExclusive(claimPathFor(projectDir, runId, resolvedClaimId), record)
273
+ } catch (e) {
274
+ // Lost a concurrent race — the winner's file is authoritative; re-read it
275
+ // byte-identically rather than overwriting (mirrors freezeSetpoint).
276
+ if (e && e.code === 'EEXIST') {
277
+ const winner = readClaim(projectDir, runId, resolvedClaimId)
278
+ if (winner)
279
+ return {
280
+ ok: true,
281
+ reused: true,
282
+ claim_id: resolvedClaimId,
283
+ profile: winner.profile ?? resolvedProfile,
284
+ content_hash: winner.content_hash,
285
+ }
286
+ }
287
+ throw e
288
+ }
289
+
290
+ // Announce by hash + profile ONLY — never the content (ID-M6-07).
291
+ appendProduceEvent(projectDir, 'produce.claim.declared', {
292
+ run_id: runId,
293
+ profile: resolvedProfile,
294
+ claim_id: resolvedClaimId,
295
+ content_hash: contentHash,
296
+ })
297
+
298
+ return {
299
+ ok: true,
300
+ reused: false,
301
+ claim_id: resolvedClaimId,
302
+ profile: resolvedProfile,
303
+ content_hash: contentHash,
304
+ }
305
+ }
306
+
307
+ /** The closed set of consumption-delta target kinds (mirrors ALLOWED_CLAIM_TARGET_KINDS). */
308
+ export const CLAIM_TARGET_KINDS = ['acceptance_criterion', 'subtask', 'proof_obligation']
309
+
310
+ /**
311
+ * Fold the run's claim ledger from the append-only log — a SEPARATE fold parallel
312
+ * to `ledger.mjs::projectLedger` / `saga.mjs::projectSaga` (the roadmap projection
313
+ * still folds `produce.roadmap.*` ONLY, so claim ops never enter the roadmap the
314
+ * Showrunner re-reads). A `produce.claim.declared` seeds an ACTIVE claim (in
315
+ * first-seen order); a `produce.claim.expired` advances it to `expired`;
316
+ * `produce.claim.consumed` is a forensic-only event that does NOT change status.
317
+ * Deterministic — two folds of the same log yield the identical ledger.
318
+ *
319
+ * @param {string} projectDir
320
+ * @param {string} runId
321
+ * @returns {{
322
+ * run_id: string,
323
+ * claims: Array<{ claim_id: string, profile: string | null, content_hash: string | null, status: 'active' | 'expired' }>,
324
+ * active_count: number,
325
+ * expired_count: number,
326
+ * updated_at: string | null,
327
+ * }}
328
+ */
329
+ export function projectClaims(projectDir, runId) {
330
+ const events = readProduceRunEventsMatching(projectDir, runId, (op) =>
331
+ op.startsWith('produce.claim.'),
332
+ )
333
+ const byId = new Map()
334
+ const order = []
335
+ let updatedAt = null
336
+
337
+ for (const e of events) {
338
+ const d = e.data || {}
339
+ const cid = typeof d.claim_id === 'string' ? d.claim_id : null
340
+ if (e.op === 'produce.claim.declared') {
341
+ if (cid && !byId.has(cid)) {
342
+ byId.set(cid, {
343
+ claim_id: cid,
344
+ profile: typeof d.profile === 'string' ? d.profile : null,
345
+ content_hash: typeof d.content_hash === 'string' ? d.content_hash : null,
346
+ status: 'active',
347
+ })
348
+ order.push(cid)
349
+ }
350
+ } else if (e.op === 'produce.claim.expired') {
351
+ if (cid && byId.has(cid)) byId.get(cid).status = 'expired'
352
+ }
353
+ if (typeof e.t === 'string') updatedAt = e.t
354
+ }
355
+
356
+ const claims = order.map((cid) => byId.get(cid))
357
+ return {
358
+ run_id: runId,
359
+ claims,
360
+ active_count: claims.filter((c) => c.status === 'active').length,
361
+ expired_count: claims.filter((c) => c.status === 'expired').length,
362
+ updated_at: updatedAt,
363
+ }
364
+ }
365
+
366
+ /**
367
+ * The DEFAULT HEAD re-validation predicate (ID-M6-04) — a deterministic
368
+ * path-exists / grep against the working tree, NEVER an LLM judgment (mirrors the
369
+ * injected `codexDeps` in probe-family.mjs / the injected existence predicate in
370
+ * resolveResumeCwd). `check` is `{ path?, pattern? }` (or a bare path string).
371
+ * Returns false when the referenced file/pattern no longer matches HEAD.
372
+ *
373
+ * @param {unknown} check
374
+ * @returns {boolean}
375
+ */
376
+ function defaultHeadCheck(check) {
377
+ const spec = typeof check === 'string' ? { path: check } : isPlainObject(check) ? check : {}
378
+ if (typeof spec.path === 'string' && spec.path) {
379
+ if (!existsSync(spec.path)) return false
380
+ if (typeof spec.pattern === 'string' && spec.pattern) {
381
+ try {
382
+ return readFileSync(spec.path, 'utf-8').includes(spec.pattern)
383
+ } catch {
384
+ return false
385
+ }
386
+ }
387
+ return true
388
+ }
389
+ // No actionable check descriptor → nothing to invalidate (still valid).
390
+ return true
391
+ }
392
+
393
+ /** One precondition matches ctx iff every key equals (or set-includes) the ctx value. */
394
+ function matchOne(cond, ctx) {
395
+ if (!isPlainObject(cond)) return false
396
+ return Object.keys(cond).every((k) => {
397
+ const expected = cond[k]
398
+ const actual = ctx[k]
399
+ if (Array.isArray(expected)) return expected.includes(actual)
400
+ if (Array.isArray(actual)) return actual.includes(expected)
401
+ return actual === expected
402
+ })
403
+ }
404
+
405
+ /**
406
+ * The claim FIRES (its trigger matches) iff EVERY precondition matches ctx
407
+ * (conjunction — a narrow gate, bounded-critic ID-M6-05). An absent/empty
408
+ * preconditions list is vacuously matched (a validated lesson always has ≥1).
409
+ */
410
+ function preconditionsMatch(preconditions, ctx) {
411
+ if (!Array.isArray(preconditions) || preconditions.length === 0) return true
412
+ const c = isPlainObject(ctx) ? ctx : {}
413
+ return preconditions.every((cond) => matchOne(cond, c))
414
+ }
415
+
416
+ /**
417
+ * Decide whether a claim may be consumed against a ctx (ID-M6-04, anti-corrosion).
418
+ * DETERMINISTIC (no LLM). SKIPS with a reason when: the claim is time-expired
419
+ * (`now() > expiry.expires_at` → `expired`); the claim's `revalidate_against_head`
420
+ * check no longer matches HEAD (`headCheck(check) === false` → `head_invalidated`);
421
+ * or the preconditions don't match ctx (`precondition_unmatched`). Otherwise
422
+ * `{ consume: true }`. `now` + `headCheck` are INJECTED (default now = new Date,
423
+ * default headCheck = the working-tree path-exists/grep).
424
+ *
425
+ * @param {Record<string, unknown>} claim — the claim ENVELOPE (carries expiry + preconditions).
426
+ * @param {Record<string, unknown>} [ctx]
427
+ * @param {{ now?: () => Date, headCheck?: (check: unknown) => boolean }} [deps]
428
+ * @returns {{ consume: true } | { consume: false, reason: string }}
429
+ */
430
+ export function evaluateClaimForConsumption(claim, ctx = {}, deps = {}) {
431
+ const now = typeof deps.now === 'function' ? deps.now : () => new Date()
432
+ const headCheck = typeof deps.headCheck === 'function' ? deps.headCheck : defaultHeadCheck
433
+ const env = isPlainObject(claim) ? claim : {}
434
+ const expiry = isPlainObject(env.expiry) ? env.expiry : {}
435
+
436
+ // (1) Time expiry.
437
+ if (typeof expiry.expires_at === 'string' && expiry.expires_at) {
438
+ const expiresAt = new Date(expiry.expires_at).getTime()
439
+ if (Number.isFinite(expiresAt) && now().getTime() > expiresAt) {
440
+ return { consume: false, reason: 'expired' }
441
+ }
442
+ }
443
+ // (2) HEAD re-validation (anti-corrosion) — deterministic, never an LLM.
444
+ if (expiry.revalidate_against_head != null && expiry.revalidate_against_head !== false) {
445
+ if (!headCheck(expiry.revalidate_against_head)) {
446
+ return { consume: false, reason: 'head_invalidated' }
447
+ }
448
+ }
449
+ // (3) Precondition match against ctx (the trigger).
450
+ if (!preconditionsMatch(env.preconditions, ctx)) {
451
+ return { consume: false, reason: 'precondition_unmatched' }
452
+ }
453
+ return { consume: true }
454
+ }
455
+
456
+ /** Read the frozen claim ENVELOPE (the `.envelope` payload), null when absent/malformed. */
457
+ function readClaimEnvelope(projectDir, runId, claimId) {
458
+ const record = readClaim(projectDir, runId, claimId)
459
+ if (!record) return null
460
+ return isPlainObject(record.envelope) ? record.envelope : null
461
+ }
462
+
463
+ /**
464
+ * Derive the bounded consumption DELTA from a claim envelope's
465
+ * `resolution_verifier` ONLY (ID-M6-06 — the down-projection). Returns
466
+ * `[{ kind, requirement, ref? }]` where `kind` is clamped to the closed
467
+ * CLAIM_TARGET_KINDS set (fail-closed default `acceptance_criterion`) and
468
+ * `requirement` is a bounded string taken FROM `resolution_verifier`. This
469
+ * function reads NOTHING ELSE off the envelope — never `provenance`,
470
+ * `negative_evidence`, `preconditions`, or a raw body — so a sentinel placed in
471
+ * those fields can never reach the consume output (the sentinel-non-leak
472
+ * invariant). NO raw-lesson-text → prompt concatenation sink lives here.
473
+ *
474
+ * @param {Record<string, unknown>} envelope
475
+ * @returns {Array<{ kind: string, requirement: string, ref?: string }>}
476
+ */
477
+ function deriveTargets(envelope) {
478
+ const rv = isPlainObject(envelope) ? envelope.resolution_verifier : undefined
479
+ const raw = Array.isArray(rv) ? rv : [rv]
480
+ const targets = []
481
+ for (const item of raw) {
482
+ if (typeof item === 'string' && item.length > 0) {
483
+ targets.push({ kind: 'acceptance_criterion', requirement: item })
484
+ } else if (isPlainObject(item)) {
485
+ const kind = CLAIM_TARGET_KINDS.includes(item.kind) ? item.kind : 'acceptance_criterion'
486
+ const requirement =
487
+ typeof item.requirement === 'string' && item.requirement
488
+ ? item.requirement
489
+ : typeof item.text === 'string'
490
+ ? item.text
491
+ : ''
492
+ const target = { kind, requirement }
493
+ if (typeof item.ref === 'string' && item.ref) target.ref = item.ref
494
+ targets.push(target)
495
+ }
496
+ }
497
+ return targets
498
+ }
499
+
500
+ /**
501
+ * Consume the run's ACTIVE claims against a ctx (ID-M6-06 — THE M6 invariant).
502
+ * For each active claim, evaluate it; a consumable claim emits ONE bounded delta
503
+ * `{ claim_id, profile, targets: [{ kind, requirement, ref? }] }` where every
504
+ * `targets[].kind ∈ CLAIM_TARGET_KINDS` and `requirement` is derived FROM
505
+ * `resolution_verifier` — a projection DOWN. The output MUST NOT carry the claim's
506
+ * `provenance` / `negative_evidence` / `preconditions` / body (never a context
507
+ * paste). Skipped claims (expired / head_invalidated / precondition_unmatched /
508
+ * unreadable) surface under `skipped[]` with a reason. PURE — appends no event
509
+ * (the CLI records `produce.claim.consumed` per target for the forensic trail).
510
+ *
511
+ * @param {string} projectDir
512
+ * @param {string} runId
513
+ * @param {Record<string, unknown>} [ctx]
514
+ * @param {{ now?: () => Date, headCheck?: (check: unknown) => boolean }} [deps]
515
+ * @returns {{
516
+ * consumed: Array<{ claim_id: string, profile: string | null, targets: Array<{ kind: string, requirement: string, ref?: string }> }>,
517
+ * skipped: Array<{ claim_id: string, reason: string }>,
518
+ * }}
519
+ */
520
+ export function consumeClaims(projectDir, runId, ctx = {}, deps = {}) {
521
+ const ledger = projectClaims(projectDir, runId)
522
+ const consumed = []
523
+ const skipped = []
524
+ for (const summary of ledger.claims) {
525
+ if (summary.status !== 'active') continue // expired claims never consume
526
+ const envelope = readClaimEnvelope(projectDir, runId, summary.claim_id)
527
+ if (!envelope) {
528
+ skipped.push({ claim_id: summary.claim_id, reason: 'unreadable_claim' })
529
+ continue
530
+ }
531
+ const verdict = evaluateClaimForConsumption(envelope, ctx, deps)
532
+ if (!verdict.consume) {
533
+ skipped.push({ claim_id: summary.claim_id, reason: verdict.reason })
534
+ continue
535
+ }
536
+ consumed.push({
537
+ claim_id: summary.claim_id,
538
+ profile: summary.profile ?? (typeof envelope.profile === 'string' ? envelope.profile : null),
539
+ targets: deriveTargets(envelope),
540
+ })
541
+ }
542
+ return { consumed, skipped }
543
+ }