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/adapter.js CHANGED
@@ -8,11 +8,26 @@
8
8
  // canonical state and emits no events.
9
9
 
10
10
  import { join } from 'node:path';
11
- import { readFileSync, statSync, existsSync, mkdtempSync, writeFileSync, chmodSync, rmSync, realpathSync } from 'node:fs';
11
+ import { readFileSync, lstatSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, realpathSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { spawnSync } from 'node:child_process';
14
14
  import { randomBytes } from 'node:crypto';
15
- import { resolveInstall, RE_SLUG } from '@phronesis/core';
15
+ import { fileURLToPath } from 'node:url';
16
+ import {
17
+ resolveInstall,
18
+ RE_SLUG,
19
+ runAction,
20
+ runContextRelevanceGuard,
21
+ runDueCheck,
22
+ appendEvent,
23
+ readEvents,
24
+ beginSkillInvocation,
25
+ completeActiveSkillInvocation,
26
+ abandonActiveSkillInvocation,
27
+ readActiveInvocation,
28
+ sweepStatus,
29
+ readAuditLog,
30
+ } from '@phronesis/core';
16
31
  import { CODEX_ADAPTER_HOOKS, CODEX_SURFACE_RESOLVER } from './layout.js';
17
32
  import { validateCodexHooksSchema } from './codex-hooks-schema.js';
18
33
 
@@ -22,6 +37,7 @@ import { validateCodexHooksSchema } from './codex-hooks-schema.js';
22
37
  const HOOKS = CODEX_ADAPTER_HOOKS;
23
38
 
24
39
  const DESKTOP_CHECKLIST = 'docs/surface-setup.md';
40
+ const CODEX_HOOK_TEMPLATE_DIR = fileURLToPath(new URL('../templates/codex-surface/hooks/', import.meta.url));
25
41
 
26
42
  // Read the install's domains from the registry (the adapter test runs against the
27
43
  // domain workspace open target — domains/{slug}/.codex/). Falls back to 'pm'.
@@ -43,19 +59,27 @@ function installDomains(root) {
43
59
 
44
60
  function isExecutable(p) {
45
61
  try {
46
- const st = statSync(p);
62
+ const st = lstatSync(p);
47
63
  // A runnable launcher/script must be a REGULAR file with an exec bit — a directory carries the
48
- // exec bit too (0755) and would otherwise read as a runnable script (core-0038 Codex round-3 F1).
64
+ // exec bit too (0755), and a symlink must not be followed during structural inspection.
49
65
  return st.isFile() && Boolean(st.mode & 0o111);
50
66
  } catch {
51
67
  return false;
52
68
  }
53
69
  }
54
70
 
71
+ function matchesCanonicalHookBytes(installedPath, templateName) {
72
+ try {
73
+ return readFileSync(installedPath).equals(readFileSync(join(CODEX_HOOK_TEMPLATE_DIR, templateName)));
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
55
79
  // Structural: the emitted domains/{slug}/.codex/ wiring matches the CODEX_ADAPTER_HOOKS
56
80
  // contract — hooks.json present + parseable, each event wired to its bare relative
57
- // launcher path, each launcher present + executable + handing off to its shared script,
58
- // each shared script present. Returns { checks: [...], compliant: bool }.
81
+ // launcher path, and each launcher present + executable + bound to the contracted
82
+ // `phronesis hook <event> --surface codex` interface. Returns { checks, compliant }.
59
83
  function checkEmission(root, slug) {
60
84
  const checks = [];
61
85
  const add = (ok, label, detail = '') => checks.push({ ok, label, detail });
@@ -85,7 +109,14 @@ function checkEmission(root, slug) {
85
109
  // The shared resolver the launchers source — infrastructure, not an event hook, but
86
110
  // every launcher fails to source it if it is absent, so it must ship + be present.
87
111
  const resolverPath = join(codexDir, 'hooks', CODEX_SURFACE_RESOLVER);
88
- add(existsSync(resolverPath), `resolver ${CODEX_SURFACE_RESOLVER} present`, existsSync(resolverPath) ? '' : 'missing — launchers cannot resolve the install root');
112
+ const resolverOk = isExecutable(resolverPath);
113
+ add(resolverOk, `resolver ${CODEX_SURFACE_RESOLVER} present + regular executable`, resolverOk ? '' : 'missing, symlinked, not regular, or not +x — launchers cannot resolve the managed CLI/install root');
114
+ const resolverCanonical = resolverOk && matchesCanonicalHookBytes(resolverPath, CODEX_SURFACE_RESOLVER);
115
+ add(
116
+ resolverCanonical,
117
+ `resolver ${CODEX_SURFACE_RESOLVER} matches canonical shipped bytes`,
118
+ resolverCanonical ? '' : 'hand-modified resolver; run `phronesis adapter test codex` for behavioral proof',
119
+ );
89
120
 
90
121
  const hooks = (config && config.hooks) || {};
91
122
  // Exact-set match against the contract: an event wired in hooks.json but absent from
@@ -123,34 +154,24 @@ function checkEmission(root, slug) {
123
154
  add(wired, `${h.event} → ${expected} (exactly one entry + command)`, wiredDetail);
124
155
 
125
156
  const launcherPath = join(codexDir, 'hooks', h.launcher);
126
- const launcherOk = existsSync(launcherPath) && isExecutable(launcherPath);
127
- add(launcherOk, `launcher ${h.launcher} present + executable`, launcherOk ? '' : 'missing or not +x');
128
-
129
- const scriptPath = join(root, '.phronesis', 'hooks', h.script);
130
- const scriptOk = existsSync(scriptPath) && isExecutable(scriptPath);
131
- add(scriptOk, `shared script .phronesis/hooks/${h.script} present + executable`, scriptOk ? '' : 'missing or not +x');
157
+ const launcherOk = isExecutable(launcherPath);
158
+ add(launcherOk, `launcher ${h.launcher} present + regular executable`, launcherOk ? '' : 'missing, symlinked, not regular, or not +x');
159
+ const launcherBound = launcherOk && matchesCanonicalHookBytes(launcherPath, h.launcher);
160
+ add(
161
+ launcherBound,
162
+ `launcher ${h.launcher} binds hook ${h.hookEvent} --surface codex`,
163
+ launcherBound ? '' : 'hand-modified launcher; run `phronesis adapter test codex` for behavioral proof',
164
+ );
132
165
  }
133
166
 
134
167
  return { checks, compliant: checks.every((c) => c.ok) };
135
168
  }
136
169
 
137
- // Behavioral: run the reinforcement launchers (Stop, UserPromptSubmit, SessionStart) through
138
- // scripted events and assert each not only REACHES the right `phronesis` subcommand but produces
139
- // the operator-facing OUTPUT for recall + due-check, the Codex hookSpecificOutput.additionalContext
140
- // envelope that actually injects the packet. Reaching `guard`/`due-check` but emitting nothing is
141
- // NOT recall injection (core-0034 review, round 4). The shim never execs the real CLI: it logs argv
142
- // AND returns CANNED guard JSON / due-check text — so the shared scripts run their envelope
143
- // formatting while this self-check stays non-mutating by construction (the REAL guard emits
144
- // recall.candidate/injected and due-check emits outcome.due/session.start, none of which may touch
145
- // the operator's install; the shim writes nothing and spawns nothing). A launcher OR its shared
146
- // .phronesis/hooks/ script replaced by a bare `exit 0` (present + executable, so the structural
147
- // emission check still passes), or one that calls the subcommand but drops its output, never
148
- // produces the envelope — so the probe fails (the "Level-1 compliant while recall is dead" gap).
149
- // Asserts: (a) Stop emits {"decision":"block"} (its own `node -e`, surfaces behind the shim) and
150
- // reaches `hooks sweep-status`; (b) UserPromptSubmit reaches `guard --surface codex` AND wraps the
151
- // guard packet into a UserPromptSubmit additionalContext envelope; (c) SessionStart wraps the
152
- // due-check text into a SessionStart additionalContext envelope; (d) the install root resolves from
153
- // a SUBDIR of the open target (its block reason carries `--dir <root>`, resolved in-shell).
170
+ // Behavioral: run the reinforcement launchers against a NON-EXECING shim for the new single CLI
171
+ // hook interface. The shim logs argv and returns nonce-bearing surface output, so the probe proves
172
+ // each launcher reaches the exact event, preserves stdout, and resolves the install root without
173
+ // ever running the real (state-mutating) guard/due-check/session-start logic on the operator install.
174
+ // The CLI hook interface itself is exercised end-to-end by validate-hooks and hook.test.js.
154
175
  function checkBehavior(root, slug) {
155
176
  const checks = [];
156
177
  const add = (ok, label, detail = '') => checks.push({ ok, label, detail });
@@ -161,40 +182,42 @@ function checkBehavior(root, slug) {
161
182
  return { checks, compliant: false };
162
183
  }
163
184
 
164
- // Sentinels the shim feeds back as the guard packet / due-check text; the probes assert these
165
- // survive into the emitted additionalContext envelope (proves the shared script wrapped them).
166
- // PER-RUN NONCE, not a fixed literal: a constant sentinel could be satisfied by a shared script
167
- // that hardcodes the envelope without wrapping anything (a vacuous pass — review round 4); a
168
- // value the shared script cannot know forces it to actually carry THIS run's result through.
185
+ // Sentinels the shim feeds back as hook-interface output; the probes assert these survive through
186
+ // each thin launcher. PER-RUN NONCE, not a fixed literal: a constant sentinel could be satisfied
187
+ // by a launcher that hardcodes the envelope (a vacuous pass review round 4); a value the
188
+ // launcher cannot know forces it to carry THIS run's result through.
169
189
  const nonce = randomBytes(8).toString('hex');
170
190
  const RECALL_SENTINEL = `ADAPTER_PROBE_RECALL_${nonce}`;
171
191
  const DUE_SENTINEL = `ADAPTER_PROBE_DUE_${nonce}`;
192
+ const PRECOMPACT_SENTINEL = `ADAPTER_PROBE_PRECOMPACT_${nonce}`;
172
193
 
173
194
  let shimDir;
174
195
  try {
175
196
  shimDir = mkdtempSync(join(tmpdir(), 'phronesis-adapter-'));
176
197
  const argvLog = join(shimDir, 'argv.log');
177
198
  const shim = join(shimDir, 'phronesis');
178
- // Logs argv, returns CANNED output for the two subcommands whose result the shared scripts
179
- // wrap into an envelope (`guard` → a fired recall result; `hooks due-check` → due text), and
180
- // exits 0 for everything else (sweep-status, skill-sweep-stale, event emit, …) so the Stop
181
- // sweep reads "unswept" and NOTHING is execed or written (non-mutating). `$1`/`$2`/`$*` are
182
- // literal shell, not JS interpolation (no `${`).
199
+ const shimHome = join(shimDir, 'home');
200
+ mkdirSync(shimHome);
201
+ // Canned hook-interface output. The per-run nonce proves stdout came from THIS invocation,
202
+ // not a launcher-forged fixed envelope. PHRONESIS_DIR is set by _resolve.sh.
183
203
  writeFileSync(
184
204
  shim,
185
205
  [
186
- '#!/usr/bin/env bash',
206
+ '#!/bin/sh',
187
207
  `printf '%s\\n' "$*" >> ${JSON.stringify(argvLog)}`,
188
- 'case "$1" in',
189
- ` guard) printf '%s' '{"fired":true,"packet":"${RECALL_SENTINEL}"}' ;;`,
190
- ` hooks) case "$2" in due-check) printf '%s' '${DUE_SENTINEL}' ;; esac ;;`,
208
+ 'case "$1:$2" in',
209
+ ` hook:recall-guard) printf '%s' '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"${RECALL_SENTINEL}"}}' ;;`,
210
+ ` hook:session-start) printf '%s' '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"${DUE_SENTINEL}"}}' ;;`,
211
+ ` hook:session-sweep) printf '{"decision":"block","reason":"--dir %s"}' "$PHRONESIS_DIR" ;;`,
212
+ ` hook:session-sweep-precompact) printf '%s' '{"systemMessage":"${PRECOMPACT_SENTINEL}"}' ;;`,
213
+ ' hook:session-sweep-subagent) : ;;',
191
214
  'esac',
192
215
  'exit 0',
193
216
  '',
194
217
  ].join('\n'),
195
218
  );
196
219
  chmodSync(shim, 0o755);
197
- const env = { ...process.env, PATH: `${shimDir}:${process.env.PATH || ''}` };
220
+ const env = { ...process.env, HOME: shimHome, PATH: `${shimDir}:${process.env.PATH || ''}` };
198
221
  const target = join(root, 'domains', slug);
199
222
  const readLog = () => {
200
223
  try {
@@ -203,9 +226,9 @@ function checkBehavior(root, slug) {
203
226
  return '';
204
227
  }
205
228
  };
206
- const runLauncher = (name, cwd, input) => spawnSync('bash', [join(codexHooks, name)], { cwd, env, input, encoding: 'utf8' });
229
+ const runLauncher = (name, cwd, input) => spawnSync('sh', [join(codexHooks, name)], { cwd, env, input, encoding: 'utf8' });
207
230
  // True iff stdout is exactly a Codex hookSpecificOutput envelope for `hookEventName` whose
208
- // additionalContext carries `sentinel` (i.e. the shared script wrapped the canned result).
231
+ // additionalContext carries `sentinel` (i.e. the launcher preserved this invocation's output).
209
232
  const envelopeInjects = (stdout, hookEventName, sentinel) => {
210
233
  try {
211
234
  const j = JSON.parse(String(stdout || '').trim());
@@ -220,8 +243,8 @@ function checkBehavior(root, slug) {
220
243
  const atTarget = runLauncher('session-sweep.sh', target, stopFixture);
221
244
  const blocks = atTarget.status === 0 && /"decision":"block"/.test(atTarget.stdout || '');
222
245
  add(blocks, 'Stop emits {"decision":"block"} (Codex capture-sweep shape)', blocks ? '' : (atTarget.stderr || atTarget.stdout || '').trim());
223
- const sweepReached = /(^|\n)hooks sweep-status\b/.test(readLog());
224
- add(sweepReached, 'Stop sweep reaches `phronesis hooks sweep-status`', sweepReached ? '' : 'the shared session-sweep.sh never invoked sweep-status (stubbed/broken script?)');
246
+ const sweepReached = /(^|\n)hook session-sweep --surface codex\b/.test(readLog());
247
+ add(sweepReached, 'Stop reaches `phronesis hook session-sweep --surface codex`', sweepReached ? '' : 'the launcher never invoked the contracted Stop event');
225
248
 
226
249
  // (b) UserPromptSubmit recall: reaches `guard --surface codex` (surface attribution) AND wraps
227
250
  // the guard packet into the additionalContext envelope (the actual injection — reaching guard
@@ -229,10 +252,10 @@ function checkBehavior(root, slug) {
229
252
  const recallLauncher = join(codexHooks, 'recall-guard.sh');
230
253
  if (existsSync(recallLauncher)) {
231
254
  const recall = runLauncher('recall-guard.sh', target, '{"session_id":"adapter_probe","prompt":"Should I wait before deciding?"}');
232
- const guardReached = /(^|\n)guard .*--surface codex\b/.test(readLog());
233
- add(guardReached, 'UserPromptSubmit recall reaches `phronesis guard --surface codex`', guardReached ? '' : 'the shared recall-guard.sh never invoked guard (stubbed/broken script?)');
255
+ const guardReached = /(^|\n)hook recall-guard --surface codex\b/.test(readLog());
256
+ add(guardReached, 'UserPromptSubmit reaches `phronesis hook recall-guard --surface codex`', guardReached ? '' : 'the launcher never invoked the contracted recall event');
234
257
  const recallInjected = envelopeInjects(recall.stdout, 'UserPromptSubmit', RECALL_SENTINEL);
235
- add(recallInjected, 'UserPromptSubmit recall injects the packet as hookSpecificOutput.additionalContext', recallInjected ? '' : 'recall-guard.sh reached guard but emitted no Codex additionalContext envelope — the operator gets no injection');
258
+ add(recallInjected, 'UserPromptSubmit preserves hookSpecificOutput.additionalContext stdout', recallInjected ? '' : 'launcher reached the hook interface but dropped/forged its stdout');
236
259
  } else {
237
260
  add(false, 'UserPromptSubmit recall launcher runnable', 'recall-guard.sh launcher missing');
238
261
  }
@@ -243,13 +266,51 @@ function checkBehavior(root, slug) {
243
266
  const startLauncher = join(codexHooks, 'session-start.sh');
244
267
  if (existsSync(startLauncher)) {
245
268
  const start = runLauncher('session-start.sh', target, '{"session_id":"adapter_probe","source":"startup"}');
269
+ const startReached = /(^|\n)hook session-start --surface codex\b/.test(readLog());
270
+ add(startReached, 'SessionStart reaches `phronesis hook session-start --surface codex`', startReached ? '' : 'the launcher never invoked the contracted session-start event');
246
271
  const dueInjected = envelopeInjects(start.stdout, 'SessionStart', DUE_SENTINEL);
247
- add(dueInjected, 'SessionStart due-check injects via hookSpecificOutput.additionalContext', dueInjected ? '' : 'session-start.sh reached due-check but did not wrap it in the Codex SessionStart additionalContext envelope');
272
+ add(dueInjected, 'SessionStart preserves hookSpecificOutput.additionalContext stdout', dueInjected ? '' : 'launcher reached the hook interface but dropped/forged its stdout');
248
273
  } else {
249
274
  add(false, 'SessionStart launcher runnable', 'session-start.sh launcher missing');
250
275
  }
251
276
 
252
- // (d) install-root resolution from a subdir of the open target (nearest-.phronesis walk-up).
277
+ // (d) The two remaining lifecycle boundaries are distinct contracted launchers. Drive both
278
+ // through the same argv-log shim; PreCompact also has a surface envelope to preserve.
279
+ const precompactLauncher = join(codexHooks, 'session-sweep-precompact.sh');
280
+ if (existsSync(precompactLauncher)) {
281
+ const precompact = runLauncher(
282
+ 'session-sweep-precompact.sh',
283
+ target,
284
+ '{"session_id":"adapter_probe_precompact","stop_hook_active":false}',
285
+ );
286
+ const precompactReached = /(^|\n)hook session-sweep-precompact --surface codex\b/.test(readLog());
287
+ add(precompactReached, 'PreCompact reaches `phronesis hook session-sweep-precompact --surface codex`', precompactReached ? '' : 'the launcher never invoked the contracted PreCompact event');
288
+ let precompactEnvelope = false;
289
+ try {
290
+ precompactEnvelope = JSON.parse(String(precompact.stdout || '').trim())?.systemMessage === PRECOMPACT_SENTINEL;
291
+ } catch {
292
+ precompactEnvelope = false;
293
+ }
294
+ add(precompactEnvelope, 'PreCompact preserves the unswept systemMessage envelope', precompactEnvelope ? '' : 'launcher reached the hook interface but dropped/forged its systemMessage stdout');
295
+ } else {
296
+ add(false, 'PreCompact launcher runnable', 'session-sweep-precompact.sh launcher missing');
297
+ }
298
+
299
+ const subagentLauncher = join(codexHooks, 'session-sweep-subagent.sh');
300
+ if (existsSync(subagentLauncher)) {
301
+ const subagent = runLauncher(
302
+ 'session-sweep-subagent.sh',
303
+ target,
304
+ '{"session_id":"adapter_probe_subagent","stop_hook_active":false}',
305
+ );
306
+ const subagentReached = subagent.status === 0
307
+ && /(^|\n)hook session-sweep-subagent --surface codex\b/.test(readLog());
308
+ add(subagentReached, 'SubagentStop reaches `phronesis hook session-sweep-subagent --surface codex`', subagentReached ? '' : (subagent.stderr || 'the launcher never invoked the contracted SubagentStop event').trim());
309
+ } else {
310
+ add(false, 'SubagentStop launcher runnable', 'session-sweep-subagent.sh launcher missing');
311
+ }
312
+
313
+ // (e) install-root resolution from a subdir of the open target (nearest-.phronesis walk-up).
253
314
  // The launcher runs from the kernel-realpath'd cwd, so it emits the realpath of the install
254
315
  // root; compare realpaths so a symlinked prefix (macOS /var → /private/var) is not a false
255
316
  // failure. The block reason's `--dir` is resolved in-shell, independent of the shim.
@@ -274,7 +335,7 @@ function checkBehavior(root, slug) {
274
335
  }
275
336
 
276
337
  // structuralOnly (core-0038 F1): run ONLY the read-only emission check (parses hooks.json, checks
277
- // launcher/script presence + executability — pure file reads, NO spawning). `checkBehavior` EXECUTES
338
+ // launcher/resolver presence + executability — pure file reads, NO spawning). `checkBehavior` EXECUTES
278
339
  // the install's hook scripts via spawnSync; that is fine for `adapter test codex` (a deliberate
279
340
  // operator self-check) but NOT for `phronesis doctor`, whose read-only promise must hold even on a
280
341
  // MODIFIED install — a tampered hook could write to the install before satisfying the probe. So
@@ -282,9 +343,11 @@ function checkBehavior(root, slug) {
282
343
  function buildReport(root, domains, { structuralOnly = false } = {}) {
283
344
  const perDomain = domains.map((slug) => {
284
345
  const emission = checkEmission(root, slug);
285
- // Only probe behavior when the wiring is structurally present (else the probe just
286
- // re-reports the same missing files as opaque spawn failures) AND behavior was requested.
287
- const behavior = !structuralOnly && emission.compliant ? checkBehavior(root, slug) : { checks: [], compliant: emission.compliant, skipped: structuralOnly || undefined };
346
+ // `adapter test` is the deliberate behavioral proof even for hand-modified bytes; doctor
347
+ // passes structuralOnly and therefore never executes an install-owned candidate.
348
+ const behavior = structuralOnly
349
+ ? { checks: [], compliant: emission.compliant, skipped: true }
350
+ : checkBehavior(root, slug);
288
351
  return { domain: slug, emission, behavior };
289
352
  });
290
353
 
@@ -333,7 +396,8 @@ function printHuman(report) {
333
396
  );
334
397
  console.log('');
335
398
  console.log(' Trust: open Codex AT the domain workspace dir above (Codex does not parent-walk to find .codex/).');
336
- console.log(' Enable the project hooks: CLI `/hooks`, or the app Settings → Coding → Hooks. Editing the config re-hashes → re-trust.');
399
+ console.log(' Enable the project hooks: CLI `/hooks`, or the app Settings → Coding → Hooks.');
400
+ console.log(' Trust note (verified on Codex v0.145.0-alpha.18): registration trust keys on hooks.json, not script bytes; a changed config should show the review banner.');
337
401
  }
338
402
 
339
403
  // PURE Codex-adapter report (core-0038 D2/D3): resolve the install + build the structural +
@@ -357,14 +421,255 @@ export function codexAdapterReport({ dir, domain, structuralOnly = false } = {})
357
421
  return { root, report: buildReport(root, domains, { structuralOnly }) };
358
422
  }
359
423
 
424
+ // --- Desktop adapter (change 0055 / CR4) ------------------------------------------------
425
+ //
426
+ // Unlike Codex (shell-wired `.codex/hooks.json`), the Craft Phronesis Desktop app is a
427
+ // FIRST-PARTY, in-process surface: its reinforcement + lifecycle loop is direct calls to
428
+ // `@phronesis/core` (runContextRelevanceGuard, runDueCheck, the skill-lifecycle producer, the
429
+ // session-boundary sweep — change 0056 makes the producer emit `surface: 'desktop'`). So the
430
+ // compliance CONTRACT the desktop stands on IS auto-assertable from the CLI: this probe drives the
431
+ // exact core call-sequence the desktop engine makes against a THROWAWAY synthetic install and
432
+ // verifies the emitted event stream. It builds its OWN temp install (it emits events, so it must
433
+ // never touch operator data — the codex probe stays non-mutating with a PATH shim; the desktop probe
434
+ // stays non-mutating by owning a disposable install) and removes it in `finally`. What it does NOT
435
+ // assert is the LIVE app UI (that the running Electron app actually calls these primitives in its
436
+ // loop and renders the felt-presence surfaces) — that is the desktop's own headless engine e2e
437
+ // (`validate-desktop`, Node 22) plus a warm-run dogfood. The split is stated in the report.
438
+
439
+ const DESKTOP_SURFACE = 'desktop';
440
+ const DESKTOP_ACTOR = 'agent';
441
+
442
+ function seedProbeInstall(root) {
443
+ mkdirSync(join(root, '.phronesis'), { recursive: true });
444
+ mkdirSync(join(root, 'domains', 'pm', 'compiled', 'decisions'), { recursive: true });
445
+ writeFileSync(
446
+ join(root, '.phronesis', 'registry.json'),
447
+ `${JSON.stringify(
448
+ { schema_version: 1, created_by: 'adapter-desktop-probe', domains: ['pm'], skills: { 'prd-draft': { version: '0.2.0' } } },
449
+ null,
450
+ 2,
451
+ )}\n`,
452
+ );
453
+ // Owned-context object for the seeded-injection fixture.
454
+ writeFileSync(
455
+ join(root, 'domains', 'pm', 'compiled', 'decisions', '2026-01-10-acme-pricing-tiers.md'),
456
+ '---\ntype: decision\nname: 2026-01-10-acme-pricing-tiers\ntitle: Acme pricing tiers\ndomain: pm\nexposure: private\ncreated: 2026-01-10\n---\n\nWe set the Acme pricing tiers at three levels.\n',
457
+ );
458
+ // Overdue decision (review_date long past, no recorded outcome) for the due-check fixture.
459
+ writeFileSync(
460
+ join(root, 'domains', 'pm', 'compiled', 'decisions', '2020-01-01-legacy-review.md'),
461
+ '---\ntype: decision\nname: 2020-01-01-legacy-review\ntitle: Legacy review obligation\ndomain: pm\nexposure: private\ncreated: 2020-01-01\nreview_date: 2020-06-01\n---\n\nA decision whose review is long overdue.\n',
462
+ );
463
+ }
464
+
465
+ // Run the four contract properties (plan §7.3) against a throwaway synthetic install. Returns
466
+ // { checks: [{ok,label,detail}], compliant }. Async — it awaits the core primitives.
467
+ async function runDesktopProbe() {
468
+ const base = mkdtempSync(join(tmpdir(), 'phronesis-desktop-adapter-'));
469
+ const checks = [];
470
+ const add = (ok, label, detail = '') => checks.push({ ok, label, detail });
471
+ try {
472
+ const root = join(base, 'install');
473
+ seedProbeInstall(root);
474
+ const S = DESKTOP_SURFACE;
475
+ const A = DESKTOP_ACTOR;
476
+ const session = 'sess_desktop_probe';
477
+ const guard = (prompt) =>
478
+ runContextRelevanceGuard({ installRoot: root, cwd: root, prompt, session, surface: S, actor: A });
479
+
480
+ // Fixture 1 — seeded-state injection: owned-context prompt fires + retrieves the seeded object.
481
+ try {
482
+ const g = await guard('what should I do about my Acme pricing tiers decision?');
483
+ const ok = g.fired === true && g.empty_result === false && g.result_count > 0;
484
+ add(ok, 'guard fixture 1: owned-context prompt injects recall (fired, non-empty)', ok ? '' : `fired=${g.fired} empty=${g.empty_result} count=${g.result_count}`);
485
+ } catch (e) {
486
+ add(false, 'guard fixture 1: owned-context prompt injects recall (fired, non-empty)', e.message);
487
+ }
488
+
489
+ // Fixture 2 — no-cue: the guard does not fire and emits nothing.
490
+ try {
491
+ const g = await guard('explain how a binary search works in general');
492
+ add(g.fired === false, 'guard fixture 2: no-cue prompt does not fire (no recall events)', g.fired === false ? '' : `guard fired on a no-cue prompt (fired=${g.fired})`);
493
+ } catch (e) {
494
+ add(false, 'guard fixture 2: no-cue prompt does not fire (no recall events)', e.message);
495
+ }
496
+
497
+ // Fixture 3 — honest negative: fires, but no matching state → empty_result.
498
+ try {
499
+ const g = await guard('what should I do about my zzqx qwrtplk rollout?');
500
+ const ok = g.fired === true && g.empty_result === true && g.result_count === 0;
501
+ add(ok, 'guard fixture 3: owned-context prompt with no matching state yields an honest negative', ok ? '' : `fired=${g.fired} empty=${g.empty_result} count=${g.result_count}`);
502
+ } catch (e) {
503
+ add(false, 'guard fixture 3: owned-context prompt with no matching state yields an honest negative', e.message);
504
+ }
505
+
506
+ // Event-stream corroboration: exactly two recall pairs (fixtures 1 + 3 — fixture 2 emitted none),
507
+ // one non-empty + one empty, all stamped surface:desktop / actor:agent.
508
+ try {
509
+ const injected = (await readEvents({ installRoot: root, type: 'recall.injected' })).filter((e) => e.session_id === session);
510
+ const cand = (await readEvents({ installRoot: root, type: 'recall.candidate' })).filter((e) => e.session_id === session);
511
+ const allDesktop = [...injected, ...cand].every((e) => e.surface === S && e.actor === A);
512
+ const empties = injected.filter((e) => e.payload.empty_result === true).length;
513
+ const nonEmpties = injected.filter((e) => e.payload.empty_result === false).length;
514
+ const ok = injected.length === 2 && cand.length === 2 && empties === 1 && nonEmpties === 1 && allDesktop;
515
+ add(ok, 'recall events: two candidate/injected pairs (surface:desktop), one empty + one non-empty; none from the no-cue prompt', ok ? '' : `candidate=${cand.length} injected=${injected.length} empty=${empties} nonEmpty=${nonEmpties} allDesktop=${allDesktop}`);
516
+ } catch (e) {
517
+ add(false, 'recall events: two candidate/injected pairs (surface:desktop), one empty + one non-empty; none from the no-cue prompt', e.message);
518
+ }
519
+
520
+ // Due-check surfacing: an overdue decision → outcome.due (surface:desktop).
521
+ try {
522
+ const res = await runDueCheck({ installRoot: root, session, surface: S, actor: A });
523
+ const dueEvents = (await readEvents({ installRoot: root, type: 'outcome.due' })).filter((e) => e.session_id === session);
524
+ const ok = res.due.length >= 1 && dueEvents.length >= 1 && dueEvents.every((e) => e.surface === S);
525
+ add(ok, 'due-check surfaces an overdue decision (outcome.due, surface:desktop)', ok ? '' : `due=${res.due.length} events=${dueEvents.length}`);
526
+ } catch (e) {
527
+ add(false, 'due-check surfaces an overdue decision (outcome.due, surface:desktop)', e.message);
528
+ }
529
+
530
+ // Sweep block-once: a planted decision.candidate is banked; the turn's sweep-close emits exactly
531
+ // ONE session.end sweep marker; a second close is a no-op (never loops).
532
+ try {
533
+ const sweepSession = 'sess_desktop_sweep';
534
+ await appendEvent({ installRoot: root, type: 'decision.candidate', session_id: sweepSession, actor: A, surface: S, payload: { proposed_decision: 'Adopt the planted tiering decision', confidence: 0.8 } });
535
+ const close = async () => {
536
+ const status = await sweepStatus({ installRoot: root, session: sweepSession });
537
+ if (status.swept) return { emitted: false };
538
+ const candidates = (await readEvents({ installRoot: root }))
539
+ .filter((e) => e.session_id === sweepSession && ['decision.candidate', 'person.candidate', 'commitment.candidate'].includes(e.type)).length;
540
+ await appendEvent({ installRoot: root, type: 'session.end', session_id: sweepSession, actor: A, surface: S, payload: { sweep: { candidates } } });
541
+ return { emitted: true, candidates };
542
+ };
543
+ const first = await close();
544
+ const second = await close(); // block-once: already swept → no second marker
545
+ const markers = (await readEvents({ installRoot: root, type: 'session.end' }))
546
+ .filter((e) => e.session_id === sweepSession && e.payload && e.payload.sweep && Number.isInteger(e.payload.sweep.candidates));
547
+ const st = await sweepStatus({ installRoot: root, session: sweepSession });
548
+ const ok = first.emitted === true && second.emitted === false && markers.length === 1 && st.swept === true && st.candidates === 1;
549
+ add(ok, 'sweep block-once: a banked candidate yields exactly one session.end sweep marker per turn (never loops)', ok ? '' : `first=${first.emitted} second=${second.emitted} markers=${markers.length} candidates=${st.candidates}`);
550
+ } catch (e) {
551
+ add(false, 'sweep block-once: a banked candidate yields exactly one session.end sweep marker per turn (never loops)', e.message);
552
+ }
553
+
554
+ // Level-2 SUCCESS path (Codex R2 F3): begin a registered skill → commit an action carrying its
555
+ // invocation_id → complete. Assert EXACTLY ONE skill.completed with the matching invocation_id and
556
+ // surface:desktop (so the "skill.invoked/completed are real" Level-2 claim is not under-tested).
557
+ try {
558
+ const okSession = 'sess_desktop_skill_ok';
559
+ const begun = await beginSkillInvocation({ installRoot: root, skill: { name: 'prd-draft' }, session: okSession, surface: S });
560
+ const invId = begun.record && begun.record.invocation_id;
561
+ await runAction({
562
+ type: 'log_decision',
563
+ payload: { title: 'Skill-run decision', created: '2026-01-05' },
564
+ ctx: { dir: root, session: okSession, surface: S, actor: A, invocation_id: invId },
565
+ });
566
+ const done = await completeActiveSkillInvocation({ installRoot: root, session: okSession, surface: S });
567
+ const completedEvents = (await readEvents({ installRoot: root, type: 'skill.completed' }))
568
+ .filter((e) => e.session_id === okSession && e.payload && e.payload.invocation_id === invId);
569
+ const ok =
570
+ begun.started === true &&
571
+ done.completed === true &&
572
+ completedEvents.length === 1 &&
573
+ completedEvents[0].surface === S &&
574
+ completedEvents[0].payload.action_types_committed >= 1;
575
+ add(ok, 'Level-2 success path: begin → act → complete emits exactly one skill.completed (surface:desktop, real invocation_id)', ok ? '' : `started=${begun.started} completed=${done.completed} completedEvents=${completedEvents.length}`);
576
+ } catch (e) {
577
+ add(false, 'Level-2 success path: begin → act → complete emits exactly one skill.completed (surface:desktop, real invocation_id)', e.message);
578
+ }
579
+
580
+ // No fabricated events on the error path (StopFailure-equivalent): abandon clears the record and
581
+ // leaves an honest gap — skill.invoked stands, NO synthetic skill.completed, an abandon audit written.
582
+ try {
583
+ const errSession = 'sess_desktop_error';
584
+ const begun = await beginSkillInvocation({ installRoot: root, skill: { name: 'prd-draft' }, session: errSession, surface: S });
585
+ const abandoned = await abandonActiveSkillInvocation({ installRoot: root, session: errSession, surface: S, reason: 'turn-error' });
586
+ const invoked = (await readEvents({ installRoot: root, type: 'skill.invoked' })).filter((e) => e.session_id === errSession);
587
+ const completed = (await readEvents({ installRoot: root, type: 'skill.completed' })).filter((e) => e.session_id === errSession);
588
+ const active = await readActiveInvocation({ installRoot: root, session: errSession });
589
+ const audit = (await readAuditLog(root)).filter((e) => e.outcome === 'skill-lifecycle-abandoned' && e.session_id === errSession);
590
+ const ok = begun.started === true && abandoned.abandoned === true && invoked.length === 1 && invoked[0].surface === S && completed.length === 0 && active === null && audit.length === 1;
591
+ add(ok, 'error path: abandon leaves an honest gap — skill.invoked stands, no fabricated skill.completed', ok ? '' : `begun=${begun.started} invoked=${invoked.length} completed=${completed.length} active=${active ? 'present' : 'null'} audit=${audit.length}`);
592
+ } catch (e) {
593
+ add(false, 'error path: abandon leaves an honest gap — skill.invoked stands, no fabricated skill.completed', e.message);
594
+ }
595
+
596
+ return { checks, compliant: checks.every((c) => c.ok) };
597
+ } finally {
598
+ rmSync(base, { recursive: true, force: true });
599
+ }
600
+ }
601
+
602
+ // PURE desktop-adapter report — async (awaits the core call-sequence). Ignores `dir`: the desktop is
603
+ // in-process with no per-install wiring to check, and the probe must emit into a throwaway install,
604
+ // never the operator's. Returns { report }.
605
+ export async function desktopAdapterReport() {
606
+ const probe = await runDesktopProbe();
607
+ return {
608
+ report: {
609
+ surface: 'desktop',
610
+ install: '(throwaway synthetic install — desktop is in-process; no per-install wiring)',
611
+ level_1: {
612
+ name: 'reinforcement',
613
+ status: probe.compliant ? 'compliant' : 'non-compliant',
614
+ wires: ['recall-guard', 'due-check', 'capture-sweep'],
615
+ },
616
+ level_2: {
617
+ name: 'skill-lifecycle',
618
+ status: probe.compliant ? 'compliant' : 'non-compliant',
619
+ reason:
620
+ 'First-party in-process minter (change 0056): skill.invoked/completed with real invocation_ids and surface:desktop; the error path abandons without a fabricated skill.completed.',
621
+ },
622
+ note: 'Auto-asserts the reinforcement + lifecycle CONTRACT via core. The live app UI half is operator-verified (the desktop headless engine e2e + a warm-run dogfood).',
623
+ checks: probe.checks,
624
+ compliant: probe.compliant,
625
+ },
626
+ };
627
+ }
628
+
629
+ function printHumanDesktop(report) {
630
+ const mark = (ok) => (ok ? '✓' : '✗');
631
+ console.log(`phronesis adapter test desktop — ${report.install}`);
632
+ console.log('');
633
+ for (const c of report.checks) {
634
+ console.log(` ${mark(c.ok)} ${c.label}${c.detail ? ` — ${c.detail}` : ''}`);
635
+ }
636
+ console.log('');
637
+ console.log(` Level 1 (reinforcement): ${report.level_1.status.toUpperCase()} — wires ${report.level_1.wires.join(', ')}`);
638
+ console.log(` Level 2 (skill-lifecycle): ${report.level_2.status.toUpperCase()} — ${report.level_2.reason}`);
639
+ console.log('');
640
+ console.log(` ${report.note}`);
641
+ console.log('');
642
+ console.log(
643
+ report.compliant
644
+ ? ' Craft Phronesis Desktop is contract-compliant: the in-process reinforcement + lifecycle loop fires correctly.'
645
+ : ' Desktop adapter contract check FAILED — see the failed checks above.',
646
+ );
647
+ }
648
+
360
649
  export async function runAdapterTest({ surface, dir, domain, json = false } = {}) {
361
650
  if (!surface) {
362
- console.error('adapter test: a <surface> is required (V1: codex).');
651
+ console.error('adapter test: a <surface> is required (V1: codex, desktop).');
363
652
  process.exitCode = 1;
364
653
  return null;
365
654
  }
655
+ if (surface === 'desktop') {
656
+ // The desktop probe runs against a THROWAWAY synthetic install (it is in-process, with no
657
+ // per-install wiring) — so --dir/--domain do not apply. Disclose rather than silently ignore
658
+ // (Fable finding 9). Not printed under --json (keep the report the sole stdout payload).
659
+ if ((dir || domain) && !json) {
660
+ console.error('note: adapter test desktop runs against a throwaway synthetic install (the desktop is in-process); --dir/--domain do not apply and are ignored.');
661
+ }
662
+ const { report } = await desktopAdapterReport();
663
+ if (json) {
664
+ console.log(JSON.stringify(report, null, 2));
665
+ } else {
666
+ printHumanDesktop(report);
667
+ }
668
+ process.exitCode = report.compliant ? 0 : 1;
669
+ return report;
670
+ }
366
671
  if (surface !== 'codex') {
367
- console.error(`adapter test: unsupported surface "${surface}". V1 ships the Codex adapter test (claude-code is the primary, always-on surface).`);
672
+ console.error(`adapter test: unsupported surface "${surface}". V1 ships the Codex and Craft Phronesis Desktop adapter tests (claude-code is the primary, always-on surface).`);
368
673
  process.exitCode = 1;
369
674
  return null;
370
675
  }