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,524 @@
|
|
|
1
|
+
// The skill registry layer (core-0015 / T3.1; specs/skills.md §"Frontmatter contract" /
|
|
2
|
+
// §"Skill registry" / §"Runtime-free invocation"; change 0013 D3 deterministic half;
|
|
3
|
+
// per-packet decisions S1–S10 in harness/tasks/core-0015-skill-registry.md).
|
|
4
|
+
//
|
|
5
|
+
// `.phronesis/registry.json`'s `skills` section (RESERVED since core-0011, CONSUMED
|
|
6
|
+
// here) declares installed skills and versions: name → { version }. The skill file
|
|
7
|
+
// path is DERIVED — `skills/{name}/SKILL.md` — a fixed contract like target_layout,
|
|
8
|
+
// never a config field. This module loads registered skills and validates each against
|
|
9
|
+
// the published frontmatter contract, plus the deterministic half of the runtime-free
|
|
10
|
+
// portability gate (change 0013 D3):
|
|
11
|
+
//
|
|
12
|
+
// - the body carries a "Without the runtime" section (degrade instructions travel in
|
|
13
|
+
// the skill body, never the runtime);
|
|
14
|
+
// - that section declares a degrade target that PARSES to the canonical capture
|
|
15
|
+
// pattern domains/{domain}/raw/degraded/YYYY-MM-DD-{skill}-{slug}.md;
|
|
16
|
+
// - the read path names no runtime invocation (reads are plain path globs — a skill
|
|
17
|
+
// readable runtime-free cannot depend on `phronesis <cmd>` to read).
|
|
18
|
+
//
|
|
19
|
+
// Failure policy (S2, the G3 lineage): an ABSENT skills section means no skills
|
|
20
|
+
// registered (additive-optional — absence can never loosen anything; no schema_version
|
|
21
|
+
// bump). A PRESENT-but-invalid section, or any invalid registered skill, fails closed
|
|
22
|
+
// ALL-OR-NOTHING with errors naming the file and field. The skills layer is a SEPARATE
|
|
23
|
+
// failure domain from the action layer: no skill execution exists in the act pipeline,
|
|
24
|
+
// so invalid skills never disable `act` — but `skills status` is loud and total.
|
|
25
|
+
//
|
|
26
|
+
// Every validation here looks up names with Object.hasOwn / own-property iteration
|
|
27
|
+
// (core-0011 rule: a skill or action type named `constructor` must fail closed, never
|
|
28
|
+
// resolve up the prototype chain), and `null` is PRESENT-and-invalid, never "absent"
|
|
29
|
+
// (core-0008 rule — agents fill optional JSON/YAML fields with null constantly).
|
|
30
|
+
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { readFile } from 'node:fs/promises';
|
|
33
|
+
import yaml from 'js-yaml';
|
|
34
|
+
import { RE_SLUG, SLUG_SOURCE, DATE_RE } from './contract.js';
|
|
35
|
+
import { REGISTRY_SCHEMA_VERSIONS } from './action-registry.js';
|
|
36
|
+
import { matchFrontmatterBlock } from './workspace-scan.js';
|
|
37
|
+
|
|
38
|
+
export const SKILL_WEIGHTS = ['light', 'heavy'];
|
|
39
|
+
export const MODEL_TIERS = ['standard', 'large', 'frontier'];
|
|
40
|
+
|
|
41
|
+
const SKILL_ENTRY_KEYS = ['version'];
|
|
42
|
+
const FM_KEYS = [
|
|
43
|
+
'name',
|
|
44
|
+
'version',
|
|
45
|
+
'invocation',
|
|
46
|
+
'weight',
|
|
47
|
+
'reads',
|
|
48
|
+
'writes',
|
|
49
|
+
'sub_agents',
|
|
50
|
+
'requires_model_tier',
|
|
51
|
+
'prompt_pin',
|
|
52
|
+
];
|
|
53
|
+
// sub_agents is the contract's one optional field; everything else is required.
|
|
54
|
+
const REQUIRED_FM_KEYS = FM_KEYS.filter((k) => k !== 'sub_agents');
|
|
55
|
+
const INVOCATION_KEYS = ['explicit', 'suggested', 'woven'];
|
|
56
|
+
|
|
57
|
+
// SemVer (major.minor.patch, optional prerelease/build) — the spec's `version` grammar.
|
|
58
|
+
const RE_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
59
|
+
|
|
60
|
+
// Path-glob grammar for reads/direct-write declarations (S5) — the deterministic
|
|
61
|
+
// "read path names no runtime invocation" half. Whitespace and backticks are what a
|
|
62
|
+
// command invocation needs and a path never does; the charset closes the rest.
|
|
63
|
+
const RE_PATH_GLOB = /^[A-Za-z0-9_.\-*/{}]+$/;
|
|
64
|
+
// Placeholders the path grammar admits. A typo'd placeholder ({active_domian}) is
|
|
65
|
+
// silent intent-drop — nothing would ever expand it — so membership is checked.
|
|
66
|
+
const PATH_PLACEHOLDERS = ['{active_domain}', '{domain}', '{skill}', '{slug}', '{name}'];
|
|
67
|
+
|
|
68
|
+
export function skillFilePath(name) {
|
|
69
|
+
return `skills/${name}/SKILL.md`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isPlainObject(v) {
|
|
73
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Registry section (S1) -------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
// Validate the top-level `skills` section: name → { version }. Absent ⇒ no skills
|
|
79
|
+
// registered. Present-but-invalid ⇒ the caller fails the skills layer all-or-nothing.
|
|
80
|
+
export function resolveSkillsSection(registry) {
|
|
81
|
+
const errors = [];
|
|
82
|
+
if (!isPlainObject(registry)) {
|
|
83
|
+
return { errors: ['.phronesis/registry.json must be a JSON object'], section: {} };
|
|
84
|
+
}
|
|
85
|
+
const section = registry.skills;
|
|
86
|
+
if (section === undefined) return { errors, section: {} };
|
|
87
|
+
// Version discipline (core-0011 rule; tightened by the core-0015 adversarial
|
|
88
|
+
// review): this loader is a standalone export, so it cannot lean on the action
|
|
89
|
+
// layer's version check having run first — an unknown version fails closed HERE,
|
|
90
|
+
// and version 0 contradicts the section's existence (it predates it).
|
|
91
|
+
if (!REGISTRY_SCHEMA_VERSIONS.includes(registry.schema_version)) {
|
|
92
|
+
errors.push(
|
|
93
|
+
`schema_version ${JSON.stringify(registry.schema_version)} is not a known registry version (${REGISTRY_SCHEMA_VERSIONS.join(', ')}) — the skills section cannot be interpreted under an unknown shape`,
|
|
94
|
+
);
|
|
95
|
+
} else if (registry.schema_version === 0) {
|
|
96
|
+
errors.push(
|
|
97
|
+
'skills requires schema_version 1 — a version-0 registry predates the section (bump the version, or remove the section: absent means no skills registered)',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!isPlainObject(section)) {
|
|
101
|
+
errors.push('skills: must be an object mapping skill name → { version } (skills.md §"Skill registry")');
|
|
102
|
+
return { errors, section: {} };
|
|
103
|
+
}
|
|
104
|
+
for (const [name, entry] of Object.entries(section)) {
|
|
105
|
+
if (!RE_SLUG.test(name)) {
|
|
106
|
+
errors.push(`skills.${name}: skill names are kebab-case slugs (the name is the directory under skills/)`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (entry === null) {
|
|
110
|
+
errors.push(`skills.${name}: null is not "absent" — remove the entry instead (removal unregisters the skill)`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!isPlainObject(entry)) {
|
|
114
|
+
errors.push(`skills.${name}: must be an object with a version field`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const unknown = Object.keys(entry).filter((k) => !SKILL_ENTRY_KEYS.includes(k));
|
|
118
|
+
if (unknown.length) {
|
|
119
|
+
errors.push(
|
|
120
|
+
`skills.${name}: unknown key(s): ${unknown.join(', ')} (the registry declares installed skills and versions — the file path is derived from the name, never configured)`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (typeof entry.version !== 'string' || !RE_SEMVER.test(entry.version)) {
|
|
124
|
+
errors.push(`skills.${name}: version must be a SemVer string (got ${JSON.stringify(entry.version)})`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (errors.length) return { errors, section: {} };
|
|
128
|
+
return { errors, section };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Field validators ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
export function parsePromptPin(value) {
|
|
134
|
+
if (typeof value !== 'string') return null;
|
|
135
|
+
const at = value.lastIndexOf('@');
|
|
136
|
+
if (at <= 0 || at === value.length - 1) return null;
|
|
137
|
+
const model = value.slice(0, at);
|
|
138
|
+
const date = value.slice(at + 1);
|
|
139
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(model)) return null;
|
|
140
|
+
if (!DATE_RE.test(date)) return null;
|
|
141
|
+
return { model, date };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// One path-glob entry (S5). `field` names the declaration in the error. Returns the
|
|
145
|
+
// error string or null.
|
|
146
|
+
function pathGlobError(field, entry) {
|
|
147
|
+
if (typeof entry !== 'string' || entry === '') {
|
|
148
|
+
return `${field}: each entry must be a non-empty path string`;
|
|
149
|
+
}
|
|
150
|
+
// KNOWN LIMIT: the runtime-invocation refusal is first-segment (after a stripped
|
|
151
|
+
// leading ./) — `bin/phronesis` passes as a path. A deeper `phronesis` segment can
|
|
152
|
+
// be a legitimate workspace path (raw/research/phronesis/…), so the check stays
|
|
153
|
+
// narrow; the whitespace/backtick refusals below close the actual command shapes.
|
|
154
|
+
const head = entry.replace(/^(?:\.\/)+/, '').split('/')[0];
|
|
155
|
+
if (head === 'phronesis' || /(^|\s)phronesis\s/.test(entry)) {
|
|
156
|
+
return `${field}: "${entry}" names a runtime invocation — runtime-free reads are plain file reads over plain Markdown, never a phronesis command (skills.md §"Runtime-free invocation")`;
|
|
157
|
+
}
|
|
158
|
+
if (/\s/.test(entry) || entry.includes('`')) {
|
|
159
|
+
return `${field}: "${entry}" is not a plain path glob (whitespace/backticks) — declarations name files, never commands`;
|
|
160
|
+
}
|
|
161
|
+
if (entry.startsWith('/') || entry.startsWith('~')) {
|
|
162
|
+
return `${field}: "${entry}" must be a workspace-relative path (the workflow travels with the export — absolute paths do not)`;
|
|
163
|
+
}
|
|
164
|
+
if (entry.includes('\\')) {
|
|
165
|
+
return `${field}: "${entry}" must be a POSIX path`;
|
|
166
|
+
}
|
|
167
|
+
if (entry.split('/').includes('..')) {
|
|
168
|
+
return `${field}: "${entry}" contains a ".." segment — declarations never escape the workspace`;
|
|
169
|
+
}
|
|
170
|
+
if (!RE_PATH_GLOB.test(entry)) {
|
|
171
|
+
return `${field}: "${entry}" is not a plain path glob (allowed: letters, digits, _ . - * / and {placeholder})`;
|
|
172
|
+
}
|
|
173
|
+
const residue = entry.replace(/\{[^{}]*\}/g, (tok) => (PATH_PLACEHOLDERS.includes(tok) ? '' : tok));
|
|
174
|
+
if (residue.includes('{') || residue.includes('}')) {
|
|
175
|
+
return `${field}: "${entry}" uses an unknown placeholder (known: ${PATH_PLACEHOLDERS.join(', ')}) — a placeholder nothing expands is silent intent-drop`;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// A direct-write path additionally never targets compiled/ or codex/ — a skill never
|
|
181
|
+
// writes those directly, with or without the runtime (skills.md Principle).
|
|
182
|
+
function directWriteError(entry) {
|
|
183
|
+
const err = pathGlobError('writes', entry);
|
|
184
|
+
if (err) return err;
|
|
185
|
+
// Case-INSENSITIVE: the primary install target (macOS APFS) is case-insensitive,
|
|
186
|
+
// so Compiled/ IS compiled/ at write time (adversarial review of core-0015 — the
|
|
187
|
+
// gate condition must match the invariant, not the byte-exact spelling).
|
|
188
|
+
const segments = entry.split('/').map((s) => s.toLowerCase());
|
|
189
|
+
if (segments.includes('compiled') || segments.includes('codex')) {
|
|
190
|
+
return `writes: "${entry}" targets ${segments.includes('codex') ? 'codex/' : 'compiled/'} — canonical mutation flows through action types only; a skill never writes compiled/ or codex/ directly (skills.md Principle)`;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Degrade section (S6 — the change-0013 D3 deterministic half) -----------------------
|
|
196
|
+
|
|
197
|
+
const DEGRADE_HEADING_RE = /^(#{2,4})[ \t]+Without the runtime[ \t]*$/;
|
|
198
|
+
const FENCE_RE = /^ {0,3}(?:```|~~~)/;
|
|
199
|
+
|
|
200
|
+
// Extract the "Without the runtime" section's text: from its heading to the next
|
|
201
|
+
// heading of the same or higher level, or EOF. FENCE-AWARE, both directions
|
|
202
|
+
// (adversarial review of core-0015): a `## Without the runtime` line inside a fenced
|
|
203
|
+
// code block must not SATISFY the check (a quoted example is not a section), and a
|
|
204
|
+
// `#`-prefixed line inside a fence within the real section must not TERMINATE it
|
|
205
|
+
// early (degrade sections plausibly carry fenced capture-template examples). Fenced
|
|
206
|
+
// lines INSIDE the section stay in the returned text — a target declared in a fenced
|
|
207
|
+
// example still counts as declared.
|
|
208
|
+
function degradeSection(body) {
|
|
209
|
+
const lines = body.split('\n');
|
|
210
|
+
let fenced = false;
|
|
211
|
+
let level = null;
|
|
212
|
+
let start = -1;
|
|
213
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
214
|
+
if (FENCE_RE.test(lines[i])) {
|
|
215
|
+
fenced = !fenced;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (fenced) continue;
|
|
219
|
+
if (level === null) {
|
|
220
|
+
const m = lines[i].match(DEGRADE_HEADING_RE);
|
|
221
|
+
if (m) {
|
|
222
|
+
level = m[1].length;
|
|
223
|
+
start = i + 1;
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (new RegExp(`^#{1,${level}}[ \\t]+`).test(lines[i])) {
|
|
228
|
+
return lines.slice(start, i).join('\n');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (level === null) return null;
|
|
232
|
+
return lines.slice(start).join('\n');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The canonical capture pattern the declared target must parse to (skills.md
|
|
236
|
+
// §"Runtime-free invocation"): domains/{domain}/raw/degraded/YYYY-MM-DD-{skill}-{slug}.md.
|
|
237
|
+
// The domain segment may be the {domain}/{active_domain} placeholder or a literal slug;
|
|
238
|
+
// the skill segment may be the {skill} placeholder or this skill's own name.
|
|
239
|
+
function degradeTargetRe(name) {
|
|
240
|
+
const domain = `(?:\\{domain\\}|\\{active_domain\\}|${SLUG_SOURCE})`;
|
|
241
|
+
const skill = `(?:\\{skill\\}|${name})`;
|
|
242
|
+
return new RegExp(`^domains/${domain}/raw/degraded/YYYY-MM-DD-${skill}-\\{slug\\}\\.md$`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function checkDegradeContract(name, body, errors) {
|
|
246
|
+
const section = degradeSection(body);
|
|
247
|
+
if (section === null) {
|
|
248
|
+
errors.push(
|
|
249
|
+
'body carries no "Without the runtime" section — degrade instructions travel in the skill body, never the runtime (skills.md §"Runtime-free invocation", change 0013)',
|
|
250
|
+
);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
// Candidate targets: every path-shaped token mentioning raw/degraded/, with
|
|
254
|
+
// trailing sentence punctuation trimmed (an un-backticked target ending a sentence
|
|
255
|
+
// must not refuse on its own full stop). At least one must parse to the canonical
|
|
256
|
+
// capture pattern; bare directory mentions are fine prose.
|
|
257
|
+
const candidates = (section.match(/[A-Za-z0-9_.\-*/{}]*raw\/degraded\/[A-Za-z0-9_.\-*/{}]*/g) || []).map(
|
|
258
|
+
(c) => c.replace(/[.,]+$/, ''),
|
|
259
|
+
);
|
|
260
|
+
if (!candidates.length) {
|
|
261
|
+
errors.push(
|
|
262
|
+
'the "Without the runtime" section declares no degrade target — captures must be declared to land in domains/{domain}/raw/degraded/YYYY-MM-DD-{skill}-{slug}.md (skills.md §"Runtime-free invocation")',
|
|
263
|
+
);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const re = degradeTargetRe(name);
|
|
267
|
+
const parsed = candidates.find((c) => re.test(c));
|
|
268
|
+
if (!parsed) {
|
|
269
|
+
errors.push(
|
|
270
|
+
`the declared degrade target "${candidates[0]}" does not parse — expected domains/{domain}/raw/degraded/YYYY-MM-DD-{skill}-{slug}.md (the {skill} segment may be the literal skill name)`,
|
|
271
|
+
);
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
return parsed;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- One skill file (S3–S7) --------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
// Validate one SKILL.md source against the frontmatter contract + the deterministic
|
|
280
|
+
// portability half. Pure over its arguments. `registeredActionTypes` is the EFFECTIVE
|
|
281
|
+
// action-type name set (resolveActionTypes(registry).effective keys) — a declared
|
|
282
|
+
// via_action_type outside it is a write nothing would ever run (silent intent-drop).
|
|
283
|
+
// Returns { errors, skill } — skill is null unless errors is empty.
|
|
284
|
+
export function validateSkillSource({ name, declaredVersion, source, registeredActionTypes }) {
|
|
285
|
+
const errors = [];
|
|
286
|
+
const registered = new Set(registeredActionTypes || []);
|
|
287
|
+
|
|
288
|
+
// The name is the registry key, slug-validated in resolveSkillsSection — but this
|
|
289
|
+
// is a standalone export, and the name is interpolated into the degrade-target
|
|
290
|
+
// regex below: a non-slug must REFUSE here, never crash the validator (adversarial
|
|
291
|
+
// review of core-0015 — fail-closed means returning errors, not throwing).
|
|
292
|
+
if (typeof name !== 'string' || !RE_SLUG.test(name)) {
|
|
293
|
+
return {
|
|
294
|
+
errors: [`skill name (registry key) ${JSON.stringify(name)} is not a kebab-case slug`],
|
|
295
|
+
skill: null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const block = matchFrontmatterBlock(source);
|
|
300
|
+
if (!block) {
|
|
301
|
+
return {
|
|
302
|
+
errors: ['no YAML frontmatter block — a skill is a Markdown file opening with --- frontmatter (skills.md §"Skill format")'],
|
|
303
|
+
skill: null,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
let fm;
|
|
307
|
+
try {
|
|
308
|
+
fm = yaml.load(block[1], { schema: yaml.JSON_SCHEMA });
|
|
309
|
+
} catch (err) {
|
|
310
|
+
return { errors: [`frontmatter is not valid YAML: ${err.message}`], skill: null };
|
|
311
|
+
}
|
|
312
|
+
if (!isPlainObject(fm)) {
|
|
313
|
+
return { errors: ['frontmatter must be a YAML mapping of contract fields'], skill: null };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const unknown = Object.keys(fm).filter((k) => !FM_KEYS.includes(k));
|
|
317
|
+
if (unknown.length) {
|
|
318
|
+
errors.push(`unknown frontmatter key(s): ${unknown.join(', ')} (the contract is skills.md §"Frontmatter contract")`);
|
|
319
|
+
}
|
|
320
|
+
for (const key of FM_KEYS) {
|
|
321
|
+
if (Object.hasOwn(fm, key) && fm[key] === null) {
|
|
322
|
+
errors.push(`${key}: null is not "absent" — omit the field instead`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (const key of REQUIRED_FM_KEYS) {
|
|
326
|
+
if (!Object.hasOwn(fm, key)) {
|
|
327
|
+
errors.push(`${key}: required (skills.md §"Frontmatter contract")`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Anything missing or null above makes per-field checks below noise; report the
|
|
331
|
+
// presence errors alone first — they are the clearest version of the failure.
|
|
332
|
+
if (errors.length) return { errors, skill: null };
|
|
333
|
+
|
|
334
|
+
if (typeof fm.name !== 'string' || !RE_SLUG.test(fm.name)) {
|
|
335
|
+
errors.push(`name: ${JSON.stringify(fm.name)} is not a kebab-case slug`);
|
|
336
|
+
} else if (fm.name !== name) {
|
|
337
|
+
errors.push(`name: "${fm.name}" does not match the registry key "${name}" (the registry key is the directory under skills/ and the skill's name)`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (typeof fm.version !== 'string' || !RE_SEMVER.test(fm.version)) {
|
|
341
|
+
errors.push(`version: ${JSON.stringify(fm.version)} is not a SemVer string`);
|
|
342
|
+
} else if (declaredVersion !== undefined && fm.version !== declaredVersion) {
|
|
343
|
+
errors.push(
|
|
344
|
+
`version: "${fm.version}" does not match the registry-declared "${declaredVersion}" — the registry declares installed skills and versions (skills.md §"Skill registry"); a mismatch means a stale or swapped file`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!isPlainObject(fm.invocation)) {
|
|
349
|
+
errors.push('invocation: must be a mapping with explicit, suggested, and woven (skills.md §"Invocation modes")');
|
|
350
|
+
} else {
|
|
351
|
+
const unknownModes = Object.keys(fm.invocation).filter((k) => !INVOCATION_KEYS.includes(k));
|
|
352
|
+
if (unknownModes.length) {
|
|
353
|
+
errors.push(`invocation: unknown mode(s): ${unknownModes.join(', ')} (modes: ${INVOCATION_KEYS.join(', ')})`);
|
|
354
|
+
}
|
|
355
|
+
for (const mode of ['explicit', 'suggested']) {
|
|
356
|
+
const v = fm.invocation[mode];
|
|
357
|
+
if (!Array.isArray(v) || v.some((t) => typeof t !== 'string' || t.trim() === '')) {
|
|
358
|
+
errors.push(
|
|
359
|
+
`invocation.${mode}: must be a list of trigger phrases (declare [] for none — an absent mode is ambiguous between "none" and "forgot")`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (typeof fm.invocation.woven !== 'boolean') {
|
|
364
|
+
errors.push('invocation.woven: must be a boolean');
|
|
365
|
+
}
|
|
366
|
+
if (
|
|
367
|
+
Array.isArray(fm.invocation.explicit) &&
|
|
368
|
+
Array.isArray(fm.invocation.suggested) &&
|
|
369
|
+
fm.invocation.explicit.length === 0 &&
|
|
370
|
+
fm.invocation.suggested.length === 0 &&
|
|
371
|
+
fm.invocation.woven === false
|
|
372
|
+
) {
|
|
373
|
+
errors.push(
|
|
374
|
+
'invocation: declares no mode at all — a skill nothing can ever trigger is silent intent-drop (declare a trigger phrase, or woven: true)',
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (!SKILL_WEIGHTS.includes(fm.weight)) {
|
|
380
|
+
errors.push(`weight: ${JSON.stringify(fm.weight)} is not in the weight vocabulary (${SKILL_WEIGHTS.join(' | ')})`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!Array.isArray(fm.reads) || fm.reads.length === 0) {
|
|
384
|
+
errors.push('reads: must be a non-empty list of path globs the skill expects to read');
|
|
385
|
+
} else {
|
|
386
|
+
for (const entry of fm.reads) {
|
|
387
|
+
const err = pathGlobError('reads', entry);
|
|
388
|
+
if (err) errors.push(err);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// writes (S4): direct-write path strings plus EXACTLY ONE via_action_types entry.
|
|
393
|
+
const writePaths = [];
|
|
394
|
+
let viaActionTypes = null;
|
|
395
|
+
if (!Array.isArray(fm.writes)) {
|
|
396
|
+
errors.push(
|
|
397
|
+
'writes: must be a list of direct-write paths plus one via_action_types entry — a skill with no writes declaration cannot be reasoned about (skills.md anti-pattern; declare via_action_types: [] for a read-only skill)',
|
|
398
|
+
);
|
|
399
|
+
} else {
|
|
400
|
+
let viaCount = 0;
|
|
401
|
+
for (const entry of fm.writes) {
|
|
402
|
+
if (typeof entry === 'string') {
|
|
403
|
+
const err = directWriteError(entry);
|
|
404
|
+
if (err) errors.push(err);
|
|
405
|
+
else writePaths.push(entry);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (isPlainObject(entry) && Object.keys(entry).length === 1 && Object.hasOwn(entry, 'via_action_types')) {
|
|
409
|
+
viaCount += 1;
|
|
410
|
+
if (viaCount > 1) {
|
|
411
|
+
errors.push('writes: more than one via_action_types entry — declare one list');
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const via = entry.via_action_types;
|
|
415
|
+
if (!Array.isArray(via) || via.some((t) => typeof t !== 'string' || t === '')) {
|
|
416
|
+
errors.push('writes.via_action_types: must be a list of action-type names (empty list = read-only skill)');
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const seen = new Set();
|
|
420
|
+
for (const type of via) {
|
|
421
|
+
if (seen.has(type)) {
|
|
422
|
+
errors.push(`writes.via_action_types: duplicate "${type}"`);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
seen.add(type);
|
|
426
|
+
if (!registered.has(type)) {
|
|
427
|
+
errors.push(
|
|
428
|
+
`writes.via_action_types: "${type}" is not a registered action type — a write nothing would ever run is silent intent-drop (registered: ${[...registered].join(', ') || 'none'})`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
viaActionTypes = via;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
errors.push(`writes: ${JSON.stringify(entry)} is neither a path string nor a via_action_types entry`);
|
|
436
|
+
}
|
|
437
|
+
if (viaCount === 0) {
|
|
438
|
+
errors.push(
|
|
439
|
+
'writes.via_action_types: required — which action types the skill may issue (empty list = read-only skill; skills.md §"Frontmatter contract")',
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let subAgents = [];
|
|
445
|
+
if (Object.hasOwn(fm, 'sub_agents')) {
|
|
446
|
+
if (!Array.isArray(fm.sub_agents) || fm.sub_agents.some((s) => typeof s !== 'string' || s.trim() === '')) {
|
|
447
|
+
errors.push('sub_agents: must be a list of subagent names');
|
|
448
|
+
} else {
|
|
449
|
+
subAgents = fm.sub_agents;
|
|
450
|
+
if (subAgents.length && fm.weight !== 'heavy') {
|
|
451
|
+
errors.push('sub_agents: only heavy skills declare read-only gather subagents (skills.md §"Frontmatter contract")');
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!MODEL_TIERS.includes(fm.requires_model_tier)) {
|
|
457
|
+
errors.push(
|
|
458
|
+
`requires_model_tier: ${JSON.stringify(fm.requires_model_tier)} is not in the tier vocabulary (${MODEL_TIERS.join(' | ')})`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const pin = parsePromptPin(fm.prompt_pin);
|
|
463
|
+
if (!pin) {
|
|
464
|
+
errors.push(
|
|
465
|
+
`prompt_pin: ${JSON.stringify(fm.prompt_pin)} does not parse — expected {model_id}@{YYYY-MM-DD} (e.g. claude-opus-4-7@2026-04-01; eval-discipline.md)`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const body = source.slice(block[0].length);
|
|
470
|
+
const degradeTarget = checkDegradeContract(name, body, errors);
|
|
471
|
+
|
|
472
|
+
if (errors.length) return { errors, skill: null };
|
|
473
|
+
return {
|
|
474
|
+
errors,
|
|
475
|
+
skill: {
|
|
476
|
+
name: fm.name,
|
|
477
|
+
version: fm.version,
|
|
478
|
+
invocation: { explicit: fm.invocation.explicit, suggested: fm.invocation.suggested, woven: fm.invocation.woven },
|
|
479
|
+
weight: fm.weight,
|
|
480
|
+
reads: fm.reads,
|
|
481
|
+
writes: { paths: writePaths, via_action_types: viaActionTypes },
|
|
482
|
+
sub_agents: subAgents,
|
|
483
|
+
requires_model_tier: fm.requires_model_tier,
|
|
484
|
+
prompt_pin: { raw: fm.prompt_pin, ...pin },
|
|
485
|
+
path: skillFilePath(name),
|
|
486
|
+
degrade_target: degradeTarget,
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// --- The loader (S2) ---------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
// Load every REGISTERED skill from an install root. All-or-nothing: any error returns
|
|
494
|
+
// { errors, skills: {} } — the skills layer is disabled until the registry and every
|
|
495
|
+
// registered SKILL.md are valid (a config error is loud and total, never partial).
|
|
496
|
+
// File errors are prefixed with the file path so the operator's next step is named.
|
|
497
|
+
export async function loadSkills({ installRoot, registry, registeredActionTypes }) {
|
|
498
|
+
const { errors, section } = resolveSkillsSection(registry);
|
|
499
|
+
if (errors.length) return { errors, skills: {} };
|
|
500
|
+
|
|
501
|
+
const all = [];
|
|
502
|
+
const skills = {};
|
|
503
|
+
for (const [name, entry] of Object.entries(section)) {
|
|
504
|
+
const rel = skillFilePath(name);
|
|
505
|
+
let source;
|
|
506
|
+
try {
|
|
507
|
+
source = await readFile(join(installRoot, rel), 'utf8');
|
|
508
|
+
} catch (err) {
|
|
509
|
+
// ENOENT is the only "missing" — an EACCES/EISDIR misreported as missing pairs
|
|
510
|
+
// a wrong diagnosis with destructive advice (adversarial review of core-0015).
|
|
511
|
+
if (err.code === 'ENOENT') {
|
|
512
|
+
all.push(`${rel}: missing — the registry declares "${name}" installed but the file does not exist (remove the skills.${name} entry to unregister)`);
|
|
513
|
+
} else {
|
|
514
|
+
all.push(`${rel}: unreadable (${err.code || err.message}) — the file exists but could not be read`);
|
|
515
|
+
}
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const res = validateSkillSource({ name, declaredVersion: entry.version, source, registeredActionTypes });
|
|
519
|
+
for (const e of res.errors) all.push(`${rel}: ${e}`);
|
|
520
|
+
if (!res.errors.length) skills[name] = res.skill;
|
|
521
|
+
}
|
|
522
|
+
if (all.length) return { errors: all, skills: {} };
|
|
523
|
+
return { errors: all, skills };
|
|
524
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Subscription match criteria + matcher (specs/event-log.md §"Subscription contract";
|
|
2
|
+
// core-0006 / T4.0 — moved from apps/cli to core in core-0013, when the in-pipeline hook
|
|
3
|
+
// executor became its second consumer: ONE shared matcher, never a re-typed copy
|
|
4
|
+
// (rules.md two-matchers drift), and apps→packages is the only legal import direction.
|
|
5
|
+
// apps/cli/src/subscription.js re-exports these for the CLI surface.
|
|
6
|
+
//
|
|
7
|
+
// T4.0 defined + validated ONLY the event-side matching shape; the full hook declaration
|
|
8
|
+
// (kind / classification / kill_switch / command / quota) is hooks.md's contract and
|
|
9
|
+
// lives in hook-executor.js (core-0013 / T4.2).
|
|
10
|
+
//
|
|
11
|
+
// V1 shape: `subscribes_to` (an array of exact event types) + an optional named `filter`
|
|
12
|
+
// (a { "payload.<field>": <value> } equality map). jq-expression filters
|
|
13
|
+
// (event-log.md:138) are the V1.5 daemon superset of this field-equality subset.
|
|
14
|
+
|
|
15
|
+
// Validate the matching shape only. Returns { ok, errors }. Does not validate hook fields.
|
|
16
|
+
export function validateSubscription(sub) {
|
|
17
|
+
if (sub === null || typeof sub !== 'object' || Array.isArray(sub)) {
|
|
18
|
+
return { ok: false, errors: ['subscription must be an object'] };
|
|
19
|
+
}
|
|
20
|
+
const errors = [];
|
|
21
|
+
if (!Array.isArray(sub.subscribes_to) || sub.subscribes_to.length === 0) {
|
|
22
|
+
errors.push('subscribes_to must be a non-empty array of event types');
|
|
23
|
+
} else if (!sub.subscribes_to.every((t) => typeof t === 'string' && t !== '')) {
|
|
24
|
+
errors.push('subscribes_to entries must be non-empty strings');
|
|
25
|
+
}
|
|
26
|
+
if (sub.filter !== undefined) {
|
|
27
|
+
if (sub.filter === null || typeof sub.filter !== 'object' || Array.isArray(sub.filter)) {
|
|
28
|
+
errors.push('filter must be an object mapping payload.<field> to a value');
|
|
29
|
+
} else {
|
|
30
|
+
for (const key of Object.keys(sub.filter)) {
|
|
31
|
+
if (!key.startsWith('payload.')) {
|
|
32
|
+
errors.push(`filter key "${key}" must address a payload field (payload.<field>)`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ok: errors.length === 0, errors };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Walk a dotted path under an object; undefined if any hop is missing or non-object.
|
|
41
|
+
// Own-property hops only (rules.md): a path segment named `constructor`/`__proto__`
|
|
42
|
+
// must read as ABSENT, never resolve up the prototype chain — an inherited value
|
|
43
|
+
// matching a filter would fire a hook on a field the event never carried.
|
|
44
|
+
function getPath(obj, path) {
|
|
45
|
+
let cur = obj;
|
|
46
|
+
for (const key of path) {
|
|
47
|
+
if (cur === null || typeof cur !== 'object' || !Object.hasOwn(cur, key)) return undefined;
|
|
48
|
+
cur = cur[key];
|
|
49
|
+
}
|
|
50
|
+
return cur;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Equality for a filter value: strict for primitives, structural (JSON) for arrays/objects so
|
|
54
|
+
// `{ "payload.tags": ["a"] }` can match without a deep-equal dependency.
|
|
55
|
+
function valueEquals(actual, expected) {
|
|
56
|
+
if (actual === expected) return true;
|
|
57
|
+
if (
|
|
58
|
+
actual !== null &&
|
|
59
|
+
expected !== null &&
|
|
60
|
+
typeof actual === 'object' &&
|
|
61
|
+
typeof expected === 'object'
|
|
62
|
+
) {
|
|
63
|
+
return JSON.stringify(actual) === JSON.stringify(expected);
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Does `event` match `subscription`? Type membership (exact), then every filter entry as an
|
|
69
|
+
// AND of payload-field equalities. A malformed filter key (not payload.<field>) cannot match.
|
|
70
|
+
export function matchesSubscription(event, subscription) {
|
|
71
|
+
if (!event || typeof event !== 'object') return false;
|
|
72
|
+
const types = Array.isArray(subscription?.subscribes_to) ? subscription.subscribes_to : [];
|
|
73
|
+
if (!types.includes(event.type)) return false;
|
|
74
|
+
|
|
75
|
+
const filter = subscription?.filter;
|
|
76
|
+
if (filter && typeof filter === 'object' && !Array.isArray(filter)) {
|
|
77
|
+
for (const [key, expected] of Object.entries(filter)) {
|
|
78
|
+
if (!key.startsWith('payload.')) return false;
|
|
79
|
+
const path = key.slice('payload.'.length).split('.');
|
|
80
|
+
if (!valueEquals(getPath(event.payload, path), expected)) return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// One object, fully checked — the per-file half of `phronesis validate`, extracted to
|
|
2
|
+
// core (core-0008) because the action spine's stage→validate→rename writer must enforce
|
|
3
|
+
// EXACTLY what the validator enforces: frontmatter contract (contract.js) plus link
|
|
4
|
+
// existence against the filesystem. Two consumers → package (rules.md); if these ever
|
|
5
|
+
// diverged, an action could commit an artifact `phronesis validate` then rejects.
|
|
6
|
+
|
|
7
|
+
import { join, relative } from 'node:path';
|
|
8
|
+
import yaml from 'js-yaml';
|
|
9
|
+
import fse from 'fs-extra';
|
|
10
|
+
import { validateFrontmatter } from './contract.js';
|
|
11
|
+
import { splitFrontmatter, contextFor } from './workspace-scan.js';
|
|
12
|
+
|
|
13
|
+
// Check one object's full source against the ontology contract. `relPath` locates the
|
|
14
|
+
// object (its layer/type-dir/name context is derived from the path, contextFor); the
|
|
15
|
+
// content is checked as given — callers validate staged bytes before they land.
|
|
16
|
+
// `pending` (optional, a Set of install-relative paths) names sibling artifacts of the
|
|
17
|
+
// SAME staged transaction: they count as existing for link checks, because the
|
|
18
|
+
// multi-dest writer commits them before this artifact's links are ever walked
|
|
19
|
+
// (core-0010's tombstone links to the codex object staged beside it). Returns the
|
|
20
|
+
// error list ([] = valid). Error strings are the validator's own.
|
|
21
|
+
export async function checkObjectContent({ installRoot, relPath, content, pending }) {
|
|
22
|
+
const ctx = contextFor(relPath);
|
|
23
|
+
if (!ctx) return [`"${relPath}" is not a typed-object path (domains/*/compiled/ or codex/)`];
|
|
24
|
+
|
|
25
|
+
const fmSrc = splitFrontmatter(content);
|
|
26
|
+
if (fmSrc === null) return ['no frontmatter block'];
|
|
27
|
+
|
|
28
|
+
let fm;
|
|
29
|
+
try {
|
|
30
|
+
// JSON_SCHEMA: no implicit type coercion — unquoted `2026-05-15` stays a string
|
|
31
|
+
// (the default schema would parse it into a Date), keeping the contract honest.
|
|
32
|
+
fm = yaml.load(fmSrc, { schema: yaml.JSON_SCHEMA });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return [`could not parse frontmatter: ${err.message}`];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { errors, links } = validateFrontmatter(fm, ctx);
|
|
38
|
+
|
|
39
|
+
// Link existence — needs the filesystem. Grammar + forward-policy were already checked
|
|
40
|
+
// in validateFrontmatter. Resolve the path FIRST: a bare slug from a codex object (no
|
|
41
|
+
// domain) is unresolvable and errors even with forward:true — forward must not be able
|
|
42
|
+
// to bless a target that can never resolve.
|
|
43
|
+
for (const link of links) {
|
|
44
|
+
const targetRel = link.cls.relPath(ctx.domain);
|
|
45
|
+
if (targetRel === null) {
|
|
46
|
+
errors.push(`link "${link.target}": a bare-slug target has no domain to resolve against (codex objects must use a full path or codex/ ref)`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (link.forward && link.cls.domainObject) continue; // forward waives existence
|
|
50
|
+
const abs = join(installRoot, targetRel);
|
|
51
|
+
if (relative(installRoot, abs).startsWith('..')) {
|
|
52
|
+
errors.push(`link "${link.target}": target resolves outside the installation`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (pending && pending.has(targetRel)) continue; // staged in the same transaction
|
|
56
|
+
if (!(await fse.pathExists(abs))) {
|
|
57
|
+
errors.push(`link "${link.target}": target does not resolve to an existing file (add forward: true if it is not compiled yet)`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return errors;
|
|
62
|
+
}
|