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
@@ -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
+ }