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/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,
|
|
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 {
|
|
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 =
|
|
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
|
|
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 +
|
|
58
|
-
//
|
|
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
|
-
|
|
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 =
|
|
127
|
-
add(launcherOk, `launcher ${h.launcher} present + executable`, launcherOk ? '' : 'missing or not +x');
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
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
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
//
|
|
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
|
-
'#!/
|
|
206
|
+
'#!/bin/sh',
|
|
187
207
|
`printf '%s\\n' "$*" >> ${JSON.stringify(argvLog)}`,
|
|
188
|
-
'case "$1" in',
|
|
189
|
-
` guard) printf '%s' '{"
|
|
190
|
-
`
|
|
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('
|
|
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
|
|
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)
|
|
224
|
-
add(sweepReached, 'Stop
|
|
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
|
|
233
|
-
add(guardReached, 'UserPromptSubmit
|
|
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
|
|
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
|
|
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)
|
|
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/
|
|
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
|
-
//
|
|
286
|
-
//
|
|
287
|
-
const behavior =
|
|
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.
|
|
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
|
|
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
|
}
|