phronesis 1.0.0 → 1.1.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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/act.js +62 -1
  3. package/src/adapter.js +366 -61
  4. package/src/cli.js +527 -32
  5. package/src/compile.js +141 -10
  6. package/src/doctor.js +63 -2
  7. package/src/hook.js +312 -0
  8. package/src/hooks-refresh.js +1363 -0
  9. package/src/hooks.js +48 -33
  10. package/src/init.js +37 -4
  11. package/src/launcher.js +2105 -0
  12. package/src/layout.js +51 -7
  13. package/src/profile.js +169 -0
  14. package/src/prompts.js +88 -0
  15. package/src/skills-bump.js +88 -20
  16. package/src/skills.js +103 -10
  17. package/templates/codex/INDEX.md +4 -2
  18. package/templates/codex/seed/ai-practice/the-workspace-is-part-of-the-thinking.md +96 -0
  19. package/templates/codex-surface/README.md +15 -16
  20. package/templates/codex-surface/hooks/_resolve.sh +25 -24
  21. package/templates/codex-surface/hooks/recall-guard.sh +5 -30
  22. package/templates/codex-surface/hooks/session-start.sh +5 -18
  23. package/templates/codex-surface/hooks/session-sweep-precompact.sh +5 -20
  24. package/templates/codex-surface/hooks/session-sweep-subagent.sh +5 -23
  25. package/templates/codex-surface/hooks/session-sweep.sh +5 -19
  26. package/templates/launcher/phronesis +95 -0
  27. package/templates/phronesis-hooks/compile-active-context.sh +12 -38
  28. package/templates/phronesis-hooks/recall-guard.sh +12 -103
  29. package/templates/phronesis-hooks/session-start.sh +10 -88
  30. package/templates/phronesis-hooks/session-sweep.sh +16 -139
  31. package/templates/phronesis-hooks/skill-lifecycle.sh +12 -37
  32. package/templates/skills/lint/SKILL.md +7 -2
  33. package/templates/skills/onboard/SKILL.md +47 -9
  34. package/templates/skills/onboard/evals/rubric.md +5 -3
  35. package/templates/skills/prd-draft/SKILL.md +34 -26
  36. package/templates/skills/prd-draft/evals/rubric.md +1 -1
  37. package/vendor/core/src/action-registry.js +6 -3
  38. package/vendor/core/src/actions.js +120 -16
  39. package/vendor/core/src/index.js +3 -0
  40. package/vendor/core/src/lint.js +22 -5
  41. package/vendor/core/src/skill-lifecycle.js +67 -7
package/src/layout.js CHANGED
@@ -26,13 +26,13 @@ export const DEFAULT_SKILLS = Object.freeze([
26
26
  Object.freeze({ name: 'research', version: '0.1.0' }),
27
27
  Object.freeze({ name: 'recall', version: '0.1.0' }),
28
28
  Object.freeze({ name: 'lint', version: '0.1.0' }),
29
- Object.freeze({ name: 'onboard', version: '0.2.0' }),
29
+ Object.freeze({ name: 'onboard', version: '0.3.0' }),
30
30
  Object.freeze({ name: 'prep-extraction', version: '0.1.0' }),
31
31
  Object.freeze({ name: 'extract-gold-dust', version: '0.1.0' }),
32
32
  Object.freeze({ name: 'meeting-prep', version: '0.1.1' }),
33
33
  Object.freeze({ name: 'stakeholder-update', version: '0.1.0' }),
34
34
  Object.freeze({ name: 'decision-log', version: '0.1.0' }),
35
- Object.freeze({ name: 'prd-draft', version: '0.1.0' }),
35
+ Object.freeze({ name: 'prd-draft', version: '0.2.0' }),
36
36
  ]);
37
37
 
38
38
  export function defaultSkillsSection() {
@@ -324,11 +324,11 @@ export function installationGitignore() {
324
324
  // the Level-2 skill-lifecycle close, PARTIAL on Codex (no invocation_id minted — no
325
325
  // fabricated events, change 0021).
326
326
  export const CODEX_ADAPTER_HOOKS = [
327
- { event: 'SessionStart', launcher: 'session-start.sh', script: 'session-start.sh', role: 'due-check', tier: 'reinforcement' },
328
- { event: 'UserPromptSubmit', launcher: 'recall-guard.sh', script: 'recall-guard.sh', role: 'recall-guard', tier: 'reinforcement' },
329
- { event: 'Stop', launcher: 'session-sweep.sh', script: 'session-sweep.sh', role: 'capture-sweep', tier: 'reinforcement' },
330
- { event: 'SubagentStop', launcher: 'session-sweep-subagent.sh', script: 'session-sweep.sh', role: 'subagent-lifecycle-close', tier: 'lifecycle' },
331
- { event: 'PreCompact', launcher: 'session-sweep-precompact.sh', script: 'session-sweep.sh', role: 'compaction-reminder', tier: 'reinforcement' },
327
+ { event: 'SessionStart', launcher: 'session-start.sh', hookEvent: 'session-start', role: 'due-check', tier: 'reinforcement' },
328
+ { event: 'UserPromptSubmit', launcher: 'recall-guard.sh', hookEvent: 'recall-guard', role: 'recall-guard', tier: 'reinforcement' },
329
+ { event: 'Stop', launcher: 'session-sweep.sh', hookEvent: 'session-sweep', role: 'capture-sweep', tier: 'reinforcement' },
330
+ { event: 'SubagentStop', launcher: 'session-sweep-subagent.sh', hookEvent: 'session-sweep-subagent', role: 'subagent-lifecycle-close', tier: 'lifecycle' },
331
+ { event: 'PreCompact', launcher: 'session-sweep-precompact.sh', hookEvent: 'session-sweep-precompact', role: 'compaction-reminder', tier: 'reinforcement' },
332
332
  ];
333
333
  const CODEX_SURFACE_HOOKS = CODEX_ADAPTER_HOOKS.map((h) => h.launcher);
334
334
  // Every file under codex-surface/hooks/ that init emits: the per-event launchers plus the
@@ -372,6 +372,50 @@ export function templateCopies(slug = DEFAULT_DOMAIN) {
372
372
  ];
373
373
  }
374
374
 
375
+ // The D9 hook-refresh partition is derived from the same copies createInstallation()
376
+ // emits. Keep the selection here, beside templateCopies(), so refresh/upgrade code
377
+ // never grows a second hand-maintained path manifest. The Claude settings file is a
378
+ // Class-C entry: only its Phronesis hook entries are managed. The two hook directories
379
+ // are closed only for reporting purposes — unknown siblings are extra-unknown and are
380
+ // never refresh targets.
381
+ export function hookManifestCopies(slug = DEFAULT_DOMAIN) {
382
+ return templateCopies(slug).flatMap((copy) => {
383
+ if (copy.from === 'claude/settings.json') {
384
+ return [{
385
+ ...copy,
386
+ kind: 'claude-hook-entries',
387
+ shippedTemplate: `templates/${copy.from}#phronesis-hook-entries`,
388
+ scanUnknownSiblings: false,
389
+ }];
390
+ }
391
+ if (copy.from.startsWith('phronesis-hooks/')) {
392
+ return [{
393
+ ...copy,
394
+ kind: 'file',
395
+ shippedTemplate: `templates/${copy.from}`,
396
+ scanUnknownSiblings: true,
397
+ }];
398
+ }
399
+ if (copy.from === 'codex-surface/hooks.json') {
400
+ return [{
401
+ ...copy,
402
+ kind: 'file',
403
+ shippedTemplate: `templates/${copy.from}`,
404
+ scanUnknownSiblings: false,
405
+ }];
406
+ }
407
+ if (copy.from.startsWith('codex-surface/hooks/')) {
408
+ return [{
409
+ ...copy,
410
+ kind: 'file',
411
+ shippedTemplate: `templates/${copy.from}`,
412
+ scanUnknownSiblings: true,
413
+ }];
414
+ }
415
+ return [];
416
+ });
417
+ }
418
+
375
419
  // Default V1 skills copied from repo-root skills/ into operator installs. The
376
420
  // rubric is runtime-referenced by every SKILL.md body; golden cases and dev notes
377
421
  // deliberately stay out of installs.
package/src/profile.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { join } from 'node:path';
11
11
  import { readFile, writeFile } from 'node:fs/promises';
12
+ import { resolveInstall, resolveDomain } from '@phronesis/core';
12
13
 
13
14
  // AGENTS.md §1 is always-loaded and ≤8192 bytes; per-field caps bound the worst case so a
14
15
  // pathological --name can't blow the budget (the budget check in fillOperatorProfile is the backstop).
@@ -114,3 +115,171 @@ export async function fillOperatorProfile(installRoot, domain, profile = {}) {
114
115
  await writeFile(userPath, userNext);
115
116
  if (agentsNext !== null) await writeFile(agentsPath, agentsNext);
116
117
  }
118
+
119
+ // core-0062 D1: in-place, NON-DESTRUCTIVE update of the stamped operator profile — the repair
120
+ // verb for the F2 friction (non-interactive `init` ships `[Name]`/`[Role]` and today's only
121
+ // remedy is a destructive `--force` re-init). Unlike fillOperatorProfile (a one-time placeholder
122
+ // fill), this updates the FIELDS whatever their current value. It touches only the stamped field
123
+ // lines, never surrounding operator prose; if a field line cannot be located UNIQUELY the file
124
+ // has diverged and it FAILS CLOSED with the manual path (refuse, never guess). Both files are
125
+ // computed before either is written (the fillOperatorProfile atomicity lesson).
126
+ function replaceUniqueLine(text, re, value, label, file) {
127
+ const hits = text.match(new RegExp(re.source, 'gm'));
128
+ if (!hits || hits.length !== 1) {
129
+ throw new Error(
130
+ `could not locate a unique "${label}" line in ${file} (found ${hits ? hits.length : 0}) — the file has diverged; edit it by hand.`,
131
+ );
132
+ }
133
+ return text.replace(re, () => value); // function replacer: value is literal (the $-token lesson from fill)
134
+ }
135
+
136
+ export async function updateOperatorProfile(installRoot, domain, profile = {}) {
137
+ const { name, role, company, workingStyle } = profile;
138
+ if (name === undefined && role === undefined && company === undefined && workingStyle === undefined) {
139
+ throw new Error('nothing to update — pass at least one of --name, --role, --company, --working-style.');
140
+ }
141
+
142
+ const userPath = join(installRoot, 'domains', domain, 'USER.md');
143
+ const agentsPath = join(installRoot, 'domains', domain, 'AGENTS.md');
144
+
145
+ let userNext;
146
+ try {
147
+ userNext = await readFile(userPath, 'utf8');
148
+ } catch {
149
+ throw new Error(`${userPath} not found — run this inside an install (\`phronesis init\`).`);
150
+ }
151
+
152
+ // Fail-closed completeness (Codex F3): validate the WHOLE stamped block before any change, not
153
+ // just the fields being set. Required anchors must each appear exactly once (so a later
154
+ // first-match read of an UNMODIFIED field, or the optional-append `- Role:` anchor, can never
155
+ // silently pick the wrong one); optional lines at most once. A diverged file is refused whole.
156
+ for (const [label, re] of [
157
+ ['# USER —', /^# USER — .*$/gm],
158
+ ['- Name:', /^- Name: .*$/gm],
159
+ ['- Role:', /^- Role: .*$/gm],
160
+ ]) {
161
+ const n = (userNext.match(re) || []).length;
162
+ if (n !== 1) {
163
+ throw new Error(`expected exactly one "${label}" line in ${userPath}, found ${n} — the file has diverged; edit it by hand.`);
164
+ }
165
+ }
166
+ for (const [label, re] of [['- Company:', /^- Company: .*$/gm], ['- Working style:', /^- Working style: .*$/gm]]) {
167
+ const n = (userNext.match(re) || []).length;
168
+ if (n > 1) {
169
+ throw new Error(`expected at most one "${label}" line in ${userPath}, found ${n} — the file has diverged; edit it by hand.`);
170
+ }
171
+ }
172
+
173
+ if (name !== undefined) {
174
+ userNext = replaceUniqueLine(userNext, /^# USER — .*$/m, `# USER — ${name}`, '# USER —', userPath);
175
+ userNext = replaceUniqueLine(userNext, /^- Name: .*$/m, `- Name: ${name}`, '- Name:', userPath);
176
+ }
177
+ if (role !== undefined) {
178
+ userNext = replaceUniqueLine(userNext, /^- Role: .*$/m, `- Role: ${role}`, '- Role:', userPath);
179
+ }
180
+ // company / working-style are USER.md-only (mirror init): update the line in place if present,
181
+ // else append after the "- Role:" line (init's insertion point). No new fields beyond init's set.
182
+ for (const [prefix, value] of [['- Company:', company], ['- Working style:', workingStyle]]) {
183
+ if (value === undefined) continue;
184
+ const lineRe = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} .*$`, 'm');
185
+ if (lineRe.test(userNext)) {
186
+ userNext = replaceUniqueLine(userNext, lineRe, `${prefix} ${value}`, prefix, userPath);
187
+ } else {
188
+ const withLine = userNext.replace(/^(- Role:.*)$/m, (m) => `${m}\n${prefix} ${value}`);
189
+ if (withLine === userNext) {
190
+ throw new Error(`the "- Role:" anchor to add a "${prefix}" line is missing in ${userPath} — edit it by hand.`);
191
+ }
192
+ userNext = withLine;
193
+ }
194
+ }
195
+
196
+ // AGENTS.md §1 identity line — only when name/role changed (company/working-style are USER.md-only).
197
+ // Reconstruct the always-loaded line from USER.md's NOW-current fields (the source of truth), so
198
+ // an unchanged field is preserved without parsing the prose line. Then budget-check (≤8192).
199
+ let agentsNext = null;
200
+ if (name !== undefined || role !== undefined) {
201
+ let agents;
202
+ try {
203
+ agents = await readFile(agentsPath, 'utf8');
204
+ } catch {
205
+ throw new Error(`${agentsPath} not found — cannot update the always-loaded operator line.`);
206
+ }
207
+ const effName = (userNext.match(/^- Name: (.*)$/m) || [])[1];
208
+ const effRole = (userNext.match(/^- Role: (.*)$/m) || [])[1];
209
+ if (effName === undefined || effRole === undefined) {
210
+ throw new Error(`USER.md is missing a "- Name:"/"- Role:" line to derive the AGENTS.md operator line in ${agentsPath} — edit by hand.`);
211
+ }
212
+ const newLine = `${effName}, ${effRole}. Profile + preferences: \`USER.md\`.`;
213
+ // Fail-closed (Codex F2): require EXACTLY one "## 1. Operator" section, and that the line we
214
+ // would rewrite actually IS the identity line — it references `USER.md`. A note or comment an
215
+ // operator inserted right under the heading must not be silently overwritten.
216
+ const headingCount = (agents.match(/^##\s*1\.\s*Operator[ \t]*$/gm) || []).length;
217
+ if (headingCount !== 1) {
218
+ throw new Error(`expected exactly one "## 1. Operator" section in ${agentsPath}, found ${headingCount} — the file has diverged; edit it by hand.`);
219
+ }
220
+ const sectionRe = /(^##\s*1\.\s*Operator[ \t]*\n\n)([^\n]+)(\n)/m;
221
+ const sm = agents.match(sectionRe);
222
+ if (!sm || !/USER\.md/.test(sm[2])) {
223
+ throw new Error(`the "## 1. Operator" line in ${agentsPath} is not the stamped operator line (expected one referencing \`USER.md\`) — edit it by hand.`);
224
+ }
225
+ agentsNext = agents.replace(sectionRe, (full, pre, _line, post) => `${pre}${newLine}${post}`);
226
+ const byteLen = Buffer.byteLength(agentsNext, 'utf8');
227
+ if (byteLen > AGENTS_BUDGET) {
228
+ throw new Error(`update would push AGENTS.md to ${byteLen} bytes, over the ${AGENTS_BUDGET}-byte always-loaded budget — shorten --name/--role.`);
229
+ }
230
+ }
231
+
232
+ await writeFile(userPath, userNext);
233
+ if (agentsNext !== null) await writeFile(agentsPath, agentsNext);
234
+ }
235
+
236
+ // `phronesis profile set` — the CLI surface over updateOperatorProfile. Resolves the install +
237
+ // domain (through the shared resolver, so a single-domain install works and a multi-domain one is
238
+ // refused, never guessed), sanitizes with init's exact field caps, fails closed with the manual
239
+ // path, and never touches an existing install without a value (no --force anywhere in the flow).
240
+ export async function runProfileSet({ dir, domain, name, role, company, workingStyle } = {}) {
241
+ const installed = resolveInstall({ dir, cwd: process.cwd() });
242
+ if (installed.error) {
243
+ console.error(installed.error);
244
+ process.exitCode = 1;
245
+ return null;
246
+ }
247
+ const installRoot = installed.root;
248
+
249
+ // Domain resolution rides the ONE shared resolver — no profile-specific copy (the "one
250
+ // resolver, every verb inherits" rule). Since core-0061 that resolver defaults to the sole
251
+ // registered domain (whatever its slug) and refuses an ambiguous multi-domain install, so
252
+ // `profile set` inherits both behaviors for free.
253
+ let resolvedDomain;
254
+ try {
255
+ const registry = JSON.parse(await readFile(join(installRoot, '.phronesis', 'registry.json'), 'utf8'));
256
+ resolvedDomain = resolveDomain({ installRoot, registry, domain, cwd: process.cwd() });
257
+ } catch (err) {
258
+ console.error(`profile set: ${err.message}`);
259
+ process.exitCode = 1;
260
+ return null;
261
+ }
262
+
263
+ let profile;
264
+ try {
265
+ profile = {
266
+ name: sanitizeProfileField(name, FIELD_MAX.name),
267
+ role: sanitizeProfileField(role, FIELD_MAX.role),
268
+ company: sanitizeProfileField(company, FIELD_MAX.company),
269
+ workingStyle: sanitizeProfileField(workingStyle, FIELD_MAX.workingStyle),
270
+ };
271
+ await updateOperatorProfile(installRoot, resolvedDomain, profile);
272
+ } catch (err) {
273
+ console.error(`profile set: ${err.message}`);
274
+ process.exitCode = 1;
275
+ return null;
276
+ }
277
+
278
+ process.exitCode = 0;
279
+ const updated = Object.entries(profile).filter(([, v]) => v !== undefined).map(([k]) => k);
280
+ const touchesAgents = profile.name !== undefined || profile.role !== undefined;
281
+ console.log(
282
+ `profile set: updated ${updated.join(', ')} in domains/${resolvedDomain}/USER.md${touchesAgents ? ' + AGENTS.md' : ''}.`,
283
+ );
284
+ return { domain: resolvedDomain, updated };
285
+ }
package/src/prompts.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import Enquirer from 'enquirer';
2
2
 
3
+ export const PERSISTENCE_PROMPT = 'Install the `phronesis` command so automatic hooks work? [Y/n]';
4
+
3
5
  // Confirm replacing a non-empty target. Interactive surfaces only; non-TTY
4
6
  // callers (CI, the fitness probe) fail closed and require --force instead.
5
7
  export async function confirmOverwrite(targetDir) {
@@ -24,3 +26,89 @@ export async function promptProfile({ name, role } = {}) {
24
26
  const enquirer = new Enquirer();
25
27
  return enquirer.prompt(questions);
26
28
  }
29
+
30
+ export function persistencePromptQuestion({ message = PERSISTENCE_PROMPT } = {}) {
31
+ return yesNoPromptQuestion({ name: 'persist', message, initial: 'y' });
32
+ }
33
+
34
+ export function yesNoPromptQuestion({ name = 'confirmed', message, initial = 'n' } = {}) {
35
+ return {
36
+ type: 'input',
37
+ name,
38
+ message,
39
+ initial,
40
+ format(value) {
41
+ return String(value ?? '').toLowerCase();
42
+ },
43
+ validate(value) {
44
+ return /^(?:y|yes|n|no)$/i.test(String(value ?? '').trim()) || 'Enter y or n.';
45
+ },
46
+ result(value) {
47
+ return !/^n/i.test(String(value ?? '').trim());
48
+ },
49
+ };
50
+ }
51
+
52
+ // D4 persistence consent. The caller owns the TTY gate. The ratified string is
53
+ // the actual Enquirer message; input formatting and result coercion use Enquirer's
54
+ // supported prompt seams, so the visible [Y/n] text is neither inferred nor dropped.
55
+ export async function confirmPersistence({ message = PERSISTENCE_PROMPT } = {}) {
56
+ const enquirer = new Enquirer();
57
+ const { persist } = await enquirer.prompt(persistencePromptQuestion({ message }));
58
+ return persist;
59
+ }
60
+
61
+ async function confirmYesNo(message) {
62
+ const enquirer = new Enquirer();
63
+ const { confirmed } = await enquirer.prompt(yesNoPromptQuestion({ message, initial: 'n' }));
64
+ return confirmed;
65
+ }
66
+
67
+ export function hookRestampPromptMessage(entry) {
68
+ if (entry.status === 'current' && entry.disposition === 'unstamped') {
69
+ return `UNSTAMPED CURRENT: record hook provenance for ${entry.path} without rewriting its bytes? [y/N]`;
70
+ }
71
+ if (entry.disposition === 'operator-modified') {
72
+ return `OPERATOR-MODIFIED: re-stamp only ${entry.path} from ${entry.shippedTemplate}, discarding its reviewed hook-file edit? [y/N]`;
73
+ }
74
+ if (entry.disposition === 'unstamped') {
75
+ return `UNSTAMPED: re-stamp only ${entry.path} from ${entry.shippedTemplate}? [y/N]`;
76
+ }
77
+ return `Re-stamp ${entry.path} from ${entry.shippedTemplate}? [y/N]`;
78
+ }
79
+
80
+ // D9 consent has two distinct authorities: the all-files offer covers only
81
+ // stamped, safely-attributable engine updates; every unstamped/conflicted path
82
+ // requires its own visibly dispositioned confirmation. Default no is fail-closed.
83
+ export async function confirmHookRestamp({ entries = [], confirm = confirmYesNo } = {}) {
84
+ if (!entries.length) return { blanket: [], targeted: [] };
85
+ const safe = entries.filter(({ disposition }) => disposition === 'safe');
86
+ const blanket = [];
87
+ const targeted = [];
88
+ let allSafe = false;
89
+ if (safe.length) {
90
+ allSafe = await confirm(
91
+ `Re-stamp all ${safe.length} safely attributable Phronesis hook paths from current shipped templates? [y/N]`,
92
+ );
93
+ if (allSafe) blanket.push(...safe.map(({ path }) => path));
94
+ }
95
+
96
+ for (const entry of entries) {
97
+ if (allSafe && entry.disposition === 'safe') continue;
98
+ const accepted = await confirm(hookRestampPromptMessage(entry));
99
+ if (accepted) targeted.push(entry.path);
100
+ }
101
+ return { blanket, targeted };
102
+ }
103
+
104
+ export async function confirmPathAppend({ profilePath } = {}) {
105
+ const enquirer = new Enquirer();
106
+ const { append } = await enquirer.prompt({
107
+ type: 'confirm',
108
+ name: 'append',
109
+ message: `Append the disclosed PATH line to ${profilePath}?`,
110
+ initial: true,
111
+ default: '[Y/n]',
112
+ });
113
+ return append;
114
+ }
@@ -179,8 +179,42 @@ export function aggregateFloor(perRunResults, {
179
179
 
180
180
  // Quality — collect per-criterion score vectors across every run.
181
181
  const perCriterion = new Map();
182
+ const applicabilityDeclared = runs.some((run) => Array.isArray(run?.rubric_criteria_exercised));
183
+ if (applicabilityDeclared && runs.some((run) => !Array.isArray(run?.rubric_criteria_exercised))) {
184
+ throw new Error('inconsistent rubric applicability: every run must declare rubric_criteria_exercised when any run does');
185
+ }
186
+ const keyByNumber = new Map();
182
187
  for (const run of runs) {
183
188
  const scores = extractCriterionScores(run.per_run_scores);
189
+ if (applicabilityDeclared) {
190
+ const declared = run.rubric_criteria_exercised.map(Number).sort((a, b) => a - b);
191
+ if (
192
+ declared.length === 0 ||
193
+ declared.some((n) => !Number.isInteger(n) || n < 1) ||
194
+ new Set(declared).size !== declared.length
195
+ ) {
196
+ throw new Error('inconsistent rubric applicability: declarations must be unique positive integers');
197
+ }
198
+ const scored = Object.keys(scores).map((key) => {
199
+ // House format is c{n}_{slug} (core-0066 item 7): require the underscore so this
200
+ // agrees with judge.py select_rubric_criteria (which matches `c(\d+)_`). A bare
201
+ // `cN` is rejected in BOTH surfaces rather than silently accepted on one.
202
+ const match = /^c(\d+)_/.exec(key);
203
+ if (!match) throw new Error(`criterion "${key}" has no numbered cN_ prefix`);
204
+ const number = Number(match[1]);
205
+ const prior = keyByNumber.get(number);
206
+ if (prior && prior !== key) {
207
+ throw new Error(`criterion ${number} changed key across runs (${prior} vs ${key})`);
208
+ }
209
+ keyByNumber.set(number, key);
210
+ return number;
211
+ }).sort((a, b) => a - b);
212
+ if (JSON.stringify(scored) !== JSON.stringify(declared)) {
213
+ throw new Error(
214
+ `inconsistent rubric scoring: declared criteria [${declared.join(', ')}] but judge scored [${scored.join(', ')}]`,
215
+ );
216
+ }
217
+ }
184
218
  for (const [crit, score] of Object.entries(scores)) {
185
219
  if (!perCriterion.has(crit)) perCriterion.set(crit, []);
186
220
  perCriterion.get(crit).push(score);
@@ -191,7 +225,7 @@ export function aggregateFloor(perRunResults, {
191
225
  const criteria = {};
192
226
  let qualityPasses = true;
193
227
  for (const [crit, vec] of perCriterion) {
194
- if (vec.length !== runs.length) {
228
+ if (!applicabilityDeclared && vec.length !== runs.length) {
195
229
  throw new Error(
196
230
  `criterion "${crit}" scored in ${vec.length}/${runs.length} runs — inconsistent rubric scoring`,
197
231
  );
@@ -199,7 +233,13 @@ export function aggregateFloor(perRunResults, {
199
233
  const min = Math.min(...vec);
200
234
  const passes = min >= qualityFloor;
201
235
  if (!passes) qualityPasses = false;
202
- criteria[crit] = { min, max: Math.max(...vec), scores: vec, passes };
236
+ criteria[crit] = {
237
+ min,
238
+ max: Math.max(...vec),
239
+ scores: vec,
240
+ applicable_runs: vec.length,
241
+ passes,
242
+ };
203
243
  }
204
244
 
205
245
  // Coverage — per-run hit-rate + fabrication tally. `asserted` is false when NO run
@@ -548,6 +588,7 @@ export async function bumpSkill(opts, deps) {
548
588
  k = DEFAULT_K,
549
589
  rebaseline = false,
550
590
  acceptRegression = false,
591
+ verifyCurrent = false,
551
592
  shapeChange = false,
552
593
  qualityFloor = DEFAULT_QUALITY_FLOOR,
553
594
  coverageFloor = DEFAULT_COVERAGE_FLOOR,
@@ -615,6 +656,20 @@ export async function bumpSkill(opts, deps) {
615
656
  const source = await readFile(skillPath, 'utf8');
616
657
  const currentVersion = readFrontmatterField(source, 'version');
617
658
  const currentPin = readFrontmatterField(source, 'prompt_pin');
659
+ if (verifyCurrent) {
660
+ if (rebaseline || acceptRegression || shapeChange) {
661
+ return {
662
+ status: 'invalid',
663
+ error: '--verify-current cannot be combined with --rebaseline, --accept-regression, or --shape-change',
664
+ };
665
+ }
666
+ if (pin.raw !== currentPin) {
667
+ return {
668
+ status: 'invalid',
669
+ error: `--verify-current requires --to to equal the deployed prompt_pin (${currentPin}); got ${pin.raw}`,
670
+ };
671
+ }
672
+ }
618
673
 
619
674
  const rubricPath = join(skillDir, 'evals', 'rubric.md');
620
675
  if (!existsSync(rubricPath)) {
@@ -653,11 +708,17 @@ export async function bumpSkill(opts, deps) {
653
708
  const expectedIds = expectedGoldenIds(goldens);
654
709
  const perRun = [];
655
710
  for (let i = 0; i < k; i += 1) {
711
+ // A gate run uses one logical evaluation instant, but raw evidence files must be
712
+ // collision-proof even when two repeats produce byte-identical results. Offset the
713
+ // evidence timestamp by one millisecond per repeat without changing model context.
714
+ const evidenceNow = now instanceof Date ? new Date(now.getTime() + i) : now;
656
715
  const results = await runCandidates({
657
716
  goldens,
658
717
  skill: parsedSkill,
659
718
  promptBody,
660
719
  modelId: pin.modelId,
720
+ promptPin: pin.raw,
721
+ writeResults: true,
661
722
  judge,
662
723
  // Pass the judge model AND the exact rubric bytes the identity was hashed from, so
663
724
  // "the scores were produced under THIS judge + THIS rubric" is an enforced invariant,
@@ -665,7 +726,7 @@ export async function bumpSkill(opts, deps) {
665
726
  // default judge.py to sonnet and re-read the rubric from its own repo root).
666
727
  judgeOptions: { model: judgeModel, rubricText: rubric },
667
728
  activeDomain,
668
- now,
729
+ now: evidenceNow,
669
730
  });
670
731
  // Verify the substrate returned EXACTLY one result per golden — no missing, duplicate,
671
732
  // or extraneous golden id. A partial substrate failure that silently dropped a hard
@@ -813,7 +874,7 @@ export async function bumpSkill(opts, deps) {
813
874
  }
814
875
 
815
876
  // --- Pass / override / rebaseline: write pin + version + PINS record -------
816
- const newVersion = bumpVersion(currentVersion, component);
877
+ const newVersion = verifyCurrent ? currentVersion : bumpVersion(currentVersion, component);
817
878
  const kind = mode === 'rebaseline' ? 'rebaseline' : verdict.status === 'regression' ? 'override' : 'baseline';
818
879
 
819
880
  const record = {
@@ -824,6 +885,7 @@ export async function bumpSkill(opts, deps) {
824
885
  accepted_at: (now || new Date()).toISOString(),
825
886
  scores,
826
887
  identity: newIdentity,
888
+ ...(verifyCurrent ? { verified_current: true } : {}),
827
889
  };
828
890
  if (kind === 'override') {
829
891
  record.from_pin = currentPin;
@@ -842,21 +904,25 @@ export async function bumpSkill(opts, deps) {
842
904
  // same body and re-passes) rather than a pinned skill with no baseline (which the next
843
905
  // bump would mistake for a first bump and re-seed without a regression check — a
844
906
  // silent-regression hole).
845
- const newSource = rewriteSkillFrontmatter(source, { pin: pin.raw, version: newVersion });
907
+ const newSource = verifyCurrent
908
+ ? source
909
+ : rewriteSkillFrontmatter(source, { pin: pin.raw, version: newVersion });
846
910
  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
- };
911
+ if (!verifyCurrent) {
912
+ try {
913
+ await atomicWrite(skillPath, newSource);
914
+ } catch (err) {
915
+ return {
916
+ ...base,
917
+ status: 'error',
918
+ reason: 'pin-write-failed',
919
+ record,
920
+ written: { pins: pinsPath },
921
+ message:
922
+ `the PINS.md baseline for ${pin.raw} was recorded, but writing the pin to ${skillPath} failed ` +
923
+ `(${err.message}). Re-run the bump to complete it — the skill body is unchanged, so it will re-pass.`,
924
+ };
925
+ }
860
926
  }
861
927
 
862
928
  const coverageNote = !aggregate.coverage.asserted
@@ -870,9 +936,11 @@ export async function bumpSkill(opts, deps) {
870
936
  kind,
871
937
  version: newVersion,
872
938
  record,
873
- written: { skill: skillPath, pins: pinsPath },
939
+ written: verifyCurrent ? { pins: pinsPath } : { skill: skillPath, pins: pinsPath },
874
940
  message:
875
- kind === 'override'
941
+ verifyCurrent
942
+ ? `verified ${skill} at its current deployment unit ${pin.raw} (v${newVersion}); baseline recorded in PINS.md${coverageNote}`
943
+ : kind === 'override'
876
944
  ? `bumped ${skill} to ${pin.raw} (v${newVersion}) — REGRESSION ACCEPTED; override recorded in PINS.md${coverageNote}`
877
945
  : kind === 'rebaseline'
878
946
  ? `rebaselined ${skill} to ${pin.raw} (v${newVersion}) under the new evaluation identity${coverageNote}`