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,245 @@
1
+ // Ontology frontmatter contract — specs/ontology.md, locked by change 0009
2
+ // (primitive vocabulary + the relationships field per change 0012 / ADR 0006).
3
+ //
4
+ // The single source for what `phronesis validate` enforces. Pure data + pure checks,
5
+ // no filesystem — link *existence* is resolved by the caller (validate.js), which has
6
+ // fs access. Extracted to packages/core (core-0007) so the T2.2 action types validate
7
+ // inputs against this same contract (the "two consumers → package" rule).
8
+
9
+ export const OBJECT_TYPES = [
10
+ 'person', 'project', 'decision', 'commitment', 'insight', 'organization',
11
+ 'codex-principle-seed', 'codex-principle-personal',
12
+ ];
13
+ export const DOMAIN_TYPES = ['person', 'project', 'decision', 'commitment', 'insight', 'organization'];
14
+ export const CODEX_PRINCIPLE_TYPES = ['codex-principle-seed', 'codex-principle-personal'];
15
+ // Entity types name what something IS (a person, an organization) — never the door's
16
+ // relationship to it (ADR 0006 / change 0012). The door's relationship semantics live
17
+ // in the `relationships` field, valid only here.
18
+ export const ENTITY_TYPES = ['person', 'organization'];
19
+
20
+ // Domain object type -> its compiled/ subdirectory (the type-dir).
21
+ export const TYPE_DIR = {
22
+ person: 'people',
23
+ project: 'projects',
24
+ decision: 'decisions',
25
+ commitment: 'commitments',
26
+ insight: 'insights',
27
+ organization: 'organizations',
28
+ };
29
+ // Frozen: the CLI scaffold consumes this list directly (one source) — a consumer
30
+ // push() must throw, not silently widen the contract process-wide.
31
+ export const COMPILED_DIRS = Object.freeze(Object.values(TYPE_DIR));
32
+
33
+ export const ENUMS = {
34
+ status: ['active', 'archived', 'superseded'],
35
+ exposure: ['private', 'context-ok', 'shareable'],
36
+ authored_by: ['agent', 'human', 'hybrid', 'seed'],
37
+ confidence: ['low', 'medium', 'high'],
38
+ outcome_status: ['pending', 'met', 'partial', 'reversed'],
39
+ stance: ['gravity', 'adopted', 'documented', 'skeptical-of'],
40
+ };
41
+
42
+ export const LINK_RELS = [
43
+ 'enriched-by', 'decided-by', 'decided-in', 'supersedes', 'superseded-by',
44
+ 'enables', 'blocks', 'cites',
45
+ ];
46
+
47
+ export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
48
+ const SLUG = '[a-z0-9]+(?:-[a-z0-9]+)*';
49
+ const DIRS = COMPILED_DIRS.join('|');
50
+
51
+ // Exported for the action spine's payload schemas (actions.js) — one slug grammar,
52
+ // not a re-typed copy that drifts. SLUG_SOURCE is the raw fragment for composing
53
+ // larger grammars (e.g. {collection}/{slug}); never slice it out of RE_SLUG.source.
54
+ export const SLUG_SOURCE = SLUG;
55
+ export const RE_SLUG = new RegExp(`^${SLUG}$`);
56
+ const RE_DOMAIN_BARE = new RegExp(`^(?:${DIRS})/${SLUG}$`);
57
+ const RE_DOMAIN_FULL = new RegExp(`^domains/${SLUG}/compiled/(?:${DIRS})/${SLUG}\\.md$`);
58
+ const RE_CODEX = new RegExp(`^codex/(?:personal/${SLUG}|seed/${SLUG}/${SLUG})$`);
59
+ const RE_RAW = new RegExp(`^domains/${SLUG}/raw/.+\\.[A-Za-z0-9]+$`);
60
+
61
+ // Classify a link target per the 0009 grammar. Returns the form, whether `forward:true`
62
+ // may waive its existence (domain-object targets only), and a resolver to the
63
+ // install-relative path of the target file (needs the source object's domain for the
64
+ // bare form; returns null when a bare slug has no domain to resolve against).
65
+ export function classifyTarget(target) {
66
+ if (typeof target !== 'string' || target.length === 0) {
67
+ return { form: 'invalid', domainObject: false, relPath: () => null };
68
+ }
69
+ if (RE_DOMAIN_BARE.test(target)) {
70
+ return {
71
+ form: 'domain-object',
72
+ domainObject: true,
73
+ relPath: (srcDomain) => (srcDomain ? `domains/${srcDomain}/compiled/${target}.md` : null),
74
+ };
75
+ }
76
+ if (RE_DOMAIN_FULL.test(target)) {
77
+ return { form: 'domain-object', domainObject: true, relPath: () => target };
78
+ }
79
+ if (RE_CODEX.test(target)) {
80
+ return { form: 'codex-object', domainObject: false, relPath: () => `${target}.md` };
81
+ }
82
+ if (RE_RAW.test(target)) {
83
+ return { form: 'raw-source', domainObject: false, relPath: () => target };
84
+ }
85
+ return { form: 'invalid', domainObject: false, relPath: () => null };
86
+ }
87
+
88
+ function enumError(fm, field, errors) {
89
+ if (fm[field] !== undefined && !ENUMS[field].includes(fm[field])) {
90
+ errors.push(`${field}: "${fm[field]}" is not one of ${ENUMS[field].join(' | ')}`);
91
+ }
92
+ }
93
+
94
+ // Type-scoped fields (change 0012): valid only on a subset of object types, refused
95
+ // everywhere else. `required` ⇒ absence on an in-scope type is an error; `check` owns
96
+ // the value's shape. An open vocabulary (relationships) gets a shape check and
97
+ // deliberately NO enum — closed where the machine routes, open where only humans read
98
+ // (ADR 0006); `lint` owns value consistency, not `validate`.
99
+ const TYPE_SCOPED_FIELDS = [
100
+ {
101
+ field: 'stance',
102
+ types: CODEX_PRINCIPLE_TYPES,
103
+ required: true,
104
+ requiredError: 'missing required field: stance (required on codex principles)',
105
+ forbiddenError: 'stance: only allowed on codex-principle objects',
106
+ check: (fm, errors) => enumError(fm, 'stance', errors),
107
+ },
108
+ {
109
+ field: 'relationships',
110
+ types: ENTITY_TYPES,
111
+ required: false,
112
+ forbiddenError: `relationships: only allowed on entity types (${ENTITY_TYPES.join(', ')})`,
113
+ check: (fm, errors) => {
114
+ if (!Array.isArray(fm.relationships)) {
115
+ errors.push('relationships: must be a list of non-empty strings');
116
+ return;
117
+ }
118
+ fm.relationships.forEach((v, i) => {
119
+ if (typeof v !== 'string' || v.trim() === '') {
120
+ errors.push(`relationships[${i}]: must be a non-empty string`);
121
+ }
122
+ });
123
+ },
124
+ },
125
+ ];
126
+
127
+ // Validate one object's parsed frontmatter against the contract. `ctx`:
128
+ // { layer: 'domain'|'codex', domain: slug|null, typeDir: string|null,
129
+ // codexKind: 'seed'|'personal'|null, name: <basename without .md> }
130
+ // Returns { errors: string[], links: [{ rel, target, forward, cls }] } — the caller
131
+ // existence-checks `links` (it needs the filesystem).
132
+ export function validateFrontmatter(fm, ctx) {
133
+ const errors = [];
134
+ const links = [];
135
+ if (fm === null || typeof fm !== 'object' || Array.isArray(fm)) {
136
+ return { errors: ['frontmatter is not a YAML mapping'], links };
137
+ }
138
+
139
+ // type
140
+ if (!fm.type) errors.push('missing required field: type');
141
+ else if (!OBJECT_TYPES.includes(fm.type)) errors.push(`type: "${fm.type}" is not a known object type`);
142
+
143
+ // universal required
144
+ for (const f of ['name', 'title', 'created', 'exposure']) {
145
+ if (fm[f] === undefined || fm[f] === null || fm[f] === '') errors.push(`missing required field: ${f}`);
146
+ }
147
+ for (const f of ['created', 'updated', 'review_date']) {
148
+ if (fm[f] !== undefined && fm[f] !== null && !DATE_RE.test(String(fm[f]))) {
149
+ errors.push(`${f}: "${fm[f]}" is not YYYY-MM-DD`);
150
+ }
151
+ }
152
+ enumError(fm, 'exposure', errors);
153
+ enumError(fm, 'status', errors);
154
+ enumError(fm, 'confidence', errors);
155
+ enumError(fm, 'outcome_status', errors);
156
+
157
+ // provenance.authored_by
158
+ const prov = fm.provenance;
159
+ if (prov === undefined || prov === null || typeof prov !== 'object') {
160
+ errors.push('missing required field: provenance.authored_by');
161
+ } else if (!prov.authored_by) {
162
+ errors.push('missing required field: provenance.authored_by');
163
+ } else if (!ENUMS.authored_by.includes(prov.authored_by)) {
164
+ errors.push(`provenance.authored_by: "${prov.authored_by}" is not one of ${ENUMS.authored_by.join(' | ')}`);
165
+ }
166
+
167
+ // name: kebab-case-slug (specs/ontology.md), and == its filename — the action-type
168
+ // contract computes the path deterministically from name, so they must agree.
169
+ if (fm.name !== undefined && fm.name !== null && fm.name !== '') {
170
+ const nm = String(fm.name);
171
+ if (!RE_SLUG.test(nm)) errors.push(`name: "${nm}" is not a kebab-case slug`);
172
+ if (ctx.name && nm !== ctx.name) errors.push(`name: "${nm}" does not match filename "${ctx.name}"`);
173
+ }
174
+
175
+ // domain field: required on domain objects (== the folder), forbidden on codex
176
+ if (ctx.layer === 'domain') {
177
+ if (!fm.domain) errors.push('missing required field: domain (required on domain objects)');
178
+ else if (fm.domain !== ctx.domain) errors.push(`domain: "${fm.domain}" does not match its workspace "${ctx.domain}"`);
179
+ } else if (fm.domain !== undefined) {
180
+ errors.push('domain: must be omitted on codex objects (the codex is unaffiliated)');
181
+ }
182
+
183
+ // type-scoped fields: stance (required on codex principles), relationships
184
+ // (optional on entity types) — each forbidden everywhere outside its types.
185
+ const isPrinciple = CODEX_PRINCIPLE_TYPES.includes(fm.type);
186
+ for (const sf of TYPE_SCOPED_FIELDS) {
187
+ if (sf.types.includes(fm.type)) {
188
+ if (sf.required) {
189
+ if (!fm[sf.field]) errors.push(sf.requiredError);
190
+ else sf.check(fm, errors);
191
+ } else if (fm[sf.field] !== undefined) {
192
+ sf.check(fm, errors);
193
+ }
194
+ } else if (fm[sf.field] !== undefined) {
195
+ errors.push(sf.forbiddenError);
196
+ }
197
+ }
198
+
199
+ // type <-> layer <-> path
200
+ if (DOMAIN_TYPES.includes(fm.type)) {
201
+ if (ctx.layer !== 'domain') errors.push(`type "${fm.type}" is a domain object but lives in the codex layer`);
202
+ else if (ctx.typeDir !== TYPE_DIR[fm.type]) {
203
+ errors.push(`type "${fm.type}" must live in compiled/${TYPE_DIR[fm.type]}/, not compiled/${ctx.typeDir || '.'}/`);
204
+ }
205
+ } else if (isPrinciple) {
206
+ if (ctx.layer !== 'codex') errors.push(`type "${fm.type}" is a codex principle but lives in a domain workspace`);
207
+ else if (fm.type === 'codex-principle-seed' && ctx.codexKind !== 'seed') {
208
+ errors.push('codex-principle-seed must live under codex/seed/');
209
+ } else if (fm.type === 'codex-principle-personal' && ctx.codexKind !== 'personal') {
210
+ errors.push('codex-principle-personal must live under codex/personal/');
211
+ }
212
+ }
213
+
214
+ // links
215
+ if (fm.links !== undefined) {
216
+ if (!Array.isArray(fm.links)) {
217
+ errors.push('links: must be a list');
218
+ } else {
219
+ fm.links.forEach((link, i) => {
220
+ if (link === null || typeof link !== 'object') {
221
+ errors.push(`links[${i}]: must be a mapping with rel + target`);
222
+ return;
223
+ }
224
+ if (!link.rel) errors.push(`links[${i}]: missing rel`);
225
+ else if (!LINK_RELS.includes(link.rel)) errors.push(`links[${i}]: rel "${link.rel}" is not in the link vocabulary`);
226
+ if (link.target === undefined || link.target === null || link.target === '') {
227
+ errors.push(`links[${i}]: missing target`);
228
+ return;
229
+ }
230
+ const cls = classifyTarget(link.target);
231
+ if (cls.form === 'invalid') {
232
+ errors.push(`links[${i}]: target "${link.target}" matches no link-target form`);
233
+ return;
234
+ }
235
+ const forward = link.forward === true;
236
+ if (forward && !cls.domainObject) {
237
+ errors.push(`links[${i}]: forward:true is only allowed on domain-object targets (not "${link.target}")`);
238
+ }
239
+ links.push({ rel: link.rel, target: link.target, forward, cls });
240
+ });
241
+ }
242
+ }
243
+
244
+ return { errors, links };
245
+ }
@@ -0,0 +1,338 @@
1
+ // Event-log core — the canonical typed-event stream (specs/event-log.md; core-0006 / T4.0).
2
+ // The substrate every hook subscribes to and every action type emits into: atomic append
3
+ // to .phronesis/events/{YYYY-MM-DD}.jsonl (daily rotation by the LOCAL-tz boundary), a typed
4
+ // record, a capture gate that keeps message/prompt bodies out of the log by default, and the
5
+ // retention/purge mechanics. Pure core: no CLI, no daemon, no filesystem watching — the
6
+ // daemon (T4.1) drives appendEvent for file.* events; the CLI (event.js) wraps emit/tail.
7
+ //
8
+ // Lives in packages/core (core-0007): this module owns the format + validation + I/O; the
9
+ // only Phronesis-specific coupling is the .phronesis/ path layout, kept in named helpers.
10
+
11
+ import { join } from 'node:path';
12
+ import { appendFile, readFile, readdir } from 'node:fs/promises';
13
+ import { createHash } from 'node:crypto';
14
+ import fse from 'fs-extra';
15
+
16
+ // The V1 vocabulary (specs/event-log.md §"V1 event types"). A typed stream rejects bare
17
+ // garbage, so emission is NOT open to anything — but it is NOT a closed set either: the
18
+ // spec allows connector-specific events, so the valid set is this vocabulary UNION the
19
+ // connector.<name>.<event> extension pattern (isValidType).
20
+ export const EVENT_TYPES = new Set([
21
+ 'session.start',
22
+ 'session.end',
23
+ 'message.user',
24
+ 'message.agent',
25
+ 'skill.invoked',
26
+ 'skill.completed',
27
+ 'action_type.requested',
28
+ 'action_type.committed',
29
+ 'compile.candidate',
30
+ 'compile.accepted',
31
+ 'compile.rejected',
32
+ 'recall.candidate',
33
+ 'recall.injected',
34
+ 'decision.candidate',
35
+ 'person.candidate',
36
+ 'commitment.candidate',
37
+ 'outcome.due',
38
+ 'schedule.due',
39
+ 'file.created',
40
+ 'file.modified',
41
+ 'file.deleted',
42
+ ]);
43
+
44
+ // Connector-authored extension namespace, e.g. connector.granola.transcript_arrived
45
+ // (event-log.md:83). The only open namespace — everything else must be a known type.
46
+ const CONNECTOR_TYPE_RE = /^connector\.[a-z0-9_-]+\.[a-z0-9][a-z0-9._-]*$/;
47
+
48
+ export function isKnownType(type) {
49
+ return EVENT_TYPES.has(type);
50
+ }
51
+
52
+ // Valid = known vocabulary ∪ the connector extension pattern. The core writes any valid
53
+ // type (the daemon uses it for file.*); the CLI additionally refuses the reserved
54
+ // set below so a caller cannot forge events that belong to an authoritative emitter.
55
+ export function isValidType(type) {
56
+ return typeof type === 'string' && (EVENT_TYPES.has(type) || CONNECTOR_TYPE_RE.test(type));
57
+ }
58
+
59
+ // Reserved-emitter types: each has a SOLE authoritative path, and the public CLI
60
+ // refuses freehand emission (event-log.md:164 integrity anti-pattern — events must
61
+ // come from authoritative sources). `file.*` is the V1.5 daemon's filesystem watch
62
+ // (reserved AND unemitted until it ships); `outcome.due` is the V1 session-start
63
+ // due-check (session-hooks.js runDueCheck, per the ratified change 0010 delta — the
64
+ // daemon's date-watch takes over at V1.5); `schedule.due` is the V1 session-start
65
+ // due-check's schedule half (schedule.js runScheduleDueCheck, per the ratified change
66
+ // 0042 delta — the daemon time-watch takes over at V1.5); `skill.invoked` / `skill.completed`
67
+ // are emitted by compliant surface adapters such as the Claude Code lifecycle
68
+ // producer. The `compile.*` lifecycle events (`compile.candidate` from the compile
69
+ // action; `compile.accepted` / `compile.rejected` from the operator review path) are
70
+ // the authoritative provenance `compile review` trusts to classify drafts — its L4
71
+ // quarantine reads the latest candidate's `suspicious_payload` flag AND `content_digest`
72
+ // (change 0027). If a caller could freehand-emit a clean `compile.candidate` with a
73
+ // matching digest, it could make a tampered draft batch-eligible (and could already
74
+ // clear a flagged one) — so the digest is "handler-computed, never caller-supplied"
75
+ // ONLY if the candidate EVENT is itself unforgeable. All authorities write through core
76
+ // appendEvent (the handler + the accept/reject moves); the refusal is a CLI-layer policy,
77
+ // not a format rule.
78
+ const RESERVED_EXACT = new Set([
79
+ 'outcome.due',
80
+ 'schedule.due',
81
+ 'skill.invoked',
82
+ 'skill.completed',
83
+ 'compile.candidate',
84
+ 'compile.accepted',
85
+ 'compile.rejected',
86
+ ]);
87
+ export function isReservedType(type) {
88
+ return typeof type === 'string' && (type.startsWith('file.') || RESERVED_EXACT.has(type));
89
+ }
90
+
91
+ // Capture-gated fields per event-type prefix (security.md + change 0005). Stored only when
92
+ // config event_log.capture_message_text is true; otherwise dropped, with a non-revealing
93
+ // stand-in retained: message bodies collapse to `length`, recall queries to `prompt_hash`.
94
+ const CAPTURE_GATED = [
95
+ { prefix: 'message.', field: 'text', keep: 'length', derive: (s) => s.length },
96
+ { prefix: 'recall.', field: 'query', keep: 'prompt_hash', derive: (s) => sha256(s) },
97
+ ];
98
+
99
+ function sha256(s) {
100
+ return createHash('sha256').update(s, 'utf8').digest('hex');
101
+ }
102
+
103
+ // --- Paths -------------------------------------------------------------------------------
104
+
105
+ export function eventsDir(installRoot) {
106
+ return join(installRoot, '.phronesis', 'events');
107
+ }
108
+
109
+ function archiveDir(installRoot) {
110
+ return join(eventsDir(installRoot), 'archive');
111
+ }
112
+
113
+ // Daily file name from a moment, by the LOCAL-tz calendar day (event-log.md:19). The record
114
+ // `ts` is UTC; the FILE is named by local day — a deliberate split so an operator's stream
115
+ // rotates on their midnight, not UTC's.
116
+ export function localDateStamp(date = new Date()) {
117
+ const y = date.getFullYear();
118
+ const m = String(date.getMonth() + 1).padStart(2, '0');
119
+ const d = String(date.getDate()).padStart(2, '0');
120
+ return `${y}-${m}-${d}`;
121
+ }
122
+
123
+ const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.jsonl$/;
124
+
125
+ // --- Ids ---------------------------------------------------------------------------------
126
+
127
+ // Zero-dep, time-sortable id: evt_<base36(epoch-ms)><base36(rand)> — ULID-shaped without the
128
+ // ulid dep. Same-ms ties fall back to append order within the day file (Decision H). The
129
+ // Date.now/Math.random ban is a Workflow-tool constraint; this is product code.
130
+ export function makeEventId(now = new Date()) {
131
+ const t = Math.floor(now.getTime()).toString(36);
132
+ const r = Math.random().toString(36).slice(2, 12).padEnd(10, '0');
133
+ return `evt_${t}${r}`;
134
+ }
135
+
136
+ // --- Config ------------------------------------------------------------------------------
137
+
138
+ // Read the installation's event_log config with privacy-safe defaults. A missing/garbled
139
+ // config fails CLOSED to capture-off (no bodies stored) — the safe default for a privacy
140
+ // gate is "do not capture."
141
+ export async function readEventLogConfig(installRoot) {
142
+ const defaults = { captureMessageText: false, retentionDays: 90 };
143
+ try {
144
+ const cfg = await fse.readJson(join(installRoot, '.phronesis', 'config.json'));
145
+ const el = (cfg && cfg.event_log) || {};
146
+ return {
147
+ captureMessageText: el.capture_message_text === true,
148
+ retentionDays:
149
+ Number.isInteger(el.retention_days) && el.retention_days > 0 ? el.retention_days : 90,
150
+ };
151
+ } catch {
152
+ return defaults;
153
+ }
154
+ }
155
+
156
+ // --- Validation + capture gate -----------------------------------------------------------
157
+
158
+ // Fail-closed validation: throw a SPECIFIC reason on the first violation (rules.md: assert
159
+ // the specific reason, not just that it failed). All fields but `payload` are mandatory and
160
+ // non-empty (event-log.md:36).
161
+ export function validateEventInput({ type, session_id, actor, surface, payload }) {
162
+ if (!isValidType(type)) {
163
+ throw new Error(
164
+ `event type "${type}" is not in the V1 vocabulary or a connector.<name>.<event> extension`,
165
+ );
166
+ }
167
+ if (typeof session_id !== 'string' || session_id === '') {
168
+ throw new Error('session_id is required and must be a non-empty string');
169
+ }
170
+ if (typeof actor !== 'string' || actor === '') {
171
+ throw new Error('actor is required and must be a non-empty string');
172
+ }
173
+ if (typeof surface !== 'string' || surface === '') {
174
+ throw new Error('surface is required and must be a non-empty string');
175
+ }
176
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
177
+ throw new Error('payload must be a JSON object');
178
+ }
179
+ }
180
+
181
+ // Apply the privacy capture gate to a payload. With capture on, the payload is returned
182
+ // untouched. With capture off, each gated field is dropped whenever it is PRESENT (any type —
183
+ // a non-string body must never reach disk either; presence-based, not string-typed, so a
184
+ // structured `{ text: {...} }` or `{ query: [...] }` cannot slip the gate). The non-revealing
185
+ // stand-in is derived only from a string body (text → length, query → prompt_hash); a
186
+ // non-string body is dropped with whatever metadata the caller already supplied. Pure:
187
+ // returns a new object, never mutates the caller's payload.
188
+ export function applyCaptureGate(type, payload, { captureMessageText }) {
189
+ const out = { ...payload };
190
+ if (captureMessageText) return out;
191
+ for (const { prefix, field, keep, derive } of CAPTURE_GATED) {
192
+ if (type.startsWith(prefix) && field in out) {
193
+ if (out[keep] === undefined && typeof out[field] === 'string') out[keep] = derive(out[field]);
194
+ delete out[field];
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
200
+ // --- Append + read -----------------------------------------------------------------------
201
+
202
+ // The universal write path. Validates, applies the capture gate (reading config unless
203
+ // captureMessageText is passed explicitly), stamps id + ts, and atomically appends one JSON
204
+ // line to the local-day file. Returns the stored record (the gated payload, the assigned id).
205
+ // `now` is injectable for deterministic tests.
206
+ export async function appendEvent({
207
+ installRoot,
208
+ type,
209
+ session_id,
210
+ actor,
211
+ surface,
212
+ payload = {},
213
+ captureMessageText,
214
+ now = new Date(),
215
+ } = {}) {
216
+ validateEventInput({ type, session_id, actor, surface, payload });
217
+ const capture =
218
+ captureMessageText === undefined
219
+ ? (await readEventLogConfig(installRoot)).captureMessageText
220
+ : captureMessageText;
221
+ const record = {
222
+ id: makeEventId(now),
223
+ ts: now.toISOString(),
224
+ session_id,
225
+ type,
226
+ actor,
227
+ surface,
228
+ payload: applyCaptureGate(type, payload, { captureMessageText: capture }),
229
+ };
230
+ const dir = eventsDir(installRoot);
231
+ await fse.ensureDir(dir);
232
+ // Single O_APPEND write of one line — atomic for line-sized records (matches the run
233
+ // ledger's appendFileSync idiom; the spec asks for atomic appends).
234
+ await appendFile(join(dir, `${localDateStamp(now)}.jsonl`), `${JSON.stringify(record)}\n`);
235
+ return record;
236
+ }
237
+
238
+ // Live day files (newest last), excluding the archive/ subdir. The archive holds rotated
239
+ // .jsonl files too, so a name-regex on the events/ dir (non-recursive) is what keeps cold
240
+ // storage out of the live stream.
241
+ export async function listDayFiles(installRoot) {
242
+ let entries;
243
+ try {
244
+ entries = await readdir(eventsDir(installRoot), { withFileTypes: true });
245
+ } catch {
246
+ return [];
247
+ }
248
+ return entries
249
+ .filter((e) => e.isFile() && DAY_FILE_RE.test(e.name))
250
+ .map((e) => e.name)
251
+ .sort();
252
+ }
253
+
254
+ // Read the live stream in chronological order (by day file, then append order within each),
255
+ // optionally filtered by exact `type` and an inclusive ISO `since` (ISO-8601 sorts as text).
256
+ export async function readEvents({ installRoot, since, type } = {}) {
257
+ const out = [];
258
+ for (const name of await listDayFiles(installRoot)) {
259
+ let content;
260
+ try {
261
+ content = await readFile(join(eventsDir(installRoot), name), 'utf8');
262
+ } catch {
263
+ continue;
264
+ }
265
+ for (const line of content.split('\n')) {
266
+ const trimmed = line.trim();
267
+ if (!trimmed) continue;
268
+ let ev;
269
+ try {
270
+ ev = JSON.parse(trimmed);
271
+ } catch {
272
+ continue; // a torn/partial line never poisons a read
273
+ }
274
+ if (type && ev.type !== type) continue;
275
+ if (since && !(ev.ts >= since)) continue;
276
+ out.push(ev);
277
+ }
278
+ }
279
+ return out;
280
+ }
281
+
282
+ // --- Retention + purge (distinct verbs) --------------------------------------------------
283
+
284
+ function isDateStamp(s) {
285
+ return typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s);
286
+ }
287
+
288
+ async function archiveFile(installRoot, name) {
289
+ await fse.ensureDir(archiveDir(installRoot));
290
+ await fse.move(join(eventsDir(installRoot), name), join(archiveDir(installRoot), name), {
291
+ overwrite: true,
292
+ });
293
+ }
294
+
295
+ // The local day exactly `days` before `now`. A file is "older than retention" when its date
296
+ // is strictly before this cutoff (a file dated exactly `days` ago is kept).
297
+ function cutoffDateStamp(now, days) {
298
+ const d = new Date(now.getTime());
299
+ d.setDate(d.getDate() - days);
300
+ return localDateStamp(d);
301
+ }
302
+
303
+ // ARCHIVE files past the retention window to events/archive/ (event-log.md:19). The mechanism
304
+ // only — AUTOMATIC scheduling is the daemon's job (T4.1); T4.0 ships an explicitly-invokable
305
+ // verb so emit pays no rotation tax.
306
+ export async function enforceRetention({ installRoot, retentionDays, now = new Date() } = {}) {
307
+ const days =
308
+ Number.isInteger(retentionDays) && retentionDays > 0
309
+ ? retentionDays
310
+ : (await readEventLogConfig(installRoot)).retentionDays;
311
+ const cutoff = cutoffDateStamp(now, days);
312
+ const archived = [];
313
+ for (const name of await listDayFiles(installRoot)) {
314
+ if (name.slice(0, 10) < cutoff) {
315
+ await archiveFile(installRoot, name);
316
+ archived.push(name);
317
+ }
318
+ }
319
+ return { archived, cutoff, retentionDays: days };
320
+ }
321
+
322
+ // DELETE day files before `before` (operator wipe / privacy, event-log.md:145). With
323
+ // `archive` the files are moved to events/archive/ instead of removed. Distinct from
324
+ // enforceRetention: purge is operator intent on an arbitrary boundary; rotate is the policy.
325
+ export async function purgeEvents({ installRoot, before, archive = false } = {}) {
326
+ if (!isDateStamp(before)) {
327
+ throw new Error('purge requires --before YYYY-MM-DD');
328
+ }
329
+ const acted = [];
330
+ for (const name of await listDayFiles(installRoot)) {
331
+ if (name.slice(0, 10) < before) {
332
+ if (archive) await archiveFile(installRoot, name);
333
+ else await fse.remove(join(eventsDir(installRoot), name));
334
+ acted.push(name);
335
+ }
336
+ }
337
+ return { files: acted, archived: archive, before };
338
+ }