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