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.
- package/package.json +1 -1
- package/src/act.js +62 -1
- package/src/adapter.js +366 -61
- package/src/cli.js +527 -32
- package/src/compile.js +141 -10
- package/src/doctor.js +63 -2
- package/src/hook.js +312 -0
- package/src/hooks-refresh.js +1363 -0
- package/src/hooks.js +48 -33
- package/src/init.js +37 -4
- package/src/launcher.js +2105 -0
- package/src/layout.js +51 -7
- package/src/profile.js +169 -0
- package/src/prompts.js +88 -0
- package/src/skills-bump.js +88 -20
- package/src/skills.js +103 -10
- package/templates/codex/INDEX.md +4 -2
- package/templates/codex/seed/ai-practice/the-workspace-is-part-of-the-thinking.md +96 -0
- package/templates/codex-surface/README.md +15 -16
- package/templates/codex-surface/hooks/_resolve.sh +25 -24
- package/templates/codex-surface/hooks/recall-guard.sh +5 -30
- package/templates/codex-surface/hooks/session-start.sh +5 -18
- package/templates/codex-surface/hooks/session-sweep-precompact.sh +5 -20
- package/templates/codex-surface/hooks/session-sweep-subagent.sh +5 -23
- package/templates/codex-surface/hooks/session-sweep.sh +5 -19
- package/templates/launcher/phronesis +95 -0
- package/templates/phronesis-hooks/compile-active-context.sh +12 -38
- package/templates/phronesis-hooks/recall-guard.sh +12 -103
- package/templates/phronesis-hooks/session-start.sh +10 -88
- package/templates/phronesis-hooks/session-sweep.sh +16 -139
- package/templates/phronesis-hooks/skill-lifecycle.sh +12 -37
- package/templates/skills/lint/SKILL.md +7 -2
- package/templates/skills/onboard/SKILL.md +47 -9
- package/templates/skills/onboard/evals/rubric.md +5 -3
- package/templates/skills/prd-draft/SKILL.md +34 -26
- package/templates/skills/prd-draft/evals/rubric.md +1 -1
- package/vendor/core/src/action-registry.js +6 -3
- package/vendor/core/src/actions.js +120 -16
- package/vendor/core/src/index.js +3 -0
- package/vendor/core/src/lint.js +22 -5
- 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.
|
|
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.
|
|
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',
|
|
328
|
-
{ event: 'UserPromptSubmit', launcher: 'recall-guard.sh',
|
|
329
|
-
{ event: 'Stop', launcher: 'session-sweep.sh',
|
|
330
|
-
{ event: 'SubagentStop', launcher: 'session-sweep-subagent.sh',
|
|
331
|
-
{ event: 'PreCompact', launcher: 'session-sweep-precompact.sh',
|
|
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
|
+
}
|
package/src/skills-bump.js
CHANGED
|
@@ -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] = {
|
|
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 =
|
|
907
|
+
const newSource = verifyCurrent
|
|
908
|
+
? source
|
|
909
|
+
: rewriteSkillFrontmatter(source, { pin: pin.raw, version: newVersion });
|
|
846
910
|
await atomicWrite(pinsPath, appendPinsRecord(pinsText, record));
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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}`
|