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/eval.js ADDED
@@ -0,0 +1,467 @@
1
+ // `phronesis eval view` (core-0029 PR1 / T7.1) — the tiny LOCAL eval data viewer
2
+ // (specs/eval-discipline.md §"Custom data viewer (not a platform dependency)").
3
+ //
4
+ // L1 — it binds localhost ONLY, writes NOTHING into the repo, and exposes NOTHING over the
5
+ // network. It renders the operator's own private golden material locally (goldens carry real
6
+ // names; change 0023), so it must never copy that material into the repo tree or serve it
7
+ // off-localhost. The render is held in memory and served on 127.0.0.1; no file is written.
8
+ //
9
+ // L2 — it renders only data that EXISTS: per skill, the golden list; input → expected →
10
+ // model_default from the GOLDENS (the text lives there, not in runs/*.json — those are judge
11
+ // scores + metadata only, judge.py); L2 per-criterion means + below-floor (signed_mean < 4)
12
+ // from results/aggregate.json. The "actual"/candidate column lights up ONLY when core-0028's
13
+ // candidate runner has persisted candidate output to results/candidates/*.json — never
14
+ // invented, never read from runs/*.json. The viewer runs no evals.
15
+
16
+ import { createServer } from 'node:http';
17
+ import { existsSync, statSync } from 'node:fs';
18
+ import { readFile, readdir } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+
21
+ export const DEFAULT_HOST = '127.0.0.1';
22
+ export const QUALITY_FLOOR = 4;
23
+
24
+ // L1 — the viewer renders the operator's private golden material, so it may bind ONLY a
25
+ // loopback address. An operator may pick IPv4/IPv6 loopback, or the convenience input
26
+ // `localhost` (canonicalized to numeric before binding — see canonicalBindHost); nothing else.
27
+ const LOCALHOST_BINDS = new Set(['127.0.0.1', '::1', 'localhost']);
28
+ export function isLocalhostBind(host) {
29
+ return LOCALHOST_BINDS.has(host);
30
+ }
31
+
32
+ // `localhost` is accepted as a convenience input, but it is NEVER handed to listen(): the OS
33
+ // resolver (tampered hostfile / unusual NSS) could map it off-loopback, and listen()'s
34
+ // callback only runs AFTER the socket has already bound — a post-listen check is too late to
35
+ // stop a momentary off-host bind. So we canonicalize `localhost` to numeric 127.0.0.1
36
+ // ourselves: the bind is loopback BY CONSTRUCTION, not by hope. (127.0.0.1 / ::1 pass through.)
37
+ export function canonicalBindHost(host) {
38
+ return host === 'localhost' ? DEFAULT_HOST : host;
39
+ }
40
+
41
+ // Defense-in-depth behind canonicalBindHost: after listen() we still assert the ACTUAL bound
42
+ // address is loopback and refuse otherwise (belt-and-suspenders, also covered by tests).
43
+ export function isLoopbackAddress(addr) {
44
+ if (typeof addr !== 'string') return false;
45
+ return addr === '::1' || /^127\./.test(addr) || /^::ffff:127\./i.test(addr);
46
+ }
47
+
48
+ function localUrl(host, port) {
49
+ const h = host.includes(':') ? `[${host}]` : host; // bracket IPv6 (::1 → [::1])
50
+ return `http://${h}:${port}/`;
51
+ }
52
+
53
+ // --- Source-root resolution (the same rule as `phronesis skills bump`, core-0031 B6) -------
54
+ // --repo, else cwd must be a repo ROOT (skills/ + changes/ present). Refuse if neither
55
+ // resolves — no silent install fallback. (Re-stated locally so PR1 is independent of the
56
+ // core-0031 merge; the two resolvers can unify once both land.)
57
+ function isDir(path) {
58
+ try {
59
+ return statSync(path).isDirectory();
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ export function resolveRepoRoot({ repo, cwd = process.cwd() } = {}) {
66
+ const root = repo || cwd;
67
+ // Require DIRECTORIES, not mere existence — a plain file named skills/ or changes/ would
68
+ // pass an existsSync check and then blow up the first readdir().
69
+ if (isDir(join(root, 'skills')) && isDir(join(root, 'changes'))) return { root };
70
+ return {
71
+ error:
72
+ `not a Phronesis repo root: ${root} (expected skills/ and changes/). ` +
73
+ 'eval view reads repo rubrics + gitignored candidate results — pass --repo <path> or run from the repo root. ' +
74
+ 'Goldens resolve from the install ($PHRONESIS_INSTALL); there is no install fallback for the repo root.',
75
+ };
76
+ }
77
+
78
+ // --- Data loading -----------------------------------------------------------
79
+
80
+ // Skills with any eval data to show: a rubric or an aggregate.json. (A skill dir alone is not
81
+ // enough — we only list ones with something to render.)
82
+ export async function collectEvalSkills(repoRoot) {
83
+ const skillsDir = join(repoRoot, 'skills');
84
+ if (!existsSync(skillsDir)) return [];
85
+ const entries = await readdir(skillsDir, { withFileTypes: true });
86
+ const out = [];
87
+ for (const e of entries) {
88
+ if (!e.isDirectory()) continue;
89
+ const evalsDir = join(skillsDir, e.name, 'evals');
90
+ if (existsSync(join(evalsDir, 'rubric.md')) || existsSync(join(evalsDir, 'results', 'aggregate.json'))) {
91
+ out.push(e.name);
92
+ }
93
+ }
94
+ return out.sort();
95
+ }
96
+
97
+ // Resolve the install golden dir the same way run_skill_goldens.py does: --goldens-dir →
98
+ // $PHRONESIS_INSTALL/corpus/skill-goldens/<skill>. Goldens carry input/expected/model_default
99
+ // TEXT; they live in the install, never the repo (change 0023).
100
+ export async function loadGoldens({ skill, goldensDir }) {
101
+ const dir = goldensDir
102
+ || (process.env.PHRONESIS_INSTALL
103
+ ? join(process.env.PHRONESIS_INSTALL, 'corpus', 'skill-goldens', skill)
104
+ : null);
105
+ if (!dir || !existsSync(dir)) return { resolved: false, source: dir || '(no $PHRONESIS_INSTALL set)', byId: {}, warnings: [] };
106
+ const files = (await readdir(dir)).filter((f) => /^golden-.*\.json$/.test(f)).sort();
107
+ const byId = {};
108
+ const warnings = [];
109
+ for (const f of files) {
110
+ let d;
111
+ try {
112
+ d = JSON.parse(await readFile(join(dir, f), 'utf8'));
113
+ } catch {
114
+ // Don't silently drop a corrupt golden — that makes bad data look like missing data.
115
+ warnings.push(`golden ${f} is unparseable JSON — skipped`);
116
+ continue;
117
+ }
118
+ if ('input' in d && 'expected' in d && 'model_default' in d) {
119
+ byId[d.id || f.replace(/\.json$/, '')] = d;
120
+ } else {
121
+ warnings.push(`golden ${f} is missing input/expected/model_default — skipped`);
122
+ }
123
+ }
124
+ return { resolved: true, source: dir, byId, warnings };
125
+ }
126
+
127
+ // The "actual" column: candidate output persisted by core-0028's runner to
128
+ // results/candidates/*.json. Each file carries { golden_id, candidate_message, loop, ... }.
129
+ // We surface the LATEST candidate per golden_id. NEVER read runs/*.json (scores only, L2).
130
+ export async function loadCandidates({ repoRoot, skill }) {
131
+ const dir = join(repoRoot, 'skills', skill, 'evals', 'results', 'candidates');
132
+ if (!existsSync(dir)) return { present: false, byGolden: {}, warnings: [] };
133
+ const files = (await readdir(dir)).filter((f) => f.endsWith('.json')).sort();
134
+ const byGolden = {};
135
+ const warnings = [];
136
+ for (const f of files) {
137
+ let d;
138
+ try {
139
+ d = JSON.parse(await readFile(join(dir, f), 'utf8'));
140
+ } catch {
141
+ warnings.push(`candidate ${f} is unparseable JSON — skipped`);
142
+ continue;
143
+ }
144
+ const gid = d.golden_id;
145
+ if (!gid) {
146
+ warnings.push(`candidate ${f} has no golden_id — skipped`);
147
+ continue;
148
+ }
149
+ // A candidate file must carry actual persisted output. Without a string candidate_message
150
+ // there is nothing to show — and counting it as "present" would light up the "actual"
151
+ // column for output that does not exist. Skip + surface it rather than render a blank.
152
+ if (typeof d.candidate_message !== 'string') {
153
+ warnings.push(`candidate ${f} (golden ${gid}) has no candidate_message string — skipped`);
154
+ continue;
155
+ }
156
+ // files sort ascending by stamp, so the last write for a gid wins (latest run).
157
+ byGolden[gid] = {
158
+ candidate_message: d.candidate_message,
159
+ outcome: d.loop?.outcome || 'unknown',
160
+ model_id: d.model_id || null,
161
+ };
162
+ }
163
+ return { present: Object.keys(byGolden).length > 0, byGolden, warnings };
164
+ }
165
+
166
+ export async function loadEvalData({ repoRoot, skill, goldensDir }) {
167
+ const evalsDir = join(repoRoot, 'skills', skill, 'evals');
168
+ const rubricPath = join(evalsDir, 'rubric.md');
169
+ const aggregatePath = join(evalsDir, 'results', 'aggregate.json');
170
+ const rubric = existsSync(rubricPath) ? await readFile(rubricPath, 'utf8') : null;
171
+ let aggregate = null;
172
+ if (existsSync(aggregatePath)) {
173
+ try {
174
+ aggregate = JSON.parse(await readFile(aggregatePath, 'utf8'));
175
+ } catch {
176
+ aggregate = { error: `aggregate.json is unparseable: ${aggregatePath}` };
177
+ }
178
+ }
179
+ const goldens = await loadGoldens({ skill, goldensDir });
180
+ const candidates = await loadCandidates({ repoRoot, skill });
181
+ const warnings = [...(goldens.warnings || []), ...(candidates.warnings || [])];
182
+ return { skill, rubric, aggregate, goldens, candidates, warnings };
183
+ }
184
+
185
+ // --- Rendering (pure — held in memory, never written to disk) ---------------
186
+
187
+ function esc(value) {
188
+ return String(value ?? '')
189
+ .replace(/&/g, '&amp;')
190
+ .replace(/</g, '&lt;')
191
+ .replace(/>/g, '&gt;')
192
+ .replace(/"/g, '&quot;');
193
+ }
194
+
195
+ const MAX_CELL_CHARS = 20000;
196
+ function truncate(text) {
197
+ if (text.length <= MAX_CELL_CHARS) return text;
198
+ return `${text.slice(0, MAX_CELL_CHARS)}\n… (truncated ${text.length - MAX_CELL_CHARS} more chars)`;
199
+ }
200
+
201
+ function renderValue(v) {
202
+ if (v === null || v === undefined) return '<span class="muted">—</span>';
203
+ const text = typeof v === 'string' ? v : JSON.stringify(v, null, 2);
204
+ return `<pre>${esc(truncate(text))}</pre>`;
205
+ }
206
+
207
+ // Below-floor criteria for a golden's aggregate row: signed_mean < 4 (the L2 pass bar).
208
+ function belowFloor(goldenAgg, criteriaLabels) {
209
+ const out = [];
210
+ const crit = goldenAgg?.criteria || {};
211
+ for (const [key, v] of Object.entries(crit)) {
212
+ if (typeof v?.signed_mean === 'number' && v.signed_mean < QUALITY_FLOOR) {
213
+ out.push({ key, label: criteriaLabels[key] || key, mean: v.signed_mean });
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+
219
+ function renderScores(goldenAgg, criteriaLabels) {
220
+ const crit = goldenAgg?.criteria || {};
221
+ const keys = Object.keys(crit);
222
+ if (!keys.length) return '<span class="muted">no scores</span>';
223
+ const rows = keys.map((key) => {
224
+ const v = crit[key];
225
+ const mean = typeof v?.signed_mean === 'number' ? v.signed_mean : null;
226
+ const fail = mean !== null && mean < QUALITY_FLOOR;
227
+ const label = criteriaLabels[key] || key;
228
+ const cls = fail ? ' class="below-floor"' : '';
229
+ const def = typeof v?.default_mean === 'number' ? ` <span class="muted">(default ${esc(v.default_mean)})</span>` : '';
230
+ return `<tr${cls}><td>${esc(label)}</td><td>${mean === null ? '—' : esc(mean)}${fail ? ' &darr; below floor' : ''}${def}</td></tr>`;
231
+ });
232
+ return `<table class="scores">${rows.join('')}</table>`;
233
+ }
234
+
235
+ function renderSkillSection(data) {
236
+ const { skill, rubric, aggregate, goldens, candidates } = data;
237
+ const hasActual = candidates.present;
238
+ const warnings = [...(data.warnings || [])];
239
+ const criteriaLabels = {};
240
+ // aggregate.criteria is the criterion-label LIST (an array). A hand-edited aggregate.json
241
+ // with a non-array criteria ({} etc.) must not crash the render — guard + surface it.
242
+ const criteriaList = Array.isArray(aggregate?.criteria) ? aggregate.criteria : [];
243
+ if (aggregate && !aggregate.error && aggregate.criteria != null && !Array.isArray(aggregate.criteria)) {
244
+ warnings.push('aggregate.json "criteria" is not an array — criterion labels unavailable');
245
+ }
246
+ for (const c of criteriaList) criteriaLabels[c.key] = c.label;
247
+
248
+ // The golden ids to show: union of aggregate's goldens, resolved golden ids, AND any
249
+ // candidate's golden_id (so a candidate for a golden retired from the corpus still shows
250
+ // — it never silently vanishes).
251
+ const aggGoldens = aggregate && !aggregate.error ? aggregate.goldens || {} : {};
252
+ const ids = [...new Set([
253
+ ...Object.keys(aggGoldens),
254
+ ...Object.keys(goldens.byId),
255
+ ...Object.keys(candidates.byGolden),
256
+ ])].sort();
257
+
258
+ const header = [
259
+ `<h2>${esc(skill)}</h2>`,
260
+ warnings.length
261
+ ? `<div class="warn warnings"><strong>data warnings:</strong><ul>${warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul></div>`
262
+ : '',
263
+ aggregate?.error
264
+ ? `<p class="warn">${esc(aggregate.error)}</p>`
265
+ : aggregate
266
+ ? `<p class="meta">judge ${esc((Array.isArray(aggregate.judge_model) ? aggregate.judge_model.join(', ') : aggregate.judge_model) || '—')} · k=${esc(aggregate.k ?? '—')} · ${ids.length} golden(s)${aggregate.total_cost_usd != null ? ` · $${esc(aggregate.total_cost_usd)}` : ''}</p>`
267
+ : '<p class="meta muted">no results/aggregate.json yet — run this skill\'s goldens to populate scores</p>',
268
+ goldens.resolved
269
+ ? ''
270
+ : `<p class="meta muted">golden text not resolved (${esc(goldens.source)}) — set $PHRONESIS_INSTALL or pass --goldens-dir to render input/expected/model_default</p>`,
271
+ hasActual
272
+ ? '<p class="meta">actual column present — candidate output persisted by the runner (core-0028)</p>'
273
+ : '<p class="meta muted">no actual column — no candidate output persisted yet (core-0028 runner has not written results/candidates/)</p>',
274
+ rubric ? `<details class="rubric"><summary>L2 rubric (the eval — vague rubric, vague scores)</summary>${renderValue(rubric)}</details>` : '',
275
+ ].join('\n');
276
+
277
+ if (!ids.length) {
278
+ return `<section>\n${header}\n<p class="muted">no goldens to display.</p>\n</section>`;
279
+ }
280
+
281
+ const cols = ['golden', 'input', 'expected', 'model_default', ...(hasActual ? ['actual'] : []), 'scores'];
282
+ const thead = `<tr>${cols.map((c) => `<th>${esc(c)}</th>`).join('')}</tr>`;
283
+ const rows = ids.map((gid) => {
284
+ const g = goldens.byId[gid];
285
+ const ga = aggGoldens[gid];
286
+ const title = ga?.title || g?.title;
287
+ const cells = [
288
+ `<td class="gid">${esc(gid)}${title ? `<br><span class="muted">${esc(title)}</span>` : ''}</td>`,
289
+ `<td>${g ? renderValue(g.input) : '<span class="muted">—</span>'}</td>`,
290
+ `<td>${g ? renderValue(g.expected) : '<span class="muted">—</span>'}</td>`,
291
+ `<td>${g ? renderValue(g.model_default) : '<span class="muted">—</span>'}</td>`,
292
+ ];
293
+ if (hasActual) {
294
+ const cand = candidates.byGolden[gid];
295
+ cells.push(
296
+ `<td>${cand ? `${renderValue(cand.candidate_message)}<span class="muted">outcome: ${esc(cand.outcome)}${cand.model_id ? ` · ${esc(cand.model_id)}` : ''}</span>` : '<span class="muted">no candidate for this golden</span>'}</td>`,
297
+ );
298
+ }
299
+ const fails = belowFloor(ga, criteriaLabels);
300
+ const failNote = fails.length
301
+ ? `<div class="fail-trace">below floor: ${fails.map((f) => `${esc(f.label)} (${esc(f.mean)})`).join('; ')}</div>`
302
+ : '';
303
+ cells.push(`<td>${renderScores(ga, criteriaLabels)}${failNote}</td>`);
304
+ return `<tr>${cells.join('')}</tr>`;
305
+ });
306
+
307
+ return `<section>\n${header}\n<table class="goldens">${thead}${rows.join('')}</table>\n</section>`;
308
+ }
309
+
310
+ export function renderEvalView({ skills, repoRoot }) {
311
+ const sections = skills.length
312
+ ? skills.map(renderSkillSection).join('\n')
313
+ : '<p class="muted">no skills with eval data (a rubric or results/aggregate.json) under skills/.</p>';
314
+ return `<!doctype html>
315
+ <html lang="en">
316
+ <head>
317
+ <meta charset="utf-8">
318
+ <meta name="robots" content="noindex,nofollow">
319
+ <title>Phronesis eval view</title>
320
+ <style>
321
+ :root { color-scheme: light; }
322
+ body { font-family: Georgia, 'Times New Roman', serif; margin: 2rem auto; max-width: 70rem; padding: 0 1.5rem; color: #1a1a1a; line-height: 1.5; }
323
+ h1 { font-size: 1.5rem; border-bottom: 2px solid #7a3b2e; padding-bottom: .4rem; }
324
+ h2 { font-size: 1.2rem; margin-top: 2.2rem; border-bottom: 1px solid #ccc; }
325
+ .lede { color: #555; font-size: .9rem; }
326
+ .meta { font-size: .85rem; margin: .2rem 0; }
327
+ .muted { color: #888; }
328
+ .warn { color: #7a3b2e; }
329
+ .warnings ul { margin: .2rem 0 .6rem 1.2rem; font-size: .8rem; }
330
+ table { border-collapse: collapse; width: 100%; margin: .6rem 0 1.4rem; font-size: .82rem; }
331
+ th, td { border: 1px solid #ccc; padding: .4rem .5rem; vertical-align: top; text-align: left; }
332
+ th { background: #f3f1ec; font-weight: bold; }
333
+ td.gid { white-space: nowrap; font-weight: bold; }
334
+ pre { white-space: pre-wrap; word-break: break-word; margin: 0; font-family: ui-monospace, Menlo, monospace; font-size: .78rem; max-height: 18rem; overflow: auto; }
335
+ table.scores { margin: 0; }
336
+ table.scores td { border: none; border-bottom: 1px dotted #ddd; padding: .15rem .3rem; font-size: .8rem; }
337
+ tr.below-floor td, .below-floor { color: #7a3b2e; }
338
+ .fail-trace { margin-top: .4rem; font-size: .78rem; color: #7a3b2e; border-top: 1px solid #e3d9c6; padding-top: .3rem; }
339
+ </style>
340
+ </head>
341
+ <body>
342
+ <h1>Phronesis eval view</h1>
343
+ <p class="lede">${esc(repoRoot)} · localhost-only · read-only. Renders goldens (input / expected / model_default) and L2 scores; the actual column appears once the runner persists candidate output.</p>
344
+ ${sections}
345
+ </body>
346
+ </html>
347
+ `;
348
+ }
349
+
350
+ // --- Server (thin; binds localhost only, serves the in-memory render) -------
351
+
352
+ export async function buildView({ repo, skill, goldensDir, cwd }) {
353
+ const resolved = resolveRepoRoot({ repo, cwd });
354
+ if (resolved.error) return { error: resolved.error };
355
+ const repoRoot = resolved.root;
356
+ // Sanitize the skill name before it reaches any path join — `join('skills', '../changes')`
357
+ // collapses to outside skills/ and would read evals/ content from anywhere in the repo.
358
+ // A kebab-case name matches the SKILL.md directory contract (same rule as the substrate's
359
+ // safeName + skills bump).
360
+ if (skill !== undefined && !/^[a-z0-9][a-z0-9-]*$/.test(skill)) {
361
+ return { error: `invalid skill name "${skill}" (expected kebab-case [a-z0-9-], matching the skills/ directory)` };
362
+ }
363
+ if (skill && !existsSync(join(repoRoot, 'skills', skill))) {
364
+ return { error: `skill not found: ${join(repoRoot, 'skills', skill)}` };
365
+ }
366
+ const names = skill ? [skill] : await collectEvalSkills(repoRoot);
367
+ // A flat --goldens-dir is per-skill; applying ONE dir across MULTIPLE skills would render
368
+ // one skill's name-bearing goldens under every skill's table (local misattribution). With a
369
+ // single skill it is unambiguous; with more than one, require --skill to disambiguate.
370
+ if (goldensDir && !skill && names.length > 1) {
371
+ return { error: `--goldens-dir is per-skill and ${names.length} skills have eval data; pass --skill <name> with it (or rely on the per-skill $PHRONESIS_INSTALL default).` };
372
+ }
373
+ const skills = [];
374
+ for (const name of names) skills.push(await loadEvalData({ repoRoot, skill: name, goldensDir }));
375
+ return { repoRoot, html: renderEvalView({ skills, repoRoot }), skills };
376
+ }
377
+
378
+ // The render carries the operator's private golden material. The page is fully self-contained
379
+ // (inline <style>, no scripts, no external assets), so a strict CSP + no-store keeps it out of
380
+ // the browser cache and off any other origin.
381
+ const HTML_HEADERS = {
382
+ 'content-type': 'text/html; charset=utf-8',
383
+ 'cache-control': 'no-store, max-age=0',
384
+ pragma: 'no-cache',
385
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
386
+ 'referrer-policy': 'no-referrer',
387
+ };
388
+
389
+ // `addressOf` is a test seam: the post-listen loopback assertion guards against a `localhost`
390
+ // that resolved off-host, which is otherwise impossible to trigger deterministically (the
391
+ // pre-bind allowlist blocks literal off-host values, and canonicalBindHost rewrites localhost
392
+ // to numeric). Tests inject one returning a non-loopback address to prove the close+reject branch.
393
+ export function serveEvalView({ html, host = DEFAULT_HOST, port = 0, addressOf = (s) => s.address() } = {}) {
394
+ if (!isLocalhostBind(host)) {
395
+ // Refuse BEFORE binding — never expose operator names off-localhost, not even briefly.
396
+ return Promise.reject(
397
+ new Error(`eval view binds localhost only (got --host ${host}); allowed: 127.0.0.1, ::1, localhost`),
398
+ );
399
+ }
400
+ // Never hand `localhost` to listen() — bind a numeric loopback address so the OS resolver
401
+ // cannot place us off-host even momentarily (listen()'s callback runs AFTER the bind).
402
+ const bindHost = canonicalBindHost(host);
403
+ const server = createServer((req, res) => {
404
+ // Read-only viewer: only GET, only the page. Never accept writes, never read a path
405
+ // from the request (no file serving — the whole render is in memory).
406
+ if (req.method !== 'GET') {
407
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store, max-age=0' });
408
+ res.end('eval view is read-only');
409
+ return;
410
+ }
411
+ res.writeHead(200, HTML_HEADERS);
412
+ res.end(html);
413
+ });
414
+ return new Promise((resolvePromise, rejectPromise) => {
415
+ server.once('error', rejectPromise);
416
+ // Bind loopback ONLY — never 0.0.0.0; the render carries real operator names.
417
+ server.listen(port, bindHost, () => {
418
+ const addr = addressOf(server);
419
+ // Defense-in-depth: verify the ACTUAL bound address is loopback. Refuse + close before
420
+ // serving a single byte off-loopback.
421
+ if (!isLoopbackAddress(addr.address)) {
422
+ server.close();
423
+ rejectPromise(
424
+ new Error(`refusing off-loopback bind: "${host}" resolved to ${addr.address}; eval view is localhost-only`),
425
+ );
426
+ return;
427
+ }
428
+ // URL from the ACTUAL bound address, not the input host — with --host localhost the
429
+ // browser could otherwise resolve a different loopback family than we bound.
430
+ resolvePromise({ server, host: addr.address, port: addr.port, url: localUrl(addr.address, addr.port) });
431
+ });
432
+ });
433
+ }
434
+
435
+ export async function runEvalView({ repo, skill, goldensDir, port = 0, host = DEFAULT_HOST, json = false, serve = true } = {}) {
436
+ const view = await buildView({ repo, skill, goldensDir, cwd: process.cwd() });
437
+ if (view.error) {
438
+ console.error(view.error);
439
+ process.exitCode = 1;
440
+ return;
441
+ }
442
+ if (json) {
443
+ console.log(JSON.stringify({ repoRoot: view.repoRoot, skills: view.skills.map((s) => s.skill) }, null, 2));
444
+ return;
445
+ }
446
+ if (!serve) {
447
+ process.stdout.write(view.html);
448
+ return;
449
+ }
450
+ if (!isLocalhostBind(host)) {
451
+ console.error(`eval view binds localhost only (got --host ${host}); allowed: 127.0.0.1, ::1, localhost`);
452
+ process.exitCode = 1;
453
+ return;
454
+ }
455
+ let served;
456
+ try {
457
+ served = await serveEvalView({ html: view.html, host, port });
458
+ } catch (err) {
459
+ // EADDRINUSE on a chosen --port, an off-loopback resolution, etc. — clean failure, not a
460
+ // stack trace.
461
+ console.error(`eval view could not start: ${err.message}`);
462
+ process.exitCode = 1;
463
+ return;
464
+ }
465
+ console.log(`Phronesis eval view — ${view.repoRoot}`);
466
+ console.log(`serving ${view.skills.length} skill(s) at ${served.url} (localhost-only; Ctrl-C to stop)`);
467
+ }
package/src/event.js ADDED
@@ -0,0 +1,212 @@
1
+ // `phronesis event` / `phronesis events` — the universal emission + read path over the
2
+ // event-log core (core-0006 / T4.0). Every surface/agent shells out to `event emit`; the
3
+ // daemon (T4.1) and surface-native bridges are faster paths to the same record. This module
4
+ // is the CLI glue only — validation, the capture gate, and I/O live in @phronesis/core.
5
+
6
+ import { watch } from 'node:fs';
7
+ import {
8
+ resolveInstall,
9
+ appendEvent,
10
+ readEvents,
11
+ enforceRetention,
12
+ purgeEvents,
13
+ enforceAuditRetention,
14
+ purgeAuditLog,
15
+ readEventLogConfig,
16
+ isReservedType,
17
+ eventsDir,
18
+ } from '@phronesis/core';
19
+
20
+ // Install resolution (explicit --dir or marker walk-up, BOTH fail closed on a missing
21
+ // .phronesis/registry.json) lives in @phronesis/core — shared with the action spine
22
+ // (core-0008) so every install-scoped command resolves identically.
23
+
24
+ // Resolve or print-and-fail. Returns the install root, or null after setting exitCode = 1.
25
+ function installOrFail(dir) {
26
+ const r = resolveInstall({ dir });
27
+ if (r.error) {
28
+ console.error(r.error);
29
+ process.exitCode = 1;
30
+ return null;
31
+ }
32
+ return r.root;
33
+ }
34
+
35
+ export async function runEventEmit({ type, session, payload, actor, surface, dir } = {}) {
36
+ const installRoot = installOrFail(dir);
37
+ if (!installRoot) return null;
38
+
39
+ if (!type) {
40
+ console.error('event emit: name an event <type> (e.g. session.start).');
41
+ process.exitCode = 1;
42
+ return null;
43
+ }
44
+ // Reserved-type refusal is a CLI policy, checked before the core write: each
45
+ // reserved type has a sole authoritative emitter, so a caller cannot forge a
46
+ // filesystem/observation/lifecycle event through the public CLI (fail-closed).
47
+ // file.* arrives with the V1.5 daemon's filesystem watch; outcome.due is emitted by
48
+ // the V1 session-start due-check (`phronesis hooks due-check` — change 0010);
49
+ // skill.invoked / skill.completed are emitted by compliant surface adapters.
50
+ if (isReservedType(type)) {
51
+ console.error(
52
+ `event emit: "${type}" is reserved for its authoritative emitter (file.*: the V1.5 daemon's filesystem watch; outcome.due / schedule.due: the session-start due-check, \`phronesis hooks due-check\`; skill.invoked/skill.completed: compliant surface adapters; compile.candidate/compile.accepted/compile.rejected: the compile action + the operator accept/reject moves — \`compile review\` trusts these for its fail-closed quarantine, so a freehand one could forge a "clean" draft). The CLI refuses freehand emission.`,
53
+ );
54
+ process.exitCode = 1;
55
+ return null;
56
+ }
57
+ if (!session) {
58
+ console.error('event emit: --session <id> is required.');
59
+ process.exitCode = 1;
60
+ return null;
61
+ }
62
+ let parsed;
63
+ try {
64
+ parsed = payload === undefined ? {} : JSON.parse(payload);
65
+ } catch {
66
+ console.error('event emit: --payload must be valid JSON.');
67
+ process.exitCode = 1;
68
+ return null;
69
+ }
70
+
71
+ // Mandatory-field defaults (Decision F): surface defaults to cli; actor reads
72
+ // PHRONESIS_ACTOR (surface adapters set it) else agent, the dominant emitter. Adapters
73
+ // SHOULD pass both explicitly; --actor / --surface override.
74
+ const resolvedActor = actor || process.env.PHRONESIS_ACTOR || 'agent';
75
+ const resolvedSurface = surface || 'cli';
76
+
77
+ try {
78
+ const record = await appendEvent({
79
+ installRoot,
80
+ type,
81
+ session_id: session,
82
+ actor: resolvedActor,
83
+ surface: resolvedSurface,
84
+ payload: parsed,
85
+ });
86
+ console.log(record.id);
87
+ process.exitCode = 0;
88
+ return record;
89
+ } catch (err) {
90
+ console.error(`event emit: ${err.message}`);
91
+ process.exitCode = 1;
92
+ return null;
93
+ }
94
+ }
95
+
96
+ export async function runEventTail({ type, since, follow, dir } = {}) {
97
+ const installRoot = installOrFail(dir);
98
+ if (!installRoot) return null;
99
+
100
+ // Validate + normalize --since to canonical ISO before comparing. readEvents compares ts as
101
+ // text, which is only correct when both sides are ISO-8601 UTC — a raw string like
102
+ // "2026-5-17" (un-padded) or "garbage" would lexically mis-filter and silently empty the
103
+ // stream. Reject anything Date can't parse; normalize the rest so the compare is sound.
104
+ let sinceIso;
105
+ if (since !== undefined) {
106
+ const parsed = Date.parse(since);
107
+ if (Number.isNaN(parsed)) {
108
+ console.error(`event tail: --since "${since}" is not a parseable date/timestamp.`);
109
+ process.exitCode = 1;
110
+ return null;
111
+ }
112
+ sinceIso = new Date(parsed).toISOString();
113
+ }
114
+
115
+ const printed = new Set();
116
+ const flush = async () => {
117
+ for (const ev of await readEvents({ installRoot, since: sinceIso, type })) {
118
+ if (printed.has(ev.id)) continue;
119
+ printed.add(ev.id);
120
+ console.log(JSON.stringify(ev));
121
+ }
122
+ };
123
+
124
+ await flush();
125
+ if (!follow) {
126
+ process.exitCode = 0;
127
+ return null;
128
+ }
129
+
130
+ // --follow: re-flush on any change to the events dir (fs.watch), with a poll fallback for
131
+ // filesystems where native watch is unreliable (no chokidar — that dep + FS watching is
132
+ // T4.1). flush() dedups by id, so a re-read only ever prints genuinely-new lines. Runs
133
+ // until interrupted; never resolves.
134
+ const trigger = () => {
135
+ flush().catch(() => {});
136
+ };
137
+ let watcher = null;
138
+ try {
139
+ watcher = watch(eventsDir(installRoot), { persistent: true }, trigger);
140
+ } catch {
141
+ watcher = null; // dir may not be watchable; the poll fallback covers it
142
+ }
143
+ const poll = setInterval(trigger, 1000);
144
+ poll.unref?.();
145
+ await new Promise(() => {});
146
+ // unreachable, but keeps the watcher referenced for the lifetime of the process
147
+ if (watcher) watcher.close();
148
+ return null;
149
+ }
150
+
151
+ export async function runEventsPurge({ before, archive, dir } = {}) {
152
+ const installRoot = installOrFail(dir);
153
+ if (!installRoot) return null;
154
+
155
+ if (!before) {
156
+ console.error('events purge: --before YYYY-MM-DD is required (the deletion boundary).');
157
+ process.exitCode = 1;
158
+ return null;
159
+ }
160
+ try {
161
+ const { files } = await purgeEvents({ installRoot, before, archive: Boolean(archive) });
162
+ const verb = archive ? 'archived' : 'deleted';
163
+ console.log(
164
+ files.length
165
+ ? `${verb} ${files.length} day file(s) before ${before}: ${files.join(', ')}`
166
+ : `no day files before ${before}.`,
167
+ );
168
+ // One retention story, one verb family (core-0027 J5 / Q2): the audit log inherits the
169
+ // same purge boundary. It is a single append file, so this is line-level, not a file move.
170
+ const audit = await purgeAuditLog({ installRoot, before, archive: Boolean(archive) });
171
+ if (audit.purged) {
172
+ console.log(`${verb} ${audit.purged} audit.log line(s) before ${before}.`);
173
+ }
174
+ process.exitCode = 0;
175
+ return files;
176
+ } catch (err) {
177
+ console.error(`events purge: ${err.message}`);
178
+ process.exitCode = 1;
179
+ return null;
180
+ }
181
+ }
182
+
183
+ export async function runEventsRotate({ dir } = {}) {
184
+ const installRoot = installOrFail(dir);
185
+ if (!installRoot) return null;
186
+
187
+ // try/catch like its sibling purge: rotate MUTATES files (fse.move), so a failed move
188
+ // (permissions, disk, collision) must surface as exit 1, not an unhandled rejection. A
189
+ // partial archive is forward-safe — every day file is either in events/ or archive/, both
190
+ // valid locations, and a re-run continues from where it stopped (the move is idempotent).
191
+ try {
192
+ const { retentionDays } = await readEventLogConfig(installRoot);
193
+ const { archived } = await enforceRetention({ installRoot, retentionDays });
194
+ console.log(
195
+ archived.length
196
+ ? `archived ${archived.length} day file(s) past ${retentionDays}-day retention: ${archived.join(', ')}`
197
+ : `no day files past ${retentionDays}-day retention.`,
198
+ );
199
+ // One retention story, one verb family (core-0027 J5 / Q2): the single-file audit log
200
+ // honors the same retention window via line-level archival to audit.log.archive.
201
+ const audit = await enforceAuditRetention({ installRoot, retentionDays });
202
+ if (audit.archived) {
203
+ console.log(`archived ${audit.archived} audit.log line(s) past ${retentionDays}-day retention.`);
204
+ }
205
+ process.exitCode = 0;
206
+ return archived;
207
+ } catch (err) {
208
+ console.error(`events rotate: ${err.message}`);
209
+ process.exitCode = 1;
210
+ return null;
211
+ }
212
+ }