@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.
- package/CHANGELOG.md +152 -0
- package/dist/cli/commands/ci-watch.mjs +49 -2
- package/dist/cli/commands/ci-watch.mjs.map +1 -1
- package/dist/cli/commands/commit.mjs +3 -3
- package/dist/cli/commands/commit.mjs.map +1 -1
- package/dist/cli/commands/event.mjs +16 -16
- package/dist/cli/commands/event.mjs.map +1 -1
- package/dist/cli/commands/merge-integrate.mjs +3 -3
- package/dist/cli/commands/merge-integrate.mjs.map +1 -1
- package/dist/cli/commands/produce.d.mts +9 -0
- package/dist/cli/commands/produce.d.mts.map +1 -0
- package/dist/cli/commands/produce.mjs +1345 -0
- package/dist/cli/commands/produce.mjs.map +1 -0
- package/dist/cli/commands/task.d.mts +16 -1
- package/dist/cli/commands/task.d.mts.map +1 -1
- package/dist/cli/commands/task.mjs +434 -266
- package/dist/cli/commands/task.mjs.map +1 -1
- package/dist/cli/coordination/auto-emit-artifact-ready.mjs +5 -5
- package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -1
- package/dist/cli/coordination/event-schema.d.mts +5 -3
- package/dist/cli/coordination/event-schema.d.mts.map +1 -1
- package/dist/cli/coordination/event-schema.mjs +245 -21
- package/dist/cli/coordination/event-schema.mjs.map +1 -1
- package/dist/cli/coordination/store.d.mts +2 -2
- package/dist/cli/coordination/store.mjs +4 -4
- package/dist/cli/coordination/store.mjs.map +1 -1
- package/dist/cli/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +2 -0
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/help.d.mts.map +1 -1
- package/dist/cli/help.mjs +30 -0
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/install/legacy-paths.d.mts.map +1 -1
- package/dist/cli/install/legacy-paths.mjs +2 -0
- package/dist/cli/install/legacy-paths.mjs.map +1 -1
- package/dist/cli/produce/blind-probe.d.mts +85 -0
- package/dist/cli/produce/blind-probe.d.mts.map +1 -0
- package/dist/cli/produce/blind-probe.mjs +217 -0
- package/dist/cli/produce/blind-probe.mjs.map +1 -0
- package/dist/cli/produce/claim.d.mts +188 -0
- package/dist/cli/produce/claim.d.mts.map +1 -0
- package/dist/cli/produce/claim.mjs +518 -0
- package/dist/cli/produce/claim.mjs.map +1 -0
- package/dist/cli/produce/done-gate.d.mts +87 -0
- package/dist/cli/produce/done-gate.d.mts.map +1 -0
- package/dist/cli/produce/done-gate.mjs +200 -0
- package/dist/cli/produce/done-gate.mjs.map +1 -0
- package/dist/cli/produce/events.d.mts +77 -0
- package/dist/cli/produce/events.d.mts.map +1 -0
- package/dist/cli/produce/events.mjs +126 -0
- package/dist/cli/produce/events.mjs.map +1 -0
- package/dist/cli/produce/evidence-oracle.d.mts +63 -0
- package/dist/cli/produce/evidence-oracle.d.mts.map +1 -0
- package/dist/cli/produce/evidence-oracle.mjs +122 -0
- package/dist/cli/produce/evidence-oracle.mjs.map +1 -0
- package/dist/cli/produce/ledger.d.mts +140 -0
- package/dist/cli/produce/ledger.d.mts.map +1 -0
- package/dist/cli/produce/ledger.mjs +272 -0
- package/dist/cli/produce/ledger.mjs.map +1 -0
- package/dist/cli/produce/probe-family.d.mts +53 -0
- package/dist/cli/produce/probe-family.d.mts.map +1 -0
- package/dist/cli/produce/probe-family.mjs +160 -0
- package/dist/cli/produce/probe-family.mjs.map +1 -0
- package/dist/cli/produce/projection.d.mts +55 -0
- package/dist/cli/produce/projection.d.mts.map +1 -0
- package/dist/cli/produce/projection.mjs +97 -0
- package/dist/cli/produce/projection.mjs.map +1 -0
- package/dist/cli/produce/run-id.d.mts +42 -0
- package/dist/cli/produce/run-id.d.mts.map +1 -0
- package/dist/cli/produce/run-id.mjs +79 -0
- package/dist/cli/produce/run-id.mjs.map +1 -0
- package/dist/cli/produce/saga.d.mts +180 -0
- package/dist/cli/produce/saga.d.mts.map +1 -0
- package/dist/cli/produce/saga.mjs +290 -0
- package/dist/cli/produce/saga.mjs.map +1 -0
- package/dist/cli/produce/scheduler.d.mts +165 -0
- package/dist/cli/produce/scheduler.d.mts.map +1 -0
- package/dist/cli/produce/scheduler.mjs +399 -0
- package/dist/cli/produce/scheduler.mjs.map +1 -0
- package/dist/cli/produce/setpoint.d.mts +52 -0
- package/dist/cli/produce/setpoint.d.mts.map +1 -0
- package/dist/cli/produce/setpoint.mjs +113 -0
- package/dist/cli/produce/setpoint.mjs.map +1 -0
- package/dist/cli/produce/verification-ttl.d.mts +75 -0
- package/dist/cli/produce/verification-ttl.d.mts.map +1 -0
- package/dist/cli/produce/verification-ttl.mjs +169 -0
- package/dist/cli/produce/verification-ttl.mjs.map +1 -0
- package/dist/cli/roadmap/{conductor-view.d.mts → showrunner-view.d.mts} +3 -3
- package/dist/cli/roadmap/showrunner-view.d.mts.map +1 -0
- package/dist/cli/roadmap/{conductor-view.mjs → showrunner-view.mjs} +7 -7
- package/dist/cli/roadmap/showrunner-view.mjs.map +1 -0
- package/dist/plugin/.claude-plugin/plugin.json +2 -1
- package/dist/plugin/skills/apt/SKILL.md +112 -38
- package/dist/plugin/skills/apt-debug/SKILL.md +14 -24
- package/dist/plugin/skills/apt-fan-out/SKILL.md +4 -4
- package/dist/plugin/skills/apt-handoff/SKILL.md +1 -1
- package/dist/plugin/skills/apt-plan/SKILL.md +5 -5
- package/dist/plugin/skills/apt-plan/adapters/{conductor.md → showrunner.md} +16 -16
- package/dist/plugin/skills/apt-produce/SKILL.md +606 -0
- package/dist/plugin/skills/apt-quick/SKILL.md +14 -22
- package/dist/plugin/skills/apt-run/SKILL.md +126 -3
- package/dist/plugin/skills/apt-ship/SKILL.md +2 -0
- package/dist/plugin/skills/apt-spar/SKILL.md +5 -3
- package/dist/plugin/skills/apt-watch-ci/SKILL.md +4 -4
- package/package.json +4 -4
- package/prompts/{conductor-framework-context.md → showrunner-framework-context.md} +1 -1
- package/prompts/{conductor-status-check.md → showrunner-status-check.md} +1 -1
- package/prompts/{conductor-sub-agent.md → showrunner-sub-agent.md} +6 -6
- package/prompts/{conductor-system.md → showrunner-system.md} +8 -8
- package/skills/apt/SKILL.md +112 -38
- package/skills/apt-debug/SKILL.md +14 -24
- package/skills/apt-fan-out/SKILL.md +4 -4
- package/skills/apt-handoff/SKILL.md +1 -1
- package/skills/apt-plan/SKILL.md +5 -5
- package/skills/apt-plan/adapters/{conductor.md → showrunner.md} +16 -16
- package/skills/apt-produce/SKILL.md +606 -0
- package/skills/apt-quick/SKILL.md +14 -22
- package/skills/apt-run/SKILL.md +126 -3
- package/skills/apt-ship/SKILL.md +2 -0
- package/skills/apt-spar/SKILL.md +5 -3
- package/skills/apt-watch-ci/SKILL.md +4 -4
- package/src/cli/commands/ci-watch.mjs +51 -2
- package/src/cli/commands/commit.mjs +3 -3
- package/src/cli/commands/event.mjs +16 -16
- package/src/cli/commands/merge-integrate.mjs +3 -3
- package/src/cli/commands/produce.mjs +1466 -0
- package/src/cli/commands/task.mjs +482 -285
- package/src/cli/coordination/auto-emit-artifact-ready.mjs +5 -5
- package/src/cli/coordination/event-schema.d.ts +4 -2
- package/src/cli/coordination/event-schema.mjs +276 -21
- package/src/cli/coordination/store.mjs +4 -4
- package/src/cli/dispatch.mjs +2 -0
- package/src/cli/help.mjs +30 -0
- package/src/cli/install/legacy-paths.mjs +2 -0
- package/src/cli/produce/blind-probe.mjs +245 -0
- package/src/cli/produce/claim.mjs +543 -0
- package/src/cli/produce/done-gate.mjs +238 -0
- package/src/cli/produce/events.mjs +131 -0
- package/src/cli/produce/evidence-oracle.mjs +133 -0
- package/src/cli/produce/ledger.mjs +284 -0
- package/src/cli/produce/probe-family.mjs +168 -0
- package/src/cli/produce/projection.mjs +105 -0
- package/src/cli/produce/run-id.mjs +84 -0
- package/src/cli/produce/saga.mjs +303 -0
- package/src/cli/produce/scheduler.mjs +423 -0
- package/src/cli/produce/setpoint.mjs +122 -0
- package/src/cli/produce/verification-ttl.mjs +191 -0
- package/src/cli/roadmap/showrunner-view.d.ts +10 -0
- package/src/cli/roadmap/{conductor-view.mjs → showrunner-view.mjs} +6 -6
- package/dist/cli/roadmap/conductor-view.d.mts.map +0 -1
- package/dist/cli/roadmap/conductor-view.mjs.map +0 -1
- 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
|
+
}
|