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/src/lint.js ADDED
@@ -0,0 +1,58 @@
1
+ // `phronesis lint` — deterministic read-only workspace health report (core-0022 PR1).
2
+ // The semantic judgment layer lives in the lint skill; this CLI surface exposes the
3
+ // mechanical foundation as JSON or a compact operator report.
4
+
5
+ import chalk from 'chalk';
6
+ import { runWorkspaceLint } from '@phronesis/core';
7
+
8
+ export async function runLint({
9
+ dir,
10
+ domain,
11
+ allDomains = false,
12
+ staleMonths,
13
+ json = false,
14
+ } = {}) {
15
+ try {
16
+ const out = await runWorkspaceLint({
17
+ dir,
18
+ cwd: process.cwd(),
19
+ domain,
20
+ allDomains,
21
+ staleMonths,
22
+ });
23
+ console.log(json ? JSON.stringify(out) : formatLint(out));
24
+ process.exitCode = 0;
25
+ return out;
26
+ } catch (err) {
27
+ console.error(`lint: ${err.message}`);
28
+ process.exitCode = 1;
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function scopeLine(out) {
34
+ const domains = out.scope.all_domains
35
+ ? `all domains (${out.scope.domains.join(', ') || 'none'})`
36
+ : out.scope.domains.join(', ');
37
+ return chalk.dim(`scope: ${domains} + codex · ${out.latency_ms}ms · stale threshold ${out.stale_months} month(s)`);
38
+ }
39
+
40
+ function findingLine(finding) {
41
+ const where = finding.path ? `${finding.path}` : finding.hook ?? finding.seed_ref ?? finding.normalized ?? finding.kind;
42
+ const target = finding.target ? ` -> ${finding.target}` : '';
43
+ return ` - ${finding.kind}: ${where}${target}`;
44
+ }
45
+
46
+ function formatLint(out) {
47
+ const lines = [scopeLine(out)];
48
+ if (out.empty_result) {
49
+ lines.push(chalk.dim('no deterministic lint findings (empty result).'));
50
+ return lines.join('\n');
51
+ }
52
+ for (const surface of out.surfaces) {
53
+ if (surface.empty_result) continue;
54
+ lines.push('', chalk.bold(surface.id.replaceAll('_', ' ')));
55
+ for (const finding of surface.findings) lines.push(findingLine(finding));
56
+ }
57
+ return lines.join('\n');
58
+ }
package/src/profile.js ADDED
@@ -0,0 +1,116 @@
1
+ // Minimal operator-profile capture for `phronesis init` (core-0039 / change 0031). Two halves:
2
+ // 1. resolveProfile — INPUT resolution behind a dependency-injected seam ({ flags, isTTY, prompt })
3
+ // so the prompt path is gate-testable without a real terminal. Off a TTY it NEVER prompts (the
4
+ // load-bearing no-hang invariant: `npx phronesis init` in CI must never block).
5
+ // 2. fillOperatorProfile — the deterministic WRITE: fills [Name]/[Role] in BOTH always-read files
6
+ // (USER.md AND AGENTS.md §1), with a compact, budget-checked §1 (≤8192, fail closed).
7
+ // Not a wizard: name + role (+ optional company/working-style, USER.md-only). The fuller profile
8
+ // stays with onboarding (a deferred surface — NOT T3.3, which is the six PM-door skills).
9
+
10
+ import { join } from 'node:path';
11
+ import { readFile, writeFile } from 'node:fs/promises';
12
+
13
+ // AGENTS.md §1 is always-loaded and ≤8192 bytes; per-field caps bound the worst case so a
14
+ // pathological --name can't blow the budget (the budget check in fillOperatorProfile is the backstop).
15
+ const FIELD_MAX = { name: 48, role: 48, company: 64, workingStyle: 80 };
16
+ const CONTROL_RE = /[\x00-\x1F\x7F]/g;
17
+ const AGENTS_BUDGET = 8192;
18
+
19
+ // Sanitize a profile value before it lands in an always-loaded file (D6): strip control chars +
20
+ // newlines (no line injection into the one-line §1), collapse internal whitespace, trim. An
21
+ // empty/whitespace value is MISSING (keep the placeholder); an over-long value FAILS CLOSED (throws),
22
+ // never silently truncates the operator's input or overflows the budget.
23
+ export function sanitizeProfileField(value, max) {
24
+ if (typeof value !== 'string') return undefined;
25
+ const cleaned = value.replace(CONTROL_RE, ' ').replace(/\s+/g, ' ').trim();
26
+ if (cleaned === '') return undefined;
27
+ if (cleaned.length > max) {
28
+ throw new Error(`profile value too long (max ${max} characters, got ${cleaned.length}): "${cleaned.slice(0, 24)}…" — shorten it.`);
29
+ }
30
+ return cleaned;
31
+ }
32
+
33
+ // Resolve { name?, role?, company?, workingStyle? } from flags, then (only on a TTY) prompt for a
34
+ // missing name/role via the injected `prompt`. Returns sanitized values (omitting unprovided ones).
35
+ export async function resolveProfile({ flags = {}, isTTY = false, prompt } = {}) {
36
+ const out = {};
37
+ for (const key of ['name', 'role', 'company', 'workingStyle']) {
38
+ const v = sanitizeProfileField(flags[key], FIELD_MAX[key]);
39
+ if (v !== undefined) out[key] = v;
40
+ }
41
+ // Interactive: on a TTY, prompt for the missing headline fields (name/role) ONLY — not a wizard,
42
+ // and NOT for company/working-style (flag-only). Off a TTY: never prompt (the no-hang invariant).
43
+ if (isTTY && typeof prompt === 'function' && (out.name === undefined || out.role === undefined)) {
44
+ const answers = (await prompt({ name: out.name, role: out.role })) || {};
45
+ if (out.name === undefined) {
46
+ const n = sanitizeProfileField(answers.name, FIELD_MAX.name);
47
+ if (n !== undefined) out.name = n;
48
+ }
49
+ if (out.role === undefined) {
50
+ const r = sanitizeProfileField(answers.role, FIELD_MAX.role);
51
+ if (r !== undefined) out.role = r;
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ // Deterministically fill the profile into BOTH always-read files. No clock → same inputs reproduce
58
+ // byte-identical files (D3). Throws on a budget overflow (fail closed) or a missing §1 line (the
59
+ // template drifted) — the caller (createInstallation, before the registry marker) rolls back.
60
+ export async function fillOperatorProfile(installRoot, domain, profile = {}) {
61
+ const { name, role, company, workingStyle } = profile;
62
+ const hasIdentity = name !== undefined || role !== undefined;
63
+ if (!hasIdentity && company === undefined && workingStyle === undefined) return; // nothing provided
64
+
65
+ const userPath = join(installRoot, 'domains', domain, 'USER.md');
66
+ const agentsPath = join(installRoot, 'domains', domain, 'AGENTS.md');
67
+
68
+ // COMPUTE + VALIDATE both files' next content BEFORE writing EITHER, so a failure (template drift,
69
+ // a budget overflow) leaves BOTH files byte-unchanged — never a half-filled profile (Codex round-1 F2).
70
+
71
+ // Use FUNCTION replacers for EVERY operator-supplied value (Codex round-2 F1): a value passed as a
72
+ // replacement STRING has its `$`-tokens interpreted by replace() — `$&` re-emits the matched
73
+ // placeholder (leaving `[Name]`), `$'` splices the entire tail of the file (ballooning it over
74
+ // budget), `$$` collapses to `$`. A function replacer substitutes the value LITERALLY. And each
75
+ // required placeholder must actually be present (F3) — a zero-replacement is template drift, not a
76
+ // silent no-op that reports success while the dead air survives.
77
+ let userNext = await readFile(userPath, 'utf8');
78
+ if (name !== undefined) {
79
+ if (!userNext.includes('[Name]')) throw new Error('USER.md has no [Name] placeholder to fill — template drift');
80
+ userNext = userNext.replaceAll('[Name]', () => name);
81
+ }
82
+ if (role !== undefined) {
83
+ if (!userNext.includes('[Role]')) throw new Error('USER.md has no [Role] placeholder to fill — template drift');
84
+ userNext = userNext.replaceAll('[Role]', () => role);
85
+ }
86
+ const extra = [];
87
+ if (company !== undefined) extra.push(`- Company: ${company}`);
88
+ if (workingStyle !== undefined) extra.push(`- Working style: ${workingStyle}`);
89
+ if (extra.length) {
90
+ const withExtra = userNext.replace(/^(- Role:.*)$/m, (m) => `${m}\n${extra.join('\n')}`);
91
+ if (withExtra === userNext) throw new Error('USER.md "- Role:" line not found — cannot add company/working-style lines');
92
+ userNext = withExtra;
93
+ }
94
+
95
+ // AGENTS.md §1 next (only when name/role provided — company/working-style are USER.md-only). An
96
+ // unprovided field keeps its placeholder. Fill + COMPACT the suffix, then budget-check.
97
+ let agentsNext = null;
98
+ if (hasIdentity) {
99
+ const agents = await readFile(agentsPath, 'utf8');
100
+ const filledName = name !== undefined ? name : '[Name]';
101
+ const filledRole = role !== undefined ? role : '[Role]';
102
+ const newLine = `${filledName}, ${filledRole}. Profile + preferences: \`USER.md\`.`;
103
+ agentsNext = agents.replace(/^\[Name\], \[Role\]\..*$/m, () => newLine); // function replacer: newLine is literal (F1)
104
+ if (agentsNext === agents) {
105
+ throw new Error('AGENTS.md §1 operator line ("[Name], [Role]. …") not found — cannot fill the profile');
106
+ }
107
+ const byteLen = Buffer.byteLength(agentsNext, 'utf8');
108
+ if (byteLen > AGENTS_BUDGET) {
109
+ throw new Error(`profile fill would push AGENTS.md to ${byteLen} bytes, over the ${AGENTS_BUDGET}-byte always-loaded budget — shorten --name/--role.`);
110
+ }
111
+ }
112
+
113
+ // All replacements matched and the budget holds → commit both writes.
114
+ await writeFile(userPath, userNext);
115
+ if (agentsNext !== null) await writeFile(agentsPath, agentsNext);
116
+ }
package/src/prompts.js ADDED
@@ -0,0 +1,26 @@
1
+ import Enquirer from 'enquirer';
2
+
3
+ // Confirm replacing a non-empty target. Interactive surfaces only; non-TTY
4
+ // callers (CI, the fitness probe) fail closed and require --force instead.
5
+ export async function confirmOverwrite(targetDir) {
6
+ const enquirer = new Enquirer();
7
+ const { overwrite } = await enquirer.prompt({
8
+ type: 'confirm',
9
+ name: 'overwrite',
10
+ message: `${targetDir} already exists and is not empty. Replace it?`,
11
+ initial: false,
12
+ });
13
+ return overwrite;
14
+ }
15
+
16
+ // Prompt for the missing headline profile fields (name/role) on an interactive TTY (core-0039). The
17
+ // caller guards on `process.stdin.isTTY` (exactly like confirmOverwrite) — this is never reached off
18
+ // a TTY, so a scripted `init` never hangs. Only the unprovided fields are asked.
19
+ export async function promptProfile({ name, role } = {}) {
20
+ const questions = [];
21
+ if (name === undefined) questions.push({ type: 'input', name: 'name', message: 'Your name (for USER.md + AGENTS.md §1):' });
22
+ if (role === undefined) questions.push({ type: 'input', name: 'role', message: 'Your role:' });
23
+ if (!questions.length) return {};
24
+ const enquirer = new Enquirer();
25
+ return enquirer.prompt(questions);
26
+ }
@@ -0,0 +1,190 @@
1
+ // `phronesis registry status` (core-0011 / T2.4; hooks since core-0013) — the diagnosis
2
+ // surface for the install's action-type registry AND its hook subscriptions: where the
3
+ // table comes from (registry section vs compiled defaults), every type's EFFECTIVE
4
+ // permission and its relation to the code default/floor, the registered layout, the
5
+ // schema summary, and every registered hook. On an invalid registry it prints the same
6
+ // load errors runAction refuses with and exits non-zero — status is how the operator
7
+ // sees WHY the action layer is disabled. Both sections go through the SAME shared
8
+ // resolvers the action core consumes (the act/status parity lesson).
9
+
10
+ import { join } from 'node:path';
11
+ import fse from 'fs-extra';
12
+ import {
13
+ ACTION_REGISTRY,
14
+ PERMISSION_ORDER,
15
+ resolveActionTypes,
16
+ resolveHooks,
17
+ resolveInstall,
18
+ } from '@phronesis/core';
19
+
20
+ // Relation of an effective permission to its code default + floor — what status
21
+ // prints next to each type. The baseline for LEGALITY is the floor (G4); the
22
+ // baseline for DISPLAY is the default, because "what changed from stock" is what
23
+ // the operator is auditing.
24
+ function permissionRelation(type, effective) {
25
+ const code = ACTION_REGISTRY[type];
26
+ if (code.deferredTo) return null; // descriptive string, not lattice-governed
27
+ if (code.approvalFloor) return 'floor — not overridable';
28
+ if (effective.permission === code.permission) return 'default';
29
+ const delta =
30
+ PERMISSION_ORDER.indexOf(effective.permission) > PERMISSION_ORDER.indexOf(code.permission)
31
+ ? 'tightened'
32
+ : 'loosened';
33
+ return `${delta} from ${code.permission}`;
34
+ }
35
+
36
+ function schemaSummary(schema) {
37
+ return Object.entries(schema)
38
+ .map(([field, spec]) => `${field}${spec.required ? '*' : ''} (${spec.kind})`)
39
+ .join(', ');
40
+ }
41
+
42
+ export async function runRegistryStatus({ dir, json = false } = {}) {
43
+ const installed = resolveInstall({ dir });
44
+ if (installed.error) {
45
+ console.error(installed.error);
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ const root = installed.root;
50
+
51
+ let registry;
52
+ try {
53
+ registry = await fse.readJson(join(root, '.phronesis', 'registry.json'));
54
+ } catch (err) {
55
+ console.error(`could not read the install registry (.phronesis/registry.json): ${err.message}`);
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+
60
+ // ONE validator for both surfaces: the loaders check everything the action layer
61
+ // depends on (incl. the domains list and the hooks section), so status can never call
62
+ // the layer disabled while `act` still writes — or vice versa (the two-matchers
63
+ // drift, rules.md).
64
+ const { errors, source, effective } = resolveActionTypes(registry);
65
+ const hookRes = resolveHooks(registry);
66
+ if (errors.length || hookRes.errors.length) {
67
+ if (errors.length) {
68
+ console.error(
69
+ 'invalid .phronesis/registry.json (action-type registry) — the action layer is disabled until it is fixed (removing the action_types section restores built-in defaults):',
70
+ );
71
+ for (const e of errors) console.error(` ✗ ${e}`);
72
+ }
73
+ if (hookRes.errors.length) {
74
+ console.error(
75
+ 'invalid .phronesis/registry.json (hooks) — the action layer is disabled until it is fixed (an absent hooks section means no registered hooks):',
76
+ );
77
+ for (const e of hookRes.errors) console.error(` ✗ ${e}`);
78
+ }
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+
83
+ const unregistered = Object.keys(ACTION_REGISTRY).filter((t) => !effective[t]);
84
+
85
+ if (json) {
86
+ const out = {
87
+ root,
88
+ schema_version: registry.schema_version,
89
+ source,
90
+ action_types: {},
91
+ unregistered,
92
+ hooks: hookRes.hooks,
93
+ };
94
+ for (const [type, entry] of Object.entries(effective)) {
95
+ const code = ACTION_REGISTRY[type];
96
+ out.action_types[type] = {
97
+ permission: entry.permission,
98
+ default_permission: code.permission,
99
+ relation: permissionRelation(type, entry),
100
+ floor: Boolean(code.approvalFloor),
101
+ // compile (H2b) carries a per-object_type layout map; every other type a single layout.
102
+ ...(entry.targetLayoutByObjectType
103
+ ? { target_layout_by_object_type: entry.targetLayoutByObjectType }
104
+ : { target_layout: entry.targetLayout }),
105
+ ...(code.note ? { note: code.note } : {}),
106
+ ...(code.deferredTo ? { deferred_to: code.deferredTo } : {}),
107
+ ...(entry.schema
108
+ ? {
109
+ schema: Object.fromEntries(
110
+ Object.entries(entry.schema).map(([f, spec]) => [f, { ...spec }]),
111
+ ),
112
+ }
113
+ : {}),
114
+ };
115
+ }
116
+ console.log(JSON.stringify(out, null, 2));
117
+ return;
118
+ }
119
+
120
+ console.log(`Phronesis action-type registry — ${root}`);
121
+ console.log(
122
+ source === 'registry'
123
+ ? `source: .phronesis/registry.json action_types (schema_version ${registry.schema_version})`
124
+ : 'source: built-in defaults (the registry predates the action_types section — re-run init on a fresh install, or add the section, to customize)',
125
+ );
126
+ console.log(`domains: ${registry.domains.join(', ')}`);
127
+ for (const [type, entry] of Object.entries(effective)) {
128
+ const code = ACTION_REGISTRY[type];
129
+ const relation = permissionRelation(type, entry);
130
+ console.log(`\n${type}`);
131
+ console.log(` permission: ${entry.permission}${relation ? ` (${relation})` : ''}`);
132
+ if (entry.targetLayoutByObjectType) {
133
+ const map = Object.entries(entry.targetLayoutByObjectType)
134
+ .map(([k, v]) => `${k} → ${v}`)
135
+ .join('; ');
136
+ console.log(` layout: ${map}${code.note ? ` — ${code.note}` : ''}`);
137
+ } else {
138
+ console.log(` layout: ${entry.targetLayout}${code.note ? ` — ${code.note}` : ''}`);
139
+ }
140
+ if (code.deferredTo) {
141
+ console.log(` deferred: handler lands at ${code.deferredTo} (not runnable yet)`);
142
+ }
143
+ if (entry.schema) {
144
+ console.log(` schema: ${schemaSummary(entry.schema)}`);
145
+ }
146
+ }
147
+ if (unregistered.length) {
148
+ console.log(
149
+ `\nunregistered — absent from action_types, these refuse to run (removed by an operator edit, or newer than this registry file): ${unregistered.join(', ')}`,
150
+ );
151
+ }
152
+
153
+ // The hook subscriptions in the registry's top-level hooks array (core-0013). The V1
154
+ // in-pipeline executor fires ONLY action_type.requested/committed subscriptions; a
155
+ // subscription to any other type is a registration the V1.5 daemon (or a surface-
156
+ // native bridge) will execute — status must say so per hook, or a registered-but-
157
+ // never-fired hook reads as live (silent intent-drop, the class this repo refuses).
158
+ const IN_PIPELINE = ['action_type.requested', 'action_type.committed'];
159
+ if (!hookRes.hooks.length) {
160
+ console.log(
161
+ '\nhooks: none registered (top-level hooks array — the V1 executor fires action_type.requested/committed subscriptions in-pipeline)',
162
+ );
163
+ } else {
164
+ console.log(
165
+ `\nhooks (${hookRes.hooks.length} registered — the V1 in-pipeline executor fires action_type.requested/committed subscriptions; other types execute from V1.5):`,
166
+ );
167
+ for (const h of hookRes.hooks) {
168
+ const filter = h.filter
169
+ ? ` filter ${Object.entries(h.filter)
170
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
171
+ .join(' AND ')}`
172
+ : '';
173
+ console.log(` ${h.id} (${h.kind}, ${h.classification})`);
174
+ console.log(` subscribes_to: ${h.subscribes_to.join(', ')}${filter}`);
175
+ const dormant = h.subscribes_to.filter((t) => !IN_PIPELINE.includes(t));
176
+ if (dormant.length === h.subscribes_to.length) {
177
+ console.log(
178
+ ` NOT FIRED in V1: no subscribed type is an in-pipeline emit point — this registration waits for the V1.5 daemon / a surface-native executor`,
179
+ );
180
+ } else if (dormant.length) {
181
+ console.log(
182
+ ` partially fired in V1: ${dormant.join(', ')} wait(s) for the V1.5 daemon / a surface-native executor`,
183
+ );
184
+ }
185
+ console.log(
186
+ ` command: ${h.command} — quota ${h.quota.max_per_session}/session, bypass ${h.kill_switch}`,
187
+ );
188
+ }
189
+ }
190
+ }
@@ -0,0 +1,134 @@
1
+ import { join, dirname } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { symlink } from 'node:fs/promises';
4
+ import fse from 'fs-extra';
5
+ import {
6
+ installationDirs,
7
+ domainDirs,
8
+ registryJson,
9
+ configJson,
10
+ scheduleJson,
11
+ scheduleExampleJson,
12
+ installationGitignore,
13
+ templateCopies,
14
+ defaultSkillCopies,
15
+ domainSymlinks,
16
+ DEFAULT_DOMAIN,
17
+ } from './layout.js';
18
+ import { refreshSkillDiscovery } from './skill-discovery.js';
19
+ import { fillOperatorProfile } from './profile.js';
20
+
21
+ const TEMPLATES_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'templates');
22
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
23
+
24
+ export async function resolveDefaultSkillSourceRoot({
25
+ templatesDir = TEMPLATES_DIR,
26
+ repoRoot = REPO_ROOT,
27
+ } = {}) {
28
+ const repoCheckout = await fse.pathExists(join(repoRoot, 'apps', 'cli', 'src', 'scaffold.js'));
29
+ if (repoCheckout) {
30
+ const repoComplete = await Promise.all(
31
+ defaultSkillCopies().map(({ from }) => fse.pathExists(join(repoRoot, from))),
32
+ );
33
+ if (repoComplete.every(Boolean)) return repoRoot;
34
+ }
35
+
36
+ const mirrorComplete = await Promise.all(
37
+ defaultSkillCopies().map(({ from }) => fse.pathExists(join(templatesDir, from))),
38
+ );
39
+ return mirrorComplete.every(Boolean) ? templatesDir : repoRoot;
40
+ }
41
+
42
+ // Create the three-layer installation at targetDir: directories, .gitkeep on empty
43
+ // leaves, the installation .gitignore, the session-file and codex templates, the
44
+ // cross-surface symlinks, and finally the registry. Pure scaffolding — no action
45
+ // types, hooks, or runtime. Idempotent: safe to re-run into the same target.
46
+ export async function createInstallation(
47
+ targetDir,
48
+ {
49
+ domain = DEFAULT_DOMAIN,
50
+ profile = {},
51
+ templatesDir = TEMPLATES_DIR,
52
+ repoRoot = REPO_ROOT,
53
+ } = {},
54
+ ) {
55
+ const dirs = [...installationDirs(), ...domainDirs(domain)];
56
+ const registry = registryJson([domain]);
57
+
58
+ for (const rel of dirs) {
59
+ await fse.ensureDir(join(targetDir, rel));
60
+ }
61
+
62
+ // .gitkeep only on leaf dirs. Durable artifact directories are intentionally
63
+ // empty until a validated artifact is written: the gate scans every file below
64
+ // artifacts/, so a scaffold sentinel would itself be invalid cargo.
65
+ // Session-file and codex templates land in container dirs (domains/{slug}/ and
66
+ // codex/), so leaf computation is otherwise unchanged. codex/seed is excluded like
67
+ // skills/: the seed principle library template tree (T5.4) populates it, so a sentinel
68
+ // would be spurious cargo alongside real codex-principle-seed objects.
69
+ const isLeaf = (d) => !dirs.some((other) => other !== d && other.startsWith(`${d}/`));
70
+ for (const rel of dirs.filter((dir) => isLeaf(dir) && dir !== 'skills' && dir !== 'codex/seed' && !dir.endsWith('/artifacts/prd'))) {
71
+ await fse.writeFile(join(targetDir, rel, '.gitkeep'), '');
72
+ }
73
+
74
+ await fse.writeFile(join(targetDir, '.gitignore'), installationGitignore());
75
+
76
+ // Session files + codex map + session-boundary hooks, copied verbatim from the
77
+ // staged templates. Hook scripts get the executable bit explicitly — fse.copy
78
+ // preserves the source mode, but a tarball/registry install may have lost it, and
79
+ // a non-executable hook is a silent no-fire.
80
+ for (const { from, to, executable } of templateCopies(domain)) {
81
+ const dest = join(targetDir, to);
82
+ await fse.copy(join(templatesDir, from), dest);
83
+ if (executable) await fse.chmod(dest, 0o755);
84
+ }
85
+
86
+ // Default V1 skills: the repo checkout copies canonical skills/; the packaged
87
+ // tarball copies the tracked templates/skills mirror.
88
+ const skillSourceRoot = await resolveDefaultSkillSourceRoot({ templatesDir, repoRoot });
89
+ for (const { from, to } of defaultSkillCopies()) {
90
+ const src = join(skillSourceRoot, from);
91
+ if (!(await fse.pathExists(src))) {
92
+ throw new Error(`default skill source missing: ${from}`);
93
+ }
94
+ await fse.copy(src, join(targetDir, to));
95
+ }
96
+
97
+ await refreshSkillDiscovery({ installRoot: targetDir, domain, registry });
98
+
99
+ // Minimal operator profile (core-0039): fill [Name]/[Role] into BOTH always-read files (USER.md +
100
+ // AGENTS.md §1) when provided. Runs AFTER §4 fill (so the AGENTS.md budget check sees the final
101
+ // bytes) and BEFORE the registry marker below — a budget-overflow / template-drift throw rolls back
102
+ // the partial install (init.js), never leaving a marked-but-half-built install. No-op when no
103
+ // profile was provided (the placeholders stay — the non-interactive no-flags path).
104
+ await fillOperatorProfile(targetDir, domain, profile);
105
+
106
+ // Cross-surface symlinks (relative target; remove-first keeps this idempotent).
107
+ for (const { link, target } of domainSymlinks(domain)) {
108
+ const linkPath = join(targetDir, link);
109
+ await fse.remove(linkPath);
110
+ await symlink(target, linkPath, 'file');
111
+ }
112
+
113
+ // Installation config — carries the single model-swap provider swap-point
114
+ // (specs/security.md model-swap invariant). Tracked, no secrets.
115
+ await fse.writeJson(join(targetDir, '.phronesis', 'config.json'), configJson(), { spaces: 2 });
116
+
117
+ // Schedule registry (core-0051 / change 0042): the EMPTY declarations file plus its sibling
118
+ // worked example. Tracked + exported; a SEPARATE failure domain from registry.json (a
119
+ // malformed schedule never disables the action layer). Written BEFORE the registry marker so
120
+ // a partial failure never leaves a marked install missing them. The `.phronesis/state/`
121
+ // runtime dir is NOT scaffolded — it is gitignored state created lazily on first completion.
122
+ await fse.writeJson(join(targetDir, '.phronesis', 'schedule.json'), scheduleJson(), { spaces: 2 });
123
+ await fse.writeJson(join(targetDir, '.phronesis', 'schedule.example.json'), scheduleExampleJson(), {
124
+ spaces: 2,
125
+ });
126
+
127
+ // registry.json is the ownership marker (isPhronesisInstall checks it). Write it
128
+ // LAST so a partial failure never leaves a half-built install that reads as healthy.
129
+ await fse.writeJson(join(targetDir, '.phronesis', 'registry.json'), registry, {
130
+ spaces: 2,
131
+ });
132
+
133
+ return { targetDir, domain, dirs };
134
+ }
package/src/search.js ADDED
@@ -0,0 +1,139 @@
1
+ // `phronesis search` / `phronesis query` / `phronesis index rebuild` — the operator-
2
+ // facing retrieval surface (core-0014 / T5.2; specs/compile-retrieve-loop.md §"The V1
3
+ // retrieval surface contract"). Porcelain over a library-first core: this file parses
4
+ // execution context and formats output ONLY — every retrieval primitive (snapshot,
5
+ // freshness repair, BM25, link walk) lives in @phronesis/core (the D7 act pattern;
6
+ // context-server.md §"V1.0 preparation"), so the V1.5 context server and any direct
7
+ // caller run the same retrieval the CLI does.
8
+
9
+ import chalk from 'chalk';
10
+ import { loadSearchIndex, rebuildSearchIndex } from '@phronesis/core';
11
+
12
+ // A failed cache write degrades retrieval (slower next load), never blocks it — but it
13
+ // is never silent either (a read-only checkout sees this once per invocation).
14
+ function warnPersist(index, command) {
15
+ if (index.persistError) {
16
+ console.error(
17
+ `${command}: warning — machine index not persisted (${index.persistError}); results are live, the cache was not updated.`,
18
+ );
19
+ }
20
+ }
21
+
22
+ export async function runSearch({ query, dir, domain, allDomains = false, includeArtifacts = false, limit, json = false } = {}) {
23
+ // A blank or whitespace-only query is an operator mistake, not a search — refuse with
24
+ // guidance. (The LIBRARY treats an unqueryable string as an honest negative; the CLI
25
+ // is where "you probably mistyped" guidance belongs.)
26
+ if (!query || !query.trim()) {
27
+ console.error('search: give a query string (e.g. phronesis search "pricing decision").');
28
+ process.exitCode = 1;
29
+ return null;
30
+ }
31
+ try {
32
+ const index = await loadSearchIndex({ dir, cwd: process.cwd(), domain, allDomains, includeArtifacts });
33
+ warnPersist(index, 'search');
34
+ const out = index.search(query, limit === undefined ? {} : { limit });
35
+ console.log(json ? JSON.stringify(out) : formatSearch(out));
36
+ process.exitCode = 0;
37
+ return out;
38
+ } catch (err) {
39
+ console.error(`search: ${err.message}`);
40
+ process.exitCode = 1;
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export async function runQuery({ query, dir, domain, allDomains = false, includeArtifacts = false, limit, hops, json = false } = {}) {
46
+ if (!query || !query.trim()) {
47
+ console.error('query: give a question string (e.g. phronesis query "what did we decide on pricing?").');
48
+ process.exitCode = 1;
49
+ return null;
50
+ }
51
+ try {
52
+ const index = await loadSearchIndex({ dir, cwd: process.cwd(), domain, allDomains, includeArtifacts });
53
+ warnPersist(index, 'query');
54
+ const opts = {};
55
+ if (limit !== undefined) opts.limit = limit;
56
+ if (hops !== undefined) opts.hops = hops;
57
+ const out = index.query(query, opts);
58
+ console.log(json ? JSON.stringify(out) : formatQuery(out));
59
+ process.exitCode = 0;
60
+ return out;
61
+ } catch (err) {
62
+ console.error(`query: ${err.message}`);
63
+ process.exitCode = 1;
64
+ return null;
65
+ }
66
+ }
67
+
68
+ export async function runIndexRebuild({ dir, json = false } = {}) {
69
+ try {
70
+ const out = await rebuildSearchIndex({ dir, cwd: process.cwd() });
71
+ console.log(
72
+ json
73
+ ? JSON.stringify({ objects: out.objects, ms: out.ms })
74
+ : `rebuilt the machine index: ${out.objects} object(s) in ${out.ms}ms (.phronesis/index/ — derived, rebuildable).`,
75
+ );
76
+ process.exitCode = 0;
77
+ return out;
78
+ } catch (err) {
79
+ console.error(`index rebuild: ${err.message}`);
80
+ process.exitCode = 1;
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function scopeLine(out) {
86
+ const domains = out.scope.all_domains
87
+ ? `all domains (${out.scope.domains.join(', ') || 'none'})`
88
+ : out.scope.domains.join(', ');
89
+ return chalk.dim(`scope: ${domains} + codex · ${out.latency_ms}ms`);
90
+ }
91
+
92
+ // The honest negative is part of the surface contract: say plainly that no workspace
93
+ // state matched (the guard composes this into its pre-answer packet — T5.2a).
94
+ function emptyLine() {
95
+ return chalk.dim('no compiled/codex state matched (empty result).');
96
+ }
97
+
98
+ function resultLines(r) {
99
+ const label = r.layer === 'codex' ? `codex ${r.type ?? '?'}` : `${r.type ?? '?'}`;
100
+ const lines = [`${chalk.bold(r.title ?? r.name ?? r.path)} ${chalk.dim(`${label} · ${r.score.toFixed(2)}`)}`];
101
+ lines.push(` ${chalk.dim(r.path)}`);
102
+ if (r.snippet) lines.push(` ${r.snippet}`);
103
+ return lines;
104
+ }
105
+
106
+ function formatSearch(out) {
107
+ const lines = [scopeLine(out)];
108
+ if (out.empty_result) {
109
+ lines.push(emptyLine());
110
+ return lines.join('\n');
111
+ }
112
+ for (const r of out.results) {
113
+ lines.push('', ...resultLines(r));
114
+ }
115
+ return lines.join('\n');
116
+ }
117
+
118
+ function formatQuery(out) {
119
+ const lines = [scopeLine(out)];
120
+ if (out.empty_result) {
121
+ lines.push(emptyLine());
122
+ return lines.join('\n');
123
+ }
124
+ for (const r of out.results) {
125
+ lines.push('', ...resultLines(r));
126
+ const linked = r.subgraph.nodes.filter((n) => n.hop > 0 && n.kind === 'object');
127
+ for (const n of linked) {
128
+ lines.push(` ↳ ${n.type ?? n.kind} ${n.name ?? n.path}${n.title ? ` ${chalk.dim(n.title)}` : ''}`);
129
+ }
130
+ const other = r.subgraph.nodes.filter((n) => n.kind === 'outside-scope' || n.kind === 'unresolved');
131
+ for (const n of other) {
132
+ lines.push(` ↳ ${chalk.dim(`${n.kind} ${n.path}`)}`);
133
+ }
134
+ if (r.sources.length > 0) {
135
+ lines.push(` ${chalk.dim(`sources: ${r.sources.join(', ')}`)}`);
136
+ }
137
+ }
138
+ return lines.join('\n');
139
+ }