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.
- package/README.md +66 -0
- package/package.json +34 -0
- package/src/act.js +61 -0
- package/src/active-context.js +56 -0
- package/src/adapter.js +386 -0
- package/src/cli.js +1893 -0
- package/src/codex-hooks-schema.js +92 -0
- package/src/compile.js +513 -0
- package/src/doctor.js +303 -0
- package/src/due.js +271 -0
- package/src/eval.js +467 -0
- package/src/event.js +212 -0
- package/src/export.js +136 -0
- package/src/guard.js +89 -0
- package/src/hooks.js +230 -0
- package/src/ingest.js +965 -0
- package/src/init.js +150 -0
- package/src/layout.js +394 -0
- package/src/links.js +83 -0
- package/src/lint.js +58 -0
- package/src/profile.js +116 -0
- package/src/prompts.js +26 -0
- package/src/registry.js +190 -0
- package/src/scaffold.js +134 -0
- package/src/search.js +139 -0
- package/src/skill-discovery.js +117 -0
- package/src/skills-bump.js +885 -0
- package/src/skills.js +355 -0
- package/src/subscription.js +5 -0
- package/src/validate.js +152 -0
- package/templates/claude/settings.json +86 -0
- package/templates/codex/INDEX.md +68 -0
- package/templates/codex/seed/ai-practice/compile-dont-just-capture.md +42 -0
- package/templates/codex/seed/ai-practice/design-for-the-distribution.md +42 -0
- package/templates/codex/seed/ai-practice/from-spec-author-to-outcome-curator.md +43 -0
- package/templates/codex/seed/ai-practice/recall-builds-trust-when-calibrated.md +70 -0
- package/templates/codex/seed/ai-practice/the-eval-is-the-spec.md +42 -0
- package/templates/codex/seed/ai-practice/the-gold-dust-leaves-no-trace.md +72 -0
- package/templates/codex/seed/ai-practice/the-model-is-not-the-moat.md +40 -0
- package/templates/codex/seed/ai-practice/when-generation-is-cheap-judgment-is-the-work.md +39 -0
- package/templates/codex/seed/judgment/contextual-stewardship-is-the-senior-skill.md +50 -0
- package/templates/codex/seed/judgment/decision-quality-is-distinct-from-outcome-quality.md +39 -0
- package/templates/codex/seed/judgment/embrace-reality-ego-and-blind-spots.md +38 -0
- package/templates/codex/seed/judgment/groups-seek-consensus-individuals-seek-truth.md +44 -0
- package/templates/codex/seed/judgment/heresies-are-where-the-edge-lives.md +42 -0
- package/templates/codex/seed/judgment/judgment-compounds-through-honest-reflection.md +38 -0
- package/templates/codex/seed/judgment/phronesis-is-judgment-in-the-particular.md +67 -0
- package/templates/codex/seed/judgment/show-me-the-apparatus.md +40 -0
- package/templates/codex/seed/judgment/small-judgment-gaps-compound-under-leverage.md +37 -0
- package/templates/codex/seed/judgment/taste-is-compressed-judgment.md +38 -0
- package/templates/codex/seed/judgment/trust-taste-only-where-feedback-is-valid.md +42 -0
- package/templates/codex/seed/judgment/weigh-the-reasoning-not-the-recommendation.md +43 -0
- package/templates/codex/seed/leadership/a-managers-output-is-the-teams-output.md +35 -0
- package/templates/codex/seed/leadership/most-team-problems-are-structural.md +40 -0
- package/templates/codex/seed/leadership/next-play.md +36 -0
- package/templates/codex/seed/leadership/psychological-safety.md +39 -0
- package/templates/codex/seed/leadership/right-size-your-teams.md +38 -0
- package/templates/codex/seed/leadership/spend-time-on-high-leverage-activities.md +36 -0
- package/templates/codex/seed/leadership/talent-density-is-the-master-variable.md +39 -0
- package/templates/codex/seed/leadership/under-pressure-calm-is-contagious.md +36 -0
- package/templates/codex/seed/leadership/your-best-alternative-is-your-leverage.md +37 -0
- package/templates/codex/seed/pm/a-problem-well-stated-is-half-solved.md +39 -0
- package/templates/codex/seed/pm/a-real-strategy-makes-planning-trivial.md +40 -0
- package/templates/codex/seed/pm/barrels-and-ammunition.md +39 -0
- package/templates/codex/seed/pm/develop-taste-in-your-beliefs.md +38 -0
- package/templates/codex/seed/pm/hire-for-progress.md +44 -0
- package/templates/codex/seed/pm/jobs-are-stable-solutions-churn.md +39 -0
- package/templates/codex/seed/pm/level-by-the-ambiguity-someone-can-own.md +39 -0
- package/templates/codex/seed/pm/listen-before-you-prescribe.md +38 -0
- package/templates/codex/seed/pm/live-in-the-future-and-build-whats-missing.md +38 -0
- package/templates/codex/seed/pm/match-effort-to-leverage.md +37 -0
- package/templates/codex/seed/pm/not-my-fault-but-my-problem.md +40 -0
- package/templates/codex/seed/pm/pick-the-job-then-refuse-everything-outside-it.md +37 -0
- package/templates/codex/seed/pm/retention-sets-the-ceiling.md +38 -0
- package/templates/codex/seed/pm/scale-with-leaders-not-process.md +38 -0
- package/templates/codex/seed/pm/solution-discovery-over-problem-validation.md +38 -0
- package/templates/codex/seed/pm/tenfold-better-on-a-painful-problem.md +40 -0
- package/templates/codex-surface/README.md +73 -0
- package/templates/codex-surface/hooks/_resolve.sh +36 -0
- package/templates/codex-surface/hooks/recall-guard.sh +30 -0
- package/templates/codex-surface/hooks/session-start.sh +18 -0
- package/templates/codex-surface/hooks/session-sweep-precompact.sh +20 -0
- package/templates/codex-surface/hooks/session-sweep-subagent.sh +23 -0
- package/templates/codex-surface/hooks/session-sweep.sh +19 -0
- package/templates/codex-surface/hooks.json +69 -0
- package/templates/domains/pm/AGENTS.md +111 -0
- package/templates/domains/pm/MEMORY.md +7 -0
- package/templates/domains/pm/USER.md +8 -0
- package/templates/phronesis-hooks/compile-active-context.sh +38 -0
- package/templates/phronesis-hooks/recall-guard.sh +103 -0
- package/templates/phronesis-hooks/session-start.sh +90 -0
- package/templates/phronesis-hooks/session-sweep.sh +140 -0
- package/templates/phronesis-hooks/skill-lifecycle.sh +37 -0
- package/templates/skills/compile/SKILL.md +279 -0
- package/templates/skills/compile/evals/rubric.md +75 -0
- package/templates/skills/decision-log/SKILL.md +156 -0
- package/templates/skills/decision-log/evals/rubric.md +97 -0
- package/templates/skills/extract-gold-dust/SKILL.md +311 -0
- package/templates/skills/extract-gold-dust/evals/rubric.md +52 -0
- package/templates/skills/ingest/SKILL.md +204 -0
- package/templates/skills/ingest/evals/rubric.md +117 -0
- package/templates/skills/lint/SKILL.md +246 -0
- package/templates/skills/lint/evals/rubric.md +139 -0
- package/templates/skills/meeting-prep/SKILL.md +195 -0
- package/templates/skills/meeting-prep/evals/rubric.md +93 -0
- package/templates/skills/onboard/SKILL.md +258 -0
- package/templates/skills/onboard/evals/rubric.md +60 -0
- package/templates/skills/prd-draft/SKILL.md +193 -0
- package/templates/skills/prd-draft/evals/rubric.md +38 -0
- package/templates/skills/prep-extraction/SKILL.md +217 -0
- package/templates/skills/prep-extraction/evals/rubric.md +45 -0
- package/templates/skills/recall/SKILL.md +155 -0
- package/templates/skills/recall/evals/rubric.md +37 -0
- package/templates/skills/research/SKILL.md +247 -0
- package/templates/skills/research/evals/rubric.md +139 -0
- package/templates/skills/stakeholder-update/SKILL.md +163 -0
- package/templates/skills/stakeholder-update/evals/rubric.md +111 -0
- package/vendor/core/package.json +25 -0
- package/vendor/core/src/action-registry.js +459 -0
- package/vendor/core/src/action-writers.js +201 -0
- package/vendor/core/src/actions.js +2140 -0
- package/vendor/core/src/active-context.js +167 -0
- package/vendor/core/src/artifacts.js +301 -0
- package/vendor/core/src/content-digest.js +21 -0
- package/vendor/core/src/contract.js +245 -0
- package/vendor/core/src/event-log.js +338 -0
- package/vendor/core/src/guard.js +398 -0
- package/vendor/core/src/hook-executor.js +527 -0
- package/vendor/core/src/index.js +180 -0
- package/vendor/core/src/ingest.js +286 -0
- package/vendor/core/src/install-root.js +42 -0
- package/vendor/core/src/layout.js +13 -0
- package/vendor/core/src/link-index.js +198 -0
- package/vendor/core/src/lint.js +368 -0
- package/vendor/core/src/schedule.js +535 -0
- package/vendor/core/src/search.js +534 -0
- package/vendor/core/src/session-hooks.js +129 -0
- package/vendor/core/src/skill-lifecycle.js +479 -0
- package/vendor/core/src/skill-registry.js +524 -0
- package/vendor/core/src/subscription.js +84 -0
- package/vendor/core/src/validate-object.js +62 -0
- 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
|
+
}
|