@aperant/framework 0.16.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 +185 -1
- 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/spar-resolve-partner.d.mts +9 -0
- package/dist/cli/commands/spar-resolve-partner.d.mts.map +1 -0
- package/dist/cli/commands/spar-resolve-partner.mjs +82 -0
- package/dist/cli/commands/spar-resolve-partner.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/config/gitignore-drift.d.mts.map +1 -1
- package/dist/cli/config/gitignore-drift.mjs +1 -0
- package/dist/cli/config/gitignore-drift.mjs.map +1 -1
- package/dist/cli/config/post-merge-sweep.d.mts.map +1 -1
- package/dist/cli/config/post-merge-sweep.mjs +1 -0
- package/dist/cli/config/post-merge-sweep.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 +4 -0
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/help.d.mts.map +1 -1
- package/dist/cli/help.mjs +31 -0
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/host/codex-config.d.mts +78 -0
- package/dist/cli/host/codex-config.d.mts.map +1 -0
- package/dist/cli/host/codex-config.mjs +169 -0
- package/dist/cli/host/codex-config.mjs.map +1 -0
- 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/cli/wfrun/run.d.mts.map +1 -1
- package/dist/cli/wfrun/run.mjs +1 -0
- package/dist/cli/wfrun/run.mjs.map +1 -1
- 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 +19 -9
- 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 +19 -9
- 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/spar-resolve-partner.mjs +85 -0
- package/src/cli/commands/task.mjs +482 -285
- package/src/cli/config/gitignore-drift.mjs +1 -0
- package/src/cli/config/post-merge-sweep.mjs +1 -0
- 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 +4 -0
- package/src/cli/help.mjs +31 -0
- package/src/cli/host/codex-config.mjs +165 -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/src/cli/wfrun/run.mjs +1 -0
- 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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host/codex-config.mjs — live codex model resolution for `apt:spar` (Decision B + C).
|
|
3
|
+
*
|
|
4
|
+
* Aperant holds ZERO model names. The only source of truth for the codex
|
|
5
|
+
* partner's model is the user's live `~/.codex/config.toml`, read at invocation
|
|
6
|
+
* time with NO fallback default — mirroring the app's "Model selection —
|
|
7
|
+
* resolver-only, provider-is-output, LIVE" rule.
|
|
8
|
+
*
|
|
9
|
+
* Two exports:
|
|
10
|
+
* - `parseTopLevelCodexModel(tomlText)` — a deterministic top-level TOML
|
|
11
|
+
* `model = "<value>"` line parser (ID-04: top-level only; `[profiles.*]`
|
|
12
|
+
* sections are inactive without `--profile`, which spar never passes).
|
|
13
|
+
* - `resolveCodexSparModel({ home, existsSync, readFileSync })` — reads
|
|
14
|
+
* `<home>/.codex/config.toml` LIVE each call and returns a fail-closed
|
|
15
|
+
* `{ available, model, source, reason }` envelope. NEVER a baked constant.
|
|
16
|
+
*
|
|
17
|
+
* Parser hardening (plan-gate spar refinement #1). The bias rule is
|
|
18
|
+
* fail-closed: when a value is ambiguous, return `reason: 'unparseable'` rather
|
|
19
|
+
* than a wrong or truncated model. A too-strict parse that loses the MCP rung
|
|
20
|
+
* (falling through to the self-resolving plugin/CLI rungs) is acceptable; a
|
|
21
|
+
* too-loose parse that passes a wrong model is a bug.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
/** Human-facing source label the SKILL + envelope reference. */
|
|
27
|
+
export const CODEX_CONFIG_SOURCE = '~/.codex/config.toml'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} ParsedCodexModel
|
|
31
|
+
* @property {string|null} model — the resolved top-level model, or null.
|
|
32
|
+
* @property {null|'no_model_key'|'unparseable'} reason — null when a model
|
|
33
|
+
* resolved; a machine reason otherwise.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse the FIRST top-level `model = "<value>"` assignment from codex
|
|
38
|
+
* `config.toml` text. Scans line-by-line, stops at the first `[section]`
|
|
39
|
+
* header (top-level scope ends there), skips comment lines, and resolves the
|
|
40
|
+
* first top-level `model` key.
|
|
41
|
+
*
|
|
42
|
+
* Handles (refinement #1): a leading UTF-8 BOM, an inline `# comment` after the
|
|
43
|
+
* value, a no-space `model="x"` assignment, single-quoted TOML literal strings,
|
|
44
|
+
* and commented-out `#model = "x"` keys (skipped). A malformed / unterminated /
|
|
45
|
+
* escaped-quote value returns `reason: 'unparseable'` — never a truncated
|
|
46
|
+
* string. The first top-level `model` line is decisive: if it is malformed the
|
|
47
|
+
* parser returns `unparseable` rather than scanning for a later valid line
|
|
48
|
+
* (TOML forbids duplicate keys, and a later-line search would be too loose).
|
|
49
|
+
*
|
|
50
|
+
* @param {string} tomlText
|
|
51
|
+
* @returns {ParsedCodexModel}
|
|
52
|
+
*/
|
|
53
|
+
export function parseTopLevelCodexModel(tomlText) {
|
|
54
|
+
if (typeof tomlText !== 'string') return { model: null, reason: 'unparseable' }
|
|
55
|
+
|
|
56
|
+
// Strip a leading UTF-8 BOM so `model = "x"` still resolves.
|
|
57
|
+
let text = tomlText
|
|
58
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
|
|
59
|
+
|
|
60
|
+
const lines = text.split(/\r?\n/)
|
|
61
|
+
for (const rawLine of lines) {
|
|
62
|
+
const line = rawLine.trimStart()
|
|
63
|
+
// A `[section]` (or `[[array.of.tables]]`) header ends the top-level
|
|
64
|
+
// scope — stop scanning; a model below this point is profile-scoped.
|
|
65
|
+
if (line.startsWith('[')) break
|
|
66
|
+
// Comment lines (incl. a commented-out `#model = "x"`) are skipped.
|
|
67
|
+
if (line.startsWith('#')) continue
|
|
68
|
+
// Top-level `model` assignment? The key must be EXACTLY `model` —
|
|
69
|
+
// `\s*=` rejects `model_provider`, `models`, `mcp_model`, etc.
|
|
70
|
+
const assign = line.match(/^model\s*=\s*(.*)$/)
|
|
71
|
+
if (!assign) continue
|
|
72
|
+
// First top-level model line is decisive (fail-closed on malformed).
|
|
73
|
+
return parseQuotedValue(assign[1])
|
|
74
|
+
}
|
|
75
|
+
return { model: null, reason: 'no_model_key' }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse a quoted TOML string value (the RHS of `model =`). Accepts a
|
|
80
|
+
* double-quoted basic string or a single-quoted literal string, each optionally
|
|
81
|
+
* followed by whitespace and/or an inline `# comment`. Any other shape
|
|
82
|
+
* (unquoted, unterminated, escaped, empty, or trailing non-comment junk) is
|
|
83
|
+
* fail-closed `unparseable`.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} value — the text after `model\s*=\s*`.
|
|
86
|
+
* @returns {ParsedCodexModel}
|
|
87
|
+
*/
|
|
88
|
+
function parseQuotedValue(value) {
|
|
89
|
+
const first = value[0]
|
|
90
|
+
if (first === '"') {
|
|
91
|
+
const m = value.match(/^"([^"\n]*)"(.*)$/)
|
|
92
|
+
if (!m) return { model: null, reason: 'unparseable' } // unterminated
|
|
93
|
+
const inner = m[1]
|
|
94
|
+
// A backslash means TOML escapes are in play — do NOT attempt to
|
|
95
|
+
// unescape; fail closed rather than risk a wrong/truncated model.
|
|
96
|
+
if (inner.includes('\\')) return { model: null, reason: 'unparseable' }
|
|
97
|
+
if (!isOnlyTrailingComment(m[2])) return { model: null, reason: 'unparseable' }
|
|
98
|
+
if (inner.length === 0) return { model: null, reason: 'unparseable' }
|
|
99
|
+
return { model: inner, reason: null }
|
|
100
|
+
}
|
|
101
|
+
if (first === "'") {
|
|
102
|
+
// TOML literal strings carry no escapes.
|
|
103
|
+
const m = value.match(/^'([^'\n]*)'(.*)$/)
|
|
104
|
+
if (!m) return { model: null, reason: 'unparseable' }
|
|
105
|
+
const inner = m[1]
|
|
106
|
+
if (!isOnlyTrailingComment(m[2])) return { model: null, reason: 'unparseable' }
|
|
107
|
+
if (inner.length === 0) return { model: null, reason: 'unparseable' }
|
|
108
|
+
return { model: inner, reason: null }
|
|
109
|
+
}
|
|
110
|
+
// Unquoted / bare value — codex `model` is always a quoted string; bias
|
|
111
|
+
// fail-closed so a malformed line never yields a wrong model.
|
|
112
|
+
return { model: null, reason: 'unparseable' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The text after a closing quote must be empty, whitespace, or an inline
|
|
117
|
+
* `# comment`. Anything else is malformed → fail closed.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} rest
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
function isOnlyTrailingComment(rest) {
|
|
123
|
+
const t = rest.trimStart()
|
|
124
|
+
return t === '' || t.startsWith('#')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @typedef {Object} CodexSparModelResolution
|
|
129
|
+
* @property {boolean} available — true iff a live top-level model resolved.
|
|
130
|
+
* @property {string|null} model — the resolved model, or null when unavailable.
|
|
131
|
+
* @property {string} source — the config source label (`~/.codex/config.toml`).
|
|
132
|
+
* @property {null|'config_missing'|'no_model_key'|'unparseable'} reason
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read `<home>/.codex/config.toml` LIVE and resolve the top-level model.
|
|
137
|
+
* Fail-closed (Decision C / ID-03): a missing file, a missing top-level `model`
|
|
138
|
+
* key, or an unparseable value returns `available: false` with a machine reason
|
|
139
|
+
* and `model: null`. It NEVER substitutes a default. There is no module-level
|
|
140
|
+
* cached value — the file is re-read on every call.
|
|
141
|
+
*
|
|
142
|
+
* `existsSync` / `readFileSync` are injected so tests stay hermetic (no real
|
|
143
|
+
* `$HOME` read).
|
|
144
|
+
*
|
|
145
|
+
* @param {{ home: string, existsSync: (p: string) => boolean, readFileSync: (p: string, enc: string) => string }} deps
|
|
146
|
+
* @returns {CodexSparModelResolution}
|
|
147
|
+
*/
|
|
148
|
+
export function resolveCodexSparModel({ home, existsSync, readFileSync }) {
|
|
149
|
+
const source = CODEX_CONFIG_SOURCE
|
|
150
|
+
const configPath = join(home, '.codex', 'config.toml')
|
|
151
|
+
if (!existsSync(configPath)) {
|
|
152
|
+
return { available: false, model: null, source, reason: 'config_missing' }
|
|
153
|
+
}
|
|
154
|
+
let text
|
|
155
|
+
try {
|
|
156
|
+
text = readFileSync(configPath, 'utf-8')
|
|
157
|
+
} catch {
|
|
158
|
+
return { available: false, model: null, source, reason: 'config_missing' }
|
|
159
|
+
}
|
|
160
|
+
const { model, reason } = parseTopLevelCodexModel(text)
|
|
161
|
+
if (model) {
|
|
162
|
+
return { available: true, model, source, reason: null }
|
|
163
|
+
}
|
|
164
|
+
return { available: false, model: null, source, reason: reason ?? 'unparseable' }
|
|
165
|
+
}
|
|
@@ -108,6 +108,7 @@ export const LEGACY_INSTALL_PATHS = Object.freeze({
|
|
|
108
108
|
'agents/apt-personas.md',
|
|
109
109
|
'agents/apt-plan.md',
|
|
110
110
|
'agents/apt-pr-review.md',
|
|
111
|
+
'agents/apt-produce.md',
|
|
111
112
|
'agents/apt-prototype.md',
|
|
112
113
|
'agents/apt-quick.md',
|
|
113
114
|
'agents/apt-release-notes.md',
|
|
@@ -153,6 +154,7 @@ export const LEGACY_INSTALL_PATHS = Object.freeze({
|
|
|
153
154
|
'commands/apt-personas.md',
|
|
154
155
|
'commands/apt-plan.md',
|
|
155
156
|
'commands/apt-pr-review.md',
|
|
157
|
+
'commands/apt-produce.md',
|
|
156
158
|
'commands/apt-prototype.md',
|
|
157
159
|
'commands/apt-quick.md',
|
|
158
160
|
'commands/apt-release-notes.md',
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @internal
|
|
3
|
+
* produce/blind-probe.mjs — the held-out cross-family blind-probe set (M2, AC4-6).
|
|
4
|
+
*
|
|
5
|
+
* A fixed 10–20 behavioral probe set the implementer/Showrunner NEVER sees
|
|
6
|
+
* (probe-blindness, US-4 / ID-06), authored + run by a provider family DISJOINT
|
|
7
|
+
* from the implementer's, re-run on a rotating sample of already-done milestones
|
|
8
|
+
* against HEAD (US-6).
|
|
9
|
+
*
|
|
10
|
+
* The probe SET is a frozen immutable artifact at
|
|
11
|
+
* `.aperant/produce/<run-id>/probes/<milestone>.json`, written ONCE via
|
|
12
|
+
* `openSync(path, 'wx')` exclusive-create — a second freeze throws EEXIST and
|
|
13
|
+
* returns the byte-identical stored set (mirrors setpoint.mjs::freezeSetpoint).
|
|
14
|
+
* Its CONTENT never enters the event log or the roadmap projection the
|
|
15
|
+
* Showrunner re-reads each tick (structural probe-blindness, ID-M2-03): the
|
|
16
|
+
* `produce.probe.frozen` event carries ONLY `{probe_set_hash, author_family}`
|
|
17
|
+
* and `produce.probe.run` carries ONLY `{result, probe_family, head_sha}`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import {
|
|
22
|
+
closeSync,
|
|
23
|
+
existsSync,
|
|
24
|
+
fsyncSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
openSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
writeSync,
|
|
29
|
+
} from 'node:fs'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import {
|
|
32
|
+
appendProduceEvent,
|
|
33
|
+
readImplementerFamily,
|
|
34
|
+
readProduceRunEventsMatching,
|
|
35
|
+
} from './events.mjs'
|
|
36
|
+
import { normalizeFamily } from './probe-family.mjs'
|
|
37
|
+
import { projectRoadmap } from './projection.mjs'
|
|
38
|
+
import { resolveRunDir } from './run-id.mjs'
|
|
39
|
+
|
|
40
|
+
/** sha256 of the canonical JSON of the probe content (the immutable hash stamp). */
|
|
41
|
+
function computeProbeSetHash(content) {
|
|
42
|
+
return createHash('sha256').update(JSON.stringify(content)).digest('hex')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The probes/<milestone>.json path for a run + milestone. */
|
|
46
|
+
export function probeSetPathFor(projectDir, runId, milestoneId) {
|
|
47
|
+
return join(resolveRunDir(projectDir, runId), 'probes', `${milestoneId}.json`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read + parse a frozen probe set. Returns null when absent or malformed. */
|
|
51
|
+
export function readProbeSet(projectDir, runId, milestoneId) {
|
|
52
|
+
const p = probeSetPathFor(projectDir, runId, milestoneId)
|
|
53
|
+
if (!existsSync(p)) return null
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(readFileSync(p, 'utf-8'))
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Exclusive-create write of the frozen probe set on the FINAL path (NOT
|
|
63
|
+
* tmp+rename), so a SECOND write throws EEXIST — the set is immutable.
|
|
64
|
+
*/
|
|
65
|
+
function writeProbeSetExclusive(probePath, probeSet) {
|
|
66
|
+
mkdirSync(join(probePath, '..'), { recursive: true })
|
|
67
|
+
const payload = `${JSON.stringify(probeSet, null, 2)}\n`
|
|
68
|
+
const fd = openSync(probePath, 'wx') // throws EEXIST when already frozen
|
|
69
|
+
try {
|
|
70
|
+
writeSync(fd, payload, 0, 'utf-8')
|
|
71
|
+
fsyncSync(fd)
|
|
72
|
+
} finally {
|
|
73
|
+
closeSync(fd)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Freeze a blind-probe set for a milestone, idempotently. REFUSES fail-closed
|
|
79
|
+
* when the author family equals the implementer family (a held-out probe an
|
|
80
|
+
* implementer authored is not held out — ID-06) or when no implementer family
|
|
81
|
+
* is recorded (ID-M2-02). On a FRESH freeze appends `produce.probe.frozen`
|
|
82
|
+
* carrying ONLY the hash + author family (never the content). A second freeze
|
|
83
|
+
* returns the byte-identical stored set (`reused: true`) and never re-logs.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} projectDir
|
|
86
|
+
* @param {string} runId
|
|
87
|
+
* @param {string} milestoneId
|
|
88
|
+
* @param {{ content: unknown, authorFamily: string, implementerFamily: string }} args
|
|
89
|
+
* @param {Date} [now]
|
|
90
|
+
* @returns {{ ok: true, reused: boolean, probe_set_hash: string, author_family: string }
|
|
91
|
+
* | { ok: false, reason: string }}
|
|
92
|
+
*/
|
|
93
|
+
export function freezeProbeSet(
|
|
94
|
+
projectDir,
|
|
95
|
+
runId,
|
|
96
|
+
milestoneId,
|
|
97
|
+
{ content, authorFamily, implementerFamily },
|
|
98
|
+
now = new Date(),
|
|
99
|
+
) {
|
|
100
|
+
if (!authorFamily) return { ok: false, reason: 'no_author_family' }
|
|
101
|
+
if (!implementerFamily) return { ok: false, reason: 'no_implementer_family' }
|
|
102
|
+
// Cross-family enforcement (normalized so claude-code === claude).
|
|
103
|
+
if (normalizeFamily(authorFamily) === normalizeFamily(implementerFamily))
|
|
104
|
+
return { ok: false, reason: 'same_family_author' }
|
|
105
|
+
|
|
106
|
+
const probePath = probeSetPathFor(projectDir, runId, milestoneId)
|
|
107
|
+
const existing = readProbeSet(projectDir, runId, milestoneId)
|
|
108
|
+
if (existing)
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
reused: true,
|
|
112
|
+
probe_set_hash: existing.probe_set_hash,
|
|
113
|
+
author_family: existing.author_family,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const probeSetHash = computeProbeSetHash(content ?? null)
|
|
117
|
+
const probeSet = {
|
|
118
|
+
run_id: runId,
|
|
119
|
+
milestone_id: milestoneId,
|
|
120
|
+
author_family: authorFamily,
|
|
121
|
+
frozen_at: now.toISOString(),
|
|
122
|
+
probe_set_hash: probeSetHash,
|
|
123
|
+
// The probe CONTENT lives in the frozen file (blind to the projection) —
|
|
124
|
+
// it NEVER enters the event log.
|
|
125
|
+
probes: content ?? null,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
writeProbeSetExclusive(probePath, probeSet)
|
|
130
|
+
} catch (e) {
|
|
131
|
+
// Lost a concurrent race — the winner's file is authoritative; re-read it
|
|
132
|
+
// byte-identically rather than overwriting (mirrors freezeSetpoint).
|
|
133
|
+
if (e && e.code === 'EEXIST') {
|
|
134
|
+
const winner = readProbeSet(projectDir, runId, milestoneId)
|
|
135
|
+
if (winner)
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
reused: true,
|
|
139
|
+
probe_set_hash: winner.probe_set_hash,
|
|
140
|
+
author_family: winner.author_family,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw e
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Announce by hash + author family ONLY — never the content (ID-M2-03).
|
|
147
|
+
appendProduceEvent(projectDir, 'produce.probe.frozen', {
|
|
148
|
+
run_id: runId,
|
|
149
|
+
milestone_id: milestoneId,
|
|
150
|
+
probe_set_hash: probeSetHash,
|
|
151
|
+
author_family: authorFamily,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
return { ok: true, reused: false, probe_set_hash: probeSetHash, author_family: authorFamily }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Record a blind-probe RUN result. REFUSES fail-closed when the probe family
|
|
159
|
+
* equals the implementer family (a probe run BY the implementer family is not
|
|
160
|
+
* cross-family — ID-08) or when no implementer family is recorded. On
|
|
161
|
+
* acceptance appends `produce.probe.run {run_id, milestone_id, result,
|
|
162
|
+
* probe_family, head_sha}` — result/family/head_sha only, never content.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} projectDir
|
|
165
|
+
* @param {{ runId: string, milestoneId: string, result: string, probeFamily: string, headSha: string }} args
|
|
166
|
+
* @returns {{ ok: true, result: string, probe_family: string, head_sha: string }
|
|
167
|
+
* | { ok: false, reason: string }}
|
|
168
|
+
*/
|
|
169
|
+
export function recordProbeRun(projectDir, { runId, milestoneId, result, probeFamily, headSha }) {
|
|
170
|
+
if (result !== 'pass' && result !== 'fail') return { ok: false, reason: 'invalid_result' }
|
|
171
|
+
if (!probeFamily) return { ok: false, reason: 'no_probe_family' }
|
|
172
|
+
if (!headSha) return { ok: false, reason: 'no_head_sha' }
|
|
173
|
+
|
|
174
|
+
const implementerFamily = readImplementerFamily(projectDir, runId, milestoneId)
|
|
175
|
+
if (!implementerFamily) return { ok: false, reason: 'no_recorded_implementer' }
|
|
176
|
+
if (normalizeFamily(probeFamily) === normalizeFamily(implementerFamily))
|
|
177
|
+
return { ok: false, reason: 'same_family_probe' }
|
|
178
|
+
|
|
179
|
+
appendProduceEvent(projectDir, 'produce.probe.run', {
|
|
180
|
+
run_id: runId,
|
|
181
|
+
milestone_id: milestoneId,
|
|
182
|
+
result,
|
|
183
|
+
probe_family: probeFamily,
|
|
184
|
+
head_sha: headSha,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
return { ok: true, result, probe_family: probeFamily, head_sha: headSha }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Select a DETERMINISTIC rotating sample of already-`done` milestones for blind
|
|
192
|
+
* re-probing against HEAD (US-6). Rotates by fewest prior probe runs first,
|
|
193
|
+
* then oldest last-probe-run first (a never-probed done milestone sorts first),
|
|
194
|
+
* then first-seen roadmap order — so two folds of the same log select the same
|
|
195
|
+
* sample.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} projectDir
|
|
198
|
+
* @param {string} runId
|
|
199
|
+
* @param {{ sample?: number }} [opts]
|
|
200
|
+
* @returns {{ run_id: string, sample_size: number, selected: Array<{ milestone_id: string, title: string, probe_run_count: number, last_probe_run_at: string | null }> }}
|
|
201
|
+
*/
|
|
202
|
+
export function selectSweepSample(projectDir, runId, { sample = 1 } = {}) {
|
|
203
|
+
const roadmap = projectRoadmap(projectDir, runId)
|
|
204
|
+
const done = roadmap.milestones.filter((m) => m.status === 'done')
|
|
205
|
+
|
|
206
|
+
const runEvents = readProduceRunEventsMatching(
|
|
207
|
+
projectDir,
|
|
208
|
+
runId,
|
|
209
|
+
(op) => op === 'produce.probe.run',
|
|
210
|
+
)
|
|
211
|
+
const stats = new Map()
|
|
212
|
+
for (const e of runEvents) {
|
|
213
|
+
const id = e.data && typeof e.data.milestone_id === 'string' ? e.data.milestone_id : null
|
|
214
|
+
if (!id) continue
|
|
215
|
+
const s = stats.get(id) || { count: 0, last: null }
|
|
216
|
+
s.count += 1
|
|
217
|
+
if (typeof e.t === 'string' && (s.last === null || e.t > s.last)) s.last = e.t
|
|
218
|
+
stats.set(id, s)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const withOrder = done.map((m, idx) => {
|
|
222
|
+
const s = stats.get(m.id) || { count: 0, last: null }
|
|
223
|
+
return { milestone: m, order: idx, count: s.count, last: s.last }
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
withOrder.sort((a, b) => {
|
|
227
|
+
if (a.count !== b.count) return a.count - b.count // fewest probe runs first
|
|
228
|
+
// oldest last-probe-run first — a never-probed done milestone (null) sorts
|
|
229
|
+
// ahead of any dated one.
|
|
230
|
+
const al = a.last ?? ''
|
|
231
|
+
const bl = b.last ?? ''
|
|
232
|
+
if (al !== bl) return al < bl ? -1 : 1
|
|
233
|
+
return a.order - b.order // first-seen roadmap order (stable tiebreak)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const n = Number.isFinite(sample) && sample > 0 ? Math.floor(sample) : 1
|
|
237
|
+
const selected = withOrder.slice(0, n).map((w) => ({
|
|
238
|
+
milestone_id: w.milestone.id,
|
|
239
|
+
title: w.milestone.title,
|
|
240
|
+
probe_run_count: w.count,
|
|
241
|
+
last_probe_run_at: w.last,
|
|
242
|
+
}))
|
|
243
|
+
|
|
244
|
+
return { run_id: runId, sample_size: selected.length, selected }
|
|
245
|
+
}
|