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,167 @@
1
+ // Active-context projection — the tracked, human-facing summary of a domain's compiled
2
+ // state (core-0017 H5 / B2; specs/compile-retrieve-loop.md §"Generated-state hygiene";
3
+ // specs/workspace-substrate.md §"Derived vs tracked generated state"). ONE file per
4
+ // domain, domains/{domain}/ACTIVE_CONTEXT.md: generated-but-tracked (kept in version
5
+ // control + the export, staleness-checked) — distinct from the DERIVED machine index
6
+ // under .phronesis/index/ (rebuildable, gitignored, export-stripped) and from MEMORY.md's
7
+ // compound-memory role.
8
+ //
9
+ // It is NOT a typed object — it lives outside compiled/, so contextFor returns null and
10
+ // `phronesis validate` skips it as content. But the validate-time staleness check reads
11
+ // it: a compiled object newer than its projection entry fails validation until a refresh
12
+ // (the post_compile hook) brings the projection current. compile is the only writer that
13
+ // refreshes it; no action type writes it directly (core-0016 review finding #2 — generated
14
+ // state is never a free-form action write).
15
+
16
+ import { join, dirname } from 'node:path';
17
+ import { readFile, writeFile, rename, readdir } from 'node:fs/promises';
18
+ import fse from 'fs-extra';
19
+ import { walkMarkdown, readObjectRecord } from './workspace-scan.js';
20
+
21
+ const PROJECTION_FILE = 'ACTIVE_CONTEXT.md';
22
+ const DATE_LINE_RE = /^### (\d{4}-\d{2}-\d{2})\s*$/;
23
+ // The strict, parseable entry line: the staleness check reads `{typeDir}/{name}` and its
24
+ // recorded freshness date back out of it. Human-readable AND machine-checkable, so the
25
+ // one file serves both the operator and the gate (the key carries the type-dir, so the
26
+ // section headings are cosmetic and the parser never depends on them).
27
+ const ENTRY_RE = /^- `([a-z0-9-]+\/[a-z0-9-]+)` — .*\(updated (\d{4}-\d{2}-\d{2})\)$/;
28
+
29
+ // One object's freshness = the latest date it reflects: created, updated (if any), and
30
+ // every dated ## Log entry. YYYY-MM-DD sorts lexically, so the string max IS the date max.
31
+ function objectFreshness(fm, body) {
32
+ const dates = [];
33
+ if (typeof fm.created === 'string') dates.push(fm.created);
34
+ if (typeof fm.updated === 'string') dates.push(fm.updated);
35
+ for (const line of body.split('\n')) {
36
+ const m = line.match(DATE_LINE_RE);
37
+ if (m) dates.push(m[1]);
38
+ }
39
+ return dates.length ? dates.sort().at(-1) : '0000-00-00';
40
+ }
41
+
42
+ // Domain dirs under domains/ (a fresh install may have none populated yet).
43
+ async function listDomainSlugs(installRoot) {
44
+ let entries;
45
+ try {
46
+ entries = await readdir(join(installRoot, 'domains'), { withFileTypes: true });
47
+ } catch {
48
+ return [];
49
+ }
50
+ return entries
51
+ .filter((e) => e.isDirectory())
52
+ .map((e) => e.name)
53
+ .sort();
54
+ }
55
+
56
+ // One domain's compiled objects → [{ key, name, typeDir, title, freshness }], key-sorted.
57
+ async function domainObjects(installRoot, domain) {
58
+ const files = await walkMarkdown(installRoot, join(installRoot, 'domains', domain, 'compiled'));
59
+ const out = [];
60
+ for (const rel of files) {
61
+ const rec = await readObjectRecord(installRoot, rel);
62
+ if (!rec || rec.ctx.layer !== 'domain') continue;
63
+ out.push({
64
+ key: `${rec.ctx.typeDir}/${rec.ctx.name}`,
65
+ name: rec.ctx.name,
66
+ typeDir: rec.ctx.typeDir,
67
+ title: typeof rec.fm.title === 'string' ? rec.fm.title : rec.ctx.name,
68
+ freshness: objectFreshness(rec.fm, rec.body),
69
+ });
70
+ }
71
+ out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
72
+ return out;
73
+ }
74
+
75
+ // Deterministic projection bytes for one domain (no clock, no machine-only state — it is
76
+ // tracked + exported, so it must be reproducible from compiled/ alone).
77
+ export async function buildActiveContextProjection({ installRoot, domain }) {
78
+ const objects = await domainObjects(installRoot, domain);
79
+ const lines = [
80
+ `# Active context — ${domain}`,
81
+ '',
82
+ `> Generated projection of ${domain}'s compiled state — derived but tracked (in version`,
83
+ '> control and `phronesis export`), refreshed by compile. `phronesis validate` flags it',
84
+ '> stale when a compiled object is newer than its entry here. Do not hand-edit.',
85
+ '',
86
+ ];
87
+ if (objects.length === 0) {
88
+ lines.push('_No compiled objects yet._', '');
89
+ return lines.join('\n');
90
+ }
91
+ const byDir = new Map();
92
+ for (const o of objects) {
93
+ if (!byDir.has(o.typeDir)) byDir.set(o.typeDir, []);
94
+ byDir.get(o.typeDir).push(o);
95
+ }
96
+ for (const typeDir of [...byDir.keys()].sort()) {
97
+ lines.push(`## ${typeDir}`, '');
98
+ for (const o of byDir.get(typeDir)) {
99
+ lines.push(`- \`${o.key}\` — ${o.title} (updated ${o.freshness})`);
100
+ }
101
+ lines.push('');
102
+ }
103
+ return lines.join('\n');
104
+ }
105
+
106
+ async function writeAtomic(abs, content) {
107
+ await fse.ensureDir(dirname(abs));
108
+ // .partial- is the scan-ignored, gitignored temp family the writers use; a crash
109
+ // mid-write never leaves a torn projection.
110
+ const tmp = `${abs}.partial-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
111
+ await writeFile(tmp, content);
112
+ await rename(tmp, abs);
113
+ }
114
+
115
+ // Refresh one domain's projection (the post_compile hook's job, and the accept path's).
116
+ export async function refreshActiveContext({ installRoot, domain }) {
117
+ const content = await buildActiveContextProjection({ installRoot, domain });
118
+ await writeAtomic(join(installRoot, 'domains', domain, PROJECTION_FILE), content);
119
+ return { domain, path: `domains/${domain}/${PROJECTION_FILE}` };
120
+ }
121
+
122
+ // Parse an existing projection's recorded entries → Map(`{typeDir}/{name}` → date).
123
+ function parseProjectionEntries(content) {
124
+ const recorded = new Map();
125
+ for (const line of content.split('\n')) {
126
+ const m = line.match(ENTRY_RE);
127
+ if (m) recorded.set(m[1], m[2]);
128
+ }
129
+ return recorded;
130
+ }
131
+
132
+ // The validate-time staleness check (H5): for each domain that HAS a projection, flag any
133
+ // recorded object whose current freshness is newer than its projection entry — the
134
+ // projection is stale until a refresh brings it current. Minimal by design (the packet's
135
+ // scope guard): present-but-stale only; missing-object and the AGENTS.md-conflict check
136
+ // are deferred. A domain with no projection is not stale (nothing claims to summarize it),
137
+ // so non-compile flows (a decision logged with no compile yet) never trip it.
138
+ export async function checkActiveContextStaleness({ installRoot }) {
139
+ const findings = [];
140
+ for (const domain of await listDomainSlugs(installRoot)) {
141
+ let projContent;
142
+ try {
143
+ projContent = await readFile(join(installRoot, 'domains', domain, PROJECTION_FILE), 'utf8');
144
+ } catch {
145
+ continue; // no projection — nothing to be stale against
146
+ }
147
+ const recorded = parseProjectionEntries(projContent);
148
+ if (recorded.size === 0) continue;
149
+ const freshnessByKey = new Map(
150
+ (await domainObjects(installRoot, domain)).map((o) => [o.key, o.freshness]),
151
+ );
152
+ for (const [key, projDate] of recorded) {
153
+ const objDate = freshnessByKey.get(key);
154
+ if (objDate === undefined) continue; // object gone (out-of-band delete) — not this check's job
155
+ if (objDate > projDate) {
156
+ findings.push({
157
+ domain,
158
+ key,
159
+ path: `domains/${domain}/compiled/${key}.md`,
160
+ objectDate: objDate,
161
+ projectionDate: projDate,
162
+ });
163
+ }
164
+ }
165
+ }
166
+ return findings;
167
+ }
@@ -0,0 +1,301 @@
1
+ // Durable operator-facing artifacts — deliberately outside the typed-object ontology.
2
+ //
3
+ // Artifacts are the small, exportable deliverable class from change 0020. They are
4
+ // non-canonical (so they do not pass through action types, compile, or the normal
5
+ // search snapshot), but they still have a narrow structural contract: one allowed
6
+ // kind, private exposure, resolved optional links, and explicit refinements.
7
+
8
+ import { join, relative, sep } from 'node:path';
9
+ import { link, open, readFile, readdir, rename, writeFile } from 'node:fs/promises';
10
+ import { randomBytes } from 'node:crypto';
11
+ import fse from 'fs-extra';
12
+ import yaml from 'js-yaml';
13
+ import { DATE_RE, LINK_RELS, RE_SLUG, classifyTarget } from './contract.js';
14
+ import { serializeFrontmatter } from './action-writers.js';
15
+ import { matchFrontmatterBlock, splitFrontmatter } from './workspace-scan.js';
16
+
17
+ export const ARTIFACT_KINDS = Object.freeze(['prd']);
18
+
19
+ const ARTIFACT_PATH_RE = new RegExp(
20
+ `^domains/(${RE_SLUG.source.slice(1, -1)})/artifacts/(${ARTIFACT_KINDS.join('|')})/(${RE_SLUG.source.slice(1, -1)})\\.md$`,
21
+ );
22
+
23
+ function localDateStamp(date = new Date()) {
24
+ const y = date.getFullYear();
25
+ const m = String(date.getMonth() + 1).padStart(2, '0');
26
+ const d = String(date.getDate()).padStart(2, '0');
27
+ return `${y}-${m}-${d}`;
28
+ }
29
+
30
+ function validDate(value) {
31
+ if (typeof value !== 'string' || !DATE_RE.test(value)) return false;
32
+ const [year, month, day] = value.split('-').map(Number);
33
+ const candidate = new Date(Date.UTC(year, month - 1, day));
34
+ return candidate.getUTCFullYear() === year && candidate.getUTCMonth() === month - 1 && candidate.getUTCDate() === day;
35
+ }
36
+
37
+ function plainObject(value) {
38
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
39
+ }
40
+
41
+ // Returns null for every non-artifact path. A malformed path underneath artifacts/
42
+ // returns a context with an error so the structural gate can name the exact file.
43
+ export function artifactContextFor(relPath) {
44
+ const normalized = relPath.split(sep).join('/');
45
+ const match = normalized.match(ARTIFACT_PATH_RE);
46
+ if (match) return { domain: match[1], kind: match[2], slug: match[3], relPath: normalized };
47
+ if (normalized.startsWith('domains/') && normalized.includes('/artifacts/')) {
48
+ return { error: `"${normalized}" is not an allowed artifact path (expected domains/{domain}/artifacts/prd/{slug}.md)` };
49
+ }
50
+ return null;
51
+ }
52
+
53
+ async function walkFiles(installRoot, root) {
54
+ let entries;
55
+ try {
56
+ entries = await readdir(root, { withFileTypes: true });
57
+ } catch {
58
+ return [];
59
+ }
60
+ const files = [];
61
+ for (const entry of entries) {
62
+ const absolute = join(root, entry.name);
63
+ if (entry.isDirectory()) files.push(...(await walkFiles(installRoot, absolute)));
64
+ else if (entry.isFile()) files.push(relative(installRoot, absolute));
65
+ }
66
+ return files;
67
+ }
68
+
69
+ // The structural gate must see every file under artifacts/, not only Markdown. A
70
+ // stray binary or text file would otherwise travel in export without any contract.
71
+ export async function listArtifactFiles(installRoot) {
72
+ return (await walkFiles(installRoot, join(installRoot, 'domains'))).filter((p) => p.split(sep).includes('artifacts'));
73
+ }
74
+
75
+ async function checkLinks({ installRoot, domain, links, errors }) {
76
+ if (links === undefined) return;
77
+ if (!Array.isArray(links)) {
78
+ errors.push('links: must be a list');
79
+ return;
80
+ }
81
+ for (const [i, link] of links.entries()) {
82
+ if (!plainObject(link)) {
83
+ errors.push(`links[${i}]: must be a mapping with rel + target`);
84
+ continue;
85
+ }
86
+ if (!link.rel) errors.push(`links[${i}]: missing rel`);
87
+ else if (!LINK_RELS.includes(link.rel)) errors.push(`links[${i}]: rel "${link.rel}" is not in the link vocabulary`);
88
+ if (link.forward !== undefined && typeof link.forward !== 'boolean') {
89
+ errors.push(`links[${i}]: forward must be a boolean when present`);
90
+ }
91
+ if (typeof link.target !== 'string' || link.target === '') {
92
+ errors.push(`links[${i}]: target must be a non-empty string`);
93
+ continue;
94
+ }
95
+ const cls = classifyTarget(link.target);
96
+ if (cls.form === 'invalid') {
97
+ errors.push(`links[${i}]: target "${link.target}" matches no link-target form`);
98
+ continue;
99
+ }
100
+ if (link.forward === true && !cls.domainObject) {
101
+ errors.push(`links[${i}]: forward:true is only allowed on domain-object targets (not "${link.target}")`);
102
+ }
103
+ const targetRel = cls.relPath(domain);
104
+ if (targetRel === null) {
105
+ errors.push(`link "${link.target}": a bare-slug target has no domain to resolve against`);
106
+ continue;
107
+ }
108
+ if (link.forward === true && cls.domainObject) continue;
109
+ const target = join(installRoot, targetRel);
110
+ if (relative(installRoot, target).startsWith('..')) {
111
+ errors.push(`link "${link.target}": target resolves outside the installation`);
112
+ } else if (!(await fse.pathExists(target))) {
113
+ errors.push(`link "${link.target}": target does not resolve to an existing file (add forward: true if it is not compiled yet)`);
114
+ }
115
+ }
116
+ }
117
+
118
+ // Validate one durable artifact's structural contract. This intentionally does not
119
+ // call validateFrontmatter: a PRD has no ontology `type`, domain object identity, or
120
+ // provenance requirement. The shared link grammar is retained.
121
+ export async function checkArtifactContent({ installRoot, relPath, content }) {
122
+ const context = artifactContextFor(relPath);
123
+ if (!context) return [`"${relPath}" is not an artifact path`];
124
+ if (context.error) return [context.error];
125
+
126
+ const block = matchFrontmatterBlock(content);
127
+ if (!block) return ['no frontmatter block'];
128
+ let fm;
129
+ try {
130
+ fm = yaml.load(block[1], { schema: yaml.JSON_SCHEMA });
131
+ } catch (error) {
132
+ return [`could not parse frontmatter: ${error.message}`];
133
+ }
134
+ if (!plainObject(fm)) return ['frontmatter is not a YAML mapping'];
135
+
136
+ const errors = [];
137
+ for (const field of ['kind', 'title', 'created', 'exposure']) {
138
+ if (fm[field] === undefined || fm[field] === null || fm[field] === '') errors.push(`missing required field: ${field}`);
139
+ }
140
+ if (fm.kind !== undefined && fm.kind !== context.kind) {
141
+ errors.push(`kind: "${fm.kind}" does not match artifact path kind "${context.kind}"`);
142
+ }
143
+ if (!ARTIFACT_KINDS.includes(fm.kind)) errors.push(`kind: "${fm.kind}" is not an allowed artifact kind (${ARTIFACT_KINDS.join(' | ')})`);
144
+ if (fm.title !== undefined && (typeof fm.title !== 'string' || fm.title.trim() === '')) errors.push('title: must be a non-empty string');
145
+ if (fm.created !== undefined && !validDate(fm.created)) errors.push(`created: "${fm.created}" is not YYYY-MM-DD (a real calendar date)`);
146
+ if (fm.exposure !== 'private') errors.push('exposure: durable artifacts must be private in V1');
147
+ if (fm.updated !== undefined) {
148
+ if (!validDate(fm.updated)) errors.push(`updated: "${fm.updated}" is not YYYY-MM-DD (a real calendar date)`);
149
+ else if (validDate(fm.created) && fm.updated < fm.created) errors.push('updated: must be greater than or equal to created');
150
+ }
151
+ if (content.slice(block[0].length).trim() === '') {
152
+ errors.push('body: durable artifacts must contain a non-whitespace document body');
153
+ }
154
+
155
+ if (fm.provenance !== undefined) {
156
+ if (!plainObject(fm.provenance)) {
157
+ errors.push('provenance: must be a mapping when present');
158
+ } else if (fm.provenance.refinements !== undefined) {
159
+ if (!Array.isArray(fm.provenance.refinements)) {
160
+ errors.push('provenance.refinements: must be an append-only list of { date, note } entries');
161
+ } else {
162
+ fm.provenance.refinements.forEach((entry, i) => {
163
+ if (!plainObject(entry)) {
164
+ errors.push(`provenance.refinements[${i}]: must be a { date, note } mapping`);
165
+ return;
166
+ }
167
+ if (!validDate(entry.date)) errors.push(`provenance.refinements[${i}].date: must be YYYY-MM-DD (a real calendar date)`);
168
+ if (typeof entry.note !== 'string' || entry.note.trim() === '') errors.push(`provenance.refinements[${i}].note: must be a non-empty string`);
169
+ });
170
+ }
171
+ }
172
+ }
173
+ await checkLinks({ installRoot, domain: context.domain, links: fm.links, errors });
174
+ return errors;
175
+ }
176
+
177
+ export async function readArtifactRecord(installRoot, relPath) {
178
+ const context = artifactContextFor(relPath);
179
+ if (!context || context.error) return null;
180
+ let content;
181
+ try {
182
+ content = await readFile(join(installRoot, relPath), 'utf8');
183
+ } catch {
184
+ return null;
185
+ }
186
+ const errors = await checkArtifactContent({ installRoot, relPath, content });
187
+ if (errors.length) return null;
188
+ const block = matchFrontmatterBlock(content);
189
+ const fm = yaml.load(block[1], { schema: yaml.JSON_SCHEMA });
190
+ return {
191
+ path: relPath,
192
+ layer: 'domain',
193
+ domain: context.domain,
194
+ codexKind: null,
195
+ type: `artifact:${fm.kind}`,
196
+ name: context.slug,
197
+ title: typeof fm.title === 'string' ? fm.title : context.slug,
198
+ status: null,
199
+ exposure: fm.exposure ?? null,
200
+ stance: null,
201
+ links: Array.isArray(fm.links) ? fm.links : [],
202
+ body: content.slice(block[0].length),
203
+ };
204
+ }
205
+
206
+ export function artifactPath({ domain, kind = 'prd', slug }) {
207
+ if (!RE_SLUG.test(domain) || !ARTIFACT_KINDS.includes(kind) || !RE_SLUG.test(slug)) {
208
+ throw new Error('artifact path requires a valid domain, allowed kind, and kebab-case slug');
209
+ }
210
+ return `domains/${domain}/artifacts/${kind}/${slug}.md`;
211
+ }
212
+
213
+ // Direct artifact writer for runtime-capable surfaces. It never emits events because
214
+ // artifacts are non-canonical. Existing paths are a fail-closed choice: only an
215
+ // explicit refinement can edit in place, preserving the artifact path as identity.
216
+ export async function writeArtifact({ installRoot, domain, kind = 'prd', slug, title, body, links, refine = false, refinementNote, now = new Date() }) {
217
+ const relPath = artifactPath({ domain, kind, slug });
218
+ const absolute = join(installRoot, relPath);
219
+ const date = localDateStamp(now);
220
+ await fse.ensureDir(join(installRoot, `domains/${domain}/artifacts/${kind}`));
221
+ // Keep the cooperative lock alongside the installation metadata rather than
222
+ // under artifacts/. That avoids both an export-visible operational directory
223
+ // and a path that the artifact structural gate would need to special-case.
224
+ const lockPath = join(installRoot, '.phronesis', `.artifact-lock-${domain}-${kind}-${slug}.lock`);
225
+ await fse.ensureDir(join(installRoot, '.phronesis'));
226
+ let lock;
227
+ try {
228
+ lock = await open(lockPath, 'wx');
229
+ } catch (error) {
230
+ if (error?.code === 'EEXIST') {
231
+ return {
232
+ ok: false,
233
+ path: relPath,
234
+ prompt: `Artifact ${relPath} is currently being written. Retry after that write completes.`,
235
+ };
236
+ }
237
+ throw error;
238
+ }
239
+
240
+ let temporary;
241
+ try {
242
+ // Check existence only after acquiring the lock. This makes same-slug creation
243
+ // fail closed across concurrent cooperative callers, rather than last-rename wins.
244
+ const exists = await fse.pathExists(absolute);
245
+ let fm;
246
+ let documentBody;
247
+ if (exists) {
248
+ if (!refine) {
249
+ return {
250
+ ok: false,
251
+ path: relPath,
252
+ prompt: `Artifact ${relPath} already exists. State explicit refine intent or choose a new slug; existing artifacts are never silently replaced.`,
253
+ };
254
+ }
255
+ const current = await readFile(absolute, 'utf8');
256
+ const errors = await checkArtifactContent({ installRoot, relPath, content: current });
257
+ if (errors.length) throw new Error(`cannot refine malformed artifact ${relPath}: ${errors.join('; ')}`);
258
+ const source = splitFrontmatter(current);
259
+ const currentBlock = matchFrontmatterBlock(current);
260
+ fm = yaml.load(source, { schema: yaml.JSON_SCHEMA });
261
+ const provenance = plainObject(fm.provenance) ? { ...fm.provenance } : {};
262
+ const refinements = Array.isArray(provenance.refinements) ? [...provenance.refinements] : [];
263
+ refinements.push({ date, note: refinementNote || 'Refined in conversation.' });
264
+ fm = { ...fm, title: title || fm.title, updated: date, provenance: { ...provenance, refinements } };
265
+ documentBody = body === undefined ? current.slice(currentBlock[0].length) : body;
266
+ } else {
267
+ if (typeof title !== 'string' || title.trim() === '') throw new Error('a new artifact requires a non-empty title');
268
+ fm = { kind, title, created: date, exposure: 'private' };
269
+ documentBody = body;
270
+ }
271
+ if (links !== undefined) fm.links = links;
272
+ const content = `${serializeFrontmatter(fm)}\n${String(documentBody ?? '').trim()}\n`;
273
+ const errors = await checkArtifactContent({ installRoot, relPath, content });
274
+ if (errors.length) throw new Error(`invalid artifact ${relPath}: ${errors.join('; ')}`);
275
+ temporary = `${absolute}.partial-${process.pid}-${randomBytes(4).toString('hex')}`;
276
+ await writeFile(temporary, content, 'utf8');
277
+ if (exists) {
278
+ await rename(temporary, absolute);
279
+ } else {
280
+ try {
281
+ // link() is an atomic no-clobber publication primitive: unlike rename(), it
282
+ // refuses EEXIST rather than replacing a concurrent new artifact.
283
+ await link(temporary, absolute);
284
+ } catch (error) {
285
+ if (error?.code === 'EEXIST') {
286
+ return {
287
+ ok: false,
288
+ path: relPath,
289
+ prompt: `Artifact ${relPath} was created concurrently. State explicit refine intent or choose a new slug.`,
290
+ };
291
+ }
292
+ throw error;
293
+ }
294
+ }
295
+ return { ok: true, path: relPath, refined: exists };
296
+ } finally {
297
+ if (temporary) await fse.remove(temporary).catch(() => {});
298
+ if (lock) await lock.close().catch(() => {});
299
+ await fse.remove(lockPath).catch(() => {});
300
+ }
301
+ }
@@ -0,0 +1,21 @@
1
+ // Content digest for the compile-review TOCTOU lock (change 0027). ONE matcher, shared by
2
+ // the compile handler (stamping `content_digest` on `compile.candidate`), `compile review`'s
3
+ // `classifyDrafts` (re-hashing the on-disk draft), and the gate — so the handler and the
4
+ // verifier can never disagree about "what the bytes are" (the two-consumers→one-matcher rule,
5
+ // rules.md). The digest is over RAW file bytes (a Buffer, no utf8 round-trip): both sides read
6
+ // the same file the same way, removing the entire "is it the string or the bytes?" class.
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import { readFile } from 'node:fs/promises';
10
+
11
+ // sha-256 over raw bytes, formatted `sha256:<lowercase-hex>` (the 0027 delta format).
12
+ export function contentDigest(bytes) {
13
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
14
+ }
15
+
16
+ // Digest the EXACT persisted bytes at an absolute path. Reads with no encoding → a Buffer,
17
+ // so the hash is over the file's real bytes (what the verifier later re-reads), not a
18
+ // utf8-decoded-then-re-encoded string. Throws on a read error (the caller fails closed).
19
+ export async function digestFile(absPath) {
20
+ return contentDigest(await readFile(absPath));
21
+ }