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
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
// The V1 hook executor (core-0013; change 0010 D3; specs/hooks.md §"Hooks subscribe to
|
|
2
|
+
// events, not vibes" / §"Hook registry format" / §"Safe-output discipline" / §"Bypass
|
|
3
|
+
// discipline"; specs/event-log.md §"Subscription contract"; packet decisions H1/H2/H6 in
|
|
4
|
+
// harness/tasks/core-0013-v1-hook-executor.md).
|
|
5
|
+
//
|
|
6
|
+
// Hooks subscribe to typed events in the install's `.phronesis/registry.json` top-level
|
|
7
|
+
// `hooks` array — the subscription shape both specs publish. The action core fires
|
|
8
|
+
// matching commands synchronously at its two emit points (`action_type.requested` /
|
|
9
|
+
// `action_type.committed`, actions.js); the daemon takes over the full stream at V1.5
|
|
10
|
+
// (change 0010). A per-action binding is expressible AS a subscription (subscribe to the
|
|
11
|
+
// action_type.* pair with a payload filter), so the per-action hooks.pre/post slots stay
|
|
12
|
+
// reserved-and-empty (action-registry.js).
|
|
13
|
+
//
|
|
14
|
+
// Command execution is a SECURITY surface — the registry is operator-editable and names
|
|
15
|
+
// an executable (H2):
|
|
16
|
+
// - commands are path-anchored under .phronesis/hooks/ (validated at load, re-checked
|
|
17
|
+
// at exec by resolved-path containment);
|
|
18
|
+
// - exec is spawn() with NO shell — the event record travels on stdin, never argv;
|
|
19
|
+
// - quota.max_per_session is enforced from the audit log (file-based, so the count
|
|
20
|
+
// survives process boundaries and bites on recursive mutation-hook loops);
|
|
21
|
+
// - PHRONESIS_SKIP=<hook-id> skips AND audits (a logged bypass, never a silent one);
|
|
22
|
+
// - EVERY fire — success, failure, timeout, skip — appends a .phronesis/audit.log
|
|
23
|
+
// entry; an audit-append failure fails the fire loudly (hooks that can fail
|
|
24
|
+
// silently are the spec's named anti-pattern).
|
|
25
|
+
//
|
|
26
|
+
// KNOWN LIMITS: the exec containment check is lexical over resolve() — a symlink inside
|
|
27
|
+
// .phronesis/hooks/ pointing elsewhere is operator-owned filesystem state, the same
|
|
28
|
+
// posture as every other install file. Filters are the V1 field-equality subset
|
|
29
|
+
// (event-log.md:138); jq expressions are the V1.5 daemon superset.
|
|
30
|
+
|
|
31
|
+
import { join, resolve, sep, isAbsolute } from 'node:path';
|
|
32
|
+
import { appendFile, readFile } from 'node:fs/promises';
|
|
33
|
+
import { spawn } from 'node:child_process';
|
|
34
|
+
import fse from 'fs-extra';
|
|
35
|
+
import { isValidType, readEventLogConfig } from './event-log.js';
|
|
36
|
+
import { validateSubscription, matchesSubscription } from './subscription.js';
|
|
37
|
+
|
|
38
|
+
export const HOOK_KINDS = ['ambient', 'gate', 'mutation'];
|
|
39
|
+
export const HOOK_CLASSIFICATIONS = ['silent', 'acknowledged', 'interactive'];
|
|
40
|
+
|
|
41
|
+
// A hung hook must not hang the act pipeline: hard kill + audited failure. Code-resident
|
|
42
|
+
// (not registry-settable) — the timeout is part of the floor, like the approval lattice.
|
|
43
|
+
export const HOOK_TIMEOUT_MS = 30_000;
|
|
44
|
+
|
|
45
|
+
const HOOK_ENTRY_KEYS = [
|
|
46
|
+
'id',
|
|
47
|
+
'kind',
|
|
48
|
+
'subscribes_to',
|
|
49
|
+
'command',
|
|
50
|
+
'quota',
|
|
51
|
+
'classification',
|
|
52
|
+
'kill_switch',
|
|
53
|
+
'filter',
|
|
54
|
+
];
|
|
55
|
+
const RE_HOOK_ID = /^[a-z0-9][a-z0-9_-]*$/;
|
|
56
|
+
const COMMAND_PREFIX = '.phronesis/hooks/';
|
|
57
|
+
|
|
58
|
+
function isPlainObject(v) {
|
|
59
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// H2 load-time path anchor: relative, no traversal, rooted in .phronesis/hooks/ with a
|
|
63
|
+
// real basename after the prefix. The exec path re-checks containment after resolve().
|
|
64
|
+
function commandAnchorError(command) {
|
|
65
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
66
|
+
return 'command must be a non-empty path string';
|
|
67
|
+
}
|
|
68
|
+
if (isAbsolute(command) || command.includes('\\')) {
|
|
69
|
+
return `command "${command}" must be a relative POSIX path under ${COMMAND_PREFIX}`;
|
|
70
|
+
}
|
|
71
|
+
if (command.split('/').includes('..')) {
|
|
72
|
+
return `command "${command}" contains a ".." segment — commands never escape ${COMMAND_PREFIX}`;
|
|
73
|
+
}
|
|
74
|
+
if (!command.startsWith(COMMAND_PREFIX) || command.length === COMMAND_PREFIX.length) {
|
|
75
|
+
return `command "${command}" must live under ${COMMAND_PREFIX} (hook commands are install-owned scripts)`;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate the top-level hooks section against the published subscription shape (H1).
|
|
81
|
+
// Absent ⇒ no hooks registered (additive-optional: absence can never loosen anything,
|
|
82
|
+
// the G3 argument — no schema_version bump). Present-but-invalid ⇒ the caller disables
|
|
83
|
+
// the action layer all-or-nothing with these errors (a config error is loud and total).
|
|
84
|
+
export function resolveHooks(registry) {
|
|
85
|
+
const errors = [];
|
|
86
|
+
if (!isPlainObject(registry)) {
|
|
87
|
+
return { errors: ['.phronesis/registry.json must be a JSON object'], hooks: [] };
|
|
88
|
+
}
|
|
89
|
+
const section = registry.hooks;
|
|
90
|
+
if (section === undefined) return { errors, hooks: [] };
|
|
91
|
+
// Same keying argument as the action_types/v0 rule (core-0011): version 0 predates
|
|
92
|
+
// every section, and schema_version is what future migrations key on.
|
|
93
|
+
if (registry.schema_version === 0) {
|
|
94
|
+
errors.push(
|
|
95
|
+
'hooks requires schema_version 1 — a version-0 registry predates the section (bump the version, or remove the section: absent means no hooks registered)',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!Array.isArray(section)) {
|
|
99
|
+
errors.push('hooks: must be an array of hook entries (hooks.md §"Hook registry format")');
|
|
100
|
+
return { errors, hooks: [] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
section.forEach((entry, i) => {
|
|
105
|
+
const label = isPlainObject(entry) && typeof entry.id === 'string' ? `hooks[${i}] (${entry.id})` : `hooks[${i}]`;
|
|
106
|
+
if (!isPlainObject(entry)) {
|
|
107
|
+
errors.push(`${label}: must be an object`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const unknown = Object.keys(entry).filter((k) => !HOOK_ENTRY_KEYS.includes(k));
|
|
111
|
+
if (unknown.length) {
|
|
112
|
+
errors.push(`${label}: unknown key(s): ${unknown.join(', ')}`);
|
|
113
|
+
}
|
|
114
|
+
if (typeof entry.id !== 'string' || !RE_HOOK_ID.test(entry.id)) {
|
|
115
|
+
errors.push(`${label}: id must be a lowercase [a-z0-9_-] identifier`);
|
|
116
|
+
} else if (seen.has(entry.id)) {
|
|
117
|
+
errors.push(`${label}: duplicate hook id "${entry.id}"`);
|
|
118
|
+
} else {
|
|
119
|
+
seen.add(entry.id);
|
|
120
|
+
}
|
|
121
|
+
if (!HOOK_KINDS.includes(entry.kind)) {
|
|
122
|
+
errors.push(`${label}: kind "${entry.kind}" is not in the hook-kind vocabulary (${HOOK_KINDS.join(' | ')})`);
|
|
123
|
+
}
|
|
124
|
+
// The matching shape (subscribes_to + filter) validates through the ONE shared
|
|
125
|
+
// subscription validator (subscription.js, T4.0) — the executor's matcher is
|
|
126
|
+
// matchesSubscription, so its validation must be the same contract, never a
|
|
127
|
+
// re-typed copy. The hooks registry additionally demands REAL event types: a
|
|
128
|
+
// subscription to a typo'd type would never fire — silent intent-drop.
|
|
129
|
+
const sub = validateSubscription({ subscribes_to: entry.subscribes_to, filter: entry.filter });
|
|
130
|
+
for (const e of sub.errors) errors.push(`${label}: ${e}`);
|
|
131
|
+
if (Array.isArray(entry.subscribes_to)) {
|
|
132
|
+
for (const t of entry.subscribes_to) {
|
|
133
|
+
if (typeof t === 'string' && t !== '' && !isValidType(t)) {
|
|
134
|
+
errors.push(`${label}: subscribes_to "${t}" is not in the V1 event vocabulary or a connector.<name>.<event> extension`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const cmdError = commandAnchorError(entry.command);
|
|
139
|
+
if (cmdError) errors.push(`${label}: ${cmdError}`);
|
|
140
|
+
if (
|
|
141
|
+
!isPlainObject(entry.quota) ||
|
|
142
|
+
Object.keys(entry.quota).some((k) => k !== 'max_per_session') ||
|
|
143
|
+
!Number.isInteger(entry.quota.max_per_session) ||
|
|
144
|
+
entry.quota.max_per_session < 1
|
|
145
|
+
) {
|
|
146
|
+
errors.push(`${label}: quota must be { max_per_session: <positive integer> } — the runaway-hook guard is not optional`);
|
|
147
|
+
}
|
|
148
|
+
if (!HOOK_CLASSIFICATIONS.includes(entry.classification)) {
|
|
149
|
+
errors.push(
|
|
150
|
+
`${label}: classification "${entry.classification}" is not in the vocabulary (${HOOK_CLASSIFICATIONS.join(' | ')})`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (typeof entry.id === 'string' && entry.kill_switch !== `PHRONESIS_SKIP=${entry.id}`) {
|
|
154
|
+
errors.push(`${label}: kill_switch must be "PHRONESIS_SKIP=${entry.id}" — the documented bypass must tell the truth`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
if (errors.length) return { errors, hooks: [] };
|
|
158
|
+
return { errors, hooks: section };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Subscription match through the ONE shared matcher (subscription.js — type membership,
|
|
162
|
+
// then AND-matched payload-field equality). Registry-array order is execution order —
|
|
163
|
+
// deterministic, operator-visible.
|
|
164
|
+
export function matchingHooks(hooks, event) {
|
|
165
|
+
return hooks.filter((h) => matchesSubscription(event, h));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- Audit (.phronesis/audit.log, JSONL) ----------------------------------------------
|
|
169
|
+
|
|
170
|
+
export function auditLogPath(installRoot) {
|
|
171
|
+
return join(installRoot, '.phronesis', 'audit.log');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function readAuditLog(installRoot) {
|
|
175
|
+
let content;
|
|
176
|
+
try {
|
|
177
|
+
content = await readFile(auditLogPath(installRoot), 'utf8');
|
|
178
|
+
} catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
const out = [];
|
|
182
|
+
for (const line of content.split('\n')) {
|
|
183
|
+
const trimmed = line.trim();
|
|
184
|
+
if (!trimmed) continue;
|
|
185
|
+
try {
|
|
186
|
+
out.push(JSON.parse(trimmed));
|
|
187
|
+
} catch {
|
|
188
|
+
// a torn/partial line never poisons a read (same posture as readEvents)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The ts is the REAL clock, deliberately not the action's injectable `now`: the audit
|
|
195
|
+
// log is the forensic record of when fires actually happened, and two audits inside one
|
|
196
|
+
// action must not share a synthetic timestamp. `entry` is spread FIRST, then ts is
|
|
197
|
+
// stamped, so a caller's `entry.ts` (forged or accidental) can NEVER override the append
|
|
198
|
+
// time — the forensic clock always wins, and retention can trust every stamped ts.
|
|
199
|
+
export async function appendAuditEntry(installRoot, entry) {
|
|
200
|
+
await fse.ensureDir(join(installRoot, '.phronesis'));
|
|
201
|
+
await appendFile(
|
|
202
|
+
auditLogPath(installRoot),
|
|
203
|
+
`${JSON.stringify({ ...entry, ts: new Date().toISOString() })}\n`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function appendAudit(installRoot, entry) {
|
|
208
|
+
await appendAuditEntry(installRoot, entry);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- Audit retention (core-0027 J5) ----------------------------------------------------
|
|
212
|
+
//
|
|
213
|
+
// .phronesis/audit.log is a SINGLE JSONL append file (unlike .phronesis/events/, which
|
|
214
|
+
// rotates whole per-day files), so it grows unbounded and the per-emit quota read
|
|
215
|
+
// (readAuditLog) scans more every fire. J5 gives it the event-log retention STORY (default
|
|
216
|
+
// 90 days, configurable — the SAME event_log.retention_days, one retention story per Q2)
|
|
217
|
+
// folded into the SAME verb family (events rotate / events purge). The MECHANISM differs: a
|
|
218
|
+
// single file has no per-day split, so retention is LINE-LEVEL filtering of the one file —
|
|
219
|
+
// kept lines rewritten, older lines moved to audit.log.archive (rotate) or dropped (purge).
|
|
220
|
+
//
|
|
221
|
+
// Single-writer assumption (the install's standing one-writer model — actions.js): these
|
|
222
|
+
// are operator-invoked maintenance verbs, run when no session is appending; the daemon
|
|
223
|
+
// serializes at V1.5. The temp+rename keeps the live file from ever being TORN — but it
|
|
224
|
+
// does NOT make the read→rewrite atomic against a concurrent append: a line appended between
|
|
225
|
+
// partitionAuditLines and the rename is on the inode the rename replaces, so it is LOST. This
|
|
226
|
+
// is strictly more exposed than event-log retention, which only moves INACTIVE past-day files
|
|
227
|
+
// (today's active file is never touched); a single always-active audit.log has no such inert
|
|
228
|
+
// target. The `events` help text surfaces this quiesce constraint to the operator. Likewise
|
|
229
|
+
// the archive-then-rewrite pair is NOT atomic as a whole: a crash between the two re-archives
|
|
230
|
+
// the same lines on retry (over-retention, never loss) — so unlike the day-file MOVE this is
|
|
231
|
+
// not idempotent. Both costs are the price of the line-level mechanism the contract chose over
|
|
232
|
+
// splitting audit.log into dated files (core-0027 J5 / round-11 S1).
|
|
233
|
+
|
|
234
|
+
export function auditArchivePath(installRoot) {
|
|
235
|
+
return join(installRoot, '.phronesis', 'audit.log.archive');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A retention-comparable ts is one toISOString produces — a canonical ISO-8601 UTC
|
|
239
|
+
// instant. The lexical `ts < cutoff` / `ts < before` compares in enforceAuditRetention and
|
|
240
|
+
// purgeAuditLog are only SOUND for that shape; any other string (a corrupt/non-ISO value
|
|
241
|
+
// like "0-not-a-date") is UNDATEABLE and must be KEPT, never lexically mis-ordered into the
|
|
242
|
+
// old set. Millis optional so a non-toISOString ISO instant still counts. (A structurally-ISO
|
|
243
|
+
// but ancient ts like year 0000 is "dateable and old" — dropping it under retention is
|
|
244
|
+
// correct; the KEEP guarantee is for ts we cannot soundly date, not for genuinely-old ones.)
|
|
245
|
+
const ISO_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
246
|
+
function isDateableTs(ts) {
|
|
247
|
+
return typeof ts === 'string' && ISO_TS_RE.test(ts);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Read the raw audit file and split each non-empty line into kept vs old by `isOld(ts)`.
|
|
251
|
+
// A line that does not parse, or whose `ts` is not a well-formed ISO-8601 UTC instant
|
|
252
|
+
// (isDateableTs), is KEPT — retention never drops a forensic line it cannot soundly date
|
|
253
|
+
// (the same "a torn line never poisons a read" posture as readAuditLog). Original line text
|
|
254
|
+
// is preserved verbatim (no re-serialization / reformat).
|
|
255
|
+
async function partitionAuditLines(installRoot, isOld) {
|
|
256
|
+
let content;
|
|
257
|
+
try {
|
|
258
|
+
content = await readFile(auditLogPath(installRoot), 'utf8');
|
|
259
|
+
} catch {
|
|
260
|
+
return { kept: [], old: [], present: false };
|
|
261
|
+
}
|
|
262
|
+
const kept = [];
|
|
263
|
+
const old = [];
|
|
264
|
+
for (const line of content.split('\n')) {
|
|
265
|
+
if (line.trim() === '') continue;
|
|
266
|
+
let ts;
|
|
267
|
+
try {
|
|
268
|
+
ts = JSON.parse(line).ts;
|
|
269
|
+
} catch {
|
|
270
|
+
ts = undefined;
|
|
271
|
+
}
|
|
272
|
+
if (isDateableTs(ts) && isOld(ts)) old.push(line);
|
|
273
|
+
else kept.push(line);
|
|
274
|
+
}
|
|
275
|
+
return { kept, old, present: true };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function rewriteAuditLog(installRoot, keptLines) {
|
|
279
|
+
const path = auditLogPath(installRoot);
|
|
280
|
+
const body = keptLines.length ? `${keptLines.join('\n')}\n` : '';
|
|
281
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
282
|
+
await fse.writeFile(tmp, body);
|
|
283
|
+
await fse.move(tmp, path, { overwrite: true });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function archiveAuditLines(installRoot, oldLines) {
|
|
287
|
+
if (!oldLines.length) return;
|
|
288
|
+
await fse.ensureDir(join(installRoot, '.phronesis'));
|
|
289
|
+
await appendFile(auditArchivePath(installRoot), `${oldLines.join('\n')}\n`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ARCHIVE audit lines older than the retention window to audit.log.archive (mirrors
|
|
293
|
+
// event-log enforceRetention; folded into `events rotate`). Retention is an INSTANT cutoff
|
|
294
|
+
// (now − retentionDays) — a single append file has no day-file boundary to round to. Bounds
|
|
295
|
+
// both unbounded growth AND the per-emit quota read, which scans the LIVE file only.
|
|
296
|
+
export async function enforceAuditRetention({ installRoot, retentionDays, now = new Date() } = {}) {
|
|
297
|
+
const days =
|
|
298
|
+
Number.isInteger(retentionDays) && retentionDays > 0
|
|
299
|
+
? retentionDays
|
|
300
|
+
: (await readEventLogConfig(installRoot)).retentionDays;
|
|
301
|
+
const cutoff = new Date(now.getTime() - days * 86_400_000).toISOString();
|
|
302
|
+
const { old, kept, present } = await partitionAuditLines(installRoot, (ts) => ts < cutoff);
|
|
303
|
+
if (!present || !old.length) return { archived: 0, kept: kept.length, cutoff, retentionDays: days };
|
|
304
|
+
await archiveAuditLines(installRoot, old);
|
|
305
|
+
await rewriteAuditLog(installRoot, kept);
|
|
306
|
+
return { archived: old.length, kept: kept.length, cutoff, retentionDays: days };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// DELETE audit lines dated before `before` (operator wipe / privacy; mirrors event-log
|
|
310
|
+
// purgeEvents, folded into `events purge`). `before` is a YYYY-MM-DD stamp; a line is old
|
|
311
|
+
// when its ISO ts sorts before it (lexical — ts "2002-01-01T…" > "2002-01-01", so a line ON
|
|
312
|
+
// the boundary day is KEPT, matching the day-file purge). With `archive`, old lines move to
|
|
313
|
+
// audit.log.archive instead of being dropped.
|
|
314
|
+
export async function purgeAuditLog({ installRoot, before, archive = false } = {}) {
|
|
315
|
+
if (typeof before !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(before)) {
|
|
316
|
+
throw new Error('audit purge requires --before YYYY-MM-DD');
|
|
317
|
+
}
|
|
318
|
+
const { old, kept, present } = await partitionAuditLines(installRoot, (ts) => ts < before);
|
|
319
|
+
if (!present || !old.length) return { purged: 0, kept: kept.length, before, archived: archive };
|
|
320
|
+
if (archive) await archiveAuditLines(installRoot, old);
|
|
321
|
+
await rewriteAuditLog(installRoot, kept);
|
|
322
|
+
return { purged: old.length, kept: kept.length, before, archived: archive };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Attempts per (hook, session), counted from the 'firing' entries appended BEFORE each
|
|
326
|
+
// spawn. Counting attempts — not completions — is what makes the quota bite on
|
|
327
|
+
// recursive mutation-hook loops: a hook whose command re-enters `phronesis act` never
|
|
328
|
+
// COMPLETES a fire while the chain grows, so a completion-counted quota would read 0
|
|
329
|
+
// at every depth and guard nothing (found by the core-0013 adversarial review). Skips
|
|
330
|
+
// never consume quota, or a kill-switched hook would burn its budget doing nothing.
|
|
331
|
+
function countAttempts(entries, hookId, sessionId) {
|
|
332
|
+
return entries.filter(
|
|
333
|
+
(e) => e.hook === hookId && e.session_id === sessionId && e.outcome === 'firing',
|
|
334
|
+
).length;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- Execution -------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
function parseSkipList(env) {
|
|
340
|
+
const raw = env.PHRONESIS_SKIP;
|
|
341
|
+
if (typeof raw !== 'string' || raw.trim() === '') return new Set();
|
|
342
|
+
return new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// spawn with NO shell; the event record on stdin; hard timeout. Resolves
|
|
346
|
+
// { exitCode, error?, stderrTail } — never rejects (failure handling is the caller's
|
|
347
|
+
// audit + gate logic).
|
|
348
|
+
//
|
|
349
|
+
// Settle on 'exit', NOT 'close': close waits for every stdio pipe to drain, and a hook
|
|
350
|
+
// that leaves a background child inheriting stderr (`something &`) would keep the pipe
|
|
351
|
+
// open — stalling the act pipeline long after the script itself exited, past the very
|
|
352
|
+
// timeout that exists to prevent hangs (found by the core-0013 adversarial review,
|
|
353
|
+
// reproduced live). The stderr tail is whatever arrived by exit time. The timeout
|
|
354
|
+
// force-settles immediately after the kill for the same reason — an unkillable or
|
|
355
|
+
// orphan-holding child must not hold the pipeline either; the audit records the
|
|
356
|
+
// timeout, and the orphan is the operator-owned script's own mess.
|
|
357
|
+
function runCommand({ file, event, env, timeoutMs, cwd }) {
|
|
358
|
+
return new Promise((resolvePromise) => {
|
|
359
|
+
let child;
|
|
360
|
+
try {
|
|
361
|
+
child = spawn(file, [], {
|
|
362
|
+
shell: false,
|
|
363
|
+
cwd,
|
|
364
|
+
env,
|
|
365
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
366
|
+
});
|
|
367
|
+
} catch (err) {
|
|
368
|
+
resolvePromise({ exitCode: null, error: err.message, stderrTail: '' });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
let stderr = '';
|
|
372
|
+
let settled = false;
|
|
373
|
+
const settle = (result) => {
|
|
374
|
+
if (settled) return;
|
|
375
|
+
settled = true;
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
resolvePromise(result);
|
|
378
|
+
};
|
|
379
|
+
const timer = setTimeout(() => {
|
|
380
|
+
child.kill('SIGKILL');
|
|
381
|
+
settle({
|
|
382
|
+
exitCode: null,
|
|
383
|
+
error: `timed out after ${timeoutMs}ms (killed)`,
|
|
384
|
+
stderrTail: stderr.slice(-300),
|
|
385
|
+
});
|
|
386
|
+
}, timeoutMs);
|
|
387
|
+
child.stderr.on('data', (chunk) => {
|
|
388
|
+
if (stderr.length < 4096) stderr += chunk.toString();
|
|
389
|
+
});
|
|
390
|
+
child.on('error', (err) => {
|
|
391
|
+
settle({ exitCode: null, error: err.message, stderrTail: stderr.slice(-300) });
|
|
392
|
+
});
|
|
393
|
+
child.on('exit', (code, signal) => {
|
|
394
|
+
settle({
|
|
395
|
+
exitCode: code,
|
|
396
|
+
error:
|
|
397
|
+
code === 0
|
|
398
|
+
? undefined
|
|
399
|
+
: code === null
|
|
400
|
+
? `killed by ${signal}`
|
|
401
|
+
: `exited ${code}`,
|
|
402
|
+
stderrTail: stderr.slice(-300),
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
child.stdin.on('error', () => {}); // a hook that closes stdin early is not an exec failure
|
|
406
|
+
child.stdin.end(`${JSON.stringify(event)}\n`);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Fire every hook matching `event`, synchronously and in registry order (H6). Every
|
|
411
|
+
// attempt and every outcome is audited (a fire writes a 'firing' attempt line BEFORE
|
|
412
|
+
// the spawn, then its 'fired'/'failed' result — the pre-spawn line is what in-flight
|
|
413
|
+
// quota counting and forensics on a crashed hook stand on); gate-kind failures are
|
|
414
|
+
// returned for the caller to translate into the pipeline's refusal shapes (refuse at
|
|
415
|
+
// requested; surface-after-write at committed — the artifact is never rolled back:
|
|
416
|
+
// agents do not delete). Once a gate has failed, the remaining matched hooks are
|
|
417
|
+
// SKIPPED (audited): a gate guards everything behind it in registry order, and firing
|
|
418
|
+
// a mutation hook for an action a gate already condemned would honor a proposal the
|
|
419
|
+
// pipeline is about to refuse.
|
|
420
|
+
//
|
|
421
|
+
// Concurrency: quota counting is read-then-append over the audit file, so two
|
|
422
|
+
// simultaneous callers in one session could each read N-1 and both fire — the same
|
|
423
|
+
// ONE-writer-per-installation assumption the action core documents (actions.js);
|
|
424
|
+
// serialization is the daemon's job (V1.5) when multi-caller surfaces arrive.
|
|
425
|
+
export async function executeHooksFor({
|
|
426
|
+
installRoot,
|
|
427
|
+
hooks,
|
|
428
|
+
event,
|
|
429
|
+
env = process.env,
|
|
430
|
+
timeoutMs = HOOK_TIMEOUT_MS,
|
|
431
|
+
} = {}) {
|
|
432
|
+
const matched = matchingHooks(hooks, event);
|
|
433
|
+
const results = [];
|
|
434
|
+
const gateFailures = [];
|
|
435
|
+
if (!matched.length) return { results, gateFailures };
|
|
436
|
+
|
|
437
|
+
const skip = parseSkipList(env);
|
|
438
|
+
const hooksDir = join(installRoot, '.phronesis', 'hooks');
|
|
439
|
+
// One disk read per emit point; attempts made within this call are counted in
|
|
440
|
+
// memory on top of it (the audit file is append-only, so the snapshot stays valid).
|
|
441
|
+
const priorEntries = await readAuditLog(installRoot);
|
|
442
|
+
const attempted = new Map();
|
|
443
|
+
const attemptsFor = (id) =>
|
|
444
|
+
(attempted.get(id) || 0) + countAttempts(priorEntries, id, event.session_id);
|
|
445
|
+
|
|
446
|
+
for (const hook of matched) {
|
|
447
|
+
const base = {
|
|
448
|
+
session_id: event.session_id,
|
|
449
|
+
hook: hook.id,
|
|
450
|
+
kind: hook.kind,
|
|
451
|
+
classification: hook.classification,
|
|
452
|
+
event_type: event.type,
|
|
453
|
+
event_id: event.id,
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
if (gateFailures.length) {
|
|
457
|
+
await appendAudit(installRoot, {
|
|
458
|
+
...base,
|
|
459
|
+
outcome: 'skipped-gate-failed',
|
|
460
|
+
gate: gateFailures[0].id,
|
|
461
|
+
});
|
|
462
|
+
results.push({ id: hook.id, outcome: 'skipped-gate-failed' });
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (skip.has(hook.id)) {
|
|
467
|
+
// Bypass discipline: documented, logged — operators invent unlogged bypasses if
|
|
468
|
+
// you don't ship a logged one.
|
|
469
|
+
await appendAudit(installRoot, { ...base, outcome: 'skipped-kill-switch' });
|
|
470
|
+
results.push({ id: hook.id, outcome: 'skipped-kill-switch' });
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (attemptsFor(hook.id) >= hook.quota.max_per_session) {
|
|
475
|
+
await appendAudit(installRoot, {
|
|
476
|
+
...base,
|
|
477
|
+
outcome: 'skipped-quota',
|
|
478
|
+
quota: hook.quota.max_per_session,
|
|
479
|
+
});
|
|
480
|
+
results.push({ id: hook.id, outcome: 'skipped-quota' });
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
attempted.set(hook.id, (attempted.get(hook.id) || 0) + 1);
|
|
484
|
+
await appendAudit(installRoot, { ...base, outcome: 'firing' });
|
|
485
|
+
|
|
486
|
+
// Exec-time containment re-check (belt to the load-time anchor): the resolved file
|
|
487
|
+
// must sit inside .phronesis/hooks/.
|
|
488
|
+
const file = resolve(installRoot, hook.command);
|
|
489
|
+
let outcome;
|
|
490
|
+
if (!file.startsWith(hooksDir + sep)) {
|
|
491
|
+
outcome = { exitCode: null, error: `command resolves outside ${COMMAND_PREFIX} — refused`, stderrTail: '' };
|
|
492
|
+
} else {
|
|
493
|
+
const started = Date.now();
|
|
494
|
+
outcome = await runCommand({
|
|
495
|
+
file,
|
|
496
|
+
event,
|
|
497
|
+
timeoutMs,
|
|
498
|
+
cwd: installRoot,
|
|
499
|
+
env: {
|
|
500
|
+
...env,
|
|
501
|
+
PHRONESIS_HOOK_ID: hook.id,
|
|
502
|
+
PHRONESIS_EVENT_TYPE: event.type,
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
outcome.durationMs = Date.now() - started;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const failed = outcome.exitCode !== 0;
|
|
509
|
+
await appendAudit(installRoot, {
|
|
510
|
+
...base,
|
|
511
|
+
outcome: failed ? 'failed' : 'fired',
|
|
512
|
+
...(outcome.exitCode !== null ? { exit_code: outcome.exitCode } : {}),
|
|
513
|
+
...(outcome.error ? { error: outcome.error } : {}),
|
|
514
|
+
...(outcome.stderrTail ? { stderr_tail: outcome.stderrTail } : {}),
|
|
515
|
+
...(outcome.durationMs !== undefined ? { duration_ms: outcome.durationMs } : {}),
|
|
516
|
+
});
|
|
517
|
+
results.push({ id: hook.id, outcome: failed ? 'failed' : 'fired', exitCode: outcome.exitCode });
|
|
518
|
+
if (failed && hook.kind === 'gate') {
|
|
519
|
+
gateFailures.push({
|
|
520
|
+
id: hook.id,
|
|
521
|
+
error: outcome.error || 'failed',
|
|
522
|
+
stderrTail: outcome.stderrTail || '',
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return { results, gateFailures };
|
|
527
|
+
}
|