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/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 = DEFAULT_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
- const draftRels = await listPendingDrafts(installRoot, domain);
237
- const { eligible, excluded } = await classifyDrafts(installRoot, draftRels);
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
- const plan = { domain, batch_eligible: eligible, individual_review: excluded };
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 ${domain} (${excluded.length} routed to individual review).`);
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
- const journal = { batch_id: batchId, rationale, domain, session, surface, created_ts: (now || new Date()).toISOString(), planned: [...eligible], cursors: {} };
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
+ }