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/compile.js
CHANGED
|
@@ -15,22 +15,45 @@ import { existsSync } from 'node:fs';
|
|
|
15
15
|
import { readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
16
16
|
import fse from 'fs-extra';
|
|
17
17
|
import {
|
|
18
|
-
DEFAULT_DOMAIN,
|
|
19
18
|
compileAccept,
|
|
20
19
|
compileReject,
|
|
21
20
|
digestFile,
|
|
22
21
|
readEvents,
|
|
22
|
+
resolveDomain,
|
|
23
23
|
resolveInstall,
|
|
24
24
|
resumeCompileAccept,
|
|
25
25
|
} from '@phronesis/core';
|
|
26
26
|
|
|
27
|
-
export async function runCompileAccept({ draftPath, session, surface, dir } = {}) {
|
|
27
|
+
export async function runCompileAccept({ draftPath, session, surface, dir, reviewedDigest } = {}) {
|
|
28
28
|
if (!draftPath) {
|
|
29
29
|
console.error('compile accept: name the <draft_path> to promote (a .phronesis/drafts/… candidate).');
|
|
30
30
|
process.exitCode = 1;
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
33
|
try {
|
|
34
|
+
// --reviewed-digest (Fable F1b): re-hash the draft IN THIS CHILD, immediately before compileAccept,
|
|
35
|
+
// and refuse if the current bytes differ from the bytes the operator reviewed — closing the
|
|
36
|
+
// engine→child spawn-window byte race at the true promotion boundary. Backward-compatible: absent →
|
|
37
|
+
// no check (a bare `compile accept` is unchanged).
|
|
38
|
+
if (typeof reviewedDigest === 'string' && reviewedDigest !== '') {
|
|
39
|
+
const installed = resolveInstall({ dir, cwd: process.cwd() });
|
|
40
|
+
if (installed.error) {
|
|
41
|
+
console.error(installed.error);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
let current = null;
|
|
46
|
+
try {
|
|
47
|
+
current = await digestFile(join(installed.root, draftPath));
|
|
48
|
+
} catch {
|
|
49
|
+
current = null;
|
|
50
|
+
}
|
|
51
|
+
if (current === null || current !== reviewedDigest) {
|
|
52
|
+
console.error('compile accept: this draft changed since it was reviewed (reviewed content_digest mismatch) — not promoted; re-review.');
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
34
57
|
// actor is NOT threaded — accept events are operator-driven by contract (the core
|
|
35
58
|
// hard-sets it). The approval signal still travels on PHRONESIS_APPROVAL_SOURCE.
|
|
36
59
|
const result = await compileAccept({
|
|
@@ -207,13 +230,35 @@ function remainingDrafts(journal) {
|
|
|
207
230
|
return journal.planned.filter((d) => !journal.cursors[d]?.accepted?.done);
|
|
208
231
|
}
|
|
209
232
|
|
|
233
|
+
// The reviewed-manifest digest binding (Codex R5 F1): true iff the draft's CURRENT bytes still equal
|
|
234
|
+
// the operator-reviewed digest persisted in the journal. Enforced at EVERY accept — the initial loop
|
|
235
|
+
// AND resume — so a re-draft after the manifest check (or across a crash) can never promote unreviewed
|
|
236
|
+
// bytes. Backward-compatible: a journal with no reviewed digest for this path (plain approve-all) → true.
|
|
237
|
+
async function reviewedDigestOk(installRoot, journal, draftRel) {
|
|
238
|
+
const reviewed = journal.reviewed_digests?.[draftRel];
|
|
239
|
+
if (reviewed === undefined) return true;
|
|
240
|
+
let current = null;
|
|
241
|
+
try {
|
|
242
|
+
current = await digestFile(join(installRoot, draftRel));
|
|
243
|
+
} catch {
|
|
244
|
+
current = null;
|
|
245
|
+
}
|
|
246
|
+
return current !== null && current === reviewed;
|
|
247
|
+
}
|
|
248
|
+
|
|
210
249
|
export async function runCompileReview({
|
|
211
|
-
domain
|
|
250
|
+
domain,
|
|
212
251
|
dir,
|
|
213
252
|
session,
|
|
214
253
|
surface = 'cli',
|
|
215
254
|
rationale,
|
|
216
255
|
approveAll = false,
|
|
256
|
+
// --approve-only <comma-list|array>: bind approve-all to a caller-reviewed SUBSET of the batch-
|
|
257
|
+
// eligible drafts (the drafts an operator actually saw + confirmed), so a draft that becomes
|
|
258
|
+
// eligible AFTER the review but before promotion is NOT swept in. This is a subset FILTER on which
|
|
259
|
+
// eligible drafts promote — it does NOT change batch-eligibility (digest-match + suspicious-excluded
|
|
260
|
+
// stay exactly as classifyDrafts/security.md define them). Absent → promote all eligible (unchanged).
|
|
261
|
+
approveOnly,
|
|
217
262
|
resume = false,
|
|
218
263
|
json = false,
|
|
219
264
|
now,
|
|
@@ -233,16 +278,84 @@ export async function runCompileReview({
|
|
|
233
278
|
|
|
234
279
|
if (resume) return resumeReview({ installRoot, session, surface, json, now });
|
|
235
280
|
|
|
236
|
-
|
|
237
|
-
|
|
281
|
+
// core-0061 D4: review is a domain-scoped verb; resolve through the shared resolver so a
|
|
282
|
+
// single-domain install (whatever its slug) reviews the right drafts and a multi-domain
|
|
283
|
+
// install refuses rather than guessing. No --domain + cwd not inside a domain → sole
|
|
284
|
+
// registered domain wins; several registered → refuse and list.
|
|
285
|
+
let resolvedDomain;
|
|
286
|
+
try {
|
|
287
|
+
const registry = JSON.parse(await readFile(join(installRoot, '.phronesis', 'registry.json'), 'utf8'));
|
|
288
|
+
resolvedDomain = resolveDomain({ installRoot, registry, domain, cwd: process.cwd() });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
console.error(`compile review: ${err.message}`);
|
|
291
|
+
process.exitCode = 1;
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const draftRels = await listPendingDrafts(installRoot, resolvedDomain);
|
|
296
|
+
const classified = await classifyDrafts(installRoot, draftRels);
|
|
297
|
+
let eligible = classified.eligible;
|
|
298
|
+
const { excluded } = classified;
|
|
238
299
|
|
|
239
300
|
if (!approveAll) {
|
|
240
|
-
|
|
301
|
+
// The plan surfaces the FULL eligible set (the caller reviews it); --approve-only applies only to
|
|
302
|
+
// the approve-all promotion below.
|
|
303
|
+
const plan = { domain: resolvedDomain, batch_eligible: eligible, individual_review: excluded };
|
|
241
304
|
if (json) console.log(JSON.stringify(plan, null, 2));
|
|
242
305
|
else printPlan(plan);
|
|
243
306
|
return plan;
|
|
244
307
|
}
|
|
245
308
|
|
|
309
|
+
// Bind the promotion to the caller's reviewed subset (never a super-set of what they confirmed). Each
|
|
310
|
+
// --approve-only entry is a path OR a `path=digest` manifest entry:
|
|
311
|
+
// - path binding: an eligible draft NOT in the reviewed set is skipped (reported in not_reviewed),
|
|
312
|
+
// never swept in.
|
|
313
|
+
// - digest binding (Codex R4 F1): a manifest digest must equal the draft's CURRENT bytes, so a
|
|
314
|
+
// same-path RE-DRAFT (new bytes + a fresh matching candidate — which classifyDrafts still calls
|
|
315
|
+
// eligible) is dropped to digest_mismatch and never promoted. Fail-closed: unreadable / missing /
|
|
316
|
+
// mismatched digest excludes it. This binds promoted BYTES to the bytes the operator reviewed.
|
|
317
|
+
// A path in --approve-only that is no longer classifyDrafts-eligible (digest-vs-candidate / suspicious
|
|
318
|
+
// / gone) already sits in `excluded`.
|
|
319
|
+
let notReviewed = [];
|
|
320
|
+
let digestMismatch = [];
|
|
321
|
+
// The operator-reviewed digest per path (from a `path=digest` manifest). Hoisted so it can be
|
|
322
|
+
// PERSISTED in the journal and re-enforced at every accept, incl. across a crash/resume (Codex R5 F1).
|
|
323
|
+
const digestByPath = new Map();
|
|
324
|
+
if (approveOnly !== undefined) {
|
|
325
|
+
const only = new Set();
|
|
326
|
+
for (const entry of (Array.isArray(approveOnly) ? approveOnly : String(approveOnly).split(',')).map((s) => String(s).trim()).filter(Boolean)) {
|
|
327
|
+
// path or path=digest. Paths contain '/', digests are hex — split on the LAST '='.
|
|
328
|
+
const eq = entry.lastIndexOf('=');
|
|
329
|
+
if (eq > 0) {
|
|
330
|
+
only.add(entry.slice(0, eq));
|
|
331
|
+
digestByPath.set(entry.slice(0, eq), entry.slice(eq + 1));
|
|
332
|
+
} else {
|
|
333
|
+
only.add(entry);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
notReviewed = eligible.filter((d) => !only.has(d));
|
|
337
|
+
eligible = eligible.filter((d) => only.has(d));
|
|
338
|
+
if (digestByPath.size > 0) {
|
|
339
|
+
const kept = [];
|
|
340
|
+
for (const draftRel of eligible) {
|
|
341
|
+
const expected = digestByPath.get(draftRel);
|
|
342
|
+
if (expected === undefined) {
|
|
343
|
+
kept.push(draftRel); // a path-only entry (no byte binding requested)
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
let current = null;
|
|
347
|
+
try {
|
|
348
|
+
current = await digestFile(join(installRoot, draftRel));
|
|
349
|
+
} catch {
|
|
350
|
+
current = null;
|
|
351
|
+
}
|
|
352
|
+
if (current !== null && current === expected) kept.push(draftRel);
|
|
353
|
+
else digestMismatch.push({ draftRel, reason: 'draft bytes changed since the operator reviewed them (reviewed-manifest content_digest mismatch) — re-review' });
|
|
354
|
+
}
|
|
355
|
+
eligible = kept;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
246
359
|
// Never overwrite an existing batch journal with a fresh approve-all. A COMPLETED batch clears
|
|
247
360
|
// its journal (below), so a journal present here means a prior batch crashed/failed mid-way and
|
|
248
361
|
// still holds the ONLY recovery receipt for its half-moved drafts (their intent + status
|
|
@@ -280,9 +393,9 @@ export async function runCompileReview({
|
|
|
280
393
|
return null;
|
|
281
394
|
}
|
|
282
395
|
if (!eligible.length) {
|
|
283
|
-
const out = { batch_id: null, accepted: [], excluded };
|
|
396
|
+
const out = { batch_id: null, accepted: [], excluded, not_reviewed: notReviewed, digest_mismatch: digestMismatch };
|
|
284
397
|
if (json) console.log(JSON.stringify(out, null, 2));
|
|
285
|
-
else console.log(`compile review: no batch-eligible drafts under ${
|
|
398
|
+
else console.log(`compile review: no batch-eligible drafts under ${resolvedDomain} in the reviewed set (${excluded.length} individual review; ${notReviewed.length} not reviewed; ${digestMismatch.length} reviewed-bytes changed).`);
|
|
286
399
|
return out;
|
|
287
400
|
}
|
|
288
401
|
|
|
@@ -291,7 +404,11 @@ export async function runCompileReview({
|
|
|
291
404
|
// Record the batch's start time (created_ts) so resume's out-of-band check can tell THIS batch's
|
|
292
405
|
// terminal events from a STALE prior review of the same — REUSED — draft path (Codex round-3 F2;
|
|
293
406
|
// the resumable-mutation rule: reconcile by recorded identity, never a non-unique key like a path).
|
|
294
|
-
|
|
407
|
+
// Persist the operator-reviewed digest for each planned draft, so the binding survives a crash and
|
|
408
|
+
// is re-enforced on resume (Codex R5 F1). Empty for a plain approve-all (backward-compatible).
|
|
409
|
+
const reviewed_digests = {};
|
|
410
|
+
for (const p of eligible) if (digestByPath.has(p)) reviewed_digests[p] = digestByPath.get(p);
|
|
411
|
+
const journal = { batch_id: batchId, rationale, domain: resolvedDomain, session, surface, created_ts: (now || new Date()).toISOString(), planned: [...eligible], reviewed_digests, cursors: {} };
|
|
295
412
|
await writeJournal(installRoot, journal);
|
|
296
413
|
|
|
297
414
|
const accepted = [];
|
|
@@ -310,6 +427,13 @@ export async function runCompileReview({
|
|
|
310
427
|
failed.push({ draftRel, error: `draft changed since classification — not promoted (${reason})` });
|
|
311
428
|
continue;
|
|
312
429
|
}
|
|
430
|
+
// Reviewed-digest binding, re-enforced immediately before THIS accept (Codex R5 F1): a draft
|
|
431
|
+
// re-drafted AFTER the up-front manifest check (a fresh clean candidate — so classifyDrafts above
|
|
432
|
+
// passes) must not promote bytes the operator never reviewed. Fail-closed on any difference.
|
|
433
|
+
if (!(await reviewedDigestOk(installRoot, journal, draftRel))) {
|
|
434
|
+
failed.push({ draftRel, error: 'draft bytes changed since the operator reviewed them (reviewed-manifest content_digest mismatch) — not promoted; re-review' });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
313
437
|
const recordBoundary = makeRecordBoundary(installRoot, draftRel, journal);
|
|
314
438
|
try {
|
|
315
439
|
const r = await compileAccept({
|
|
@@ -334,7 +458,7 @@ export async function runCompileReview({
|
|
|
334
458
|
|
|
335
459
|
const remaining = remainingDrafts(journal);
|
|
336
460
|
if (!remaining.length) await clearJournal(installRoot);
|
|
337
|
-
const out = { batch_id: batchId, rationale, accepted, failed, excluded, remaining };
|
|
461
|
+
const out = { batch_id: batchId, rationale, accepted, failed, excluded, remaining, not_reviewed: notReviewed, digest_mismatch: digestMismatch };
|
|
338
462
|
if (json) console.log(JSON.stringify(out, null, 2));
|
|
339
463
|
else printReviewResult(out);
|
|
340
464
|
if (failed.length) process.exitCode = 1;
|
|
@@ -435,6 +559,13 @@ async function resumeReview({ installRoot, session, surface, json, now }) {
|
|
|
435
559
|
failed.push({ draftRel, error: 'no longer provably clean on resume (L4) — routed to individual review' });
|
|
436
560
|
continue;
|
|
437
561
|
}
|
|
562
|
+
// Reviewed-digest binding, re-enforced on RESUME too (Codex R5 F1): a draft re-drafted between
|
|
563
|
+
// the crash and this resume (a fresh clean candidate — so L4 above passes) must not promote
|
|
564
|
+
// bytes the operator never reviewed. The reviewed digest was persisted in the journal.
|
|
565
|
+
if (!(await reviewedDigestOk(installRoot, journal, draftRel))) {
|
|
566
|
+
failed.push({ draftRel, error: 'draft bytes changed since the operator reviewed them (reviewed-manifest content_digest mismatch) — not promoted; re-review' });
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
438
569
|
const r = await compileAccept({
|
|
439
570
|
dir: installRoot,
|
|
440
571
|
cwd: process.cwd(),
|
package/src/doctor.js
CHANGED
|
@@ -24,9 +24,10 @@ import { resolveInstall, resolveHooks, RE_SLUG, validateScheduleRegistry, schedu
|
|
|
24
24
|
import { registryJson } from './layout.js';
|
|
25
25
|
import { skillsStatusReport } from './skills.js';
|
|
26
26
|
import { codexAdapterReport } from './adapter.js';
|
|
27
|
+
import { inspectLauncher } from './launcher.js';
|
|
27
28
|
|
|
28
29
|
// The fixed check id set (D6) — the --json contract asserts on exactly these.
|
|
29
|
-
export const DOCTOR_CHECK_IDS = ['install_root', 'domain', 'skills', 'hooks', 'adapter_codex', 'user_profile', 'pending_drafts', 'schedule'];
|
|
30
|
+
export const DOCTOR_CHECK_IDS = ['install_root', 'launcher', 'domain', 'skills', 'hooks', 'adapter_codex', 'user_profile', 'pending_drafts', 'schedule', 'provider'];
|
|
30
31
|
|
|
31
32
|
function isExecutable(p) {
|
|
32
33
|
try {
|
|
@@ -51,7 +52,7 @@ function isFileParseableJson(p) {
|
|
|
51
52
|
|
|
52
53
|
// Build the structured doctor report — PURE w.r.t. the install (read-only). Returns the D6 shape:
|
|
53
54
|
// { root, checks:[{ id, status, detail }], summary:{ ok, warn, fail }, exit }.
|
|
54
|
-
export async function doctorReport({ dir } = {}) {
|
|
55
|
+
export async function doctorReport({ dir, home, pathEnv } = {}) {
|
|
55
56
|
const checks = [];
|
|
56
57
|
const add = (id, status, detail) => checks.push({ id, status, detail });
|
|
57
58
|
// Run a check body; turn ANY throw into a check of `failClass` (never a crash — F2/F3).
|
|
@@ -73,6 +74,11 @@ export async function doctorReport({ dir } = {}) {
|
|
|
73
74
|
const root = installed.root;
|
|
74
75
|
add('install_root', 'ok', root);
|
|
75
76
|
|
|
77
|
+
// launcher (change 0065 D7) — inspect the managed dispatcher + manifest and PATH without
|
|
78
|
+
// executing ANY candidate. A broken/absent launcher disables external automation but does not
|
|
79
|
+
// corrupt the workspace itself, so it is WARN-level; doctor remains a report, never a fixer.
|
|
80
|
+
await guarded('launcher', 'warn', () => launcherHealth({ home, pathEnv }));
|
|
81
|
+
|
|
76
82
|
// Shared registry read — a present-but-corrupt registry.json (resolveInstall passes on existence)
|
|
77
83
|
// fails the registry-derived checks, never crashes (F3).
|
|
78
84
|
let registry = null;
|
|
@@ -143,9 +149,33 @@ export async function doctorReport({ dir } = {}) {
|
|
|
143
149
|
// hard contract check lives. Read-only (validateScheduleRegistry + scheduleStaleness are pure).
|
|
144
150
|
await guarded('schedule', 'warn', () => scheduleHealth(root));
|
|
145
151
|
|
|
152
|
+
// provider (change 0050) — read out the model-routing block (active provider + per-tier ids) so
|
|
153
|
+
// the model-swap surface is visible in the health overview. Non-secret POINTERS only: doctor never
|
|
154
|
+
// reads or prints key material (keys are app-side; invariant #4 / change 0051). Absent or malformed
|
|
155
|
+
// is WARN, never fail — routing config is optional and pre-0050 installs are valid without it.
|
|
156
|
+
await guarded('provider', 'warn', () => providerRouting(root));
|
|
157
|
+
|
|
146
158
|
return finalize(root, checks);
|
|
147
159
|
}
|
|
148
160
|
|
|
161
|
+
function launcherHealth({ home, pathEnv } = {}) {
|
|
162
|
+
const report = inspectLauncher({ ...(home === undefined ? {} : { home }), ...(pathEnv === undefined ? {} : { pathEnv }) });
|
|
163
|
+
const pathDetail = report.pathCandidate
|
|
164
|
+
? `PATH probe: ${report.pathCandidate}`
|
|
165
|
+
: 'PATH probe: no executable phronesis found';
|
|
166
|
+
if (!report.compliant) {
|
|
167
|
+
return [
|
|
168
|
+
'warn',
|
|
169
|
+
`${report.issues.map((issue) => issue.detail).join('; ')}; ${pathDetail}`,
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
const m = report.manifest;
|
|
173
|
+
return [
|
|
174
|
+
'ok',
|
|
175
|
+
`managed dispatcher + launcher.env valid (owner=${m.PHR_BACKEND}, version=${m.PHR_VERSION}, generation=${m.PHR_GENERATION}); target + runtime current; ${pathDetail}`,
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
149
179
|
function finalize(root, checks) {
|
|
150
180
|
const summary = { ok: 0, warn: 0, fail: 0 };
|
|
151
181
|
for (const c of checks) summary[c.status] += 1;
|
|
@@ -251,6 +281,37 @@ function gitignoreCoversState(root) {
|
|
|
251
281
|
}
|
|
252
282
|
}
|
|
253
283
|
|
|
284
|
+
async function providerRouting(root) {
|
|
285
|
+
const cfgPath = join(root, '.phronesis', 'config.json');
|
|
286
|
+
if (!existsSync(cfgPath)) {
|
|
287
|
+
return ['warn', 'no .phronesis/config.json — model-routing provider block not configured (change 0050)'];
|
|
288
|
+
}
|
|
289
|
+
let cfg;
|
|
290
|
+
try {
|
|
291
|
+
cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return ['warn', `.phronesis/config.json unreadable (${err.message}) — provider routing not shown`];
|
|
294
|
+
}
|
|
295
|
+
const p = cfg && typeof cfg === 'object' ? cfg.provider : null;
|
|
296
|
+
if (!p || typeof p !== 'object' || typeof p.id !== 'string' || typeof p.model !== 'string') {
|
|
297
|
+
return ['warn', 'provider routing block absent or malformed (expected provider.id + provider.model) — change 0050'];
|
|
298
|
+
}
|
|
299
|
+
// Non-secret pointers only — never keys (keys are app-side; invariant #4 / change 0051).
|
|
300
|
+
const tiers =
|
|
301
|
+
p.tiers && typeof p.tiers === 'object'
|
|
302
|
+
? p.tiers
|
|
303
|
+
: p.perProvider && typeof p.perProvider === 'object' && p.perProvider[p.id]
|
|
304
|
+
? p.perProvider[p.id]
|
|
305
|
+
: null;
|
|
306
|
+
const tierStr = tiers
|
|
307
|
+
? ['standard', 'large', 'frontier', 'utility']
|
|
308
|
+
.filter((k) => typeof tiers[k] === 'string')
|
|
309
|
+
.map((k) => `${k}=${tiers[k]}`)
|
|
310
|
+
.join(', ') || '(no per-tier ids)'
|
|
311
|
+
: '(no per-tier ids configured)';
|
|
312
|
+
return ['ok', `active provider: ${p.id}; model: ${p.model}; tiers: ${tierStr}`];
|
|
313
|
+
}
|
|
314
|
+
|
|
254
315
|
async function pendingDrafts(root) {
|
|
255
316
|
const base = join(root, '.phronesis', 'drafts');
|
|
256
317
|
if (!existsSync(base)) return ['ok', 'no pending compile drafts'];
|
package/src/hook.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// `phronesis hook <event> --surface <surface>` — the single resolved executable interface
|
|
2
|
+
// used by every emitted hook script (change 0065 D2). Surface scripts only resolve one CLI
|
|
3
|
+
// binary and hand stdin through; JSON parsing, install discovery, core calls, and output shaping
|
|
4
|
+
// live here inside the runtime carried by the managed launcher.
|
|
5
|
+
|
|
6
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join, relative, sep } from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
abandonActiveSkillInvocation,
|
|
11
|
+
appendEvent,
|
|
12
|
+
beginSkillInvocation,
|
|
13
|
+
completeActiveSkillInvocation,
|
|
14
|
+
refreshActiveContext,
|
|
15
|
+
resolveInstall,
|
|
16
|
+
runContextRelevanceGuard,
|
|
17
|
+
sweepStaleSkillInvocation,
|
|
18
|
+
sweepStatus,
|
|
19
|
+
} from '@phronesis/core';
|
|
20
|
+
import { collectHooksDueCheck, meshZeroWarningMessage } from './hooks.js';
|
|
21
|
+
import { launcherPaths } from './launcher.js';
|
|
22
|
+
|
|
23
|
+
export const HOOK_EVENTS = Object.freeze([
|
|
24
|
+
'recall-guard',
|
|
25
|
+
'session-start',
|
|
26
|
+
'session-sweep',
|
|
27
|
+
'session-sweep-subagent',
|
|
28
|
+
'session-sweep-precompact',
|
|
29
|
+
'skill-lifecycle',
|
|
30
|
+
'compile-active-context',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
async function readStdin() {
|
|
34
|
+
let data = '';
|
|
35
|
+
for await (const chunk of process.stdin) data += chunk;
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseInput(raw) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(String(raw ?? ''));
|
|
42
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function markerExists(root) {
|
|
49
|
+
return Boolean(root) && existsSync(join(root, '.phronesis', 'registry.json'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Preserve the old surface resolution order: a validated Codex-provided root, then a validated
|
|
53
|
+
// Claude project root, then nearest-marker walk-up from cwd. A bad hint never overrides a real
|
|
54
|
+
// install discoverable from cwd.
|
|
55
|
+
function hookInstallRoot() {
|
|
56
|
+
for (const candidate of [process.env.PHRONESIS_DIR, process.env.CLAUDE_PROJECT_DIR]) {
|
|
57
|
+
if (markerExists(candidate)) return realpathOr(candidate);
|
|
58
|
+
}
|
|
59
|
+
const installed = resolveInstall({ cwd: process.cwd() });
|
|
60
|
+
return installed.root || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function realpathOr(path) {
|
|
64
|
+
try {
|
|
65
|
+
return realpathSync(path);
|
|
66
|
+
} catch {
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function safeSurface(value) {
|
|
72
|
+
const clean = String(value || 'claude-code').replace(/[^a-z0-9-]/g, '');
|
|
73
|
+
return clean || 'claude-code';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function actor() {
|
|
77
|
+
return process.env.PHRONESIS_ACTOR || 'agent';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function modelFacingPhronesis() {
|
|
81
|
+
const managedPath = launcherPaths({ home: homedir() }).dispatcher;
|
|
82
|
+
try {
|
|
83
|
+
const state = lstatSync(managedPath);
|
|
84
|
+
if (state.isFile() && !state.isSymbolicLink() && (state.mode & 0o111) !== 0) {
|
|
85
|
+
return managedPath;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// A missing or unreadable managed slot falls back to the PATH-resolved command.
|
|
89
|
+
}
|
|
90
|
+
return 'phronesis';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function domainFromCwd(root, cwd) {
|
|
94
|
+
if (!root || !cwd) return undefined;
|
|
95
|
+
const rel = relative(realpathOr(root), realpathOr(cwd));
|
|
96
|
+
const parts = rel.split(sep);
|
|
97
|
+
return parts[0] === 'domains' && parts[1] ? parts[1] : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeJson(value) {
|
|
101
|
+
process.stdout.write(JSON.stringify(value));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function recallGuard({ input, installRoot, surface }) {
|
|
105
|
+
const session = typeof input?.session_id === 'string' ? input.session_id : '';
|
|
106
|
+
const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
|
|
107
|
+
if (!session || !prompt || !installRoot) return;
|
|
108
|
+
try {
|
|
109
|
+
const eventCwd = typeof input.cwd === 'string' ? input.cwd : process.cwd();
|
|
110
|
+
const result = await runContextRelevanceGuard({
|
|
111
|
+
installRoot,
|
|
112
|
+
cwd: process.cwd(),
|
|
113
|
+
prompt,
|
|
114
|
+
session,
|
|
115
|
+
surface,
|
|
116
|
+
actor: actor(),
|
|
117
|
+
domain: domainFromCwd(installRoot, eventCwd),
|
|
118
|
+
env: process.env,
|
|
119
|
+
});
|
|
120
|
+
if (result?.fired === true && typeof result.packet === 'string' && result.packet.trim()) {
|
|
121
|
+
writeJson({
|
|
122
|
+
hookSpecificOutput: {
|
|
123
|
+
hookEventName: 'UserPromptSubmit',
|
|
124
|
+
additionalContext: result.packet,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Recall is add-only. A malformed prompt or broken guard degrades quietly, matching the
|
|
130
|
+
// previous adapter's guarded node process and never blocking prompt submission.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function sessionStart({ input, installRoot, surface }) {
|
|
135
|
+
const session = typeof input?.session_id === 'string' ? input.session_id : '';
|
|
136
|
+
if (!session || !installRoot) return;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await sweepStaleSkillInvocation({ installRoot, session });
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(`hook session-start: ${err.message}`);
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await appendEvent({
|
|
145
|
+
installRoot,
|
|
146
|
+
type: 'session.start',
|
|
147
|
+
session_id: session,
|
|
148
|
+
actor: actor(),
|
|
149
|
+
surface,
|
|
150
|
+
payload: { surface },
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error(`hook session-start: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const due = await collectHooksDueCheck({ installRoot, session, actor: actor(), surface });
|
|
157
|
+
if (!due.output) return;
|
|
158
|
+
if (surface === 'codex') {
|
|
159
|
+
writeJson({
|
|
160
|
+
hookSpecificOutput: {
|
|
161
|
+
hookEventName: 'SessionStart',
|
|
162
|
+
additionalContext: due.output,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
process.stdout.write(`${due.output}\n`);
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.error(`hook session-start: ${err.message}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function completeLifecycle({ installRoot, session, input, boundaryEvent, surface }) {
|
|
174
|
+
try {
|
|
175
|
+
const hookInput = { ...(input || {}) };
|
|
176
|
+
if (!hookInput.hook_event_name) hookInput.hook_event_name = boundaryEvent;
|
|
177
|
+
const result = await completeActiveSkillInvocation({
|
|
178
|
+
installRoot,
|
|
179
|
+
session,
|
|
180
|
+
hookInput,
|
|
181
|
+
reason: boundaryEvent,
|
|
182
|
+
surface,
|
|
183
|
+
});
|
|
184
|
+
if (result?.warning) writeJson({ systemMessage: meshZeroWarningMessage(result.warning) });
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error(`hook session-sweep: ${err.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sweepReminder(session, installRoot, phronesis) {
|
|
191
|
+
return `Phronesis sweep reminder: context is about to compact. Review the conversation for unstored decisions, people, and commitments — emit *.candidate events / run the action types, then mark the sweep: "${phronesis}" event emit session.end --session ${session} --dir ${installRoot} --payload '{"sweep":{"candidates":N}}' (candidates 0 = honest negative).`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sweepBlock(session, installRoot, phronesis) {
|
|
195
|
+
return {
|
|
196
|
+
decision: 'block',
|
|
197
|
+
reason:
|
|
198
|
+
`Phronesis capture sweep (session ${session}): before ending, review this conversation for anything that should have been stored — decisions made, people/context learned, commitments given. ` +
|
|
199
|
+
`Emit a typed candidate for each ("${phronesis}" event emit decision.candidate|person.candidate|commitment.candidate --session ${session} --dir ${installRoot} --payload ...) and/or run the matching action types ("${phronesis}" act ... --dir ${installRoot}). ` +
|
|
200
|
+
`Then mark the sweep — honestly, 0 candidates is a valid answer: "${phronesis}" event emit session.end --session ${session} --dir ${installRoot} --payload '{"sweep":{"candidates":N}}'. After the marker is emitted, stop again.`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function sessionSweep({ event, input, installRoot, surface }) {
|
|
205
|
+
const session = typeof input?.session_id === 'string' ? input.session_id : '';
|
|
206
|
+
if (!session || !installRoot) return;
|
|
207
|
+
|
|
208
|
+
if (event === 'session-sweep-subagent') {
|
|
209
|
+
await completeLifecycle({ installRoot, session, input, boundaryEvent: 'SubagentStop', surface });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let swept = false;
|
|
214
|
+
try {
|
|
215
|
+
swept = (await sweepStatus({ installRoot, session })).swept;
|
|
216
|
+
} catch {
|
|
217
|
+
// A failed status read is conservatively pending, matching the old shell `if` branch.
|
|
218
|
+
}
|
|
219
|
+
// Preserve the old branch order: an already-swept PreCompact is silent, while a pending
|
|
220
|
+
// PreCompact emits its reminder before the Stop loop guard is considered.
|
|
221
|
+
if (swept) {
|
|
222
|
+
if (event === 'session-sweep') {
|
|
223
|
+
await completeLifecycle({ installRoot, session, input, boundaryEvent: 'Stop', surface });
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (event === 'session-sweep-precompact') {
|
|
228
|
+
const phronesis = modelFacingPhronesis();
|
|
229
|
+
const reminder = sweepReminder(session, installRoot, phronesis);
|
|
230
|
+
if (surface === 'codex') writeJson({ systemMessage: reminder });
|
|
231
|
+
else process.stdout.write(`${reminder}\n`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (input.stop_hook_active === true) {
|
|
235
|
+
await completeLifecycle({ installRoot, session, input, boundaryEvent: 'Stop', surface });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const phronesis = modelFacingPhronesis();
|
|
239
|
+
process.stdout.write(`${JSON.stringify(sweepBlock(session, installRoot, phronesis))}\n`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function skillLifecycle({ input, installRoot, surface, mode }) {
|
|
243
|
+
if (!installRoot || (mode !== 'start' && mode !== 'abandon')) return;
|
|
244
|
+
try {
|
|
245
|
+
if (mode === 'start') {
|
|
246
|
+
await beginSkillInvocation({ installRoot, hookInput: input || {}, surface });
|
|
247
|
+
} else {
|
|
248
|
+
await abandonActiveSkillInvocation({
|
|
249
|
+
installRoot,
|
|
250
|
+
hookInput: input || {},
|
|
251
|
+
reason: 'StopFailure',
|
|
252
|
+
surface,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error(`hook skill-lifecycle: ${err.message}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function compileActiveContext({ input, installRoot }) {
|
|
261
|
+
const path = typeof input?.payload?.path === 'string' ? input.payload.path : '';
|
|
262
|
+
const parts = path.split('/');
|
|
263
|
+
const domain = parts[0] === 'domains' && parts[1] ? parts[1] : '';
|
|
264
|
+
if (!domain || !installRoot) return;
|
|
265
|
+
try {
|
|
266
|
+
const registry = JSON.parse(readFileSync(join(installRoot, '.phronesis', 'registry.json'), 'utf8'));
|
|
267
|
+
if (!Array.isArray(registry.domains) || !registry.domains.includes(domain)) return;
|
|
268
|
+
await refreshActiveContext({ installRoot, domain });
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.error(`hook compile-active-context: ${err.message}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function runHook({ event, surface: surfaceArg, mode } = {}) {
|
|
275
|
+
const surface = safeSurface(surfaceArg);
|
|
276
|
+
const input = parseInput(await readStdin());
|
|
277
|
+
const installRoot = hookInstallRoot();
|
|
278
|
+
|
|
279
|
+
if (!HOOK_EVENTS.includes(event)) {
|
|
280
|
+
console.error(`hook: unknown event "${event || ''}" (expected: ${HOOK_EVENTS.join(' | ')}).`);
|
|
281
|
+
process.exitCode = 1;
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
if (!installRoot && event !== 'recall-guard') {
|
|
285
|
+
console.error(`hook ${event}: no phronesis install found from ${process.cwd()}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
switch (event) {
|
|
289
|
+
case 'recall-guard':
|
|
290
|
+
await recallGuard({ input, installRoot, surface });
|
|
291
|
+
break;
|
|
292
|
+
case 'session-start':
|
|
293
|
+
await sessionStart({ input, installRoot, surface });
|
|
294
|
+
break;
|
|
295
|
+
case 'session-sweep':
|
|
296
|
+
case 'session-sweep-subagent':
|
|
297
|
+
case 'session-sweep-precompact':
|
|
298
|
+
await sessionSweep({ event, input, installRoot, surface });
|
|
299
|
+
break;
|
|
300
|
+
case 'skill-lifecycle':
|
|
301
|
+
await skillLifecycle({ input, installRoot, surface, mode });
|
|
302
|
+
break;
|
|
303
|
+
case 'compile-active-context':
|
|
304
|
+
await compileActiveContext({ input, installRoot });
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Surface hooks are non-blocking by contract. Valid events always exit 0 even when an internal
|
|
309
|
+
// best-effort operation reported an error on stderr.
|
|
310
|
+
process.exitCode = 0;
|
|
311
|
+
return { event, surface };
|
|
312
|
+
}
|