phronesis 1.0.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 (142) hide show
  1. package/README.md +66 -0
  2. package/package.json +34 -0
  3. package/src/act.js +61 -0
  4. package/src/active-context.js +56 -0
  5. package/src/adapter.js +386 -0
  6. package/src/cli.js +1893 -0
  7. package/src/codex-hooks-schema.js +92 -0
  8. package/src/compile.js +513 -0
  9. package/src/doctor.js +303 -0
  10. package/src/due.js +271 -0
  11. package/src/eval.js +467 -0
  12. package/src/event.js +212 -0
  13. package/src/export.js +136 -0
  14. package/src/guard.js +89 -0
  15. package/src/hooks.js +230 -0
  16. package/src/ingest.js +965 -0
  17. package/src/init.js +150 -0
  18. package/src/layout.js +394 -0
  19. package/src/links.js +83 -0
  20. package/src/lint.js +58 -0
  21. package/src/profile.js +116 -0
  22. package/src/prompts.js +26 -0
  23. package/src/registry.js +190 -0
  24. package/src/scaffold.js +134 -0
  25. package/src/search.js +139 -0
  26. package/src/skill-discovery.js +117 -0
  27. package/src/skills-bump.js +885 -0
  28. package/src/skills.js +355 -0
  29. package/src/subscription.js +5 -0
  30. package/src/validate.js +152 -0
  31. package/templates/claude/settings.json +86 -0
  32. package/templates/codex/INDEX.md +68 -0
  33. package/templates/codex/seed/ai-practice/compile-dont-just-capture.md +42 -0
  34. package/templates/codex/seed/ai-practice/design-for-the-distribution.md +42 -0
  35. package/templates/codex/seed/ai-practice/from-spec-author-to-outcome-curator.md +43 -0
  36. package/templates/codex/seed/ai-practice/recall-builds-trust-when-calibrated.md +70 -0
  37. package/templates/codex/seed/ai-practice/the-eval-is-the-spec.md +42 -0
  38. package/templates/codex/seed/ai-practice/the-gold-dust-leaves-no-trace.md +72 -0
  39. package/templates/codex/seed/ai-practice/the-model-is-not-the-moat.md +40 -0
  40. package/templates/codex/seed/ai-practice/when-generation-is-cheap-judgment-is-the-work.md +39 -0
  41. package/templates/codex/seed/judgment/contextual-stewardship-is-the-senior-skill.md +50 -0
  42. package/templates/codex/seed/judgment/decision-quality-is-distinct-from-outcome-quality.md +39 -0
  43. package/templates/codex/seed/judgment/embrace-reality-ego-and-blind-spots.md +38 -0
  44. package/templates/codex/seed/judgment/groups-seek-consensus-individuals-seek-truth.md +44 -0
  45. package/templates/codex/seed/judgment/heresies-are-where-the-edge-lives.md +42 -0
  46. package/templates/codex/seed/judgment/judgment-compounds-through-honest-reflection.md +38 -0
  47. package/templates/codex/seed/judgment/phronesis-is-judgment-in-the-particular.md +67 -0
  48. package/templates/codex/seed/judgment/show-me-the-apparatus.md +40 -0
  49. package/templates/codex/seed/judgment/small-judgment-gaps-compound-under-leverage.md +37 -0
  50. package/templates/codex/seed/judgment/taste-is-compressed-judgment.md +38 -0
  51. package/templates/codex/seed/judgment/trust-taste-only-where-feedback-is-valid.md +42 -0
  52. package/templates/codex/seed/judgment/weigh-the-reasoning-not-the-recommendation.md +43 -0
  53. package/templates/codex/seed/leadership/a-managers-output-is-the-teams-output.md +35 -0
  54. package/templates/codex/seed/leadership/most-team-problems-are-structural.md +40 -0
  55. package/templates/codex/seed/leadership/next-play.md +36 -0
  56. package/templates/codex/seed/leadership/psychological-safety.md +39 -0
  57. package/templates/codex/seed/leadership/right-size-your-teams.md +38 -0
  58. package/templates/codex/seed/leadership/spend-time-on-high-leverage-activities.md +36 -0
  59. package/templates/codex/seed/leadership/talent-density-is-the-master-variable.md +39 -0
  60. package/templates/codex/seed/leadership/under-pressure-calm-is-contagious.md +36 -0
  61. package/templates/codex/seed/leadership/your-best-alternative-is-your-leverage.md +37 -0
  62. package/templates/codex/seed/pm/a-problem-well-stated-is-half-solved.md +39 -0
  63. package/templates/codex/seed/pm/a-real-strategy-makes-planning-trivial.md +40 -0
  64. package/templates/codex/seed/pm/barrels-and-ammunition.md +39 -0
  65. package/templates/codex/seed/pm/develop-taste-in-your-beliefs.md +38 -0
  66. package/templates/codex/seed/pm/hire-for-progress.md +44 -0
  67. package/templates/codex/seed/pm/jobs-are-stable-solutions-churn.md +39 -0
  68. package/templates/codex/seed/pm/level-by-the-ambiguity-someone-can-own.md +39 -0
  69. package/templates/codex/seed/pm/listen-before-you-prescribe.md +38 -0
  70. package/templates/codex/seed/pm/live-in-the-future-and-build-whats-missing.md +38 -0
  71. package/templates/codex/seed/pm/match-effort-to-leverage.md +37 -0
  72. package/templates/codex/seed/pm/not-my-fault-but-my-problem.md +40 -0
  73. package/templates/codex/seed/pm/pick-the-job-then-refuse-everything-outside-it.md +37 -0
  74. package/templates/codex/seed/pm/retention-sets-the-ceiling.md +38 -0
  75. package/templates/codex/seed/pm/scale-with-leaders-not-process.md +38 -0
  76. package/templates/codex/seed/pm/solution-discovery-over-problem-validation.md +38 -0
  77. package/templates/codex/seed/pm/tenfold-better-on-a-painful-problem.md +40 -0
  78. package/templates/codex-surface/README.md +73 -0
  79. package/templates/codex-surface/hooks/_resolve.sh +36 -0
  80. package/templates/codex-surface/hooks/recall-guard.sh +30 -0
  81. package/templates/codex-surface/hooks/session-start.sh +18 -0
  82. package/templates/codex-surface/hooks/session-sweep-precompact.sh +20 -0
  83. package/templates/codex-surface/hooks/session-sweep-subagent.sh +23 -0
  84. package/templates/codex-surface/hooks/session-sweep.sh +19 -0
  85. package/templates/codex-surface/hooks.json +69 -0
  86. package/templates/domains/pm/AGENTS.md +111 -0
  87. package/templates/domains/pm/MEMORY.md +7 -0
  88. package/templates/domains/pm/USER.md +8 -0
  89. package/templates/phronesis-hooks/compile-active-context.sh +38 -0
  90. package/templates/phronesis-hooks/recall-guard.sh +103 -0
  91. package/templates/phronesis-hooks/session-start.sh +90 -0
  92. package/templates/phronesis-hooks/session-sweep.sh +140 -0
  93. package/templates/phronesis-hooks/skill-lifecycle.sh +37 -0
  94. package/templates/skills/compile/SKILL.md +279 -0
  95. package/templates/skills/compile/evals/rubric.md +75 -0
  96. package/templates/skills/decision-log/SKILL.md +156 -0
  97. package/templates/skills/decision-log/evals/rubric.md +97 -0
  98. package/templates/skills/extract-gold-dust/SKILL.md +311 -0
  99. package/templates/skills/extract-gold-dust/evals/rubric.md +52 -0
  100. package/templates/skills/ingest/SKILL.md +204 -0
  101. package/templates/skills/ingest/evals/rubric.md +117 -0
  102. package/templates/skills/lint/SKILL.md +246 -0
  103. package/templates/skills/lint/evals/rubric.md +139 -0
  104. package/templates/skills/meeting-prep/SKILL.md +195 -0
  105. package/templates/skills/meeting-prep/evals/rubric.md +93 -0
  106. package/templates/skills/onboard/SKILL.md +258 -0
  107. package/templates/skills/onboard/evals/rubric.md +60 -0
  108. package/templates/skills/prd-draft/SKILL.md +193 -0
  109. package/templates/skills/prd-draft/evals/rubric.md +38 -0
  110. package/templates/skills/prep-extraction/SKILL.md +217 -0
  111. package/templates/skills/prep-extraction/evals/rubric.md +45 -0
  112. package/templates/skills/recall/SKILL.md +155 -0
  113. package/templates/skills/recall/evals/rubric.md +37 -0
  114. package/templates/skills/research/SKILL.md +247 -0
  115. package/templates/skills/research/evals/rubric.md +139 -0
  116. package/templates/skills/stakeholder-update/SKILL.md +163 -0
  117. package/templates/skills/stakeholder-update/evals/rubric.md +111 -0
  118. package/vendor/core/package.json +25 -0
  119. package/vendor/core/src/action-registry.js +459 -0
  120. package/vendor/core/src/action-writers.js +201 -0
  121. package/vendor/core/src/actions.js +2140 -0
  122. package/vendor/core/src/active-context.js +167 -0
  123. package/vendor/core/src/artifacts.js +301 -0
  124. package/vendor/core/src/content-digest.js +21 -0
  125. package/vendor/core/src/contract.js +245 -0
  126. package/vendor/core/src/event-log.js +338 -0
  127. package/vendor/core/src/guard.js +398 -0
  128. package/vendor/core/src/hook-executor.js +527 -0
  129. package/vendor/core/src/index.js +180 -0
  130. package/vendor/core/src/ingest.js +286 -0
  131. package/vendor/core/src/install-root.js +42 -0
  132. package/vendor/core/src/layout.js +13 -0
  133. package/vendor/core/src/link-index.js +198 -0
  134. package/vendor/core/src/lint.js +368 -0
  135. package/vendor/core/src/schedule.js +535 -0
  136. package/vendor/core/src/search.js +534 -0
  137. package/vendor/core/src/session-hooks.js +129 -0
  138. package/vendor/core/src/skill-lifecycle.js +479 -0
  139. package/vendor/core/src/skill-registry.js +524 -0
  140. package/vendor/core/src/subscription.js +84 -0
  141. package/vendor/core/src/validate-object.js +62 -0
  142. package/vendor/core/src/workspace-scan.js +125 -0
@@ -0,0 +1,885 @@
1
+ // `phronesis skills bump` (core-0031 / T7.4 + the T7.2 coverage floor) — the
2
+ // deployment-unit gate. Bumping a skill's prompt_pin runs the candidate-execution
3
+ // substrate (core-0028) k times under the proposed pin, aggregates to a STRICT-min
4
+ // floor on BOTH axes (quality + coverage), refuses the comparison if the evaluation
5
+ // identity drifted, blocks an either-axis regression vs the previous accepted pin,
6
+ // increments the skill version, and writes the new pin ONLY on pass
7
+ // (specs/eval-discipline.md §"Prompt + model is the deployment unit").
8
+ //
9
+ // This module is orchestration + decision. It executes NO skill itself (the substrate
10
+ // does) and runs NO judge itself (the substrate's one judge does). The substrate is
11
+ // injected (deps.runCandidates) so the fitness gate runs deterministic, no model/credits.
12
+ //
13
+ // Why a NEW aggregator and not harness/evals/compile/run_floor.py's aggregate(): that
14
+ // routine is compile-specific (locked to judge.py's six CRITERIA) and SOFT-floored
15
+ // (passes = mean >= floor and lo >= floor-1, a one-point slack). The spec's floor is the
16
+ // literal "minimum score ... not the mean" (eval-discipline.md), criteria-agnostic. So
17
+ // this builds a generic, strict-min aggregator: per criterion of the skill's OWN rubric,
18
+ // the minimum across the k runs must clear the floor — a [5,5,5,5,3] 5/3 split FAILS at
19
+ // floor 4 (min 3 < 4), where run_floor's soft floor would pass it.
20
+
21
+ import { createHash } from 'node:crypto';
22
+ import { existsSync } from 'node:fs';
23
+ import { open, readFile, rename, rm, writeFile } from 'node:fs/promises';
24
+ import { join } from 'node:path';
25
+
26
+ export const DEFAULT_K = 5;
27
+ // A hard cap on k: each run is k × goldens model+judge calls, so an un-bounded --k turns a
28
+ // typo (`--k 1000`) into a runaway model/judge bill. 50 is far above any real reliability
29
+ // sample; above it, refuse and tell the operator to lower it.
30
+ export const MAX_K = 50;
31
+ export const DEFAULT_QUALITY_FLOOR = 4;
32
+ // Quality scores are integers 1-5 (judge.py). A floor outside [1,5] (e.g. 0 or negative)
33
+ // would silently disable the quality axis — reject it.
34
+ export const QUALITY_FLOOR_RANGE = [1, 5];
35
+ // The coverage floor defaults to 1.0 (every warranted capture fires in the WORST run) —
36
+ // fail-closed; a skill declares a looser floor explicitly. The reliability protocol is a
37
+ // floor, not a mean, on this axis too.
38
+ export const DEFAULT_COVERAGE_FLOOR = 1;
39
+ // Coverage is a warranted-capture hit-rate ∈ [0,1]. A negative floor would disable the
40
+ // axis; a floor > 1 would fail even a perfect run. 0 is allowed (disables coverage) but
41
+ // warned at the call site.
42
+ export const COVERAGE_FLOOR_RANGE = [0, 1];
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Pin + version
46
+ // ---------------------------------------------------------------------------
47
+
48
+ // A model id is letters/digits with `.`, `-`, `_` separators only — NO whitespace,
49
+ // newlines, or YAML-significant characters. The pin is written verbatim into SKILL.md
50
+ // YAML frontmatter (rewriteSkillFrontmatter), so an unvalidated id with a newline could
51
+ // inject a frontmatter line (`prompt_pin: gpt-5\nweight: light@...`). Matches the real
52
+ // ids: gpt-5, claude-opus-4-8, us.anthropic.claude-opus-4-8.
53
+ export const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
54
+
55
+ // prompt_pin = {model_id}@{date} (specs/skills.md). The candidate runs under model_id.
56
+ export function parsePin(pin) {
57
+ if (typeof pin !== 'string' || pin.trim() === '') {
58
+ return { error: 'pin must be a non-empty {model_id}@{date} string' };
59
+ }
60
+ const at = pin.lastIndexOf('@');
61
+ if (at <= 0 || at === pin.length - 1) {
62
+ return { error: `pin must be {model_id}@{date} (got "${pin}")` };
63
+ }
64
+ const modelId = pin.slice(0, at).trim();
65
+ const date = pin.slice(at + 1).trim();
66
+ if (!MODEL_ID_RE.test(modelId)) {
67
+ return {
68
+ error:
69
+ `pin model id "${modelId}" is not allowlisted — only letters, digits, and . - _ ` +
70
+ '(no whitespace, newlines, or YAML-significant characters that could corrupt the frontmatter)',
71
+ };
72
+ }
73
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
74
+ return { error: `pin date must be YYYY-MM-DD (got "${date}")` };
75
+ }
76
+ // raw is RECONSTRUCTED from the validated parts — never the raw input — so trailing
77
+ // junk or interior whitespace in the input can never reach SKILL.md.
78
+ return { modelId, date, raw: `${modelId}@${date}` };
79
+ }
80
+
81
+ // B5: every pin change increments the version. Output-shape change → MAJOR (skills.md:43);
82
+ // a model-only prompt_pin swap (no output-shape change) → MINOR. There is no
83
+ // re-pin-without-version path.
84
+ export function bumpVersion(version, component = 'minor') {
85
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version || '').trim());
86
+ if (!m) throw new Error(`skill version is not SemVer X.Y.Z: "${version}"`);
87
+ let [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])];
88
+ if (component === 'major') return `${major + 1}.0.0`;
89
+ if (component === 'minor') return `${major}.${minor + 1}.0`;
90
+ if (component === 'patch') return `${major}.${minor}.${patch + 1}`;
91
+ throw new Error(`unknown SemVer component: ${component}`);
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Score extraction (tolerant of the judge's known format drift — rules.md: coerce
96
+ // "5"/5.0 → 5; a floor that fails on the judge's formatting is a lying gate)
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export function coerceScore(value) {
100
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
101
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
102
+ return Number(value);
103
+ }
104
+ if (value && typeof value === 'object') return coerceScore(value.score);
105
+ return NaN;
106
+ }
107
+
108
+ // per_run_scores is the judge's output. judge.py returns { scores: {<crit>: {score}} };
109
+ // a function judge may return {<crit>: {score}} or {<crit>: 5} directly. Normalize both.
110
+ export function extractCriterionScores(perRunScores) {
111
+ if (!perRunScores || typeof perRunScores !== 'object') {
112
+ throw new Error('a candidate run is missing per_run_scores — cannot gate the quality axis');
113
+ }
114
+ const scores = perRunScores.scores && typeof perRunScores.scores === 'object'
115
+ ? perRunScores.scores
116
+ : perRunScores;
117
+ const out = {};
118
+ for (const [crit, raw] of Object.entries(scores)) {
119
+ // judge.py siblings `meta` beside `scores`, so the unwrap above already drops it; this
120
+ // guards a flat function-judge that returns criteria and a `meta` key in one object.
121
+ if (crit === 'meta') continue;
122
+ const n = coerceScore(raw);
123
+ if (!Number.isFinite(n)) throw new Error(`criterion "${crit}" has a non-numeric score`);
124
+ out[crit] = n;
125
+ }
126
+ if (Object.keys(out).length === 0) {
127
+ throw new Error('a candidate run produced an empty rubric score set — cannot gate quality');
128
+ }
129
+ return out;
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Coverage axis (read off the substrate's action_trace coverage markers)
134
+ // ---------------------------------------------------------------------------
135
+
136
+ // Per run: warranted captures that fired = hits; warranted that did NOT fire = misses;
137
+ // UNwarranted that fired = fabrications (a signal failure, eval-discipline.md anti-
138
+ // fabrication rule); unwarranted not-fired = honest restraint. Hit-rate is over the
139
+ // warranted set only; an honest-negative run (nothing warranted) scores a full 1.0.
140
+ export function coverageFromTrace(actionTrace) {
141
+ const markers = (actionTrace || []).filter((e) => e && e.kind === 'coverage');
142
+ let warrantedTotal = 0;
143
+ let warrantedFired = 0;
144
+ let fabrications = 0;
145
+ for (const m of markers) {
146
+ const warranted = m.payload?.warranted === true;
147
+ const fired = m.result === 'fired';
148
+ if (warranted) {
149
+ warrantedTotal += 1;
150
+ if (fired) warrantedFired += 1;
151
+ } else if (fired) {
152
+ fabrications += 1;
153
+ }
154
+ }
155
+ const hitRate = warrantedTotal === 0 ? 1 : warrantedFired / warrantedTotal;
156
+ return { warrantedTotal, warrantedFired, fabrications, hitRate };
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // The generic, strict-min aggregator (B3) — over the FLATTENED k×goldens runs.
161
+ // Quality: per criterion, min across all runs ≥ floor (every golden's worst run clears
162
+ // it). Coverage: min per-run hit-rate across all runs ≥ floor; any fabrication anywhere
163
+ // fails outright.
164
+ // ---------------------------------------------------------------------------
165
+
166
+ export function aggregateFloor(perRunResults, {
167
+ qualityFloor = DEFAULT_QUALITY_FLOOR,
168
+ coverageFloor = DEFAULT_COVERAGE_FLOOR,
169
+ } = {}) {
170
+ const runs = Array.isArray(perRunResults) ? perRunResults : [];
171
+ if (runs.length === 0) {
172
+ return {
173
+ runs: 0,
174
+ empty: true,
175
+ quality: { criteria: {}, passes: false },
176
+ coverage: { hit_rate_min: 0, fabrications: 0, asserted: false, per_run: [], passes: false },
177
+ };
178
+ }
179
+
180
+ // Quality — collect per-criterion score vectors across every run.
181
+ const perCriterion = new Map();
182
+ for (const run of runs) {
183
+ const scores = extractCriterionScores(run.per_run_scores);
184
+ for (const [crit, score] of Object.entries(scores)) {
185
+ if (!perCriterion.has(crit)) perCriterion.set(crit, []);
186
+ perCriterion.get(crit).push(score);
187
+ }
188
+ }
189
+ // Every run must score every criterion — a missing criterion means inconsistent
190
+ // scoring (or a rubric mismatch), which must not silently pass.
191
+ const criteria = {};
192
+ let qualityPasses = true;
193
+ for (const [crit, vec] of perCriterion) {
194
+ if (vec.length !== runs.length) {
195
+ throw new Error(
196
+ `criterion "${crit}" scored in ${vec.length}/${runs.length} runs — inconsistent rubric scoring`,
197
+ );
198
+ }
199
+ const min = Math.min(...vec);
200
+ const passes = min >= qualityFloor;
201
+ if (!passes) qualityPasses = false;
202
+ criteria[crit] = { min, max: Math.max(...vec), scores: vec, passes };
203
+ }
204
+
205
+ // Coverage — per-run hit-rate + fabrication tally. `asserted` is false when NO run
206
+ // carried a warranted expectation: the coverage axis then passes vacuously (an honest
207
+ // negative is valid, eval-discipline.md), but the gate must surface that it asserted
208
+ // nothing rather than report a verified coverage pass (the change-0010 misfire class).
209
+ const perRunCoverage = runs.map((run) => coverageFromTrace(run.action_trace));
210
+ const hitRateMin = Math.min(...perRunCoverage.map((c) => c.hitRate));
211
+ const fabrications = perRunCoverage.reduce((sum, c) => sum + c.fabrications, 0);
212
+ const asserted = perRunCoverage.some((c) => c.warrantedTotal > 0);
213
+ const coveragePasses = fabrications === 0 && hitRateMin >= coverageFloor;
214
+
215
+ return {
216
+ runs: runs.length,
217
+ quality: { criteria, passes: qualityPasses },
218
+ coverage: {
219
+ hit_rate_min: hitRateMin,
220
+ fabrications,
221
+ asserted,
222
+ per_run: perRunCoverage.map((c) => ({
223
+ warranted_total: c.warrantedTotal,
224
+ warranted_fired: c.warrantedFired,
225
+ fabrications: c.fabrications,
226
+ hit_rate: c.hitRate,
227
+ })),
228
+ passes: coveragePasses,
229
+ },
230
+ };
231
+ }
232
+
233
+ // The per-criterion + coverage floor numbers a baseline record carries (B4a "the scores").
234
+ export function floorScores(aggregate) {
235
+ const quality = {};
236
+ for (const [crit, c] of Object.entries(aggregate.quality.criteria)) quality[crit] = c.min;
237
+ return {
238
+ quality,
239
+ coverage: {
240
+ hit_rate_floor: aggregate.coverage.hit_rate_min,
241
+ fabrications: aggregate.coverage.fabrications,
242
+ asserted: aggregate.coverage.asserted,
243
+ },
244
+ };
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Evaluation identity (B4b / B10) — a floor number is only comparable against the SAME
249
+ // eval. Record name-free hashes/versions of everything the scores depend on; refuse the
250
+ // comparison if any drifted.
251
+ // ---------------------------------------------------------------------------
252
+
253
+ function sha256(value) {
254
+ return createHash('sha256').update(String(value), 'utf8').digest('hex');
255
+ }
256
+
257
+ export function computeEvalIdentity({ corpus, rubric, registry, runnerProtocolVersion, judgePin }) {
258
+ return {
259
+ corpus: sha256(corpus ?? ''),
260
+ rubric: sha256(rubric ?? ''),
261
+ registry: sha256(registry ?? ''),
262
+ runner_protocol: String(runnerProtocolVersion ?? ''),
263
+ judge: sha256(judgePin ?? ''),
264
+ };
265
+ }
266
+
267
+ const IDENTITY_COMPONENTS = ['corpus', 'rubric', 'registry', 'runner_protocol', 'judge'];
268
+
269
+ export function identityDrift(before, after) {
270
+ if (!before || !after) return [];
271
+ const drifted = [];
272
+ for (const k of IDENTITY_COMPONENTS) {
273
+ if ((before[k] ?? '') !== (after[k] ?? '')) drifted.push(k);
274
+ }
275
+ return drifted;
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // The decision (B3 / B4 / B8 / B10)
280
+ // ---------------------------------------------------------------------------
281
+
282
+ function absoluteFloorCheck(aggregate, qualityFloor, coverageFloor) {
283
+ const qualityFailures = Object.entries(aggregate.quality.criteria)
284
+ .filter(([, c]) => c.min < qualityFloor)
285
+ .map(([crit, c]) => ({ axis: 'quality', criterion: crit, floor: qualityFloor, got: c.min }));
286
+ const coverageFail = aggregate.coverage.hit_rate_min < coverageFloor
287
+ ? [{ axis: 'coverage', floor: coverageFloor, got: aggregate.coverage.hit_rate_min }]
288
+ : [];
289
+ const failures = [...qualityFailures, ...coverageFail];
290
+ return { passes: failures.length === 0, failures };
291
+ }
292
+
293
+ function regressionCheck(baselineScores, aggregate, coverageFloor) {
294
+ const regressed = [];
295
+ const baseQuality = baselineScores?.quality || {};
296
+ for (const [crit, c] of Object.entries(aggregate.quality.criteria)) {
297
+ const base = baseQuality[crit];
298
+ if (base === undefined) {
299
+ // A criterion absent from the baseline under matched identity is a rubric the
300
+ // baseline never measured — treat as a regression rather than a silent pass.
301
+ regressed.push({ axis: 'quality', criterion: crit, baseline: null, got: c.min });
302
+ } else if (c.min < base) {
303
+ regressed.push({ axis: 'quality', criterion: crit, baseline: base, got: c.min });
304
+ }
305
+ }
306
+ // The reverse direction: a criterion the baseline measured but the new run no longer
307
+ // scores (consistently, so aggregateFloor didn't throw) is an unverified disappearance —
308
+ // never silently let it pass as "no regression."
309
+ for (const crit of Object.keys(baseQuality)) {
310
+ if (!(crit in aggregate.quality.criteria)) {
311
+ regressed.push({ axis: 'quality', criterion: crit, baseline: baseQuality[crit], got: null });
312
+ }
313
+ }
314
+ // Coverage RELATIVE regression: a dip that stays at/above the absolute floor but below
315
+ // the accepted baseline's recorded floor. (A drop BELOW the absolute floor is already a
316
+ // hard block in decideBump, so this branch only ever catches above-floor dips.) Fails
317
+ // CLOSED, symmetric with the missing-quality-criterion branch: a baseline that recorded
318
+ // no `hit_rate_floor` (cross-version / hand-edited PINS.md) falls back to the absolute
319
+ // coverage floor rather than skipping the check.
320
+ const baseCoverage = baselineScores?.coverage?.hit_rate_floor ?? coverageFloor;
321
+ if (aggregate.coverage.hit_rate_min < baseCoverage) {
322
+ regressed.push({ axis: 'coverage', baseline: baseCoverage, got: aggregate.coverage.hit_rate_min });
323
+ }
324
+ return { regressed };
325
+ }
326
+
327
+ // mode: 'normal' | 'rebaseline'. (accept-regression is a caller policy over a 'regression'
328
+ // verdict, not a separate decision mode — the gate's verdict is the same; only whether the
329
+ // caller proceeds differs.)
330
+ export function decideBump({
331
+ mode = 'normal',
332
+ baseline,
333
+ aggregate,
334
+ newIdentity,
335
+ qualityFloor = DEFAULT_QUALITY_FLOOR,
336
+ coverageFloor = DEFAULT_COVERAGE_FLOOR,
337
+ }) {
338
+ if (aggregate.empty || aggregate.runs === 0) {
339
+ return { status: 'blocked-empty', detail: 'no candidate runs were produced — cannot gate' };
340
+ }
341
+
342
+ // Fabrication is a hard fail in EVERY mode and is NOT a "regression" overridable by
343
+ // --accept-regression: it is a signal-integrity failure (anti-Goodhart). Only fixing the
344
+ // skill clears it.
345
+ if (aggregate.coverage.fabrications > 0) {
346
+ return { status: 'blocked-fabrication', fabrications: aggregate.coverage.fabrications };
347
+ }
348
+
349
+ const absolute = absoluteFloorCheck(aggregate, qualityFloor, coverageFloor);
350
+
351
+ // First bump (no baseline) and --rebaseline both seed under the absolute floor on BOTH
352
+ // axes, no regression check (B4 / B10). The absolute floor is never bypassable — it is
353
+ // the one path with no regression check to catch a fabricating / under-capturing seed.
354
+ if (mode === 'rebaseline' || !baseline) {
355
+ if (!absolute.passes) return { status: 'blocked-absolute', absolute };
356
+ return { status: 'pass', seeded: true, absolute };
357
+ }
358
+
359
+ // Normal bump with a baseline: identity drift REFUSES the comparison first (B10) — a
360
+ // changed corpus/rubric/registry/runner/judge could make a real regression appear to
361
+ // pass. Neither pass nor regression; the operator must --rebaseline.
362
+ const drift = identityDrift(baseline.identity, newIdentity);
363
+ if (drift.length) return { status: 'refused-drift', drift };
364
+
365
+ // The absolute floor — quality's rubric pass bar (≥ qualityFloor on every criterion) AND
366
+ // the coverage floor (min per-run hit-rate ≥ coverageFloor) — is a HARD block in EVERY
367
+ // mode, never overridable. --accept-regression overrides only a RELATIVE dip that stays
368
+ // AT OR ABOVE both floors (e.g. 5 → 4), never a skill that fell BELOW a floor (5 → 2, or
369
+ // a coverage collapse below the declared floor). Both axes are treated symmetrically:
370
+ // without this, a coverage absolute failure slipped past the (formerly quality-only)
371
+ // guard into the overridable regression branch, so --accept-regression — or a baseline
372
+ // seeded at a lower hit_rate_floor — could ship coverage below the advertised floor.
373
+ if (!absolute.passes) {
374
+ return { status: 'blocked-absolute', absolute };
375
+ }
376
+
377
+ const reg = regressionCheck(baseline.scores, aggregate, coverageFloor);
378
+ if (reg.regressed.length) return { status: 'regression', regressed: reg.regressed };
379
+ return { status: 'pass' };
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // PINS.md — the committed, name-free baseline-per-pin record (B4 / B8 / B10). One
384
+ // self-contained record per accepted bump; kind ∈ baseline | override | rebaseline; every
385
+ // record carries scores + identity; the CURRENT baseline is the last record.
386
+ // ---------------------------------------------------------------------------
387
+
388
+ const PINS_HEADER = `# PINS — accepted prompt_pin baselines
389
+
390
+ Name-free, git-tracked, one record per accepted bump (specs/eval-discipline.md
391
+ §"Promotion semantics"). Each record carries the per-criterion quality floor + coverage
392
+ hit-rate floor that the next bump compares against, and the evaluation identity those
393
+ scores were produced under. \`kind\`: \`baseline\` (clean pass), \`override\`
394
+ (\`--accept-regression\` over a real regression), \`rebaseline\` (\`--rebaseline\` reset
395
+ after evaluation-identity drift). The current accepted baseline is the LAST record.
396
+ `;
397
+
398
+ export function renderPinsRecord(record) {
399
+ const heading = `## ${record.pin} — ${record.kind} (v${record.version})`;
400
+ return `${heading}\n\n\`\`\`json\n${JSON.stringify(record, null, 2)}\n\`\`\`\n`;
401
+ }
402
+
403
+ export function parsePins(text) {
404
+ if (typeof text !== 'string' || text.trim() === '') return { records: [] };
405
+ const records = [];
406
+ const re = /```json\s*\n([\s\S]*?)\n```/g;
407
+ let m;
408
+ while ((m = re.exec(text)) !== null) {
409
+ try {
410
+ records.push(JSON.parse(m[1]));
411
+ } catch {
412
+ throw new Error('PINS.md contains an unparseable record block');
413
+ }
414
+ }
415
+ return { records };
416
+ }
417
+
418
+ export function currentBaseline(records) {
419
+ if (!records || records.length === 0) return null;
420
+ return records[records.length - 1];
421
+ }
422
+
423
+ export function appendPinsRecord(existingText, record) {
424
+ const base = existingText && existingText.trim() !== '' ? existingText.replace(/\s*$/, '') : PINS_HEADER.replace(/\s*$/, '');
425
+ return `${base}\n\n${renderPinsRecord(record).replace(/\s*$/, '')}\n`;
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // SKILL.md frontmatter rewrite (write pin + version on pass only)
430
+ // ---------------------------------------------------------------------------
431
+
432
+ export function rewriteSkillFrontmatter(source, { pin, version }) {
433
+ if (typeof source !== 'string' || !source.startsWith('---')) {
434
+ throw new Error('SKILL.md does not start with a --- frontmatter fence');
435
+ }
436
+ const end = source.indexOf('\n---', 3);
437
+ if (end === -1) throw new Error('SKILL.md frontmatter is not closed with ---');
438
+ const fmEnd = end + 1; // index of the closing --- line start
439
+ const front = source.slice(0, fmEnd);
440
+ const rest = source.slice(fmEnd);
441
+ let out = front;
442
+ if (version !== undefined) {
443
+ if (!/^version:.*$/m.test(out)) throw new Error('SKILL.md frontmatter has no version field');
444
+ out = out.replace(/^version:.*$/m, `version: ${version}`);
445
+ }
446
+ if (pin !== undefined) {
447
+ if (!/^prompt_pin:.*$/m.test(out)) throw new Error('SKILL.md frontmatter has no prompt_pin field');
448
+ out = out.replace(/^prompt_pin:.*$/m, `prompt_pin: ${pin}`);
449
+ }
450
+ return out + rest;
451
+ }
452
+
453
+ export function readFrontmatterField(source, field) {
454
+ const end = source.indexOf('\n---', 3);
455
+ const front = end === -1 ? source : source.slice(0, end);
456
+ const m = new RegExp(`^${field}:\\s*(.+)$`, 'm').exec(front);
457
+ return m ? m[1].trim() : null;
458
+ }
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Repo resolution (B6) — explicit --repo, else cwd must be a repo ROOT (skills/ +
462
+ // changes/ present). Refuse if neither resolves; no silent install fallback (bump is a
463
+ // dev/release op on git-tracked source).
464
+ // ---------------------------------------------------------------------------
465
+
466
+ export function resolveRepoRoot({ repo, cwd = process.cwd() }) {
467
+ const root = repo || cwd;
468
+ const isRepo = existsSync(join(root, 'skills')) && existsSync(join(root, 'changes'));
469
+ if (!isRepo) {
470
+ return {
471
+ error:
472
+ `not a Phronesis repo root: ${root} (expected skills/ and changes/). ` +
473
+ 'skills bump operates on git-tracked source — pass --repo <path> or run from the repo root. ' +
474
+ 'There is no install fallback; the install picks up the pin on init/update.',
475
+ };
476
+ }
477
+ return { root };
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Result-completeness + write-safety helpers
482
+ // ---------------------------------------------------------------------------
483
+
484
+ // The golden id the substrate stamps on each result (run_candidates: golden.id || name ||
485
+ // golden-N). Recompute it identically so the gate can prove one result per golden.
486
+ export function expectedGoldenIds(goldens) {
487
+ return (goldens || []).map((g, i) => g?.id || g?.name || `golden-${i + 1}`);
488
+ }
489
+
490
+ // Compare the substrate's returned golden ids to the corpus's as a MULTISET — catches a
491
+ // missing golden, a duplicate (re-ran one, dropped another), an extraneous result, and a
492
+ // result with no golden_id. Returns a human-readable mismatch description, or null if exact.
493
+ export function goldenResultMismatch(expectedIds, results) {
494
+ if (!Array.isArray(results)) return 'returned a non-array result set';
495
+ const got = results.map((r) => (r && typeof r === 'object' ? r.golden_id : undefined));
496
+ if (got.some((id) => typeof id !== 'string' || id === '')) {
497
+ return 'returned a result with no golden_id';
498
+ }
499
+ const expected = [...expectedIds].sort();
500
+ const actual = [...got].sort();
501
+ if (expected.length !== actual.length || expected.some((id, i) => id !== actual[i])) {
502
+ return `returned golden ids [${actual.join(', ')}] ≠ corpus [${expected.join(', ')}]`;
503
+ }
504
+ return null;
505
+ }
506
+
507
+ // Atomic write: write a temp sibling then rename over the target (rename is atomic within a
508
+ // dir), so a crash mid-write can never leave a torn PINS.md / SKILL.md. Under the skill lock
509
+ // the temp name needn't be unique, but the pid suffix is cheap insurance.
510
+ async function atomicWrite(path, content) {
511
+ const tmp = `${path}.${process.pid}.tmp`;
512
+ await writeFile(tmp, content);
513
+ await rename(tmp, path);
514
+ }
515
+
516
+ // A per-skill advisory lock (git-style index.lock): O_EXCL create fails if a concurrent
517
+ // bump for the same skill holds it, so two bumps can't interleave their PINS read-modify-
518
+ // write and lose a record. Returns { path } on acquire, { error } on conflict. A crash
519
+ // leaves a stale lock the operator clears by hand — the same contract as git.
520
+ async function acquireSkillLock(skillDir) {
521
+ const lockPath = join(skillDir, '.bump.lock');
522
+ try {
523
+ const fh = await open(lockPath, 'wx');
524
+ await fh.write(`bump in progress · pid ${process.pid}\n`);
525
+ await fh.close();
526
+ return { path: lockPath };
527
+ } catch (err) {
528
+ if (err.code === 'EEXIST') {
529
+ return {
530
+ error:
531
+ `another \`skills bump\` for this skill is in progress (lock: ${lockPath}). ` +
532
+ 'If you are certain no other bump is running, remove the lock file and retry.',
533
+ };
534
+ }
535
+ throw err;
536
+ }
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // The orchestrator (B1) — consume the substrate; orchestrate + decide only.
541
+ // ---------------------------------------------------------------------------
542
+
543
+ export async function bumpSkill(opts, deps) {
544
+ const {
545
+ skill,
546
+ toPin,
547
+ repoRoot,
548
+ k = DEFAULT_K,
549
+ rebaseline = false,
550
+ acceptRegression = false,
551
+ shapeChange = false,
552
+ qualityFloor = DEFAULT_QUALITY_FLOOR,
553
+ coverageFloor = DEFAULT_COVERAGE_FLOOR,
554
+ rationale = null,
555
+ acceptedBy = 'operator',
556
+ activeDomain,
557
+ } = opts;
558
+ const {
559
+ runCandidates,
560
+ parseSkillSource,
561
+ runnerProtocolVersion,
562
+ registrySection,
563
+ judgePin,
564
+ judgeModel,
565
+ loadCorpus,
566
+ judge,
567
+ now,
568
+ } = deps;
569
+
570
+ const mode = rebaseline ? 'rebaseline' : 'normal';
571
+ if (typeof skill !== 'string' || skill.trim() === '') throw new Error('bumpSkill requires a skill name');
572
+ // Sanitize the skill name before it reaches any path join (the substrate sanitizes its
573
+ // own workspace paths; the gate that writes git-tracked source must match that
574
+ // discipline). A kebab-case skill name matches the SKILL.md directory contract.
575
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(skill)) {
576
+ return { status: 'invalid', error: `invalid skill name "${skill}" (expected kebab-case [a-z0-9-], matching the skills/ directory)` };
577
+ }
578
+ const pin = parsePin(toPin);
579
+ if (pin.error) return { status: 'invalid', error: pin.error };
580
+ if (!Number.isInteger(k) || k < 1) return { status: 'invalid', error: '--k must be a positive integer' };
581
+ if (k > MAX_K) {
582
+ return {
583
+ status: 'invalid',
584
+ error: `--k ${k} exceeds the cap of ${MAX_K} — each run is k × goldens model+judge calls, so a large k is almost always a typo. Lower it (a reliability floor needs only a handful of runs).`,
585
+ };
586
+ }
587
+ if (!Number.isFinite(qualityFloor) || qualityFloor < QUALITY_FLOOR_RANGE[0] || qualityFloor > QUALITY_FLOOR_RANGE[1]) {
588
+ return {
589
+ status: 'invalid',
590
+ error: `--quality-floor must be within [${QUALITY_FLOOR_RANGE.join(', ')}] (judge scores are integers 1-5); got ${qualityFloor}. A floor outside the scale would disable the quality axis.`,
591
+ };
592
+ }
593
+ if (!Number.isFinite(coverageFloor) || coverageFloor < COVERAGE_FLOOR_RANGE[0] || coverageFloor > COVERAGE_FLOOR_RANGE[1]) {
594
+ return {
595
+ status: 'invalid',
596
+ error: `--coverage-floor must be within [${COVERAGE_FLOOR_RANGE.join(', ')}] (a warranted-capture hit-rate); got ${coverageFloor}. A negative floor would disable the coverage axis.`,
597
+ };
598
+ }
599
+ // The escape hatches MUST carry a rationale — the override / rebaseline record is the
600
+ // committed audit trail (B8/B10), and a null rationale destroys its only value. Fail
601
+ // fast, before the (expensive) substrate runs.
602
+ if ((rebaseline || acceptRegression) && (typeof rationale !== 'string' || rationale.trim() === '')) {
603
+ const flag = rebaseline ? '--rebaseline' : '--accept-regression';
604
+ return {
605
+ status: 'invalid',
606
+ error: `${flag} requires a non-empty --rationale — it is the audit trail for the escape hatch, and a missing rationale makes the recorded override/rebaseline unauditable.`,
607
+ };
608
+ }
609
+
610
+ const skillDir = join(repoRoot, 'skills', skill);
611
+ const skillPath = join(skillDir, 'SKILL.md');
612
+ if (!existsSync(skillPath)) {
613
+ return { status: 'invalid', error: `skill not found: ${skillPath}` };
614
+ }
615
+ const source = await readFile(skillPath, 'utf8');
616
+ const currentVersion = readFrontmatterField(source, 'version');
617
+ const currentPin = readFrontmatterField(source, 'prompt_pin');
618
+
619
+ const rubricPath = join(skillDir, 'evals', 'rubric.md');
620
+ if (!existsSync(rubricPath)) {
621
+ return { status: 'invalid', error: `skill has no L2 rubric: ${rubricPath} (cannot gate quality)` };
622
+ }
623
+ const rubric = await readFile(rubricPath, 'utf8');
624
+
625
+ // Corpus + signedness (B7). The gate ALWAYS runs; unsigned annotates, never bypasses.
626
+ const corpus = await loadCorpus({ skill, repoRoot });
627
+ const goldens = corpus?.goldens || [];
628
+ const corpusSigned = corpus?.signed === true;
629
+ const gateStatus = corpusSigned ? 'authoritative' : 'provisional';
630
+ if (goldens.length === 0) {
631
+ return {
632
+ status: 'blocked',
633
+ reason: 'empty-corpus',
634
+ corpus_signed: corpusSigned,
635
+ gate_status: gateStatus,
636
+ message:
637
+ `no goldens resolved for "${skill}" — cannot gate (corpus: ${corpus?.source || 'unresolved'}). ` +
638
+ 'Goldens live in the install ($PHRONESIS_INSTALL/corpus/skill-goldens/<skill>), never the repo; ' +
639
+ 'pass --goldens-dir or set $PHRONESIS_INSTALL.',
640
+ };
641
+ }
642
+
643
+ const newIdentity = computeEvalIdentity({
644
+ corpus: corpus.identity ?? corpus.corpus ?? '',
645
+ rubric,
646
+ registry: registrySection,
647
+ runnerProtocolVersion,
648
+ judgePin,
649
+ });
650
+
651
+ // Run the substrate k times under the proposed pin; flatten k × goldens per-run results.
652
+ const { skill: parsedSkill, promptBody } = parseSkillSource({ name: skill, source, declaredVersion: currentVersion });
653
+ const expectedIds = expectedGoldenIds(goldens);
654
+ const perRun = [];
655
+ for (let i = 0; i < k; i += 1) {
656
+ const results = await runCandidates({
657
+ goldens,
658
+ skill: parsedSkill,
659
+ promptBody,
660
+ modelId: pin.modelId,
661
+ judge,
662
+ // Pass the judge model AND the exact rubric bytes the identity was hashed from, so
663
+ // "the scores were produced under THIS judge + THIS rubric" is an enforced invariant,
664
+ // not a coincidence of two independent disk reads (the substrate would otherwise
665
+ // default judge.py to sonnet and re-read the rubric from its own repo root).
666
+ judgeOptions: { model: judgeModel, rubricText: rubric },
667
+ activeDomain,
668
+ now,
669
+ });
670
+ // Verify the substrate returned EXACTLY one result per golden — no missing, duplicate,
671
+ // or extraneous golden id. A partial substrate failure that silently dropped a hard
672
+ // golden would let the floor aggregate only the survivors (a false green); the gate
673
+ // floors on the worst case, so a dropped worst case is the most dangerous loss.
674
+ const mismatch = goldenResultMismatch(expectedIds, results);
675
+ if (mismatch) {
676
+ return {
677
+ skill,
678
+ pin: pin.raw,
679
+ from_pin: currentPin,
680
+ corpus_signed: corpusSigned,
681
+ gate_status: gateStatus,
682
+ status: 'blocked',
683
+ reason: 'incomplete-results',
684
+ message:
685
+ `substrate run ${i + 1}/${k} ${mismatch} — the gate requires exactly one result per ` +
686
+ `corpus golden (${expectedIds.length}). A dropped, duplicated, or malformed result would let the ` +
687
+ 'floor aggregate only the survivors. Re-run the gate; if it persists the substrate needs attention.',
688
+ };
689
+ }
690
+ perRun.push(...results);
691
+ }
692
+
693
+ // A non-`completed` substrate run (max_turns_exceeded / error / a malformed result with
694
+ // no loop) is an infrastructure failure, not a candidate — its partial trace + any
695
+ // truncated scores must NEVER fold into a release decision. The reliability protocol
696
+ // floors on the worst run, and a crashed run is the worst: hard-block, name the count.
697
+ const incomplete = perRun.filter((r) => !r || !r.loop || r.loop.outcome !== 'completed');
698
+ if (incomplete.length) {
699
+ const outcomes = [...new Set(incomplete.map((r) => r?.loop?.outcome || 'malformed'))];
700
+ return {
701
+ skill,
702
+ pin: pin.raw,
703
+ from_pin: currentPin,
704
+ corpus_signed: corpusSigned,
705
+ gate_status: gateStatus,
706
+ status: 'blocked',
707
+ reason: 'incomplete-runs',
708
+ message:
709
+ `${incomplete.length}/${perRun.length} candidate run(s) did not complete (${outcomes.join(', ')}) — ` +
710
+ 'an errored or truncated run cannot count toward a release decision. Re-run the gate; ' +
711
+ 'if it persists the substrate or credentials need attention.',
712
+ };
713
+ }
714
+
715
+ const aggregate = aggregateFloor(perRun, { qualityFloor, coverageFloor });
716
+
717
+ // Serialize the PINS read → decide → write critical section under a per-skill lock so two
718
+ // concurrent bumps of the same skill cannot interleave their read-modify-write and lose a
719
+ // record. Release it on EVERY exit path (finally), refusal or pass alike.
720
+ const lock = await acquireSkillLock(skillDir);
721
+ if (lock.error) {
722
+ return {
723
+ skill,
724
+ pin: pin.raw,
725
+ from_pin: currentPin,
726
+ corpus_signed: corpusSigned,
727
+ gate_status: gateStatus,
728
+ status: 'blocked',
729
+ reason: 'locked',
730
+ message: lock.error,
731
+ };
732
+ }
733
+ try {
734
+ const pinsPath = join(skillDir, 'evals', 'PINS.md');
735
+ const pinsText = existsSync(pinsPath) ? await readFile(pinsPath, 'utf8') : '';
736
+ const { records } = parsePins(pinsText);
737
+ const baseline = currentBaseline(records);
738
+
739
+ const verdict = decideBump({ mode, baseline, aggregate, newIdentity, qualityFloor, coverageFloor });
740
+ const scores = floorScores(aggregate);
741
+ const component = shapeChange ? 'major' : 'minor';
742
+
743
+ const base = {
744
+ skill,
745
+ pin: pin.raw,
746
+ from_pin: currentPin,
747
+ current_version: currentVersion,
748
+ corpus_signed: corpusSigned,
749
+ gate_status: gateStatus,
750
+ coverage_asserted: aggregate.coverage.asserted,
751
+ aggregate,
752
+ verdict,
753
+ };
754
+
755
+ // --- Refusals / hard blocks (never bypassable, never write) ---------------
756
+ if (verdict.status === 'refused-drift') {
757
+ return {
758
+ ...base,
759
+ status: 'refused',
760
+ reason: 'evaluation-identity drift',
761
+ drift: verdict.drift,
762
+ message:
763
+ `evaluation identity drifted (${verdict.drift.join(', ')}) since the baseline pin — ` +
764
+ 'the floor comparison is invalid (a changed corpus/rubric/registry/runner/judge could mask a real ' +
765
+ 'regression). Re-run with --rebaseline to reset the comparison basis under the new identity.',
766
+ };
767
+ }
768
+ if (verdict.status === 'blocked-fabrication') {
769
+ return {
770
+ ...base,
771
+ status: 'blocked',
772
+ reason: 'fabrication',
773
+ message:
774
+ `the candidate fabricated ${verdict.fabrications} unwarranted capture(s) (coverage anti-fabrication ` +
775
+ 'rule, eval-discipline.md) — a signal-integrity failure, not a regression. --accept-regression cannot ' +
776
+ 'override it; fix the skill.',
777
+ };
778
+ }
779
+ if (verdict.status === 'blocked-absolute') {
780
+ const axes = [...new Set(verdict.absolute.failures.map((f) => f.axis))];
781
+ const seedContext = !baseline || mode === 'rebaseline';
782
+ const bar = [
783
+ axes.includes('quality') ? `quality ≥ ${qualityFloor} on every criterion` : null,
784
+ axes.includes('coverage') ? `coverage hit-rate ≥ ${coverageFloor}` : null,
785
+ ].filter(Boolean).join(' AND ');
786
+ return {
787
+ ...base,
788
+ status: 'blocked',
789
+ reason: seedContext ? (mode === 'rebaseline' ? 'rebaseline absolute floor' : 'first-bump absolute floor') : 'below absolute floor',
790
+ absolute: verdict.absolute,
791
+ message:
792
+ `the absolute floor was not met on the ${axes.join(' + ')} axis (${bar}). ` +
793
+ 'The absolute floor is a hard block on BOTH axes — --accept-regression overrides a relative dip ' +
794
+ 'that stays above the floor, never a skill that fell below it. Not bypassable.',
795
+ };
796
+ }
797
+ if (verdict.status === 'blocked-empty') {
798
+ return { ...base, status: 'blocked', reason: 'empty', message: verdict.detail };
799
+ }
800
+
801
+ // --- Regression: blocked unless --accept-regression -----------------------
802
+ if (verdict.status === 'regression' && !acceptRegression) {
803
+ return {
804
+ ...base,
805
+ status: 'blocked',
806
+ reason: 'regression',
807
+ regressed: verdict.regressed,
808
+ message:
809
+ 'a regression on ' + [...new Set(verdict.regressed.map((r) => r.axis))].join(' + ') +
810
+ ' vs the accepted pin blocks the bump. Override with --accept-regression (logs a structured ' +
811
+ 'override record to PINS.md) only if the regression is acceptable.',
812
+ };
813
+ }
814
+
815
+ // --- Pass / override / rebaseline: write pin + version + PINS record -------
816
+ const newVersion = bumpVersion(currentVersion, component);
817
+ const kind = mode === 'rebaseline' ? 'rebaseline' : verdict.status === 'regression' ? 'override' : 'baseline';
818
+
819
+ const record = {
820
+ pin: pin.raw,
821
+ version: newVersion,
822
+ kind,
823
+ accepted_by: acceptedBy,
824
+ accepted_at: (now || new Date()).toISOString(),
825
+ scores,
826
+ identity: newIdentity,
827
+ };
828
+ if (kind === 'override') {
829
+ record.from_pin = currentPin;
830
+ record.regressed_axis = verdict.regressed;
831
+ record.rationale = rationale;
832
+ }
833
+ if (kind === 'rebaseline') {
834
+ record.from_pin = currentPin;
835
+ record.identity_before = baseline ? baseline.identity : null;
836
+ record.rationale = rationale;
837
+ }
838
+
839
+ // Write the PINS baseline record BEFORE the SKILL.md pin mutation, each write ATOMIC
840
+ // (temp + rename — a crash can never leave a torn file). If the SKILL write still fails,
841
+ // the safe residue is an orphan baseline (recoverable — the next bump re-measures the
842
+ // same body and re-passes) rather than a pinned skill with no baseline (which the next
843
+ // bump would mistake for a first bump and re-seed without a regression check — a
844
+ // silent-regression hole).
845
+ const newSource = rewriteSkillFrontmatter(source, { pin: pin.raw, version: newVersion });
846
+ await atomicWrite(pinsPath, appendPinsRecord(pinsText, record));
847
+ try {
848
+ await atomicWrite(skillPath, newSource);
849
+ } catch (err) {
850
+ return {
851
+ ...base,
852
+ status: 'error',
853
+ reason: 'pin-write-failed',
854
+ record,
855
+ written: { pins: pinsPath },
856
+ message:
857
+ `the PINS.md baseline for ${pin.raw} was recorded, but writing the pin to ${skillPath} failed ` +
858
+ `(${err.message}). Re-run the bump to complete it — the skill body is unchanged, so it will re-pass.`,
859
+ };
860
+ }
861
+
862
+ const coverageNote = !aggregate.coverage.asserted
863
+ ? ' [coverage axis not exercised — no warranted captures in the corpus]'
864
+ : coverageFloor === 0
865
+ ? ' [coverage floor 0 — coverage regressions are disabled for this baseline]'
866
+ : '';
867
+ return {
868
+ ...base,
869
+ status: 'ok',
870
+ kind,
871
+ version: newVersion,
872
+ record,
873
+ written: { skill: skillPath, pins: pinsPath },
874
+ message:
875
+ kind === 'override'
876
+ ? `bumped ${skill} to ${pin.raw} (v${newVersion}) — REGRESSION ACCEPTED; override recorded in PINS.md${coverageNote}`
877
+ : kind === 'rebaseline'
878
+ ? `rebaselined ${skill} to ${pin.raw} (v${newVersion}) under the new evaluation identity${coverageNote}`
879
+ : `bumped ${skill} to ${pin.raw} (v${newVersion}); baseline recorded in PINS.md` +
880
+ (corpusSigned ? '' : ' [provisional — corpus unsigned]') + coverageNote,
881
+ };
882
+ } finally {
883
+ await rm(lock.path, { force: true });
884
+ }
885
+ }