@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.
Files changed (174) hide show
  1. package/CHANGELOG.md +185 -1
  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/spar-resolve-partner.d.mts +9 -0
  15. package/dist/cli/commands/spar-resolve-partner.d.mts.map +1 -0
  16. package/dist/cli/commands/spar-resolve-partner.mjs +82 -0
  17. package/dist/cli/commands/spar-resolve-partner.mjs.map +1 -0
  18. package/dist/cli/commands/task.d.mts +16 -1
  19. package/dist/cli/commands/task.d.mts.map +1 -1
  20. package/dist/cli/commands/task.mjs +434 -266
  21. package/dist/cli/commands/task.mjs.map +1 -1
  22. package/dist/cli/config/gitignore-drift.d.mts.map +1 -1
  23. package/dist/cli/config/gitignore-drift.mjs +1 -0
  24. package/dist/cli/config/gitignore-drift.mjs.map +1 -1
  25. package/dist/cli/config/post-merge-sweep.d.mts.map +1 -1
  26. package/dist/cli/config/post-merge-sweep.mjs +1 -0
  27. package/dist/cli/config/post-merge-sweep.mjs.map +1 -1
  28. package/dist/cli/coordination/auto-emit-artifact-ready.mjs +5 -5
  29. package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -1
  30. package/dist/cli/coordination/event-schema.d.mts +5 -3
  31. package/dist/cli/coordination/event-schema.d.mts.map +1 -1
  32. package/dist/cli/coordination/event-schema.mjs +245 -21
  33. package/dist/cli/coordination/event-schema.mjs.map +1 -1
  34. package/dist/cli/coordination/store.d.mts +2 -2
  35. package/dist/cli/coordination/store.mjs +4 -4
  36. package/dist/cli/coordination/store.mjs.map +1 -1
  37. package/dist/cli/dispatch.d.mts.map +1 -1
  38. package/dist/cli/dispatch.mjs +4 -0
  39. package/dist/cli/dispatch.mjs.map +1 -1
  40. package/dist/cli/help.d.mts.map +1 -1
  41. package/dist/cli/help.mjs +31 -0
  42. package/dist/cli/help.mjs.map +1 -1
  43. package/dist/cli/host/codex-config.d.mts +78 -0
  44. package/dist/cli/host/codex-config.d.mts.map +1 -0
  45. package/dist/cli/host/codex-config.mjs +169 -0
  46. package/dist/cli/host/codex-config.mjs.map +1 -0
  47. package/dist/cli/install/legacy-paths.d.mts.map +1 -1
  48. package/dist/cli/install/legacy-paths.mjs +2 -0
  49. package/dist/cli/install/legacy-paths.mjs.map +1 -1
  50. package/dist/cli/produce/blind-probe.d.mts +85 -0
  51. package/dist/cli/produce/blind-probe.d.mts.map +1 -0
  52. package/dist/cli/produce/blind-probe.mjs +217 -0
  53. package/dist/cli/produce/blind-probe.mjs.map +1 -0
  54. package/dist/cli/produce/claim.d.mts +188 -0
  55. package/dist/cli/produce/claim.d.mts.map +1 -0
  56. package/dist/cli/produce/claim.mjs +518 -0
  57. package/dist/cli/produce/claim.mjs.map +1 -0
  58. package/dist/cli/produce/done-gate.d.mts +87 -0
  59. package/dist/cli/produce/done-gate.d.mts.map +1 -0
  60. package/dist/cli/produce/done-gate.mjs +200 -0
  61. package/dist/cli/produce/done-gate.mjs.map +1 -0
  62. package/dist/cli/produce/events.d.mts +77 -0
  63. package/dist/cli/produce/events.d.mts.map +1 -0
  64. package/dist/cli/produce/events.mjs +126 -0
  65. package/dist/cli/produce/events.mjs.map +1 -0
  66. package/dist/cli/produce/evidence-oracle.d.mts +63 -0
  67. package/dist/cli/produce/evidence-oracle.d.mts.map +1 -0
  68. package/dist/cli/produce/evidence-oracle.mjs +122 -0
  69. package/dist/cli/produce/evidence-oracle.mjs.map +1 -0
  70. package/dist/cli/produce/ledger.d.mts +140 -0
  71. package/dist/cli/produce/ledger.d.mts.map +1 -0
  72. package/dist/cli/produce/ledger.mjs +272 -0
  73. package/dist/cli/produce/ledger.mjs.map +1 -0
  74. package/dist/cli/produce/probe-family.d.mts +53 -0
  75. package/dist/cli/produce/probe-family.d.mts.map +1 -0
  76. package/dist/cli/produce/probe-family.mjs +160 -0
  77. package/dist/cli/produce/probe-family.mjs.map +1 -0
  78. package/dist/cli/produce/projection.d.mts +55 -0
  79. package/dist/cli/produce/projection.d.mts.map +1 -0
  80. package/dist/cli/produce/projection.mjs +97 -0
  81. package/dist/cli/produce/projection.mjs.map +1 -0
  82. package/dist/cli/produce/run-id.d.mts +42 -0
  83. package/dist/cli/produce/run-id.d.mts.map +1 -0
  84. package/dist/cli/produce/run-id.mjs +79 -0
  85. package/dist/cli/produce/run-id.mjs.map +1 -0
  86. package/dist/cli/produce/saga.d.mts +180 -0
  87. package/dist/cli/produce/saga.d.mts.map +1 -0
  88. package/dist/cli/produce/saga.mjs +290 -0
  89. package/dist/cli/produce/saga.mjs.map +1 -0
  90. package/dist/cli/produce/scheduler.d.mts +165 -0
  91. package/dist/cli/produce/scheduler.d.mts.map +1 -0
  92. package/dist/cli/produce/scheduler.mjs +399 -0
  93. package/dist/cli/produce/scheduler.mjs.map +1 -0
  94. package/dist/cli/produce/setpoint.d.mts +52 -0
  95. package/dist/cli/produce/setpoint.d.mts.map +1 -0
  96. package/dist/cli/produce/setpoint.mjs +113 -0
  97. package/dist/cli/produce/setpoint.mjs.map +1 -0
  98. package/dist/cli/produce/verification-ttl.d.mts +75 -0
  99. package/dist/cli/produce/verification-ttl.d.mts.map +1 -0
  100. package/dist/cli/produce/verification-ttl.mjs +169 -0
  101. package/dist/cli/produce/verification-ttl.mjs.map +1 -0
  102. package/dist/cli/roadmap/{conductor-view.d.mts → showrunner-view.d.mts} +3 -3
  103. package/dist/cli/roadmap/showrunner-view.d.mts.map +1 -0
  104. package/dist/cli/roadmap/{conductor-view.mjs → showrunner-view.mjs} +7 -7
  105. package/dist/cli/roadmap/showrunner-view.mjs.map +1 -0
  106. package/dist/cli/wfrun/run.d.mts.map +1 -1
  107. package/dist/cli/wfrun/run.mjs +1 -0
  108. package/dist/cli/wfrun/run.mjs.map +1 -1
  109. package/dist/plugin/.claude-plugin/plugin.json +2 -1
  110. package/dist/plugin/skills/apt/SKILL.md +112 -38
  111. package/dist/plugin/skills/apt-debug/SKILL.md +14 -24
  112. package/dist/plugin/skills/apt-fan-out/SKILL.md +4 -4
  113. package/dist/plugin/skills/apt-handoff/SKILL.md +1 -1
  114. package/dist/plugin/skills/apt-plan/SKILL.md +5 -5
  115. package/dist/plugin/skills/apt-plan/adapters/{conductor.md → showrunner.md} +16 -16
  116. package/dist/plugin/skills/apt-produce/SKILL.md +606 -0
  117. package/dist/plugin/skills/apt-quick/SKILL.md +14 -22
  118. package/dist/plugin/skills/apt-run/SKILL.md +126 -3
  119. package/dist/plugin/skills/apt-ship/SKILL.md +2 -0
  120. package/dist/plugin/skills/apt-spar/SKILL.md +19 -9
  121. package/dist/plugin/skills/apt-watch-ci/SKILL.md +4 -4
  122. package/package.json +4 -4
  123. package/prompts/{conductor-framework-context.md → showrunner-framework-context.md} +1 -1
  124. package/prompts/{conductor-status-check.md → showrunner-status-check.md} +1 -1
  125. package/prompts/{conductor-sub-agent.md → showrunner-sub-agent.md} +6 -6
  126. package/prompts/{conductor-system.md → showrunner-system.md} +8 -8
  127. package/skills/apt/SKILL.md +112 -38
  128. package/skills/apt-debug/SKILL.md +14 -24
  129. package/skills/apt-fan-out/SKILL.md +4 -4
  130. package/skills/apt-handoff/SKILL.md +1 -1
  131. package/skills/apt-plan/SKILL.md +5 -5
  132. package/skills/apt-plan/adapters/{conductor.md → showrunner.md} +16 -16
  133. package/skills/apt-produce/SKILL.md +606 -0
  134. package/skills/apt-quick/SKILL.md +14 -22
  135. package/skills/apt-run/SKILL.md +126 -3
  136. package/skills/apt-ship/SKILL.md +2 -0
  137. package/skills/apt-spar/SKILL.md +19 -9
  138. package/skills/apt-watch-ci/SKILL.md +4 -4
  139. package/src/cli/commands/ci-watch.mjs +51 -2
  140. package/src/cli/commands/commit.mjs +3 -3
  141. package/src/cli/commands/event.mjs +16 -16
  142. package/src/cli/commands/merge-integrate.mjs +3 -3
  143. package/src/cli/commands/produce.mjs +1466 -0
  144. package/src/cli/commands/spar-resolve-partner.mjs +85 -0
  145. package/src/cli/commands/task.mjs +482 -285
  146. package/src/cli/config/gitignore-drift.mjs +1 -0
  147. package/src/cli/config/post-merge-sweep.mjs +1 -0
  148. package/src/cli/coordination/auto-emit-artifact-ready.mjs +5 -5
  149. package/src/cli/coordination/event-schema.d.ts +4 -2
  150. package/src/cli/coordination/event-schema.mjs +276 -21
  151. package/src/cli/coordination/store.mjs +4 -4
  152. package/src/cli/dispatch.mjs +4 -0
  153. package/src/cli/help.mjs +31 -0
  154. package/src/cli/host/codex-config.mjs +165 -0
  155. package/src/cli/install/legacy-paths.mjs +2 -0
  156. package/src/cli/produce/blind-probe.mjs +245 -0
  157. package/src/cli/produce/claim.mjs +543 -0
  158. package/src/cli/produce/done-gate.mjs +238 -0
  159. package/src/cli/produce/events.mjs +131 -0
  160. package/src/cli/produce/evidence-oracle.mjs +133 -0
  161. package/src/cli/produce/ledger.mjs +284 -0
  162. package/src/cli/produce/probe-family.mjs +168 -0
  163. package/src/cli/produce/projection.mjs +105 -0
  164. package/src/cli/produce/run-id.mjs +84 -0
  165. package/src/cli/produce/saga.mjs +303 -0
  166. package/src/cli/produce/scheduler.mjs +423 -0
  167. package/src/cli/produce/setpoint.mjs +122 -0
  168. package/src/cli/produce/verification-ttl.mjs +191 -0
  169. package/src/cli/roadmap/showrunner-view.d.ts +10 -0
  170. package/src/cli/roadmap/{conductor-view.mjs → showrunner-view.mjs} +6 -6
  171. package/src/cli/wfrun/run.mjs +1 -0
  172. package/dist/cli/roadmap/conductor-view.d.mts.map +0 -1
  173. package/dist/cli/roadmap/conductor-view.mjs.map +0 -1
  174. 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
+ }