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/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, '&')
|
|
190
|
+
.replace(/</g, '<')
|
|
191
|
+
.replace(/>/g, '>')
|
|
192
|
+
.replace(/"/g, '"');
|
|
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 ? ' ↓ 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
|
+
}
|