phronesis 1.0.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 (142) hide show
  1. package/README.md +66 -0
  2. package/package.json +34 -0
  3. package/src/act.js +61 -0
  4. package/src/active-context.js +56 -0
  5. package/src/adapter.js +386 -0
  6. package/src/cli.js +1893 -0
  7. package/src/codex-hooks-schema.js +92 -0
  8. package/src/compile.js +513 -0
  9. package/src/doctor.js +303 -0
  10. package/src/due.js +271 -0
  11. package/src/eval.js +467 -0
  12. package/src/event.js +212 -0
  13. package/src/export.js +136 -0
  14. package/src/guard.js +89 -0
  15. package/src/hooks.js +230 -0
  16. package/src/ingest.js +965 -0
  17. package/src/init.js +150 -0
  18. package/src/layout.js +394 -0
  19. package/src/links.js +83 -0
  20. package/src/lint.js +58 -0
  21. package/src/profile.js +116 -0
  22. package/src/prompts.js +26 -0
  23. package/src/registry.js +190 -0
  24. package/src/scaffold.js +134 -0
  25. package/src/search.js +139 -0
  26. package/src/skill-discovery.js +117 -0
  27. package/src/skills-bump.js +885 -0
  28. package/src/skills.js +355 -0
  29. package/src/subscription.js +5 -0
  30. package/src/validate.js +152 -0
  31. package/templates/claude/settings.json +86 -0
  32. package/templates/codex/INDEX.md +68 -0
  33. package/templates/codex/seed/ai-practice/compile-dont-just-capture.md +42 -0
  34. package/templates/codex/seed/ai-practice/design-for-the-distribution.md +42 -0
  35. package/templates/codex/seed/ai-practice/from-spec-author-to-outcome-curator.md +43 -0
  36. package/templates/codex/seed/ai-practice/recall-builds-trust-when-calibrated.md +70 -0
  37. package/templates/codex/seed/ai-practice/the-eval-is-the-spec.md +42 -0
  38. package/templates/codex/seed/ai-practice/the-gold-dust-leaves-no-trace.md +72 -0
  39. package/templates/codex/seed/ai-practice/the-model-is-not-the-moat.md +40 -0
  40. package/templates/codex/seed/ai-practice/when-generation-is-cheap-judgment-is-the-work.md +39 -0
  41. package/templates/codex/seed/judgment/contextual-stewardship-is-the-senior-skill.md +50 -0
  42. package/templates/codex/seed/judgment/decision-quality-is-distinct-from-outcome-quality.md +39 -0
  43. package/templates/codex/seed/judgment/embrace-reality-ego-and-blind-spots.md +38 -0
  44. package/templates/codex/seed/judgment/groups-seek-consensus-individuals-seek-truth.md +44 -0
  45. package/templates/codex/seed/judgment/heresies-are-where-the-edge-lives.md +42 -0
  46. package/templates/codex/seed/judgment/judgment-compounds-through-honest-reflection.md +38 -0
  47. package/templates/codex/seed/judgment/phronesis-is-judgment-in-the-particular.md +67 -0
  48. package/templates/codex/seed/judgment/show-me-the-apparatus.md +40 -0
  49. package/templates/codex/seed/judgment/small-judgment-gaps-compound-under-leverage.md +37 -0
  50. package/templates/codex/seed/judgment/taste-is-compressed-judgment.md +38 -0
  51. package/templates/codex/seed/judgment/trust-taste-only-where-feedback-is-valid.md +42 -0
  52. package/templates/codex/seed/judgment/weigh-the-reasoning-not-the-recommendation.md +43 -0
  53. package/templates/codex/seed/leadership/a-managers-output-is-the-teams-output.md +35 -0
  54. package/templates/codex/seed/leadership/most-team-problems-are-structural.md +40 -0
  55. package/templates/codex/seed/leadership/next-play.md +36 -0
  56. package/templates/codex/seed/leadership/psychological-safety.md +39 -0
  57. package/templates/codex/seed/leadership/right-size-your-teams.md +38 -0
  58. package/templates/codex/seed/leadership/spend-time-on-high-leverage-activities.md +36 -0
  59. package/templates/codex/seed/leadership/talent-density-is-the-master-variable.md +39 -0
  60. package/templates/codex/seed/leadership/under-pressure-calm-is-contagious.md +36 -0
  61. package/templates/codex/seed/leadership/your-best-alternative-is-your-leverage.md +37 -0
  62. package/templates/codex/seed/pm/a-problem-well-stated-is-half-solved.md +39 -0
  63. package/templates/codex/seed/pm/a-real-strategy-makes-planning-trivial.md +40 -0
  64. package/templates/codex/seed/pm/barrels-and-ammunition.md +39 -0
  65. package/templates/codex/seed/pm/develop-taste-in-your-beliefs.md +38 -0
  66. package/templates/codex/seed/pm/hire-for-progress.md +44 -0
  67. package/templates/codex/seed/pm/jobs-are-stable-solutions-churn.md +39 -0
  68. package/templates/codex/seed/pm/level-by-the-ambiguity-someone-can-own.md +39 -0
  69. package/templates/codex/seed/pm/listen-before-you-prescribe.md +38 -0
  70. package/templates/codex/seed/pm/live-in-the-future-and-build-whats-missing.md +38 -0
  71. package/templates/codex/seed/pm/match-effort-to-leverage.md +37 -0
  72. package/templates/codex/seed/pm/not-my-fault-but-my-problem.md +40 -0
  73. package/templates/codex/seed/pm/pick-the-job-then-refuse-everything-outside-it.md +37 -0
  74. package/templates/codex/seed/pm/retention-sets-the-ceiling.md +38 -0
  75. package/templates/codex/seed/pm/scale-with-leaders-not-process.md +38 -0
  76. package/templates/codex/seed/pm/solution-discovery-over-problem-validation.md +38 -0
  77. package/templates/codex/seed/pm/tenfold-better-on-a-painful-problem.md +40 -0
  78. package/templates/codex-surface/README.md +73 -0
  79. package/templates/codex-surface/hooks/_resolve.sh +36 -0
  80. package/templates/codex-surface/hooks/recall-guard.sh +30 -0
  81. package/templates/codex-surface/hooks/session-start.sh +18 -0
  82. package/templates/codex-surface/hooks/session-sweep-precompact.sh +20 -0
  83. package/templates/codex-surface/hooks/session-sweep-subagent.sh +23 -0
  84. package/templates/codex-surface/hooks/session-sweep.sh +19 -0
  85. package/templates/codex-surface/hooks.json +69 -0
  86. package/templates/domains/pm/AGENTS.md +111 -0
  87. package/templates/domains/pm/MEMORY.md +7 -0
  88. package/templates/domains/pm/USER.md +8 -0
  89. package/templates/phronesis-hooks/compile-active-context.sh +38 -0
  90. package/templates/phronesis-hooks/recall-guard.sh +103 -0
  91. package/templates/phronesis-hooks/session-start.sh +90 -0
  92. package/templates/phronesis-hooks/session-sweep.sh +140 -0
  93. package/templates/phronesis-hooks/skill-lifecycle.sh +37 -0
  94. package/templates/skills/compile/SKILL.md +279 -0
  95. package/templates/skills/compile/evals/rubric.md +75 -0
  96. package/templates/skills/decision-log/SKILL.md +156 -0
  97. package/templates/skills/decision-log/evals/rubric.md +97 -0
  98. package/templates/skills/extract-gold-dust/SKILL.md +311 -0
  99. package/templates/skills/extract-gold-dust/evals/rubric.md +52 -0
  100. package/templates/skills/ingest/SKILL.md +204 -0
  101. package/templates/skills/ingest/evals/rubric.md +117 -0
  102. package/templates/skills/lint/SKILL.md +246 -0
  103. package/templates/skills/lint/evals/rubric.md +139 -0
  104. package/templates/skills/meeting-prep/SKILL.md +195 -0
  105. package/templates/skills/meeting-prep/evals/rubric.md +93 -0
  106. package/templates/skills/onboard/SKILL.md +258 -0
  107. package/templates/skills/onboard/evals/rubric.md +60 -0
  108. package/templates/skills/prd-draft/SKILL.md +193 -0
  109. package/templates/skills/prd-draft/evals/rubric.md +38 -0
  110. package/templates/skills/prep-extraction/SKILL.md +217 -0
  111. package/templates/skills/prep-extraction/evals/rubric.md +45 -0
  112. package/templates/skills/recall/SKILL.md +155 -0
  113. package/templates/skills/recall/evals/rubric.md +37 -0
  114. package/templates/skills/research/SKILL.md +247 -0
  115. package/templates/skills/research/evals/rubric.md +139 -0
  116. package/templates/skills/stakeholder-update/SKILL.md +163 -0
  117. package/templates/skills/stakeholder-update/evals/rubric.md +111 -0
  118. package/vendor/core/package.json +25 -0
  119. package/vendor/core/src/action-registry.js +459 -0
  120. package/vendor/core/src/action-writers.js +201 -0
  121. package/vendor/core/src/actions.js +2140 -0
  122. package/vendor/core/src/active-context.js +167 -0
  123. package/vendor/core/src/artifacts.js +301 -0
  124. package/vendor/core/src/content-digest.js +21 -0
  125. package/vendor/core/src/contract.js +245 -0
  126. package/vendor/core/src/event-log.js +338 -0
  127. package/vendor/core/src/guard.js +398 -0
  128. package/vendor/core/src/hook-executor.js +527 -0
  129. package/vendor/core/src/index.js +180 -0
  130. package/vendor/core/src/ingest.js +286 -0
  131. package/vendor/core/src/install-root.js +42 -0
  132. package/vendor/core/src/layout.js +13 -0
  133. package/vendor/core/src/link-index.js +198 -0
  134. package/vendor/core/src/lint.js +368 -0
  135. package/vendor/core/src/schedule.js +535 -0
  136. package/vendor/core/src/search.js +534 -0
  137. package/vendor/core/src/session-hooks.js +129 -0
  138. package/vendor/core/src/skill-lifecycle.js +479 -0
  139. package/vendor/core/src/skill-registry.js +524 -0
  140. package/vendor/core/src/subscription.js +84 -0
  141. package/vendor/core/src/validate-object.js +62 -0
  142. package/vendor/core/src/workspace-scan.js +125 -0
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # phronesis
2
+
3
+ The Phronesis CLI. Ports the pming scaffold (ESM + enquirer/chalk/fs-extra/slugify)
4
+ forward to the three-layer installation layout, leaving pming's two-layer `context/`
5
+ assumptions behind.
6
+
7
+ ## Usage
8
+
9
+ ```
10
+ npx phronesis init [dir] # default dir: phronesis-installation
11
+ npx phronesis init . --force # replace a non-empty target
12
+ npx phronesis init . --domain pm # choose the domain workspace (default: pm)
13
+
14
+ # Minimal operator profile (fills USER.md + AGENTS.md §1; no [Name]/[Role] dead air):
15
+ npx phronesis init . --name "Ada Lovelace" --role "Founder"
16
+ # optional, USER.md-only: --company "Analytical Engines" --working-style "async, terse"
17
+ # On a terminal, a missing --name/--role is prompted. A non-interactive (scripted/CI) init
18
+ # never prompts or blocks — it completes and leaves the placeholders.
19
+ ```
20
+
21
+ The published V1.0 package exposes only the `phronesis` binary; `pming` remains
22
+ the historical reference name, not a public install alias.
23
+
24
+ ## What `init` creates (specs/workspace-substrate.md)
25
+
26
+ ```
27
+ <dir>/
28
+ ├── codex/ Layer 3 — shared across all domains (seed/ + personal/)
29
+ ├── domains/
30
+ │ └── pm/ a domain workspace
31
+ │ ├── raw/ Layer 1 — append-only source material
32
+ │ ├── compiled/ Layer 2 — durable current state
33
+ │ ├── memory/ session-scoped working memory
34
+ │ └── exports/ disposable renders
35
+ ├── skills/
36
+ └── .phronesis/ runtime + configuration (registry.json)
37
+ ```
38
+
39
+ Source of truth is the folder: plain Markdown, no database, no upload step.
40
+
41
+ ## Scope
42
+
43
+ `init` only — the three-layer **structure** (core-0001 / T1.1). Deliberately
44
+ deferred to later packets:
45
+
46
+ - Session-file content — `AGENTS.md`, the `CLAUDE.md` / `.cursorrules` symlinks,
47
+ `USER.md`, `MEMORY.md`, `codex/INDEX.md` (T1.2).
48
+ - Immutable PM-shaped seed principles in `codex/seed/` (T5.4).
49
+ - Daemon (`phronesisd`), hooks, action types, event log (T2.x / T4.x).
50
+ - `MIGRATION.md` for existing pming workspaces (T1.x follow-up).
51
+
52
+ ## Tests
53
+
54
+ ```
55
+ npm run -w phronesis test # node --test: asserts the generated layout
56
+ npm run -w phronesis sync-package-assets
57
+ ```
58
+
59
+ The same end-to-end scaffold is exercised by `harness/fitness/validate-workspace`,
60
+ which graduated from a docs-only stub to enforcing the export-shape invariant
61
+ (plain folders, no legacy `context/`) once this CLI landed. Model-swap and
62
+ skill-portability stay PENDING until the compile/retrieve loop exists (ADR 0001).
63
+
64
+ `sync-package-assets` refreshes the tracked publish assets from their canonical
65
+ sources: root `skills/` into `templates/skills/`, and `packages/core` into
66
+ `vendor/core`. The fitness gate fails if either tracked copy drifts.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "phronesis",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Phronesis CLI + daemon. Ports the pming scaffold forward to the three-layer installation layout.",
7
+ "bin": {
8
+ "phronesis": "src/cli.js"
9
+ },
10
+ "scripts": {
11
+ "prepack": "node scripts/mirror-skills.mjs check",
12
+ "prepublishOnly": "node scripts/verify-installability.mjs",
13
+ "sync-package-assets": "node scripts/mirror-skills.mjs sync",
14
+ "test": "node --test test/*.test.js"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "templates",
22
+ "vendor"
23
+ ],
24
+ "dependencies": {
25
+ "@phronesis/core": "file:vendor/core",
26
+ "chalk": "^5.6.2",
27
+ "enquirer": "^2.4.1",
28
+ "fs-extra": "^11.3.3",
29
+ "js-yaml": "^4.1.0",
30
+ "lunr": "^2.3.9",
31
+ "slugify": "^1.6.6"
32
+ },
33
+ "license": "MIT"
34
+ }
package/src/act.js ADDED
@@ -0,0 +1,61 @@
1
+ // `phronesis act <type> --json '<payload>'` — the action-type execution floor
2
+ // (core-0008 / T2.2; ontology.md §"Action type contract"; change 0008). Plumbing, not
3
+ // porcelain: a named internal surface skills compose and adapters/tests target, kept
4
+ // out of the top-level --help. This handler ONLY parses and delegates (D7) — every
5
+ // check, event, gate, and write lives behind runAction in @phronesis/core, so the CLI
6
+ // and any direct caller are the same action.
7
+ //
8
+ // Input discipline: SEMANTIC action inputs (titles, slugs, insights, links) live inside
9
+ // --json and are schema-validated by the core; flags carry only EXECUTION CONTEXT
10
+ // (--session/--domain/--actor/--surface/--dir). The approval signal (D5) is the
11
+ // adapter-set env PHRONESIS_APPROVAL_SOURCE — deliberately not a flag and not payload.
12
+
13
+ import { resolveInvocationIdForAct, runAction } from '@phronesis/core';
14
+
15
+ export async function runAct({ type, json, session, domain, actor, surface, dir, invocationId } = {}) {
16
+ if (!type) {
17
+ console.error('act: name an action <type> (e.g. log_decision).');
18
+ process.exitCode = 1;
19
+ return null;
20
+ }
21
+ let payload;
22
+ try {
23
+ payload = json === undefined ? {} : JSON.parse(json);
24
+ } catch {
25
+ console.error('act: --json must be valid JSON.');
26
+ process.exitCode = 1;
27
+ return null;
28
+ }
29
+
30
+ try {
31
+ const resolvedInvocation = await resolveInvocationIdForAct({
32
+ dir,
33
+ cwd: process.cwd(),
34
+ session,
35
+ callerInvocationId: invocationId,
36
+ envInvocationId: process.env.PHRONESIS_INVOCATION_ID,
37
+ surface: surface || 'cli',
38
+ });
39
+ const result = await runAction({
40
+ type,
41
+ payload,
42
+ ctx: {
43
+ dir,
44
+ cwd: process.cwd(),
45
+ session,
46
+ domain,
47
+ actor: actor || process.env.PHRONESIS_ACTOR || undefined,
48
+ surface,
49
+ invocation_id: resolvedInvocation.invocation_id,
50
+ approvalSource: process.env.PHRONESIS_APPROVAL_SOURCE || undefined,
51
+ },
52
+ });
53
+ console.log(JSON.stringify(result));
54
+ process.exitCode = 0;
55
+ return result;
56
+ } catch (err) {
57
+ console.error(`act: ${err.message}`);
58
+ process.exitCode = 1;
59
+ return null;
60
+ }
61
+ }
@@ -0,0 +1,56 @@
1
+ // `phronesis active-context refresh --domain <slug>` (core-0017 H5) — regenerate the
2
+ // domain's tracked active-context projection (domains/{domain}/ACTIVE_CONTEXT.md) from its
3
+ // compiled/ state. Plumbing, not porcelain: the post-compile hook shells out to this on
4
+ // every compile commit, and the validate-time staleness check is its backstop. The
5
+ // generator is deterministic and lives in @phronesis/core (refreshActiveContext) — this
6
+ // surface only resolves the install, refuses a phantom domain, and delegates.
7
+
8
+ import { join } from 'node:path';
9
+ import fse from 'fs-extra';
10
+ import { resolveInstall, refreshActiveContext } from '@phronesis/core';
11
+
12
+ export async function runActiveContextRefresh({ dir, domain, json = false } = {}) {
13
+ if (!domain) {
14
+ console.error('active-context refresh: --domain <slug> is required.');
15
+ process.exitCode = 1;
16
+ return null;
17
+ }
18
+ const installed = resolveInstall({ dir, cwd: process.cwd() });
19
+ if (installed.error) {
20
+ console.error(installed.error);
21
+ process.exitCode = 1;
22
+ return null;
23
+ }
24
+ const installRoot = installed.root;
25
+
26
+ let registry;
27
+ try {
28
+ registry = await fse.readJson(join(installRoot, '.phronesis', 'registry.json'));
29
+ } catch (err) {
30
+ console.error(`active-context refresh: could not read the install registry: ${err.message}`);
31
+ process.exitCode = 1;
32
+ return null;
33
+ }
34
+ // Never conjure a domains/{slug}/ tree as a side effect (the resolveDomain posture,
35
+ // applied to the projection generator) — refuse an unregistered domain.
36
+ const registered = Array.isArray(registry.domains) ? registry.domains : [];
37
+ if (!registered.includes(domain)) {
38
+ console.error(
39
+ `active-context refresh: domain "${domain}" is not registered in this installation (registered: ${registered.join(', ') || 'none'}).`,
40
+ );
41
+ process.exitCode = 1;
42
+ return null;
43
+ }
44
+
45
+ try {
46
+ const res = await refreshActiveContext({ installRoot, domain });
47
+ if (json) console.log(JSON.stringify(res));
48
+ else console.log(`active-context: refreshed ${res.path}`);
49
+ process.exitCode = 0;
50
+ return res;
51
+ } catch (err) {
52
+ console.error(`active-context refresh: ${err.message}`);
53
+ process.exitCode = 1;
54
+ return null;
55
+ }
56
+ }
package/src/adapter.js ADDED
@@ -0,0 +1,386 @@
1
+ // `phronesis adapter test <surface>` — the per-surface adapter self-check
2
+ // (specs/tech-stack.md §"Adapter test command"; specs/event-log.md §"Adapter test";
3
+ // change 0028 / core-0034). V1 ships the Codex adapter test: it asserts the deterministic
4
+ // emission + install-root-resolution mechanics of the project-local .codex/ wiring and
5
+ // reports the compliance LEVEL — Level 1 (reinforcement) and whether Level 2
6
+ // (skill-lifecycle) is met or partial — then points at the operator-verified desktop-app
7
+ // checklist (the app half a CLI cannot auto-assert). It is read-only: it writes no
8
+ // canonical state and emits no events.
9
+
10
+ import { join } from 'node:path';
11
+ import { readFileSync, statSync, existsSync, mkdtempSync, writeFileSync, chmodSync, rmSync, realpathSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { resolveInstall, RE_SLUG } from '@phronesis/core';
16
+ import { CODEX_ADAPTER_HOOKS, CODEX_SURFACE_RESOLVER } from './layout.js';
17
+ import { validateCodexHooksSchema } from './codex-hooks-schema.js';
18
+
19
+ // The Codex adapter event→launcher→script→tier contract is the single source in layout.js
20
+ // (alongside the templateCopies that emit it), so this self-check can never validate
21
+ // against a wiring different from what init actually wrote.
22
+ const HOOKS = CODEX_ADAPTER_HOOKS;
23
+
24
+ const DESKTOP_CHECKLIST = 'docs/surface-setup.md';
25
+
26
+ // Read the install's domains from the registry (the adapter test runs against the
27
+ // domain workspace open target — domains/{slug}/.codex/). Falls back to 'pm'.
28
+ function installDomains(root) {
29
+ try {
30
+ const reg = JSON.parse(readFileSync(join(root, '.phronesis', 'registry.json'), 'utf8'));
31
+ // Validate members are kebab-case SLUGs (RE_SLUG — core-0038 F2/round-3): a `[null]`/`[{}]` would
32
+ // reach checkEmission's path.join as a non-string and throw; a `"../outside"`/`"pm/evil"` would
33
+ // path-traverse out of the domains namespace. Either breaks codexAdapterReport's
34
+ // `{ root, report } | { error }` contract / fail-closed posture. A malformed/empty list falls
35
+ // back to the default domain.
36
+ const valid = Array.isArray(reg.domains) ? reg.domains.filter((d) => typeof d === 'string' && RE_SLUG.test(d)) : [];
37
+ if (valid.length) return valid;
38
+ } catch {
39
+ /* fall through */
40
+ }
41
+ return ['pm'];
42
+ }
43
+
44
+ function isExecutable(p) {
45
+ try {
46
+ const st = statSync(p);
47
+ // 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).
49
+ return st.isFile() && Boolean(st.mode & 0o111);
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ // Structural: the emitted domains/{slug}/.codex/ wiring matches the CODEX_ADAPTER_HOOKS
56
+ // 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 }.
59
+ function checkEmission(root, slug) {
60
+ const checks = [];
61
+ const add = (ok, label, detail = '') => checks.push({ ok, label, detail });
62
+ const codexDir = join(root, 'domains', slug, '.codex');
63
+ const hooksJsonPath = join(codexDir, 'hooks.json');
64
+
65
+ if (!existsSync(hooksJsonPath)) {
66
+ add(false, `domains/${slug}/.codex/hooks.json present`, 'missing — run `phronesis init`');
67
+ return { checks, compliant: false };
68
+ }
69
+ let config;
70
+ try {
71
+ config = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
72
+ } catch (err) {
73
+ add(false, `domains/${slug}/.codex/hooks.json parses`, err.message);
74
+ return { checks, compliant: false };
75
+ }
76
+ add(true, `domains/${slug}/.codex/hooks.json present + parses`);
77
+ const schema = validateCodexHooksSchema(config, { source: `domains/${slug}/.codex/hooks.json` });
78
+ add(
79
+ schema.ok,
80
+ `domains/${slug}/.codex/hooks.json schema-pure (Codex strict parser)`,
81
+ schema.ok ? '' : schema.errors.join('; '),
82
+ );
83
+ if (!schema.ok) return { checks, compliant: false };
84
+
85
+ // The shared resolver the launchers source — infrastructure, not an event hook, but
86
+ // every launcher fails to source it if it is absent, so it must ship + be present.
87
+ 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');
89
+
90
+ const hooks = (config && config.hooks) || {};
91
+ // Exact-set match against the contract: an event wired in hooks.json but absent from
92
+ // CODEX_ADAPTER_HOOKS (a hand-edit, or a template that drifted ahead of the constant) is
93
+ // unvetted wiring — flag it, don't silently accept it (core-0034 review #4).
94
+ const contractEvents = new Set(HOOKS.map((h) => h.event));
95
+ const extras = Object.keys(hooks).filter((e) => !contractEvents.has(e));
96
+ add(extras.length === 0, 'no events wired beyond the adapter contract', extras.length ? `unexpected: ${extras.join(', ')}` : '');
97
+
98
+ for (const h of HOOKS) {
99
+ // EXACT contract, not just "[0] matches": exactly ONE entry per event, each with exactly
100
+ // ONE command. A second entry under a contracted event (or a second command nested in the
101
+ // entry) pointing at an uncontracted script is unvetted wiring a `[0]`-only read sails past
102
+ // — a hand-edit or a template drifted ahead of the constant. Reject it (core-0034 review #3).
103
+ const entries = Array.isArray(hooks[h.event]) ? hooks[h.event] : [];
104
+ const oneEntry = entries.length === 1;
105
+ // Guard EVERY member access: a hand-edited hooks.json can carry a null entry or null command
106
+ // (`[null]`, or `{hooks:[null]}` from a trailing comma / half-deleted block). The check must
107
+ // report compliant:false on those, never crash — checkEmission is NOT wrapped in try/catch,
108
+ // so an unguarded deref would abort the operator-facing self-check + its --json consumers.
109
+ const innerHooks = oneEntry && entries[0] && Array.isArray(entries[0].hooks) ? entries[0].hooks : [];
110
+ const oneCommand = innerHooks.length === 1;
111
+ const command = oneCommand && innerHooks[0] ? innerHooks[0].command : undefined;
112
+ const expected = `.codex/hooks/${h.launcher}`;
113
+ // Bare, relative, no-argument command (the variant flag is baked into the launcher so
114
+ // the wiring holds whether Codex execs directly or via a shell — core-0034).
115
+ const wired = oneEntry && oneCommand && command === expected;
116
+ const wiredDetail = !oneEntry
117
+ ? `${entries.length} hook entries wired (contract: exactly 1)`
118
+ : !oneCommand
119
+ ? `${innerHooks.length} commands under the entry (contract: exactly 1)`
120
+ : wired
121
+ ? ''
122
+ : `got: ${JSON.stringify(command)}`;
123
+ add(wired, `${h.event} → ${expected} (exactly one entry + command)`, wiredDetail);
124
+
125
+ 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');
132
+ }
133
+
134
+ return { checks, compliant: checks.every((c) => c.ok) };
135
+ }
136
+
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).
154
+ function checkBehavior(root, slug) {
155
+ const checks = [];
156
+ const add = (ok, label, detail = '') => checks.push({ ok, label, detail });
157
+ const codexHooks = join(root, 'domains', slug, '.codex', 'hooks');
158
+ const sweepLauncher = join(codexHooks, 'session-sweep.sh');
159
+ if (!existsSync(sweepLauncher)) {
160
+ add(false, 'Stop launcher runnable', 'session-sweep.sh launcher missing');
161
+ return { checks, compliant: false };
162
+ }
163
+
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.
169
+ const nonce = randomBytes(8).toString('hex');
170
+ const RECALL_SENTINEL = `ADAPTER_PROBE_RECALL_${nonce}`;
171
+ const DUE_SENTINEL = `ADAPTER_PROBE_DUE_${nonce}`;
172
+
173
+ let shimDir;
174
+ try {
175
+ shimDir = mkdtempSync(join(tmpdir(), 'phronesis-adapter-'));
176
+ const argvLog = join(shimDir, 'argv.log');
177
+ 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 `${`).
183
+ writeFileSync(
184
+ shim,
185
+ [
186
+ '#!/usr/bin/env bash',
187
+ `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 ;;`,
191
+ 'esac',
192
+ 'exit 0',
193
+ '',
194
+ ].join('\n'),
195
+ );
196
+ chmodSync(shim, 0o755);
197
+ const env = { ...process.env, PATH: `${shimDir}:${process.env.PATH || ''}` };
198
+ const target = join(root, 'domains', slug);
199
+ const readLog = () => {
200
+ try {
201
+ return readFileSync(argvLog, 'utf8');
202
+ } catch {
203
+ return '';
204
+ }
205
+ };
206
+ const runLauncher = (name, cwd, input) => spawnSync('bash', [join(codexHooks, name)], { cwd, env, input, encoding: 'utf8' });
207
+ // 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).
209
+ const envelopeInjects = (stdout, hookEventName, sentinel) => {
210
+ try {
211
+ const j = JSON.parse(String(stdout || '').trim());
212
+ return j?.hookSpecificOutput?.hookEventName === hookEventName && String(j?.hookSpecificOutput?.additionalContext ?? '').includes(sentinel);
213
+ } catch {
214
+ return false;
215
+ }
216
+ };
217
+ const stopFixture = '{"session_id":"adapter_probe","stop_hook_active":false}';
218
+
219
+ // (a) Stop emission shape + reaches sweep-status, run from the open target.
220
+ const atTarget = runLauncher('session-sweep.sh', target, stopFixture);
221
+ const blocks = atTarget.status === 0 && /"decision":"block"/.test(atTarget.stdout || '');
222
+ 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?)');
225
+
226
+ // (b) UserPromptSubmit recall: reaches `guard --surface codex` (surface attribution) AND wraps
227
+ // the guard packet into the additionalContext envelope (the actual injection — reaching guard
228
+ // but emitting nothing is not recall, round 4).
229
+ const recallLauncher = join(codexHooks, 'recall-guard.sh');
230
+ if (existsSync(recallLauncher)) {
231
+ 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?)');
234
+ 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');
236
+ } else {
237
+ add(false, 'UserPromptSubmit recall launcher runnable', 'recall-guard.sh launcher missing');
238
+ }
239
+
240
+ // (c) SessionStart due-check: wraps the due-check text into the SessionStart additionalContext
241
+ // envelope. This subsumes "reached due-check" — the sentinel only originates from the due-check
242
+ // shim response, so its presence proves the chain ran AND wrapped (round 4).
243
+ const startLauncher = join(codexHooks, 'session-start.sh');
244
+ if (existsSync(startLauncher)) {
245
+ const start = runLauncher('session-start.sh', target, '{"session_id":"adapter_probe","source":"startup"}');
246
+ 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');
248
+ } else {
249
+ add(false, 'SessionStart launcher runnable', 'session-start.sh launcher missing');
250
+ }
251
+
252
+ // (d) install-root resolution from a subdir of the open target (nearest-.phronesis walk-up).
253
+ // The launcher runs from the kernel-realpath'd cwd, so it emits the realpath of the install
254
+ // root; compare realpaths so a symlinked prefix (macOS /var → /private/var) is not a false
255
+ // failure. The block reason's `--dir` is resolved in-shell, independent of the shim.
256
+ const subdir = join(target, 'raw', 'inbox');
257
+ const fromSub = runLauncher('session-sweep.sh', existsSync(subdir) ? subdir : target, stopFixture);
258
+ let realRoot = root;
259
+ try {
260
+ realRoot = realpathSync(root);
261
+ } catch {
262
+ /* keep root */
263
+ }
264
+ const out = fromSub.stdout || '';
265
+ const resolved = fromSub.status === 0 && (out.includes(`--dir ${realRoot}`) || out.includes(`--dir ${root}`));
266
+ add(resolved, 'install root resolves from a subdirectory of the open target', resolved ? '' : (fromSub.stderr || out).trim());
267
+ } catch (err) {
268
+ add(false, 'behavioral probe ran', err.message);
269
+ } finally {
270
+ if (shimDir) rmSync(shimDir, { recursive: true, force: true });
271
+ }
272
+
273
+ return { checks, compliant: checks.every((c) => c.ok) };
274
+ }
275
+
276
+ // 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
278
+ // the install's hook scripts via spawnSync; that is fine for `adapter test codex` (a deliberate
279
+ // operator self-check) but NOT for `phronesis doctor`, whose read-only promise must hold even on a
280
+ // MODIFIED install — a tampered hook could write to the install before satisfying the probe. So
281
+ // doctor passes structuralOnly:true (no execution); the full behavioral probe stays in adapter test.
282
+ function buildReport(root, domains, { structuralOnly = false } = {}) {
283
+ const perDomain = domains.map((slug) => {
284
+ 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 };
288
+ return { domain: slug, emission, behavior };
289
+ });
290
+
291
+ const mechanicsOk = structuralOnly
292
+ ? perDomain.every((d) => d.emission.compliant)
293
+ : perDomain.every((d) => d.emission.compliant && (d.emission.compliant ? d.behavior.compliant : false));
294
+ // Level 1 = the reinforcement loop is correctly wired + fires (recall / due-check /
295
+ // sweep). Level 2 (skill.invoked/completed) is PARTIAL on Codex by contract: no
296
+ // UserPromptExpansion / StopFailure / matchable Skill PreToolUse, so no invocation_id is
297
+ // minted and NO fabricated lifecycle events are emitted (event-log.md; change 0021).
298
+ const reinforcementEvents = HOOKS.filter((h) => h.tier === 'reinforcement').map((h) => h.role);
299
+ return {
300
+ surface: 'codex',
301
+ install: root,
302
+ level_1: { name: 'reinforcement', status: mechanicsOk ? 'compliant' : 'non-compliant', wires: reinforcementEvents },
303
+ level_2: {
304
+ name: 'skill-lifecycle',
305
+ status: 'partial',
306
+ reason: 'Codex has no UserPromptExpansion / StopFailure / matchable Skill PreToolUse — no invocation_id minted; no fabricated skill.invoked/completed (change 0021).',
307
+ },
308
+ desktop_app: { status: 'operator-verified', evidence: 'spike-discharged 2026-06-21 (CLI + desktop app)', checklist: DESKTOP_CHECKLIST },
309
+ domains: perDomain,
310
+ compliant: mechanicsOk,
311
+ };
312
+ }
313
+
314
+ function printHuman(report) {
315
+ const mark = (ok) => (ok ? '✓' : '✗');
316
+ console.log(`phronesis adapter test codex — ${report.install}`);
317
+ console.log('');
318
+ for (const d of report.domains) {
319
+ console.log(` domains/${d.domain}/.codex/`);
320
+ for (const c of [...d.emission.checks, ...d.behavior.checks]) {
321
+ console.log(` ${mark(c.ok)} ${c.label}${c.detail ? ` — ${c.detail}` : ''}`);
322
+ }
323
+ console.log('');
324
+ }
325
+ console.log(` Level 1 (reinforcement): ${report.level_1.status.toUpperCase()} — wires ${report.level_1.wires.join(', ')}`);
326
+ console.log(` Level 2 (skill-lifecycle): PARTIAL — ${report.level_2.reason}`);
327
+ console.log(` Desktop app: operator-verified (${report.desktop_app.evidence}). Re-confirm via ${report.desktop_app.checklist}`);
328
+ console.log('');
329
+ console.log(
330
+ report.compliant
331
+ ? ' Codex adapter is correctly wired: Level-1 reinforcement compliant (with the skill-lifecycle-partial note).'
332
+ : ' Codex adapter is NOT correctly wired — see the failed checks above; re-run `phronesis init`.',
333
+ );
334
+ console.log('');
335
+ 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.');
337
+ }
338
+
339
+ // PURE Codex-adapter report (core-0038 D2/D3): resolve the install + build the structural +
340
+ // behavioral report with NO console output and NO process.exitCode side-effect. `runAdapterTest`
341
+ // (the CLI wrapper, which keeps print + exit) AND `phronesis doctor` both consume it, so the
342
+ // diagnosis can never disagree. `buildReport` is already pure — its only writes are an ephemeral
343
+ // OS-temp shim OUTSIDE the install (core-0034 proved the install is byte-identical after a run),
344
+ // so composing it keeps `doctor` install-read-only. Returns { root, report } or { error }.
345
+ export function codexAdapterReport({ dir, domain, structuralOnly = false } = {}) {
346
+ const r = resolveInstall({ dir });
347
+ if (r.error) return { error: r.error };
348
+ // The EXPLICIT domain override must also be a kebab-case slug (core-0038 Codex round-4 F1): the
349
+ // registry path is RE_SLUG-filtered, but an unchecked `--domain ../../x` would path-traverse out
350
+ // of domains/{slug} — and checkBehavior EXECUTES the launcher it finds there (spawnSync). Fail
351
+ // closed on a non-slug override before any path is built.
352
+ if (domain !== undefined && !(typeof domain === 'string' && RE_SLUG.test(domain))) {
353
+ return { error: `adapter test: invalid --domain "${domain}" — a domain is a kebab-case slug (no path separators or traversal).` };
354
+ }
355
+ const root = r.root;
356
+ const domains = domain ? [domain] : installDomains(root);
357
+ return { root, report: buildReport(root, domains, { structuralOnly }) };
358
+ }
359
+
360
+ export async function runAdapterTest({ surface, dir, domain, json = false } = {}) {
361
+ if (!surface) {
362
+ console.error('adapter test: a <surface> is required (V1: codex).');
363
+ process.exitCode = 1;
364
+ return null;
365
+ }
366
+ 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).`);
368
+ process.exitCode = 1;
369
+ return null;
370
+ }
371
+
372
+ const res = codexAdapterReport({ dir, domain });
373
+ if (res.error) {
374
+ console.error(res.error);
375
+ process.exitCode = 1;
376
+ return null;
377
+ }
378
+ const report = res.report;
379
+ if (json) {
380
+ console.log(JSON.stringify(report, null, 2));
381
+ } else {
382
+ printHuman(report);
383
+ }
384
+ process.exitCode = report.compliant ? 0 : 1;
385
+ return report;
386
+ }