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,2140 @@
1
+ // The shared action core — T2.2's spine (core-0008; specs/ontology.md §"Action type
2
+ // contract"; contract decisions D1–D12 in harness/tasks/core-0008-action-spine.md).
3
+ //
4
+ // One `runAction({ type, payload, ctx })` path that EVERY action type and EVERY caller
5
+ // (the `phronesis act` CLI today; daemon/GUI/MCP adapters later) routes through —
6
+ // identical schema validation, permission gate, event emission, and staged atomic
7
+ // write regardless of who calls. No business logic lives outside this module and its
8
+ // writers (action-writers.js); adapters only parse and delegate (D7).
9
+ //
10
+ // The contract order, with the audit and the gate placed deliberately (D5/D6):
11
+ // 1. schema-validate the payload + execution context (incl. domain registered, D10)
12
+ // — an ill-formed call fails closed BEFORE it is a proposal: no event;
13
+ // 2. emit `action_type.requested` — the proposal is now audited;
14
+ // 3. permission gate — a refused approval-required call exits non-zero with NO
15
+ // `committed`, but the `requested` event already recorded the attempt (denials
16
+ // leave a trail);
17
+ // 4. compute the deterministic target, assemble frontmatter + body;
18
+ // 5. stage to temp → validate the staged artifact against the ontology contract →
19
+ // atomic rename (rules.md:167 — no partial write survives any failure);
20
+ // 6. emit `action_type.committed`; return { ok, path, changes_summary }.
21
+ //
22
+ // Hooks (D11, completed by core-0013/D3): the pre_*/post_* step of the spec's contract
23
+ // rides the event pair — pre ≈ `requested` before the write, post ≈ `committed` after
24
+ // it — and since core-0013 the V1 executor fires registry-matched hook commands
25
+ // SYNCHRONOUSLY (hook-executor.js; subscriptions live in the install registry's
26
+ // top-level `hooks` array). requested-subscribed hooks fire PRE-WRITE, only after
27
+ // every pipeline refusal point has passed (permission/exposure/collision/build's
28
+ // second factors) — a refused proposal fires NO hooks, though its requested trail
29
+ // stays in the stream (D6; Codex round on core-0013). A gate-kind hook failing there
30
+ // refuses the write; at `committed` the write is already real, so a gate failure
31
+ // surfaces without rollback (agents do not delete). ambient/mutation failures are
32
+ // audited, never blocking. Every fire — success, failure, skip — lands in
33
+ // .phronesis/audit.log.
34
+ //
35
+ // Approval (D5 — detective at T2.2, preventive in T3.2): `ask-once`/`requires-approval`
36
+ // actions refuse without an approval signal. The signal travels on a channel separate
37
+ // from the action payload — `ctx.approvalSource`, an adapter-set value (the `act` CLI
38
+ // reads env PHRONESIS_APPROVAL_SOURCE) — and is recorded VERBATIM as `approval_source`
39
+ // on `action_type.committed`. It is provenance, not proof: argv and env are both
40
+ // caller-controlled, so this layer cannot prevent an agent from self-approving; the
41
+ // preventive guarantee (that the signal reflects real human assent) is T3.2's
42
+ // porcelain, the sole legitimate setter after a real interaction.
43
+
44
+ import { join, resolve, relative, sep, isAbsolute } from 'node:path';
45
+ import { readFile, readdir } from 'node:fs/promises';
46
+ import yaml from 'js-yaml';
47
+ import fse from 'fs-extra';
48
+ import { ENUMS, LINK_RELS, RE_SLUG, SLUG_SOURCE, DATE_RE, TYPE_DIR, classifyTarget } from './contract.js';
49
+ import {
50
+ buildActionTypesSection,
51
+ resolveActionTypesAgainst,
52
+ layoutPattern,
53
+ } from './action-registry.js';
54
+ import { resolveHooks, executeHooksFor, matchingHooks, readAuditLog } from './hook-executor.js';
55
+ import { checkObjectContent } from './validate-object.js';
56
+ import { appendEvent, readEvents, localDateStamp } from './event-log.js';
57
+ import { digestFile } from './content-digest.js';
58
+ import { resolveInstall } from './install-root.js';
59
+ import { DEFAULT_DOMAIN } from './layout.js';
60
+ import {
61
+ slugify,
62
+ serializeFrontmatter,
63
+ formatLogEntry,
64
+ appendLogBlock,
65
+ splitObjectSource,
66
+ stageValidateRename,
67
+ stageValidateRenameAll,
68
+ } from './action-writers.js';
69
+
70
+ // --- Payload schemas (D2 — hand-rolled, zero-dep) ------------------------------------
71
+ //
72
+ // A JS field-spec object per action satisfies ontology's "JSON schema" for 6 fixed
73
+ // shapes; no ajv (revisit at T2.4 if standard JSON-Schema serialization is wanted).
74
+ // Semantic inputs live HERE, in the payload — execution context (session/domain/actor/
75
+ // surface/dir) and the approval signal are deliberately out-of-payload (D5).
76
+ // Unknown payload fields fail closed: a schema that ignores what it does not know
77
+ // would let a typo'd field silently drop operator intent.
78
+
79
+ function checkLinksField(links, errors) {
80
+ if (!Array.isArray(links)) {
81
+ errors.push('links: must be a list');
82
+ return;
83
+ }
84
+ links.forEach((link, i) => {
85
+ if (link === null || typeof link !== 'object' || Array.isArray(link)) {
86
+ errors.push(`links[${i}]: must be a mapping with rel + target`);
87
+ return;
88
+ }
89
+ const extra = Object.keys(link).filter((k) => !['rel', 'target', 'forward'].includes(k));
90
+ if (extra.length) errors.push(`links[${i}]: unknown key(s): ${extra.join(', ')}`);
91
+ if (!link.rel) errors.push(`links[${i}]: missing rel`);
92
+ else if (!LINK_RELS.includes(link.rel)) {
93
+ errors.push(`links[${i}]: rel "${link.rel}" is not in the link vocabulary`);
94
+ }
95
+ if (link.target === undefined || link.target === null || link.target === '') {
96
+ errors.push(`links[${i}]: missing target`);
97
+ return;
98
+ }
99
+ const cls = classifyTarget(link.target);
100
+ if (cls.form === 'invalid') {
101
+ errors.push(`links[${i}]: target "${link.target}" matches no link-target form`);
102
+ return;
103
+ }
104
+ if (link.forward !== undefined && typeof link.forward !== 'boolean') {
105
+ errors.push(`links[${i}]: forward must be a boolean`);
106
+ }
107
+ if (link.forward === true && !cls.domainObject) {
108
+ errors.push(`links[${i}]: forward:true is only allowed on domain-object targets (not "${link.target}")`);
109
+ }
110
+ });
111
+ }
112
+
113
+ function isCalendarDate(s) {
114
+ const [y, m, d] = s.split('-').map(Number);
115
+ const dt = new Date(Date.UTC(y, m - 1, d));
116
+ return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
117
+ }
118
+
119
+ function checkField(name, spec, value, errors) {
120
+ if (value === undefined) {
121
+ if (spec.required) errors.push(`missing required payload field: ${name}`);
122
+ return;
123
+ }
124
+ // null is PRESENT and invalid, never "absent": agents fill optional JSON fields with
125
+ // null constantly, and a null that slips the schema either crashes the handler after
126
+ // the requested event (breaking the no-event-before-validity ordering) or commits
127
+ // `field: null` junk into canonical state. Fail closed here, where step 1 lives.
128
+ if (value === null) {
129
+ errors.push(`${name}: null is not a valid value — omit the field instead`);
130
+ return;
131
+ }
132
+ switch (spec.kind) {
133
+ case 'string':
134
+ if (typeof value !== 'string') errors.push(`${name}: must be a string`);
135
+ else if (spec.nonEmpty && value.trim() === '') errors.push(`${name}: must be a non-empty string`);
136
+ break;
137
+ case 'slug':
138
+ if (typeof value !== 'string' || !RE_SLUG.test(value)) {
139
+ errors.push(`${name}: "${value}" is not a kebab-case slug`);
140
+ }
141
+ break;
142
+ case 'date':
143
+ // Shape AND calendar validity: DATE_RE alone admits 2026-02-31, and a date an
144
+ // action persists is a date something later schedules on (the daemon's
145
+ // `outcome.due`-style jobs, T4.1). The frontmatter contract stays shape-only
146
+ // (changing it is a spec mutation); the action layer fails closed here.
147
+ if (typeof value !== 'string' || !DATE_RE.test(value) || !isCalendarDate(value)) {
148
+ errors.push(`${name}: "${value}" is not YYYY-MM-DD (a real calendar date)`);
149
+ }
150
+ break;
151
+ case 'enum':
152
+ if (!spec.values.includes(value)) {
153
+ errors.push(`${name}: "${value}" is not one of ${spec.values.join(' | ')}`);
154
+ }
155
+ break;
156
+ case 'links':
157
+ checkLinksField(value, errors);
158
+ break;
159
+ case 'paths':
160
+ // A non-empty list of install-relative source-path strings (compile's
161
+ // source_paths[], core-0017). The full DECLARED source set: trust is the MAX over
162
+ // it (H4), so an empty list has no set to derive trust from — fail closed here. The
163
+ // strings' raw-path validity + per-source trust are re-derived by the handler (it
164
+ // reads each source's ingest provenance), never trusted from the payload.
165
+ if (!Array.isArray(value)) {
166
+ errors.push(`${name}: must be a list of source paths`);
167
+ } else if (value.length === 0) {
168
+ errors.push(`${name}: must list at least one source path (trust is resolved over the declared set)`);
169
+ } else {
170
+ value.forEach((p, i) => {
171
+ if (typeof p !== 'string' || p.trim() === '') {
172
+ errors.push(`${name}[${i}]: must be a non-empty path string`);
173
+ } else if (isAbsolute(p) || p.split(/[\\/]/).includes('..')) {
174
+ // source_paths are PORTABLE install-relative raw paths (Q8). An absolute path
175
+ // (e.g. /Users/…/install/domains/…) would leak a machine-local path into
176
+ // provenance + the candidate event — exported, non-portable state. A `..`
177
+ // segment escapes the install. Reject both pre-event, fail-closed.
178
+ errors.push(
179
+ `${name}[${i}]: "${p}" must be an install-relative path (no leading "/" or ".." segments) — source paths are portable workspace-relative, never machine-absolute`,
180
+ );
181
+ }
182
+ });
183
+ }
184
+ break;
185
+ default:
186
+ errors.push(`${name}: schema kind "${spec.kind}" has no check (fail closed)`);
187
+ }
188
+ }
189
+
190
+ function validatePayload(type, schema, payload) {
191
+ const errors = [];
192
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
193
+ return ['payload must be a JSON object'];
194
+ }
195
+ const allowed = Object.keys(schema);
196
+ for (const k of Object.keys(payload)) {
197
+ if (!allowed.includes(k)) {
198
+ errors.push(`payload field "${k}" is not in the ${type} schema (allowed: ${allowed.join(', ')})`);
199
+ }
200
+ }
201
+ for (const [name, spec] of Object.entries(schema)) {
202
+ checkField(name, spec, payload[name], errors);
203
+ }
204
+ return errors;
205
+ }
206
+
207
+ // Copy payload links into frontmatter shape: rel + target always, forward only when
208
+ // asserted — never propagate extra keys into the artifact.
209
+ function fmLinks(links) {
210
+ return links.map(({ rel, target, forward }) =>
211
+ forward === true ? { rel, target, forward: true } : { rel, target },
212
+ );
213
+ }
214
+
215
+ // Frontmatter provenance.authored_by from the event actor vocabulary: the operator's
216
+ // own invocation is a human-authored artifact; every agentic path is agent. (hybrid is
217
+ // a compile-flow value; seed is vendor-shipped — neither originates here.)
218
+ function authoredByFrom(actor) {
219
+ return actor === 'operator' ? 'human' : 'agent';
220
+ }
221
+
222
+ // Deterministic basenames must stay inside every filesystem's limits: a runaway title
223
+ // slugifies into a >255-byte basename and fails ENAMETOOLONG *after* the requested
224
+ // event. Fail closed in prepare (pre-event) instead — no silent truncation, which
225
+ // would quietly change the deterministic path contract.
226
+ const MAX_NAME_LENGTH = 120;
227
+
228
+ function checkNameLength(name) {
229
+ if (name.length > MAX_NAME_LENGTH) {
230
+ return [`computed name "${name.slice(0, 40)}…" is ${name.length} chars (max ${MAX_NAME_LENGTH}) — supply a shorter slug`];
231
+ }
232
+ return [];
233
+ }
234
+
235
+ // --- The two reference actions (D3/D9/D12) -------------------------------------------
236
+
237
+ const DECISION_CALIBRATION = [
238
+ 'expected_outcome',
239
+ 'success_signal',
240
+ 'observation_source',
241
+ 'review_prompt',
242
+ 'review_date',
243
+ 'confidence',
244
+ ];
245
+
246
+ // `log_decision` — the new-file writer (auto-run). Deterministic path (D3): local day
247
+ // (or payload `created`) + slugified title (or payload `slug`); frontmatter `name` is
248
+ // the FULL basename `YYYY-MM-DD-{slug}` (the validator requires name == filename).
249
+ // New-file: collisions fail closed — never overwrite an existing decision.
250
+ const logDecision = {
251
+ permission: 'auto-run',
252
+ newFileOnly: true,
253
+ targetLayout: 'domains/{domain}/compiled/decisions/YYYY-MM-DD-{slug}.md',
254
+ schema: {
255
+ title: { kind: 'string', required: true, nonEmpty: true },
256
+ body: { kind: 'string' },
257
+ slug: { kind: 'slug' },
258
+ created: { kind: 'date' },
259
+ exposure: { kind: 'enum', values: ENUMS.exposure },
260
+ expected_outcome: { kind: 'string' },
261
+ success_signal: { kind: 'string' },
262
+ observation_source: { kind: 'string' },
263
+ review_prompt: { kind: 'string' },
264
+ review_date: { kind: 'date' },
265
+ confidence: { kind: 'enum', values: ENUMS.confidence },
266
+ links: { kind: 'links' },
267
+ },
268
+ prepare(payload, { domain, now }) {
269
+ const dateStamp = payload.created || localDateStamp(now);
270
+ const slug = payload.slug || slugify(payload.title);
271
+ if (!slug) {
272
+ return { errors: [`title "${payload.title}" yields an empty slug — supply a payload "slug"`] };
273
+ }
274
+ const name = `${dateStamp}-${slug}`;
275
+ const lengthErrors = checkNameLength(name);
276
+ if (lengthErrors.length) return { errors: lengthErrors };
277
+ return {
278
+ errors: [],
279
+ plan: { relPath: `domains/${domain}/compiled/decisions/${name}.md`, name, dateStamp },
280
+ };
281
+ },
282
+ async build({ payload, plan, domain, session, actor }) {
283
+ const fm = {
284
+ type: 'decision',
285
+ name: plan.name,
286
+ title: payload.title,
287
+ domain,
288
+ exposure: payload.exposure || 'private', // D9 — never auto-promote beyond private
289
+ created: plan.dateStamp,
290
+ };
291
+ for (const f of DECISION_CALIBRATION) {
292
+ if (payload[f] !== undefined) fm[f] = payload[f];
293
+ }
294
+ if (payload.links !== undefined) fm.links = fmLinks(payload.links);
295
+ fm.provenance = { origin_session_id: session, authored_by: authoredByFrom(actor) };
296
+ const body = payload.body ? `\n${String(payload.body).trim()}\n` : '';
297
+ return {
298
+ content: `${serializeFrontmatter(fm)}${body}`,
299
+ linksUpdated: payload.links ? payload.links.length : 0,
300
+ changesSummary: `logged decision "${payload.title}" → ${plan.relPath}`,
301
+ };
302
+ },
303
+ };
304
+
305
+ // `enrich_person` — the append-or-create writer. Auto-run when the person exists;
306
+ // ask-once when new (the gate refuses creation without an approval signal).
307
+ // Append = read existing (prior-conformance checked, E3 policy — uniform across every
308
+ // existing-file path since core-0009) → append one D12 dated block →
309
+ // stage→validate→rename; the prior bytes are preserved verbatim (frontmatter
310
+ // untouched — create-only fields are refused on append, not silently dropped).
311
+ const enrichPerson = {
312
+ permission: 'ask-once-if-new',
313
+ newFileOnly: false,
314
+ targetLayout: 'domains/{domain}/compiled/people/{name}.md',
315
+ schema: {
316
+ name: { kind: 'slug', required: true },
317
+ insight: { kind: 'string', required: true, nonEmpty: true },
318
+ title: { kind: 'string', nonEmpty: true },
319
+ date: { kind: 'date' },
320
+ exposure: { kind: 'enum', values: ENUMS.exposure },
321
+ links: { kind: 'links' },
322
+ },
323
+ prepare(payload, { domain, now }) {
324
+ const dateStamp = payload.date || localDateStamp(now);
325
+ const lengthErrors = checkNameLength(payload.name);
326
+ if (lengthErrors.length) return { errors: lengthErrors };
327
+ return {
328
+ errors: [],
329
+ plan: {
330
+ relPath: `domains/${domain}/compiled/people/${payload.name}.md`,
331
+ name: payload.name,
332
+ dateStamp,
333
+ },
334
+ };
335
+ },
336
+ async build({ payload, plan, domain, session, actor, installRoot, exists }) {
337
+ if (exists) {
338
+ const createOnly = ['title', 'exposure', 'links'].filter((f) => payload[f] !== undefined);
339
+ if (createOnly.length) {
340
+ throw new Error(
341
+ `enrich_person: ${createOnly.join(', ')} only apply when creating a new person — "${payload.name}" exists, and append preserves its frontmatter verbatim`,
342
+ );
343
+ }
344
+ const target = await readConformantTarget({
345
+ installRoot,
346
+ relPath: plan.relPath,
347
+ type: 'enrich_person',
348
+ });
349
+ return {
350
+ content: appendLogBlock(target.content, plan.dateStamp, payload.insight),
351
+ linksUpdated: 0,
352
+ changesSummary: `appended a ${plan.dateStamp} insight to person "${payload.name}"`,
353
+ };
354
+ }
355
+ if (!payload.title) {
356
+ throw new Error('enrich_person: creating a new person requires a "title" in the payload');
357
+ }
358
+ const fm = {
359
+ type: 'person',
360
+ name: payload.name,
361
+ title: payload.title,
362
+ domain,
363
+ exposure: payload.exposure || 'private',
364
+ created: plan.dateStamp,
365
+ };
366
+ if (payload.links !== undefined) fm.links = fmLinks(payload.links);
367
+ fm.provenance = { origin_session_id: session, authored_by: authoredByFrom(actor) };
368
+ return {
369
+ content: `${serializeFrontmatter(fm)}\n## Log\n\n${formatLogEntry(plan.dateStamp, payload.insight)}`,
370
+ linksUpdated: payload.links ? payload.links.length : 0,
371
+ changesSummary: `created person "${payload.name}" with a ${plan.dateStamp} insight`,
372
+ };
373
+ },
374
+ };
375
+
376
+ // --- The three single-file writers (core-0009; per-action decisions E1–E3) ------------
377
+
378
+ // Read an EXISTING target and refuse to modify one that fails the contract independent
379
+ // of this edit (E3's prior-conformance policy, applied to every read-modify-write path):
380
+ // the staged whole-file validation would catch it anyway, but as a confusing schema
381
+ // error on an edit that didn't cause it. Returns { content, fm, body } when conformant.
382
+ async function readConformantTarget({ installRoot, relPath, type }) {
383
+ const content = await readFile(join(installRoot, relPath), 'utf8');
384
+ const errors = await checkObjectContent({ installRoot, relPath, content });
385
+ if (errors.length) {
386
+ throw new Error(
387
+ `${type}: target ${relPath} fails the ontology contract independent of this edit — run \`phronesis validate\`/migrate first (${errors.join('; ')})`,
388
+ );
389
+ }
390
+ const split = splitObjectSource(content);
391
+ // Unreachable after a green contract check (no frontmatter is a contract failure),
392
+ // kept as a guard so a future contract loosening cannot turn this into a crash.
393
+ if (!split) throw new Error(`${type}: target ${relPath} has no frontmatter block`);
394
+ return {
395
+ content,
396
+ fm: yaml.load(split.yamlSource, { schema: yaml.JSON_SCHEMA }),
397
+ body: split.body,
398
+ };
399
+ }
400
+
401
+ // `update_project_state` — the project append-or-create writer. UPDATE (the common
402
+ // case) is auto-run: appends one dated D12 block, optionally updating the headline
403
+ // frontmatter fields (`title`, `status`) by VALUE — the body, including the prior log,
404
+ // is carried over verbatim (the D12 writer may trim trailing blank lines inside the
405
+ // Log section before appending; everything else is byte-preserved). CREATE follows the
406
+ // new-object ask-once rule (E1, uniform with enrich_person): refuse without an
407
+ // approval signal. `exposure` and `links` are create-only — append never silently
408
+ // rewrites the permission posture or the link graph. No `updated:` stamp in V1: the
409
+ // dated block carries the date.
410
+ const updateProjectState = {
411
+ permission: 'ask-once-if-new',
412
+ newFileOnly: false,
413
+ targetLayout: 'domains/{domain}/compiled/projects/{name}.md',
414
+ schema: {
415
+ name: { kind: 'slug', required: true },
416
+ update: { kind: 'string', required: true, nonEmpty: true },
417
+ title: { kind: 'string', nonEmpty: true },
418
+ status: { kind: 'enum', values: ENUMS.status },
419
+ date: { kind: 'date' },
420
+ exposure: { kind: 'enum', values: ENUMS.exposure },
421
+ links: { kind: 'links' },
422
+ },
423
+ prepare(payload, { domain, now }) {
424
+ const dateStamp = payload.date || localDateStamp(now);
425
+ const lengthErrors = checkNameLength(payload.name);
426
+ if (lengthErrors.length) return { errors: lengthErrors };
427
+ return {
428
+ errors: [],
429
+ plan: {
430
+ relPath: `domains/${domain}/compiled/projects/${payload.name}.md`,
431
+ name: payload.name,
432
+ dateStamp,
433
+ },
434
+ };
435
+ },
436
+ async build({ payload, plan, domain, session, actor, installRoot, exists }) {
437
+ if (exists) {
438
+ const createOnly = ['exposure', 'links'].filter((f) => payload[f] !== undefined);
439
+ if (createOnly.length) {
440
+ throw new Error(
441
+ `update_project_state: ${createOnly.join(', ')} only apply when creating a new project — "${payload.name}" exists, and updates never rewrite its permission posture or links`,
442
+ );
443
+ }
444
+ const target = await readConformantTarget({
445
+ installRoot,
446
+ relPath: plan.relPath,
447
+ type: 'update_project_state',
448
+ });
449
+ const headline = ['title', 'status'].filter((f) => payload[f] !== undefined);
450
+ let base = target.content;
451
+ if (headline.length) {
452
+ const fm = target.fm;
453
+ for (const f of headline) fm[f] = payload[f];
454
+ base = `${serializeFrontmatter(fm)}${target.body}`;
455
+ }
456
+ const set = headline.length ? ` and set ${headline.map((f) => `${f}: ${payload[f]}`).join(', ')}` : '';
457
+ return {
458
+ content: appendLogBlock(base, plan.dateStamp, payload.update),
459
+ linksUpdated: 0,
460
+ changesSummary: `appended a ${plan.dateStamp} update to project "${payload.name}"${set}`,
461
+ };
462
+ }
463
+ if (!payload.title) {
464
+ throw new Error('update_project_state: creating a new project requires a "title" in the payload');
465
+ }
466
+ const fm = {
467
+ type: 'project',
468
+ name: payload.name,
469
+ title: payload.title,
470
+ domain,
471
+ exposure: payload.exposure || 'private',
472
+ created: plan.dateStamp,
473
+ };
474
+ if (payload.status !== undefined) fm.status = payload.status;
475
+ if (payload.links !== undefined) fm.links = fmLinks(payload.links);
476
+ fm.provenance = { origin_session_id: session, authored_by: authoredByFrom(actor) };
477
+ return {
478
+ content: `${serializeFrontmatter(fm)}\n## Log\n\n${formatLogEntry(plan.dateStamp, payload.update)}`,
479
+ linksUpdated: payload.links ? payload.links.length : 0,
480
+ changesSummary: `created project "${payload.name}" with a ${plan.dateStamp} update`,
481
+ };
482
+ },
483
+ };
484
+
485
+ // `track_commitment` — the commitment new-file writer (auto-run). Mirrors log_decision:
486
+ // deterministic dated path (D3), name == full basename, collision fails closed. `due`
487
+ // is required and date-validated but NOT persisted into any index here (E2/D4): the
488
+ // reminder that fires on its arrival is the daemon's `outcome.due`-style job (T4.1).
489
+ const trackCommitment = {
490
+ permission: 'auto-run',
491
+ newFileOnly: true,
492
+ targetLayout: 'domains/{domain}/compiled/commitments/YYYY-MM-DD-{slug}.md',
493
+ schema: {
494
+ title: { kind: 'string', required: true, nonEmpty: true },
495
+ due: { kind: 'date', required: true },
496
+ body: { kind: 'string' },
497
+ slug: { kind: 'slug' },
498
+ created: { kind: 'date' },
499
+ exposure: { kind: 'enum', values: ENUMS.exposure },
500
+ links: { kind: 'links' },
501
+ },
502
+ prepare(payload, { domain, now }) {
503
+ const dateStamp = payload.created || localDateStamp(now);
504
+ const slug = payload.slug || slugify(payload.title);
505
+ if (!slug) {
506
+ return { errors: [`title "${payload.title}" yields an empty slug — supply a payload "slug"`] };
507
+ }
508
+ const name = `${dateStamp}-${slug}`;
509
+ const lengthErrors = checkNameLength(name);
510
+ if (lengthErrors.length) return { errors: lengthErrors };
511
+ return {
512
+ errors: [],
513
+ plan: { relPath: `domains/${domain}/compiled/commitments/${name}.md`, name, dateStamp },
514
+ };
515
+ },
516
+ async build({ payload, plan, domain, session, actor }) {
517
+ const fm = {
518
+ type: 'commitment',
519
+ name: plan.name,
520
+ title: payload.title,
521
+ domain,
522
+ exposure: payload.exposure || 'private',
523
+ created: plan.dateStamp,
524
+ due: payload.due,
525
+ };
526
+ if (payload.links !== undefined) fm.links = fmLinks(payload.links);
527
+ fm.provenance = { origin_session_id: session, authored_by: authoredByFrom(actor) };
528
+ const body = payload.body ? `\n${String(payload.body).trim()}\n` : '';
529
+ return {
530
+ content: `${serializeFrontmatter(fm)}${body}`,
531
+ linksUpdated: payload.links ? payload.links.length : 0,
532
+ changesSummary: `tracked commitment "${payload.title}" (due ${payload.due}) → ${plan.relPath}`,
533
+ };
534
+ },
535
+ };
536
+
537
+ // `record_outcome` — the read-modify-write of an EXISTING decision (E3; requires
538
+ // operator approval, trust-scopes :24). Two-factor: the permission gate consumes the
539
+ // approval signal (D5), and the build refuses without a non-empty operator-authored
540
+ // `lesson` — BOTH refusals land after `action_type.requested`, so the denial leaves a
541
+ // trail (D6). The target is a semantic input and lives in the payload. Writes exactly
542
+ // three fields — `outcome_status`, `outcome_recorded` (stamped), `lesson` — preserving
543
+ // the body byte-for-byte and every other frontmatter field by VALUE (the reserialize
544
+ // may normalize formatting; the gate asserts value equality, E3).
545
+ const recordOutcome = {
546
+ permission: 'requires-approval',
547
+ approvalFloor: true, // the code-resident floor (G4) — never read from the registry
548
+ newFileOnly: false,
549
+ targetLayout: 'domains/{domain}/compiled/decisions/{name}.md',
550
+ note: 'updates an existing decision in place',
551
+ schema: {
552
+ target: { kind: 'string', required: true, nonEmpty: true },
553
+ outcome_status: { kind: 'enum', values: ENUMS.outcome_status, required: true },
554
+ // Present-ness is the schema's concern; the NON-EMPTY check is the second approval
555
+ // factor and deliberately lives post-event in build (denials must leave a trail).
556
+ lesson: { kind: 'string' },
557
+ },
558
+ prepare(payload, { domain, now }) {
559
+ const prefix = 'decisions/';
560
+ const name = payload.target.startsWith(prefix) ? payload.target.slice(prefix.length) : null;
561
+ if (!name || !RE_SLUG.test(name)) {
562
+ return {
563
+ errors: [
564
+ `target "${payload.target}" must name a decision in the active domain as decisions/{name} (e.g. decisions/2026-01-10-adopt-seat-pricing)`,
565
+ ],
566
+ };
567
+ }
568
+ return {
569
+ errors: [],
570
+ // dateStamp is the outcome_recorded stamp (build gets no clock of its own — D7
571
+ // determinism flows through prepare's injectable `now`).
572
+ plan: { relPath: `domains/${domain}/compiled/decisions/${name}.md`, name, dateStamp: localDateStamp(now) },
573
+ };
574
+ },
575
+ async build({ payload, plan, installRoot, exists }) {
576
+ if (typeof payload.lesson !== 'string' || payload.lesson.trim() === '') {
577
+ throw new Error(
578
+ 'record_outcome: a non-empty operator-authored "lesson" is required — the lesson is the operator\'s authoring act, the second approval factor',
579
+ );
580
+ }
581
+ if (!exists) {
582
+ throw new Error(
583
+ `record_outcome: ${plan.relPath} does not exist — record an outcome only on a logged decision`,
584
+ );
585
+ }
586
+ const target = await readConformantTarget({
587
+ installRoot,
588
+ relPath: plan.relPath,
589
+ type: 'record_outcome',
590
+ });
591
+ const fm = target.fm;
592
+ // Re-recording is a legitimate flow (pending → met; partial → reversed) but it
593
+ // REPLACES prior operator-authored canonical content — say so in the summary
594
+ // rather than letting the overwrite pass as a first recording.
595
+ const prior =
596
+ fm.outcome_status !== undefined || fm.lesson !== undefined
597
+ ? ` (replaced prior outcome "${fm.outcome_status ?? 'unset'}" and its lesson)`
598
+ : '';
599
+ fm.outcome_status = payload.outcome_status;
600
+ fm.outcome_recorded = plan.dateStamp;
601
+ fm.lesson = payload.lesson;
602
+ return {
603
+ content: `${serializeFrontmatter(fm)}${target.body}`,
604
+ linksUpdated: 0,
605
+ changesSummary: `recorded outcome "${payload.outcome_status}" on ${payload.target}${prior}`,
606
+ };
607
+ },
608
+ };
609
+
610
+ // --- promote_to_codex (core-0010; per-action decisions F1–F4) --------------------------
611
+
612
+ const RE_FROM_SEED = new RegExp(`^${SLUG_SOURCE}(?:/${SLUG_SOURCE})?$`);
613
+
614
+ // Resolve a from_seed reference to { collection, slug }. Explicit `{collection}/{slug}`
615
+ // resolves directly; a bare slug is accepted when it matches exactly ONE seed
616
+ // collection (the spec's bare-slug examples) and fails closed on collision (F3).
617
+ async function resolveSeedRef({ installRoot, fromSeed }) {
618
+ if (fromSeed.includes('/')) {
619
+ const [collection, slug] = fromSeed.split('/');
620
+ return { collection, slug };
621
+ }
622
+ const seedRoot = join(installRoot, 'codex', 'seed');
623
+ let collections = [];
624
+ try {
625
+ collections = (await readdir(seedRoot, { withFileTypes: true }))
626
+ .filter((e) => e.isDirectory())
627
+ .map((e) => e.name);
628
+ } catch (err) {
629
+ // Only an ABSENT seed tree means "no collections"; an unreadable one is its own
630
+ // problem and must not masquerade as not-found.
631
+ if (err.code !== 'ENOENT') {
632
+ throw new Error(`promote_to_codex: could not read codex/seed/: ${err.message}`);
633
+ }
634
+ collections = [];
635
+ }
636
+ const matches = [];
637
+ for (const collection of collections) {
638
+ if (await fse.pathExists(join(seedRoot, collection, `${fromSeed}.md`))) {
639
+ matches.push(collection);
640
+ }
641
+ }
642
+ if (matches.length > 1) {
643
+ throw new Error(
644
+ `promote_to_codex: from_seed "${fromSeed}" is ambiguous — it exists in ${matches
645
+ .map((c) => `codex/seed/${c}/`)
646
+ .join(' and ')}; name it as {collection}/{slug}`,
647
+ );
648
+ }
649
+ if (matches.length === 0) {
650
+ throw new Error(
651
+ `promote_to_codex: from_seed "${fromSeed}" does not resolve to any codex/seed/{collection}/${fromSeed}.md`,
652
+ );
653
+ }
654
+ return { collection: matches[0], slug: fromSeed };
655
+ }
656
+
657
+ // F2 convergence is SEMANTIC, not byte-bound: the crash that leaves codex-without-
658
+ // tombstone also destroys the session (and may cross midnight), so the recovery re-run
659
+ // arrives with a different session id / day / rationale wording — its rebuilt bytes can
660
+ // NEVER match. An existing destination counts as this promotion's committed first write
661
+ // when it is a contract-valid codex-principle-personal whose provenance ties it to the
662
+ // SAME source (the supersedes link on the insight path; adopted_from_seed on the seed
663
+ // path). Anything else at the destination fails closed. (Found by the core-0010
664
+ // adversarial review: byte-identity convergence only held for same-session replays.)
665
+ async function isPriorPromotion({ installRoot, relPath, existing, sameSource }) {
666
+ if ((await checkObjectContent({ installRoot, relPath, content: existing })).length) return false;
667
+ const split = splitObjectSource(existing);
668
+ if (!split) return false;
669
+ const fm = yaml.load(split.yamlSource, { schema: yaml.JSON_SCHEMA });
670
+ if (!fm || fm.type !== 'codex-principle-personal') return false;
671
+ return sameSource(fm);
672
+ }
673
+
674
+ // `promote_to_codex` — the ONLY cross-layer write: domain insight (or shipped seed) →
675
+ // installation-shared `codex/personal/`. Requires approval (Scope 1) AND an
676
+ // operator-authored `rationale` (the second factor — no silent graduation); the
677
+ // operator-authored path additionally requires an explicit `stance` (a judgment, no
678
+ // silent default; the seed path defaults to `adopted` — adoption IS adoption, F3).
679
+ //
680
+ // F1 — tombstone, never delete (security.md: agents do not delete): the operator-
681
+ // authored path writes the codex object AND marks the source insight
682
+ // `status: superseded` + a `superseded-by` link; the source file remains. The seed
683
+ // path never touches `codex/seed/` — read, never write.
684
+ //
685
+ // F2 — two-file transaction with all-or-nothing intent: both artifacts are staged and
686
+ // validated before either commits; commits land codex-object-FIRST (the recoverable
687
+ // direction). A death between the writes leaves codex-without-tombstone until a re-run,
688
+ // which detects the identical codex object (no-op) and completes the tombstone — never
689
+ // codex-without-tombstone permanently, never tombstone-without-codex.
690
+ //
691
+ // The codex object does NOT copy the source insight's links: an insight's bare-slug
692
+ // targets resolve in ITS domain, and the codex is unaffiliated — a copied bare slug
693
+ // could never resolve (the validator would refuse it). The promoted object carries one
694
+ // `supersedes` link back to the full-path source instead; seed links (already valid
695
+ // from codex context) copy by value.
696
+ const promoteToCodex = {
697
+ permission: 'requires-approval',
698
+ approvalFloor: true, // the code-resident floor (G4) — never read from the registry
699
+ newFileOnly: false, // collision handled per-write: identical re-run converges (F2)
700
+ targetLayout: 'codex/personal/{slug}.md',
701
+ note: 'plus a source tombstone on the insight path',
702
+ schema: {
703
+ insight: { kind: 'slug' },
704
+ from_seed: { kind: 'string', nonEmpty: true },
705
+ // rationale and stance presence are the post-event second factors (checked in
706
+ // build, so the denial leaves a requested trail); the schema checks types only.
707
+ rationale: { kind: 'string' },
708
+ stance: { kind: 'enum', values: ENUMS.stance },
709
+ slug: { kind: 'slug' },
710
+ },
711
+ prepare(payload, { now }) {
712
+ const hasInsight = payload.insight !== undefined;
713
+ const hasSeed = payload.from_seed !== undefined;
714
+ if (hasInsight === hasSeed) {
715
+ return {
716
+ errors: [
717
+ 'exactly one of "insight" (operator-authored path) or "from_seed" (adoption path) is required',
718
+ ],
719
+ };
720
+ }
721
+ if (hasSeed && !RE_FROM_SEED.test(payload.from_seed)) {
722
+ return {
723
+ errors: [
724
+ `from_seed "${payload.from_seed}" must be {collection}/{slug} (or a bare slug that resolves unambiguously)`,
725
+ ],
726
+ };
727
+ }
728
+ const slug =
729
+ payload.slug || (hasInsight ? payload.insight : payload.from_seed.split('/').pop());
730
+ const lengthErrors = checkNameLength(slug);
731
+ if (lengthErrors.length) return { errors: lengthErrors };
732
+ return {
733
+ errors: [],
734
+ plan: {
735
+ relPath: `codex/personal/${slug}.md`,
736
+ name: slug,
737
+ dateStamp: localDateStamp(now),
738
+ },
739
+ };
740
+ },
741
+ async build({ payload, plan, domain, session, actor, installRoot }) {
742
+ if (typeof payload.rationale !== 'string' || payload.rationale.trim() === '') {
743
+ throw new Error(
744
+ 'promote_to_codex: a non-empty operator-authored "rationale" is required — no silent graduation into the codex',
745
+ );
746
+ }
747
+
748
+ if (payload.insight !== undefined) {
749
+ // --- Operator-authored path: codex object + source tombstone (two writes, F2).
750
+ if (payload.stance === undefined) {
751
+ throw new Error(
752
+ `promote_to_codex: promoting your own insight requires an explicit "stance" (${ENUMS.stance.join(' | ')}) — a judgment, not a default`,
753
+ );
754
+ }
755
+ const srcRel = `domains/${domain}/compiled/insights/${payload.insight}.md`;
756
+ if (!(await fse.pathExists(join(installRoot, srcRel)))) {
757
+ throw new Error(
758
+ `promote_to_codex: ${srcRel} does not exist — promote an insight that is compiled in the active domain`,
759
+ );
760
+ }
761
+ const src = await readConformantTarget({
762
+ installRoot,
763
+ relPath: srcRel,
764
+ type: 'promote_to_codex',
765
+ });
766
+
767
+ // Only an ACTIVE insight — or the exact re-run of THIS promotion — may proceed.
768
+ // Superseded-without-a-link, superseded-by-something-else, and archived all
769
+ // refuse: promoting them would silently rewrite a status the operator (or a
770
+ // prior promotion) already set.
771
+ const codexTarget = `codex/personal/${plan.name}`;
772
+ const supersededBy = (src.fm.links || []).filter((l) => l && l.rel === 'superseded-by');
773
+ const foreign = supersededBy.filter((l) => l.target !== codexTarget);
774
+ const tombstonedByThisPromotion =
775
+ src.fm.status === 'superseded' && supersededBy.length > 0 && foreign.length === 0;
776
+ const active = src.fm.status === undefined || src.fm.status === 'active';
777
+ if (!active && !tombstonedByThisPromotion) {
778
+ throw new Error(
779
+ `promote_to_codex: ${srcRel} has status "${src.fm.status}"${
780
+ foreign.length ? ` and is superseded by "${foreign[0].target}"` : ''
781
+ } — only an active insight (or a re-run of this exact promotion) can be promoted`,
782
+ );
783
+ }
784
+
785
+ const codexFm = {
786
+ type: 'codex-principle-personal',
787
+ name: plan.name,
788
+ title: src.fm.title,
789
+ exposure: 'private', // D9 — codex principles start private
790
+ created: plan.dateStamp,
791
+ stance: payload.stance,
792
+ links: [{ rel: 'supersedes', target: srcRel }],
793
+ provenance: { origin_session_id: session, authored_by: authoredByFrom(actor) },
794
+ };
795
+ const codexContent = appendLogBlock(
796
+ `${serializeFrontmatter(codexFm)}${src.body}`,
797
+ plan.dateStamp,
798
+ `Promoted from ${srcRel}. Rationale: ${payload.rationale.trim()}`,
799
+ );
800
+
801
+ // Semantic convergence (F2): an existing destination that IS a prior promotion
802
+ // of this source counts as the committed first write; anything else fails closed.
803
+ let codexCommitted = false;
804
+ const codexAbs = join(installRoot, plan.relPath);
805
+ if (await fse.pathExists(codexAbs)) {
806
+ const existing = await readFile(codexAbs, 'utf8');
807
+ codexCommitted =
808
+ existing === codexContent ||
809
+ (await isPriorPromotion({
810
+ installRoot,
811
+ relPath: plan.relPath,
812
+ existing,
813
+ sameSource: (fm) =>
814
+ (fm.links || []).some((l) => l && l.rel === 'supersedes' && l.target === srcRel),
815
+ }));
816
+ if (!codexCommitted) {
817
+ throw new Error(
818
+ `promote_to_codex: ${plan.relPath} already exists and is not a prior promotion of ${srcRel} — fail closed (resolve the destination out-of-band)`,
819
+ );
820
+ }
821
+ }
822
+
823
+ const tombFm = { ...src.fm }; // shallow clone — never mutate the parsed source
824
+ tombFm.status = 'superseded';
825
+ const alreadyLinked = supersededBy.some((l) => l.target === codexTarget);
826
+ if (!alreadyLinked) {
827
+ tombFm.links = [...(tombFm.links || []), { rel: 'superseded-by', target: codexTarget }];
828
+ }
829
+ const tombContent = `${serializeFrontmatter(tombFm)}${src.body}`;
830
+
831
+ // Write only what this run actually changes; count only the edges it adds.
832
+ const writes = [];
833
+ let linksUpdated = 0;
834
+ if (!codexCommitted) {
835
+ // Codex object FIRST — the recoverable direction (F2). skipIfIdentical stays
836
+ // as the race-window backstop.
837
+ writes.push({ relPath: plan.relPath, content: codexContent, overwrite: false, skipIfIdentical: true });
838
+ linksUpdated += 1; // the supersedes edge
839
+ }
840
+ if (tombContent !== src.content) {
841
+ writes.push({ relPath: srcRel, content: tombContent, overwrite: true });
842
+ if (!alreadyLinked) linksUpdated += 1; // the superseded-by edge
843
+ }
844
+ const noOp = writes.length === 0;
845
+ const changesSummary = noOp
846
+ ? `insight "${payload.insight}" is already promoted to ${plan.relPath} — no changes (idempotent re-run)`
847
+ : codexCommitted
848
+ ? `completed the promotion of insight "${payload.insight}" → ${plan.relPath} (codex object from a prior run; source tombstoned, not deleted)`
849
+ : `promoted insight "${payload.insight}" → ${plan.relPath} (source tombstoned, not deleted)`;
850
+ return { writes, noOp, linksUpdated, changesSummary };
851
+ }
852
+
853
+ // --- Adoption-from-seed path: ONE write; codex/seed/ is immutable — read, never write.
854
+ const { collection, slug: seedSlug } = await resolveSeedRef({
855
+ installRoot,
856
+ fromSeed: payload.from_seed,
857
+ });
858
+ const seedRel = `codex/seed/${collection}/${seedSlug}.md`;
859
+ if (!(await fse.pathExists(join(installRoot, seedRel)))) {
860
+ throw new Error(`promote_to_codex: ${seedRel} does not exist`);
861
+ }
862
+ const seed = await readConformantTarget({
863
+ installRoot,
864
+ relPath: seedRel,
865
+ type: 'promote_to_codex',
866
+ });
867
+
868
+ const codexFm = {
869
+ type: 'codex-principle-personal',
870
+ name: plan.name,
871
+ title: seed.fm.title,
872
+ exposure: 'private',
873
+ created: plan.dateStamp,
874
+ stance: payload.stance || 'adopted', // F3 — adoption IS adoption; override-able
875
+ };
876
+ if (seed.fm.links !== undefined) codexFm.links = seed.fm.links;
877
+ codexFm.provenance = {
878
+ origin_session_id: session,
879
+ authored_by: authoredByFrom(actor),
880
+ adopted_from_seed: `${collection}/${seedSlug}`,
881
+ };
882
+ const codexContent = appendLogBlock(
883
+ `${serializeFrontmatter(codexFm)}${seed.body}`,
884
+ plan.dateStamp,
885
+ `Adopted from ${seedRel}. Rationale: ${payload.rationale.trim()}`,
886
+ );
887
+
888
+ // Same semantic convergence as the insight path: a destination that is already
889
+ // THIS seed's adoption is a no-op re-run, anything else fails closed. (Adopting
890
+ // the same seed under an explicit DIFFERENT slug is permitted — an operator
891
+ // choice made with approval; surfacing duplicate adoptions is lint's job, T5.3.)
892
+ const codexAbs = join(installRoot, plan.relPath);
893
+ if (await fse.pathExists(codexAbs)) {
894
+ const existing = await readFile(codexAbs, 'utf8');
895
+ const adopted =
896
+ existing === codexContent ||
897
+ (await isPriorPromotion({
898
+ installRoot,
899
+ relPath: plan.relPath,
900
+ existing,
901
+ sameSource: (fm) =>
902
+ fm.provenance && fm.provenance.adopted_from_seed === `${collection}/${seedSlug}`,
903
+ }));
904
+ if (!adopted) {
905
+ throw new Error(
906
+ `promote_to_codex: ${plan.relPath} already exists and is not an adoption of ${seedRel} — fail closed (resolve the destination out-of-band)`,
907
+ );
908
+ }
909
+ return {
910
+ writes: [],
911
+ noOp: true,
912
+ linksUpdated: 0,
913
+ changesSummary: `seed "${collection}/${seedSlug}" is already adopted at ${plan.relPath} — no changes (idempotent re-run)`,
914
+ };
915
+ }
916
+
917
+ return {
918
+ writes: [
919
+ { relPath: plan.relPath, content: codexContent, overwrite: false, skipIfIdentical: true },
920
+ ],
921
+ linksUpdated: seed.fm.links ? seed.fm.links.length : 0,
922
+ changesSummary: `adopted seed "${collection}/${seedSlug}" → ${plan.relPath} (seed untouched)`,
923
+ };
924
+ },
925
+ };
926
+
927
+ // --- compile (core-0017 / T5.1; the flagship agentic quality-gate writer) -------------
928
+ //
929
+ // The 7th action type — the only one whose CONTENT is model-judged (by the skill,
930
+ // skills/compile/SKILL.md) but whose COMMIT stays deterministic (this handler, H1). It
931
+ // writes the two synthesis primitives `insight` + `organization` (H2) and routes by
932
+ // trust: a run over trusted sources auto-writes canonical compiled/ state; a run touching
933
+ // ANY untrusted source drafts the WHOLE run to .phronesis/drafts/ and ends at
934
+ // compile.candidate, awaiting operator accept/reject (H3/H4; specs/security.md
935
+ // untrusted-content isolation). It is append-or-create (H8): a new object is created; an
936
+ // existing one gets a dated ## Log entry + an idempotent evidence-link append (the
937
+ // link-append mode no other writer has — an insight's enriched-by set IS its provenance
938
+ // and grows per source). Agency lives in the SKILL; the handler is the enforcement that
939
+ // degrades away (change 0013): on return, raw/degraded/ captures reconcile through it.
940
+
941
+ // H4 #1 — trust is RE-DERIVED, never payload-claimed. A source is `trusted` only when an
942
+ // in-install, readable raw file carries provenance `trust_tier: trusted`; everything else
943
+ // (missing file, no frontmatter, no/non-`trusted` trust_tier, a non-raw path, a path that
944
+ // escapes the install) is `untrusted` — the security.md "everything else is untrusted"
945
+ // default, fail-closed. An escaping or non-raw path is classified untrusted and never read.
946
+ async function deriveSourceTrust(installRoot, sourcePath) {
947
+ const abs = resolve(installRoot, sourcePath);
948
+ const rel = relative(installRoot, abs);
949
+ if (rel === '' || rel.startsWith('..')) return 'untrusted';
950
+ if (classifyTarget(rel).form !== 'raw-source') return 'untrusted';
951
+ let content;
952
+ try {
953
+ content = await readFile(abs, 'utf8');
954
+ } catch {
955
+ return 'untrusted';
956
+ }
957
+ const split = splitObjectSource(content);
958
+ if (!split) return 'untrusted';
959
+ let fm;
960
+ try {
961
+ fm = yaml.load(split.yamlSource, { schema: yaml.JSON_SCHEMA });
962
+ } catch {
963
+ return 'untrusted';
964
+ }
965
+ return fm && typeof fm === 'object' && !Array.isArray(fm) && fm.trust_tier === 'trusted'
966
+ ? 'trusted'
967
+ : 'untrusted';
968
+ }
969
+
970
+ // Trust over the FULL declared set (H4): the MAX where untrusted dominates — any untrusted
971
+ // source drafts the whole run. (Residual: an agent that UNDER-declares its sources is
972
+ // outside the handler's deterministic reach; the Rule of Two — external action + codex
973
+ // promotion independently human-gated — is the backstop. A deterministic manifest-vs-ingest
974
+ // cross-check is deferred to V1.5, which has the connector audit stream.)
975
+ async function resolveRunTrust(installRoot, sourcePaths) {
976
+ for (const sp of sourcePaths) {
977
+ if ((await deriveSourceTrust(installRoot, sp)) === 'untrusted') return 'untrusted';
978
+ }
979
+ return 'trusted';
980
+ }
981
+
982
+ function compileProvenance({ session, actor, sourcePaths, suspicious }) {
983
+ const prov = { origin_session_id: session, authored_by: authoredByFrom(actor) };
984
+ if (sourcePaths && sourcePaths.length) prov.source_paths = [...sourcePaths];
985
+ // suspicious_payload (Q6 — a reason string: the richer signal for T7.5's batch
986
+ // pull-out; presence is the flag, so it degrades identically to a bool). No contract
987
+ // change needed — the validator checks provenance.authored_by only and tolerates extra
988
+ // subfields (promote_to_codex already stamps provenance.adopted_from_seed).
989
+ if (suspicious !== undefined) prov.suspicious_payload = suspicious;
990
+ return prov;
991
+ }
992
+
993
+ // Idempotent evidence-link append (H8): add each ABSENT payload link to frontmatter `links`
994
+ // — never rewrite or remove an existing edge (rel+target identity). Returns
995
+ // { content, added }. The deliberate divergence from update_project_state/enrich_person
996
+ // (which refuse links on append): an insight's links are provenance that grows per source.
997
+ function appendLinksIfAbsent(target, payloadLinks) {
998
+ if (!payloadLinks || payloadLinks.length === 0) return { content: target.content, added: 0 };
999
+ const existing = Array.isArray(target.fm.links) ? target.fm.links : [];
1000
+ const present = (l) => existing.some((e) => e && e.rel === l.rel && e.target === l.target);
1001
+ const toAdd = fmLinks(payloadLinks).filter((l) => !present(l));
1002
+ if (toAdd.length === 0) return { content: target.content, added: 0 };
1003
+ const fm = { ...target.fm, links: [...existing, ...toAdd] };
1004
+ return { content: `${serializeFrontmatter(fm)}${target.body}`, added: toAdd.length };
1005
+ }
1006
+
1007
+ const compile = {
1008
+ permission: 'auto-run', // the lattice value; the untrusted→draft TRUST gate is
1009
+ // code-resident write-routing (H6), not a permission value — the two compose: the
1010
+ // permission gate admits the call, the trust gate routes the write.
1011
+ newFileOnly: false, // append-or-create (H8)
1012
+ targetLayoutByObjectType: {
1013
+ insight: `domains/{domain}/compiled/${TYPE_DIR.insight}/{name}.md`,
1014
+ organization: `domains/{domain}/compiled/${TYPE_DIR.organization}/{name}.md`,
1015
+ },
1016
+ note: 'agentic quality-gate writer for insight + organization (trusted→canonical, untrusted→draft)',
1017
+ schema: {
1018
+ object_type: { kind: 'enum', values: ['insight', 'organization'], required: true },
1019
+ name: { kind: 'slug', required: true },
1020
+ // title is create-only (H8): required when creating, refused on append — so it is
1021
+ // OPTIONAL in the schema (an update omits it) and required-on-create in build, exactly
1022
+ // like enrich_person's title. Schema-requiring it would reject every legitimate update.
1023
+ title: { kind: 'string', nonEmpty: true },
1024
+ body: { kind: 'string', required: true, nonEmpty: true },
1025
+ source_paths: { kind: 'paths', required: true },
1026
+ links: { kind: 'links' },
1027
+ exposure: { kind: 'enum', values: ENUMS.exposure },
1028
+ suspicious_payload: { kind: 'string', nonEmpty: true },
1029
+ },
1030
+ async prepare(payload, { domain, now, installRoot }) {
1031
+ const typeDir = TYPE_DIR[payload.object_type];
1032
+ // object_type is enum-validated upstream, but only insight/organization have a
1033
+ // compile type-dir; guard fail-closed against a future enum/handler drift.
1034
+ if (payload.object_type !== 'insight' && payload.object_type !== 'organization') {
1035
+ return { errors: [`compile writes insight | organization only, not "${payload.object_type}"`] };
1036
+ }
1037
+ const lengthErrors = checkNameLength(payload.name);
1038
+ if (lengthErrors.length) return { errors: lengthErrors };
1039
+ const trust = await resolveRunTrust(installRoot, payload.source_paths);
1040
+ return {
1041
+ errors: [],
1042
+ plan: {
1043
+ // The CANONICAL path is plan.relPath: the layout check, the exists/permission
1044
+ // gate, and (on the trusted path) the write all key on it. draftRelPath is where
1045
+ // untrusted bytes land — validated AS the canonical shape (H3's path split).
1046
+ relPath: `domains/${domain}/compiled/${typeDir}/${payload.name}.md`,
1047
+ draftRelPath: `.phronesis/drafts/${domain}/${typeDir}/${payload.name}.md`,
1048
+ name: payload.name,
1049
+ objectType: payload.object_type,
1050
+ dateStamp: localDateStamp(now),
1051
+ trust,
1052
+ },
1053
+ };
1054
+ },
1055
+ async build({ payload, plan, domain, session, actor, installRoot, exists }) {
1056
+ const drafting = plan.trust === 'untrusted';
1057
+ // suspicious_payload is an agent-lifted reason string from instruction-shaped UNTRUSTED
1058
+ // content — cap it before it reaches durable state (frontmatter on create, the
1059
+ // compile.candidate event on draft). Operator-facing by design (security.md:46/:52), so
1060
+ // a length cap, not redaction; capping at the source covers both sinks.
1061
+ const suspicious =
1062
+ typeof payload.suspicious_payload === 'string'
1063
+ ? payload.suspicious_payload.slice(0, 500)
1064
+ : payload.suspicious_payload;
1065
+ const draftResult = (content, linksUpdated, verb) => ({
1066
+ content,
1067
+ draft: {
1068
+ writeRelPath: plan.draftRelPath,
1069
+ sourcePaths: [...payload.source_paths],
1070
+ links: payload.links ? fmLinks(payload.links) : [],
1071
+ ...(suspicious !== undefined ? { suspicious } : {}),
1072
+ },
1073
+ linksUpdated,
1074
+ changesSummary: `compile: drafted ${verb} ${plan.objectType} "${payload.name}" (untrusted source — pending operator review) → ${plan.draftRelPath}`,
1075
+ });
1076
+
1077
+ if (exists) {
1078
+ // --- UPDATE (append-or-create existing path, H8) ---
1079
+ const createOnly = ['title', 'exposure'].filter((f) => payload[f] !== undefined);
1080
+ if (createOnly.length) {
1081
+ throw new Error(
1082
+ `compile: ${createOnly.join(', ')} only apply when creating a new ${plan.objectType} — "${payload.name}" exists; an update appends a dated ## Log entry + idempotent evidence links and never rewrites the headline or exposure`,
1083
+ );
1084
+ }
1085
+ const tgt = await readConformantTarget({ installRoot, relPath: plan.relPath, type: 'compile' });
1086
+ const linkRes = appendLinksIfAbsent(tgt, payload.links);
1087
+ let next = linkRes.content;
1088
+ const entry = formatLogEntry(plan.dateStamp, payload.body);
1089
+ // Idempotent ## Log append: a byte-identical entry already present (a same-day
1090
+ // re-run) is NOT re-added — this is what makes a byte-identical re-compile a no-op
1091
+ // while a materially-new payload (a different entry body) appends a dated block.
1092
+ if (!next.includes(entry)) next = appendLogBlock(next, plan.dateStamp, payload.body);
1093
+
1094
+ if (drafting) {
1095
+ // Untrusted update → a draft of the UPDATED object (existing bytes + new entry +
1096
+ // new links), canonical untouched until accept. An identical re-draft converges
1097
+ // by re-staging + re-firing compile.candidate (no canonical change either way).
1098
+ // suspicious_payload rides the compile.candidate event (the durable review-surface
1099
+ // record per security.md:52) — on the update path the existing object's frontmatter
1100
+ // provenance is carried over verbatim (H8), so the flag is review-time here, not
1101
+ // re-stamped into provenance the way the create path does.
1102
+ return draftResult(next, linkRes.added, 'an update to');
1103
+ }
1104
+ if (next === tgt.content) {
1105
+ // H8 — byte-identical re-compile: a full no-op (requested + committed{no_op:true},
1106
+ // no file write), the promote_to_codex convergence pattern.
1107
+ return {
1108
+ writes: [],
1109
+ noOp: true,
1110
+ linksUpdated: 0,
1111
+ changesSummary: `compile: ${plan.objectType} "${payload.name}" is unchanged from this source — no-op (idempotent re-run)`,
1112
+ };
1113
+ }
1114
+ return {
1115
+ content: next,
1116
+ linksUpdated: linkRes.added,
1117
+ changesSummary: `compile: updated ${plan.objectType} "${payload.name}"${
1118
+ linkRes.added ? ` (+${linkRes.added} evidence link${linkRes.added === 1 ? '' : 's'})` : ' (changelog)'
1119
+ } → ${plan.relPath}`,
1120
+ };
1121
+ }
1122
+
1123
+ // --- CREATE (the spokes→hub model: links + exposure set at creation, H7/H8) ---
1124
+ if (!payload.title) {
1125
+ throw new Error(`compile: creating a new ${plan.objectType} requires a "title" in the payload`);
1126
+ }
1127
+ const fm = {
1128
+ type: plan.objectType,
1129
+ name: plan.name,
1130
+ title: payload.title,
1131
+ domain,
1132
+ exposure: payload.exposure || 'private', // H7 — trusted auto-write is private only
1133
+ created: plan.dateStamp,
1134
+ };
1135
+ if (payload.links !== undefined) fm.links = fmLinks(payload.links);
1136
+ fm.provenance = compileProvenance({ session, actor, sourcePaths: payload.source_paths, suspicious });
1137
+ const content = `${serializeFrontmatter(fm)}\n## Log\n\n${formatLogEntry(plan.dateStamp, payload.body)}`;
1138
+ const linksUpdated = payload.links ? payload.links.length : 0;
1139
+ if (drafting) return draftResult(content, linksUpdated, 'a new');
1140
+ return {
1141
+ content,
1142
+ linksUpdated,
1143
+ changesSummary: `compile: created ${plan.objectType} "${payload.name}" → ${plan.relPath}`,
1144
+ };
1145
+ },
1146
+ };
1147
+
1148
+ // --- The code table (core-0011 externalized schema/permission/layout to the install
1149
+ // registry; this table is the trusted baseline it is validated against) --------------
1150
+ //
1151
+ // All 7 V1 action types are REGISTERED (name → permission default + target layout) and
1152
+ // all 7 carry handlers — the core-0008 reference pair, the core-0009 single-file writers,
1153
+ // core-0010's promote_to_codex, and core-0017's compile (the agentic quality-gate writer;
1154
+ // its target is the per-object_type map, H2b). Permission defaults are
1155
+ // specs/trust-scopes.md §Scope 1 (update_project_state's
1156
+ // create-missing branch is ask-once per E1, uniform with enrich_person — the
1157
+ // table's auto-run row is the update-existing common case).
1158
+ //
1159
+ // Since core-0011 (T2.4) the operator-facing registration lives in the install's
1160
+ // `.phronesis/registry.json` `action_types` section: runAction resolves the EFFECTIVE
1161
+ // schema / permission / targetLayout from it per call (action-registry.js holds the
1162
+ // lattice: permission floor via `approvalFloor`, tighten-only schemas, fixed layouts).
1163
+ // This table stays the code-resident floor + handler source the section is generated
1164
+ // from and validated against. `targetLayout` is checked contract: prepare()'s computed
1165
+ // relPath must match it (the run-time layoutPattern check), and a registry layout that
1166
+ // differs from it fails closed at load.
1167
+ //
1168
+ // Concurrency assumption: ONE writer per installation at a time (the V1 reality — one
1169
+ // operator, one session). Two simultaneous appends to the same object are last-write-
1170
+ // wins; serialization is the daemon's job (T4.1) when multi-caller surfaces arrive.
1171
+ export const ACTION_REGISTRY = {
1172
+ log_decision: logDecision,
1173
+ enrich_person: enrichPerson,
1174
+ update_project_state: updateProjectState,
1175
+ track_commitment: trackCommitment,
1176
+ record_outcome: recordOutcome,
1177
+ promote_to_codex: promoteToCodex,
1178
+ compile,
1179
+ };
1180
+
1181
+ export function listActionTypes() {
1182
+ return Object.keys(ACTION_REGISTRY);
1183
+ }
1184
+
1185
+ // The scaffold's action_types section (apps/cli registryJson consumes this) and the
1186
+ // per-install effective-table resolver, both bound to the code table — adapters and
1187
+ // the `registry status` surface never pass the table themselves.
1188
+ export function defaultActionTypesSection() {
1189
+ return buildActionTypesSection(ACTION_REGISTRY);
1190
+ }
1191
+
1192
+ export function resolveActionTypes(registry) {
1193
+ return resolveActionTypesAgainst({ registry, codeTable: ACTION_REGISTRY });
1194
+ }
1195
+
1196
+ // --- Permission gate (D5 — detective; specs/trust-scopes.md Scope 1) ------------------
1197
+
1198
+ export function permissionGate(permission, { exists = false, approvalSource } = {}) {
1199
+ const signal = typeof approvalSource === 'string' && approvalSource.trim() !== '';
1200
+ if (permission === 'auto-run') return { allowed: true, gated: false };
1201
+ if (permission === 'ask-once-if-new') {
1202
+ if (exists) return { allowed: true, gated: false };
1203
+ if (signal) return { allowed: true, gated: true };
1204
+ return {
1205
+ allowed: false,
1206
+ reason:
1207
+ 'creating a NEW file is approval-gated (ask-once) — no approval signal present. An approving adapter sets PHRONESIS_APPROVAL_SOURCE (T3.2 porcelain is the legitimate setter).',
1208
+ };
1209
+ }
1210
+ if (permission === 'requires-approval') {
1211
+ if (signal) return { allowed: true, gated: true };
1212
+ return { allowed: false, reason: 'requires operator approval — no approval signal present.' };
1213
+ }
1214
+ return { allowed: false, reason: `permission "${permission}" has no gate rule (fail closed).` };
1215
+ }
1216
+
1217
+ // --- Execution-context resolution (D10) -----------------------------------------------
1218
+
1219
+ // Resolve the active domain: explicit ctx.domain, else the domains/{slug}/ the cwd sits
1220
+ // in, else DEFAULT_DOMAIN — and the resolved slug MUST be in the install registry. An
1221
+ // unregistered domain refuses; an action must never conjure a phantom domains/{slug}/
1222
+ // tree as a side effect (the event-log --dir fail-closed lesson, applied to domains).
1223
+ // The registry is read ONCE per runAction (it also carries action_types since T2.4)
1224
+ // and passed in parsed. EXPORTED since core-0014: the retrieval surface (search.js)
1225
+ // resolves its default scope through this exact function — one resolver, two consumers
1226
+ // (the rules.md shared-matcher class applied to domain resolution; a re-typed sibling
1227
+ // would drift on the registered-domain refusal).
1228
+ export function resolveDomain({ installRoot, registry, domain, cwd }) {
1229
+ let slug = domain;
1230
+ if (!slug && cwd) {
1231
+ const parts = relative(installRoot, resolve(cwd)).split(sep);
1232
+ if (parts[0] === 'domains' && parts[1]) slug = parts[1];
1233
+ }
1234
+ if (!slug) slug = DEFAULT_DOMAIN;
1235
+
1236
+ const registered = Array.isArray(registry.domains) ? registry.domains : [];
1237
+ if (!registered.includes(slug)) {
1238
+ throw new Error(
1239
+ `domain "${slug}" is not registered in this installation (registered: ${registered.join(', ') || 'none'}). Refusing — actions never create a domain as a side effect.`,
1240
+ );
1241
+ }
1242
+ return slug;
1243
+ }
1244
+
1245
+ // --- runAction — the one shared path ---------------------------------------------------
1246
+
1247
+ // ctx (execution context — everything that is NOT action semantics):
1248
+ // dir explicit install dir (marker-checked; optional)
1249
+ // cwd where the caller runs (install/domain discovery; default process.cwd())
1250
+ // session REQUIRED — session_id on both events
1251
+ // domain explicit domain slug (else cwd-derived, else DEFAULT_DOMAIN; D10)
1252
+ // actor / surface event identity (defaults: agent / cli, like `event emit`)
1253
+ // invocation_id attribution provenance supplied by a skill adapter or controlled
1254
+ // harness; caller-controlled metadata, never authority
1255
+ // approvalSource the adapter-set approval signal (D5); recorded verbatim when gated
1256
+ // now injectable clock for deterministic tests (D7 equivalence)
1257
+ export async function runAction({ type, payload = {}, ctx = {} } = {}) {
1258
+ // 1 — registration + execution context + payload schema: fail closed BEFORE any event.
1259
+ // Own-property check, never a truthy prototype-chain lookup: `act constructor`
1260
+ // must be an unknown type, not Object.prototype.constructor wearing a trench coat.
1261
+ if (!Object.hasOwn(ACTION_REGISTRY, type)) {
1262
+ throw new Error(`unknown action type "${type}" — registered: ${listActionTypes().join(', ')}`);
1263
+ }
1264
+
1265
+ const installed = resolveInstall({ dir: ctx.dir, cwd: ctx.cwd });
1266
+ if (installed.error) throw new Error(installed.error);
1267
+ const installRoot = installed.root;
1268
+
1269
+ const session = ctx.session;
1270
+ if (typeof session !== 'string' || session === '') {
1271
+ throw new Error('execution context: session is required (a non-empty session id).');
1272
+ }
1273
+ const actor = ctx.actor || 'agent';
1274
+ const surface = ctx.surface || 'cli';
1275
+ const invocationId =
1276
+ typeof ctx.invocation_id === 'string' && ctx.invocation_id.trim() !== ''
1277
+ ? ctx.invocation_id
1278
+ : undefined;
1279
+ const now = ctx.now || new Date();
1280
+
1281
+ // invocation_id is execution-context provenance, not action semantics. A model can
1282
+ // place arbitrary JSON in payload, so discard a payload-supplied value before schema
1283
+ // validation, planning, event logging, or writing. The adapter/harness context is the
1284
+ // only source the core ever stamps; it remains provenance, not proof.
1285
+ let actionPayload = payload;
1286
+ if (payload !== null && typeof payload === 'object' && !Array.isArray(payload)) {
1287
+ actionPayload = { ...payload };
1288
+ delete actionPayload.invocation_id;
1289
+ }
1290
+
1291
+ let registry;
1292
+ try {
1293
+ registry = await fse.readJson(join(installRoot, '.phronesis', 'registry.json'));
1294
+ } catch (err) {
1295
+ throw new Error(`could not read the install registry (.phronesis/registry.json): ${err.message}`);
1296
+ }
1297
+
1298
+ // T2.4 — the EFFECTIVE table for this install: registry schema/permission/layout
1299
+ // over the code handlers, validated against the code-resident floor (G3–G6). An
1300
+ // invalid section disables the whole action layer (all-or-nothing, fail closed);
1301
+ // an absent one means the compiled defaults. A type whose entry was removed from
1302
+ // a present section is unregistered here and refuses.
1303
+ const resolved = resolveActionTypes(registry);
1304
+ if (resolved.errors.length) {
1305
+ throw new Error(
1306
+ `invalid .phronesis/registry.json (action-type registry) — the action layer is disabled until it is fixed (run \`phronesis registry status\`; removing the action_types section restores built-in defaults): ${resolved.errors.join('; ')}`,
1307
+ );
1308
+ }
1309
+ // The V1 hook executor's subscriptions load with the same all-or-nothing policy
1310
+ // (core-0013 H1): an invalid hooks section disables the action layer — the pipeline
1311
+ // cannot honor a contract it cannot parse. An ABSENT section means no hooks.
1312
+ const hookRes = resolveHooks(registry);
1313
+ if (hookRes.errors.length) {
1314
+ throw new Error(
1315
+ `invalid .phronesis/registry.json (hooks) — the action layer is disabled until it is fixed (run \`phronesis registry status\`; an absent hooks section means no registered hooks): ${hookRes.errors.join('; ')}`,
1316
+ );
1317
+ }
1318
+
1319
+ const effective = resolved.effective[type];
1320
+ if (!effective) {
1321
+ throw new Error(
1322
+ `"${type}" is not registered in this installation's action_types (.phronesis/registry.json) — registered: ${Object.keys(resolved.effective).join(', ') || 'none'}.`,
1323
+ );
1324
+ }
1325
+ // Deferral/relocation refusals fire AFTER the effective-table lookup, so the table
1326
+ // is authoritative: an install that unregistered `compile` hears "not registered"
1327
+ // — the same answer `registry status` gives — never a stale "registered, but
1328
+ // deferred" from the code table alone.
1329
+ if (effective.deferredTo) {
1330
+ throw new Error(
1331
+ `"${type}" is registered, but its handler is deferred to ${effective.deferredTo} (the agentic quality-gate action) — not runnable yet.`,
1332
+ );
1333
+ }
1334
+ if (effective.implementedIn) {
1335
+ throw new Error(
1336
+ `"${type}" is registered, but its handler lands in ${effective.implementedIn} — not runnable yet (fail closed).`,
1337
+ );
1338
+ }
1339
+
1340
+ const domain = resolveDomain({ installRoot, registry, domain: ctx.domain, cwd: ctx.cwd });
1341
+
1342
+ const schemaErrors = validatePayload(type, effective.schema, actionPayload);
1343
+ if (schemaErrors.length) {
1344
+ throw new Error(`invalid ${type} payload: ${schemaErrors.join('; ')}`);
1345
+ }
1346
+ // prepare may be async (compile re-derives source trust from disk, core-0017 H4);
1347
+ // await passes a plain object straight through, so the six sync handlers are unchanged.
1348
+ // installRoot is in the ctx so a handler that reads provenance to PLAN (compile's trust
1349
+ // routing) can — reading a source's trust is gating, not judging its content (H1/Q8).
1350
+ const prepared = await effective.prepare(actionPayload, { domain, now, installRoot });
1351
+ if (prepared.errors.length) {
1352
+ throw new Error(`invalid ${type} payload: ${prepared.errors.join('; ')}`);
1353
+ }
1354
+ const { plan } = prepared;
1355
+
1356
+ // G6 — the registered layout is checked contract, not decoration: the computed
1357
+ // PRIMARY target must sit inside it, failing closed BEFORE it becomes a proposal.
1358
+ // Scope: this covers plan.relPath only — a multi-write transaction's secondary
1359
+ // writes (promote_to_codex's tombstone) are constrained by the staged ontology
1360
+ // validation, not by this pattern. compile (H2b) checks the canonical relPath against
1361
+ // the layout for ITS object_type — discriminator bound to path, so a mismatch fails
1362
+ // closed (the canonical path is what the trusted write AND the draft validate against).
1363
+ const layoutForType = effective.targetLayoutByObjectType
1364
+ ? effective.targetLayoutByObjectType[plan.objectType]
1365
+ : effective.targetLayout;
1366
+ if (!layoutForType || !layoutPattern(layoutForType, { domain }).test(plan.relPath)) {
1367
+ throw new Error(
1368
+ `${type}: computed target ${plan.relPath} escapes the registered layout ${layoutForType || effective.targetLayout || '(none for object_type)'} — fail closed`,
1369
+ );
1370
+ }
1371
+
1372
+ // 2 — the call is well-formed: it is now a PROPOSAL, audited before the gate (D6) so
1373
+ // a denial still leaves a trail.
1374
+ const requestedEvent = await appendEvent({
1375
+ installRoot,
1376
+ type: 'action_type.requested',
1377
+ session_id: session,
1378
+ actor,
1379
+ surface,
1380
+ payload: { action_type: type, params: actionPayload, ...(invocationId ? { invocation_id: invocationId } : {}) },
1381
+ now,
1382
+ });
1383
+
1384
+ // 3 — permission gate (D5). Refusal: non-zero, no committed, requested already logged.
1385
+ const exists = await fse.pathExists(join(installRoot, plan.relPath));
1386
+ const gate = permissionGate(effective.permission, { exists, approvalSource: ctx.approvalSource });
1387
+ if (!gate.allowed) {
1388
+ throw new Error(`${type} refused: ${gate.reason}`);
1389
+ }
1390
+
1391
+ // 3b — exposure escalation gate. Ontology: exposure is the primary permission posture
1392
+ // and "the agent never auto-promotes beyond private" — so a payload-supplied exposure
1393
+ // above the private default is an opt-UP and consumes the same approval signal the
1394
+ // permission gate does (D9 read fail-closed; refusal is audited like any denial).
1395
+ // EXCEPTION (core-0017 H7): on compile's untrusted DRAFT path the exposure is a
1396
+ // SUGGESTION surfaced at draft review, not a canonical escalation — operator approval
1397
+ // happens at accept (acceptance IS the approval). So only the canonical write paths
1398
+ // consume the gate here; a draft carries its suggested exposure to review.
1399
+ const signal = typeof ctx.approvalSource === 'string' && ctx.approvalSource.trim() !== '';
1400
+ const drafting = plan.trust === 'untrusted'; // compile-only; undefined ⇒ false elsewhere
1401
+ const exposureGated = typeof actionPayload.exposure === 'string' && actionPayload.exposure !== 'private';
1402
+ if (exposureGated && !signal && !drafting) {
1403
+ throw new Error(
1404
+ `${type} refused: exposure "${actionPayload.exposure}" exceeds the private default — opting an object up requires an approval signal (the agent never auto-promotes beyond private).`,
1405
+ );
1406
+ }
1407
+ const recordApproval = gate.gated || (exposureGated && !drafting);
1408
+
1409
+ // 4 — collision policy (D3): new-file actions never overwrite.
1410
+ if (effective.newFileOnly && exists) {
1411
+ throw new Error(
1412
+ `${type} refused: ${plan.relPath} already exists (new-file actions fail closed on collision — never overwrite).`,
1413
+ );
1414
+ }
1415
+
1416
+ // 5 — assemble, then stage → validate-against-the-contract → atomic rename. A handler
1417
+ // returning `writes` is a multi-destination transaction (promote_to_codex's codex
1418
+ // object + source tombstone) and names its own per-write collision semantics; the
1419
+ // single-content shape stays the default for every single-file action.
1420
+ const built = await effective.build({ payload: actionPayload, plan, domain, session, actor, installRoot, exists });
1421
+
1422
+ // 5b — requested-subscribed hooks fire PRE-WRITE, after EVERY pipeline refusal point
1423
+ // (permission, exposure, collision, build's post-event second factors) — core-0013
1424
+ // H6 as amended by the Codex round: a proposal the pipeline itself refuses fires NO
1425
+ // hooks, because a mutation hook executing a command for an unapproved proposal
1426
+ // would be side effects an agent can drive by proposing actions it knows will
1427
+ // refuse. The stream still carries the refused proposal's requested trail (D6); a
1428
+ // gate-kind failure here refuses the write with that same trail. (The V1.5 daemon
1429
+ // subscribes to the stream, where refused proposals are visible — its firing
1430
+ // semantics are recorded as that packet's question, changes/0001 T4.1.)
1431
+ const requestedHooks = await executeHooksFor({
1432
+ installRoot,
1433
+ hooks: hookRes.hooks,
1434
+ event: requestedEvent,
1435
+ });
1436
+ if (requestedHooks.gateFailures.length) {
1437
+ const f = requestedHooks.gateFailures[0];
1438
+ throw new Error(
1439
+ `${type} refused by gate hook "${f.id}" at action_type.requested: ${f.error}${
1440
+ f.stderrTail ? ` — ${f.stderrTail.trim()}` : ''
1441
+ } (every fire is audited in .phronesis/audit.log)`,
1442
+ );
1443
+ }
1444
+
1445
+ // 5c — compile's untrusted TERMINAL (core-0017 H3): a draft, not a commit. The bytes
1446
+ // are written to .phronesis/drafts/ but validated AS their canonical compiled/ shape
1447
+ // (the validateRelPath path split — contextFor rejects a drafts/ path otherwise), and
1448
+ // the run ends at compile.candidate. NO action_type.committed fires — no canonical
1449
+ // state changed — so the committed-subscribed projection hook (H5) does NOT run for a
1450
+ // draft (correct: the projection tracks canonical state). Operator accept/reject
1451
+ // (compileAccept/compileReject) is where the canonical write and its committed land.
1452
+ // The draft overwrites any prior candidate at this path (idempotent convergence: an
1453
+ // identical re-draft re-fires the same candidate, no new canonical state).
1454
+ if (built.draft) {
1455
+ await stageValidateRename({
1456
+ installRoot,
1457
+ relPath: built.draft.writeRelPath,
1458
+ validateRelPath: plan.relPath,
1459
+ content: built.content,
1460
+ overwrite: true,
1461
+ });
1462
+ try {
1463
+ // change 0027 (D2): bind the candidate to the EXACT persisted draft bytes. Re-read the
1464
+ // file just written (not built.content) and digest the raw bytes via the SAME shared
1465
+ // digestFile that `compile review`'s classifyDrafts re-hashes with — so the handler and
1466
+ // the verifier can never disagree about "the bytes". HANDLER-computed, never caller-supplied:
1467
+ // content_digest is not a compile action-input field, so validatePayload rejects an attempt
1468
+ // to forge it (a forgeable digest would defeat the whole check).
1469
+ const content_digest = await digestFile(join(installRoot, built.draft.writeRelPath));
1470
+ await appendEvent({
1471
+ installRoot,
1472
+ type: 'compile.candidate',
1473
+ session_id: session,
1474
+ actor,
1475
+ surface,
1476
+ payload: {
1477
+ source_paths: built.draft.sourcePaths,
1478
+ draft_path: built.draft.writeRelPath,
1479
+ links: built.draft.links,
1480
+ ...(built.draft.suspicious !== undefined
1481
+ ? { suspicious_payload: built.draft.suspicious }
1482
+ : {}),
1483
+ content_digest,
1484
+ },
1485
+ now,
1486
+ });
1487
+ } catch (err) {
1488
+ // Same "WAS written, but the audit failed" guarantee as the committed path: the
1489
+ // draft already landed; say so plainly so a re-run is an idempotent re-draft, not a
1490
+ // blind retry into a confusing state.
1491
+ throw new Error(
1492
+ `${type}: the draft WAS written to ${built.draft.writeRelPath}, but the compile.candidate audit event failed to append: ${err.message}`,
1493
+ );
1494
+ }
1495
+ return { ok: true, path: built.draft.writeRelPath, draft: true, changes_summary: built.changesSummary };
1496
+ }
1497
+
1498
+ if (built.writes) {
1499
+ if (built.writes.length) await stageValidateRenameAll({ installRoot, writes: built.writes });
1500
+ } else {
1501
+ await stageValidateRename({
1502
+ installRoot,
1503
+ relPath: plan.relPath,
1504
+ content: built.content,
1505
+ overwrite: exists && !effective.newFileOnly,
1506
+ });
1507
+ }
1508
+
1509
+ // 6 — the write is real: audit the commit (approval_source only when a gate actually
1510
+ // consumed a signal — D5 provenance, not flag-presence). If THIS append fails, the
1511
+ // artifact already landed: say so plainly instead of implying total failure, so the
1512
+ // operator knows state changed and a new-file retry will (correctly) hit collision.
1513
+ // `wrote` is the transaction's committed write set (a skipIfIdentical race-window
1514
+ // no-op stays in it — it is part of the audited transaction) — the failure message
1515
+ // and the `paths` audit both derive from it (0012 riders): a multi-write transaction
1516
+ // names EVERY file (the tombstone is canonical state too), a single-write
1517
+ // transaction whose one file IS `path` adds no redundant array, and a converging
1518
+ // no-op logs no phantom mutation.
1519
+ const wrote = built.writes ? built.writes.map((w) => w.relPath) : [plan.relPath];
1520
+ let committedEvent;
1521
+ try {
1522
+ committedEvent = await appendEvent({
1523
+ installRoot,
1524
+ type: 'action_type.committed',
1525
+ session_id: session,
1526
+ actor,
1527
+ surface,
1528
+ payload: {
1529
+ action_type: type,
1530
+ path: plan.relPath,
1531
+ ...(wrote.length && !(wrote.length === 1 && wrote[0] === plan.relPath)
1532
+ ? { paths: wrote }
1533
+ : {}),
1534
+ links_updated: built.linksUpdated,
1535
+ ...(built.noOp ? { no_op: true } : {}),
1536
+ ...(invocationId ? { invocation_id: invocationId } : {}),
1537
+ ...(recordApproval ? { approval_source: ctx.approvalSource } : {}),
1538
+ },
1539
+ now,
1540
+ });
1541
+ } catch (err) {
1542
+ throw new Error(
1543
+ wrote.length
1544
+ ? `${type}: the artifact WAS written to ${wrote.join(' and ')}, but the action_type.committed audit event failed to append: ${err.message}`
1545
+ : `${type}: the run was a no-op (nothing written), but the action_type.committed audit event failed to append: ${err.message}`,
1546
+ );
1547
+ }
1548
+
1549
+ // 6b — the committed emit point is the second hook execution point (core-0013 H6).
1550
+ // The write is already real, so EVERY failure past this line — a gate-kind hook, or
1551
+ // the executor's own audit append dying — surfaces WITHOUT rollback (agents do not
1552
+ // delete) in the same "WAS written, but" shape as a failed event append, so the
1553
+ // operator knows state changed and never retries blindly into a collision.
1554
+ let committedHooks;
1555
+ try {
1556
+ committedHooks = await executeHooksFor({
1557
+ installRoot,
1558
+ hooks: hookRes.hooks,
1559
+ event: committedEvent,
1560
+ });
1561
+ } catch (err) {
1562
+ throw new Error(
1563
+ wrote.length
1564
+ ? `${type}: the artifact WAS written to ${wrote.join(' and ')}, but hook execution after action_type.committed failed: ${err.message}`
1565
+ : `${type}: the run was a no-op (nothing written), but hook execution after action_type.committed failed: ${err.message}`,
1566
+ );
1567
+ }
1568
+ if (committedHooks.gateFailures.length) {
1569
+ const f = committedHooks.gateFailures[0];
1570
+ const detail = `${f.error}${f.stderrTail ? ` — ${f.stderrTail.trim()}` : ''}`;
1571
+ throw new Error(
1572
+ wrote.length
1573
+ ? `${type}: the artifact WAS written to ${wrote.join(' and ')}, but gate hook "${f.id}" failed at action_type.committed: ${detail} (audited in .phronesis/audit.log; the write is not rolled back)`
1574
+ : `${type}: the run was a no-op (nothing written), but gate hook "${f.id}" failed at action_type.committed: ${detail} (audited in .phronesis/audit.log)`,
1575
+ );
1576
+ }
1577
+
1578
+ return { ok: true, path: plan.relPath, changes_summary: built.changesSummary };
1579
+ }
1580
+
1581
+ // --- compile accept / reject (core-0017 H9; the operator review primitives) ------------
1582
+ //
1583
+ // A draft (compile.candidate) is transient, non-canonical state in .phronesis/drafts/.
1584
+ // Accept promotes it to compiled/ (a canonical write that rides the same validated atomic
1585
+ // writer + emits its OWN requested/committed pair so the pair stays session_id-coherent —
1586
+ // the original compile's requested fired in the AGENT session, accept's in the operator
1587
+ // REVIEW session). Reject discards it. Batch-approval UX is T7.5; this ships the
1588
+ // primitives + the compile.accepted/rejected events. ("agents do not delete" governs
1589
+ // CANONICAL state; a .phronesis/drafts/ file is a temp, so reject may remove it.)
1590
+
1591
+ // Map a draft path to its canonical accepted path, validating the shape (fail closed on
1592
+ // anything that is not .phronesis/drafts/{domain}/{insights|organizations}/{name}.md).
1593
+ function resolveDraftPaths(installRoot, draftPathArg) {
1594
+ if (typeof draftPathArg !== 'string' || draftPathArg.trim() === '') {
1595
+ return { error: 'a draft path is required' };
1596
+ }
1597
+ const abs = resolve(installRoot, draftPathArg);
1598
+ const rel = relative(installRoot, abs);
1599
+ const parts = rel.split(sep);
1600
+ if (parts[0] !== '.phronesis' || parts[1] !== 'drafts' || parts.length !== 5 || !parts[4].endsWith('.md')) {
1601
+ return { error: `"${draftPathArg}" is not a compile draft path (.phronesis/drafts/{domain}/{type-dir}/{name}.md)` };
1602
+ }
1603
+ const [, , domain, typeDir, file] = parts;
1604
+ if (typeDir !== TYPE_DIR.insight && typeDir !== TYPE_DIR.organization) {
1605
+ return {
1606
+ error: `draft type-dir "${typeDir}" is not a compile object dir (${TYPE_DIR.insight} | ${TYPE_DIR.organization})`,
1607
+ };
1608
+ }
1609
+ return {
1610
+ draftRel: `.phronesis/drafts/${domain}/${typeDir}/${file}`,
1611
+ acceptedRel: `domains/${domain}/compiled/${typeDir}/${file}`,
1612
+ domain,
1613
+ };
1614
+ }
1615
+
1616
+ function reviewCtx({ dir, cwd, session, surface, now }) {
1617
+ const installed = resolveInstall({ dir, cwd });
1618
+ if (installed.error) throw new Error(installed.error);
1619
+ if (typeof session !== 'string' || session === '') {
1620
+ throw new Error('execution context: session is required (the review session id).');
1621
+ }
1622
+ return {
1623
+ installRoot: installed.root,
1624
+ session,
1625
+ // actor is HARD-SET to operator (event-log.md:51 + change 0015 #7: accept/reject are
1626
+ // operator-driven). Never threaded from a flag/env — an agent-driven environment must
1627
+ // not be able to attribute a promotion-to-canonical to itself (the draft gate's point).
1628
+ actor: 'operator',
1629
+ surface: surface || 'cli',
1630
+ now: now || new Date(),
1631
+ };
1632
+ }
1633
+
1634
+ // The frontmatter links of a parsed object (rel+target pairs only) — used at accept to
1635
+ // count the edges a promotion actually ADDS over the canonical original (links_updated).
1636
+ function frontmatterLinks(fm) {
1637
+ return fm && Array.isArray(fm.links) ? fm.links.filter((l) => l && l.rel && l.target) : [];
1638
+ }
1639
+
1640
+ // Promote a draft to canonical compiled/ state. Emits action_type.requested →
1641
+ // (atomic validated move draft→compiled/, draft discarded) → action_type.committed
1642
+ // (approval_source recorded — acceptance IS the approval) → compile.accepted. A failure
1643
+ // AFTER the move surfaces the spine's "WAS moved, but the audit failed" guarantee — no
1644
+ // rollback, so the operator knows canonical state already changed.
1645
+ // The hooks subscribed to action_type.committed whose effect is PROVABLY CONVERGENT —
1646
+ // re-running them recomputes from canonical state and double-acts nothing — so on a resume
1647
+ // with an ambiguous (firing-only) receipt they are safe to replay. V1's only committed
1648
+ // compile hook is the active-context projection refresh (layout.js:79). Every OTHER
1649
+ // committed hook is default-denied on an ambiguous receipt (L8): an operator/skill-added hook
1650
+ // may carry a non-idempotent side effect, so a firing-only receipt fails closed to manual
1651
+ // repair. A new hook earns recovery only by a deliberate addition to this set (a spec change),
1652
+ // never implicitly. (Codex round 9: hard-coded set, not a registry attribute — HOOK_ENTRY_KEYS
1653
+ // rejects unknown keys, so a `recoverable` attribute would need a hooks schema change.)
1654
+ export const CONVERGENT_COMMITTED_HOOKS = new Set(['compile-active-context']);
1655
+
1656
+ // The compile.accepted payload — shared by the fresh accept and the resumable reconcile so
1657
+ // the change-0025 batch-correlation fields (optional batch_id + rationale, absent on a
1658
+ // single-draft accept) are stamped in exactly one place.
1659
+ function compileAcceptedPayload({ draftRel, acceptedRel, batchId, rationale }) {
1660
+ return {
1661
+ draft_path: draftRel,
1662
+ accepted_path: acceptedRel,
1663
+ ...(typeof batchId === 'string' && batchId !== '' ? { batch_id: batchId } : {}),
1664
+ ...(typeof rationale === 'string' && rationale !== '' ? { rationale } : {}),
1665
+ };
1666
+ }
1667
+
1668
+ export async function compileAccept({
1669
+ dir,
1670
+ cwd,
1671
+ draftPath,
1672
+ session,
1673
+ surface,
1674
+ approvalSource,
1675
+ now,
1676
+ batchId,
1677
+ rationale,
1678
+ recordBoundary,
1679
+ } = {}) {
1680
+ const ctx = reviewCtx({ dir, cwd, session, surface, now });
1681
+ const { installRoot } = ctx;
1682
+ const realSignal = typeof approvalSource === 'string' && approvalSource.trim() !== '';
1683
+
1684
+ const paths = resolveDraftPaths(installRoot, draftPath);
1685
+ if (paths.error) throw new Error(`compile accept: ${paths.error}`);
1686
+ const { draftRel, acceptedRel, domain } = paths;
1687
+
1688
+ let registry;
1689
+ try {
1690
+ registry = await fse.readJson(join(installRoot, '.phronesis', 'registry.json'));
1691
+ } catch (err) {
1692
+ throw new Error(`could not read the install registry (.phronesis/registry.json): ${err.message}`);
1693
+ }
1694
+ // Accept is a canonical write, so it honors the SAME action-layer gate runAction does
1695
+ // (adversarial review): a malformed action_types section disables the layer all-or-
1696
+ // nothing, and an unregistered/deferred compile refuses — a draft must not back-door
1697
+ // around the registry the spine enforces. Plus the hooks loader (the projection refresh).
1698
+ const resolved = resolveActionTypes(registry);
1699
+ if (resolved.errors.length) {
1700
+ throw new Error(
1701
+ `invalid .phronesis/registry.json (action-type registry) — the action layer is disabled until it is fixed (run \`phronesis registry status\`): ${resolved.errors.join('; ')}`,
1702
+ );
1703
+ }
1704
+ const eff = resolved.effective.compile;
1705
+ if (!eff) {
1706
+ throw new Error(
1707
+ "compile accept: \"compile\" is not registered in this installation's action_types — cannot promote a draft to a type the install does not run.",
1708
+ );
1709
+ }
1710
+ if (eff.deferredTo || eff.implementedIn) {
1711
+ throw new Error('compile accept: the compile handler is not runnable in this installation (deferred) — cannot promote.');
1712
+ }
1713
+ const hookRes = resolveHooks(registry);
1714
+ if (hookRes.errors.length) {
1715
+ throw new Error(
1716
+ `invalid .phronesis/registry.json (hooks) — the action layer is disabled until it is fixed: ${hookRes.errors.join('; ')}`,
1717
+ );
1718
+ }
1719
+ // Never conjure a domain as a side effect (the resolveDomain posture, applied to the
1720
+ // promotion path) — a draft whose domain segment is unregistered refuses.
1721
+ if (!Array.isArray(registry.domains) || !registry.domains.includes(domain)) {
1722
+ throw new Error(
1723
+ `compile accept: draft domain "${domain}" is not registered in this installation (registered: ${(registry.domains || []).join(', ') || 'none'}) — refusing.`,
1724
+ );
1725
+ }
1726
+
1727
+ const draftAbs = join(installRoot, draftRel);
1728
+ let content;
1729
+ try {
1730
+ content = await readFile(draftAbs, 'utf8');
1731
+ } catch {
1732
+ throw new Error(
1733
+ `compile accept: no draft at ${draftRel} (already accepted or rejected, or never staged).`,
1734
+ );
1735
+ }
1736
+ // Parse the draft frontmatter once: its exposure gates the escalation check, its links
1737
+ // feed the accurate links_updated count.
1738
+ const draftSplit = splitObjectSource(content);
1739
+ let draftFm = null;
1740
+ if (draftSplit) {
1741
+ try {
1742
+ draftFm = yaml.load(draftSplit.yamlSource, { schema: yaml.JSON_SCHEMA });
1743
+ } catch {
1744
+ draftFm = null;
1745
+ }
1746
+ }
1747
+ const draftExposure = draftFm && typeof draftFm === 'object' ? draftFm.exposure : undefined;
1748
+
1749
+ // A fresh requested in the REVIEW session (H9 Q9 — keeps the requested/committed pair
1750
+ // session_id-coherent; the original compile's requested fired in the agent session).
1751
+ const requestedEvent = await appendEvent({
1752
+ installRoot,
1753
+ type: 'action_type.requested',
1754
+ session_id: ctx.session,
1755
+ actor: ctx.actor,
1756
+ surface: ctx.surface,
1757
+ payload: { action_type: 'compile', params: { accept: draftRel } },
1758
+ now: ctx.now,
1759
+ });
1760
+
1761
+ // Exposure escalation at accept (H7, post-requested so the refusal leaves a trail): a
1762
+ // draft carries its suggested exposure unenforced (the draft path bypasses the spine's
1763
+ // escalation gate), so promoting one ABOVE private to canonical requires a real approval
1764
+ // signal here — the bare accept is the approval for a PRIVATE object only, never an
1765
+ // above-private escalation. (Adversarial review: a synthetic approval must not auto-bless
1766
+ // an untrusted draft's suggested context-ok/shareable.)
1767
+ if (typeof draftExposure === 'string' && draftExposure !== 'private' && !realSignal) {
1768
+ throw new Error(
1769
+ `compile accept: this draft sets exposure "${draftExposure}" above private — promoting it requires an approval signal (set PHRONESIS_APPROVAL_SOURCE; the bare accept approves a private object only).`,
1770
+ );
1771
+ }
1772
+ const approval = realSignal ? approvalSource : 'operator:compile-accept';
1773
+
1774
+ // The move: validate the draft bytes AS the canonical path, land atomically (overwrite
1775
+ // when canonical exists — the H8 update case; fresh write for create), then discard the
1776
+ // transient draft. The move completes BEFORE the committed audit (H9 failure semantics).
1777
+ const canonicalExists = await fse.pathExists(join(installRoot, acceptedRel));
1778
+ // links_updated = the edges this promotion ADDS over the canonical original (create:
1779
+ // all of them; update: the new evidence edges only) — accurate audit fidelity (review).
1780
+ let linksAdded = frontmatterLinks(draftFm).length;
1781
+ if (canonicalExists) {
1782
+ try {
1783
+ const origSplit = splitObjectSource(await readFile(join(installRoot, acceptedRel), 'utf8'));
1784
+ const origFm = origSplit ? yaml.load(origSplit.yamlSource, { schema: yaml.JSON_SCHEMA }) : null;
1785
+ const origSet = new Set(frontmatterLinks(origFm).map((l) => JSON.stringify([l.rel, l.target])));
1786
+ linksAdded = frontmatterLinks(draftFm).filter((l) => !origSet.has(JSON.stringify([l.rel, l.target]))).length;
1787
+ } catch {
1788
+ // keep the draft-total fallback if the original is unreadable
1789
+ }
1790
+ }
1791
+ // Requested-subscribed hooks fire PRE-MOVE — after the exposure refusal point, before the
1792
+ // write — exactly as the spine runs them before any canonical write (5b). A gate-kind
1793
+ // failure refuses the promotion (the requested trail stays; the draft is untouched);
1794
+ // mutation/ambient fire is audited, never blocking. Without this, accept — the
1795
+ // untrusted→canonical trust boundary — would skip the very gate hooks a direct compile is
1796
+ // subject to (adversarial review P1: a gate hook on action_type.requested filtered to
1797
+ // compile refused a compile but not its promotion).
1798
+ const requestedHooks = await executeHooksFor({ installRoot, hooks: hookRes.hooks, event: requestedEvent });
1799
+ if (requestedHooks.gateFailures.length) {
1800
+ const f = requestedHooks.gateFailures[0];
1801
+ throw new Error(
1802
+ `compile accept refused by gate hook "${f.id}" at action_type.requested: ${f.error}${
1803
+ f.stderrTail ? ` — ${f.stderrTail.trim()}` : ''
1804
+ } (audited in .phronesis/audit.log; the draft is untouched)`,
1805
+ );
1806
+ }
1807
+ await stageValidateRename({ installRoot, relPath: acceptedRel, content, overwrite: canonicalExists });
1808
+ try {
1809
+ await fse.remove(draftAbs);
1810
+ } catch (err) {
1811
+ throw new Error(
1812
+ `compile accept: the artifact WAS promoted to ${acceptedRel}, but discarding the draft ${draftRel} failed: ${err.message} (re-run accept — the move is idempotent)`,
1813
+ );
1814
+ }
1815
+ // The move is the point of no return. Tell the journal the draft is gone + canonical is
1816
+ // present + the intent needed to finish (the draft can no longer re-supply it) BEFORE the
1817
+ // post-move boundaries, so a crash at any of them is recoverable (L7). recordBoundary is a
1818
+ // best-effort durability hook — core owns the boundary SEQUENCE; the caller persists it.
1819
+ await recordBoundary?.('moved', {
1820
+ accepted_path: acceptedRel,
1821
+ draft_path: draftRel,
1822
+ approval_source: approval,
1823
+ links_added: linksAdded,
1824
+ batch_id: batchId,
1825
+ rationale,
1826
+ });
1827
+
1828
+ let committedEvent;
1829
+ try {
1830
+ committedEvent = await appendEvent({
1831
+ installRoot,
1832
+ type: 'action_type.committed',
1833
+ session_id: ctx.session,
1834
+ actor: ctx.actor,
1835
+ surface: ctx.surface,
1836
+ payload: { action_type: 'compile', path: acceptedRel, links_updated: linksAdded, approval_source: approval },
1837
+ now: ctx.now,
1838
+ });
1839
+ } catch (err) {
1840
+ throw new Error(
1841
+ `compile accept: the artifact WAS moved to ${acceptedRel}, but the action_type.committed audit event failed to append: ${err.message}`,
1842
+ );
1843
+ }
1844
+ // Record the committed event_id the moment it succeeds — the cursor keys on THIS id, never
1845
+ // by path (an update-accept has a prior committed for the same accepted_path; by-path would
1846
+ // read that stale prior as "done" and skip the boundary, round-10 BLOCKER).
1847
+ await recordBoundary?.('committed', { event_id: committedEvent.id });
1848
+
1849
+ // committed-subscribed hooks fire here (the H5 active-context projection refresh rides
1850
+ // this event — accepting a draft changes canonical state, so the projection must
1851
+ // refresh, exactly as a direct trusted compile does).
1852
+ let committedHooks;
1853
+ try {
1854
+ committedHooks = await executeHooksFor({ installRoot, hooks: hookRes.hooks, event: committedEvent });
1855
+ } catch (err) {
1856
+ throw new Error(
1857
+ `compile accept: the artifact WAS moved to ${acceptedRel}, but hook execution after action_type.committed failed: ${err.message}`,
1858
+ );
1859
+ }
1860
+ if (committedHooks.gateFailures.length) {
1861
+ const f = committedHooks.gateFailures[0];
1862
+ throw new Error(
1863
+ `compile accept: the artifact WAS moved to ${acceptedRel}, but gate hook "${f.id}" failed at action_type.committed: ${f.error}${
1864
+ f.stderrTail ? ` — ${f.stderrTail.trim()}` : ''
1865
+ } (audited in .phronesis/audit.log; the write is not rolled back)`,
1866
+ );
1867
+ }
1868
+ await recordBoundary?.('projection', {});
1869
+
1870
+ try {
1871
+ await appendEvent({
1872
+ installRoot,
1873
+ type: 'compile.accepted',
1874
+ session_id: ctx.session,
1875
+ actor: ctx.actor,
1876
+ surface: ctx.surface,
1877
+ payload: compileAcceptedPayload({ draftRel, acceptedRel, batchId, rationale }),
1878
+ now: ctx.now,
1879
+ });
1880
+ } catch (err) {
1881
+ throw new Error(
1882
+ `compile accept: the artifact WAS moved + committed to ${acceptedRel}, but the compile.accepted audit event failed to append: ${err.message}`,
1883
+ );
1884
+ }
1885
+ await recordBoundary?.('accepted', {});
1886
+
1887
+ return {
1888
+ ok: true,
1889
+ draft_path: draftRel,
1890
+ accepted_path: acceptedRel,
1891
+ approval_source: approval,
1892
+ ...(typeof batchId === 'string' && batchId !== '' ? { batch_id: batchId } : {}),
1893
+ ...(typeof rationale === 'string' && rationale !== '' ? { rationale } : {}),
1894
+ };
1895
+ }
1896
+
1897
+ async function findCommittedEvent(installRoot, eventId) {
1898
+ if (typeof eventId !== 'string' || eventId === '') return null;
1899
+ const events = await readEvents({ installRoot, type: 'action_type.committed' });
1900
+ return events.find((e) => e.id === eventId) || null;
1901
+ }
1902
+
1903
+ // L8 — replay the committed-subscribed hooks on a resume, governed by per-hook recoverability.
1904
+ // Per matched committed hook, read its audit receipt keyed on the committed event_id (NOT the
1905
+ // session, NOT the path — the firing/fired/failed line carries event_id, hook-executor.js):
1906
+ // - a settled GATE `failed` receipt → the gate REFUSED (the fresh path throws) → FAIL CLOSED;
1907
+ // - a clean settled receipt (`fired`, or a NON-gate `failed` = ran-but-non-blocking) → SKIP;
1908
+ // - NO receipt → it never ran → RUN it (and enforce its gate result, as the fresh path does);
1909
+ // - a firing-only (ambiguous) receipt → re-run ONLY if the hook is provably convergent
1910
+ // (CONVERGENT_COMMITTED_HOOKS); otherwise FAIL CLOSED → manual repair (a non-idempotent
1911
+ // side effect may or may not have completed; neither blind replay nor blind skip is safe).
1912
+ // The gate enforcement (failed-receipt fail-closed + replay-gateFailures check) mirrors
1913
+ // compileAccept's committed-gate boundary so resume can never become a gate-AVOIDANCE path.
1914
+ async function reconcileCommittedHooks({ installRoot, hooks, committedEvent }) {
1915
+ const matched = matchingHooks(hooks, committedEvent);
1916
+ if (!matched.length) return;
1917
+ const audit = await readAuditLog(installRoot);
1918
+ for (const hook of matched) {
1919
+ const receipts = audit.filter((e) => e.hook === hook.id && e.event_id === committedEvent.id);
1920
+ const failed = receipts.some((e) => e.outcome === 'failed');
1921
+ const fired = receipts.some((e) => e.outcome === 'fired');
1922
+ // A settled FAILED receipt on a GATE hook is a refusal the FRESH path throws on
1923
+ // (compileAccept's committed-gate check) — recovery must NOT approve past it. Treating a
1924
+ // `failed` receipt as merely "settled, skip" turned the recovery path into a gate-AVOIDANCE
1925
+ // path (the highest-risk finding): a committed gate hook that blocked the fresh accept would
1926
+ // be silently skipped on resume and compile.accepted appended anyway. Surface it; do not
1927
+ // mark the boundary done.
1928
+ if (failed && hook.kind === 'gate') {
1929
+ throw new Error(
1930
+ `resume compile accept: committed gate hook "${hook.id}" has a settled FAILED receipt for committed event ` +
1931
+ `${committedEvent.id} — the gate refused this promotion and recovery must not approve past it. Resolve the ` +
1932
+ 'gate failure by hand, then re-run the resume.',
1933
+ );
1934
+ }
1935
+ // A clean settled receipt (a `fired`, or a NON-gate `failed` = ran-but-non-blocking, exactly
1936
+ // as the fresh path treats a non-gate failure) → the hook ran to completion → boundary done
1937
+ // for it, skip (no double-act).
1938
+ if (fired || failed) continue;
1939
+ const firingOnly = receipts.some((e) => e.outcome === 'firing');
1940
+ if (firingOnly && !CONVERGENT_COMMITTED_HOOKS.has(hook.id)) {
1941
+ throw new Error(
1942
+ `resume compile accept: committed hook "${hook.id}" has an ambiguous receipt (a "firing" line with no ` +
1943
+ `"fired"/"failed" for committed event ${committedEvent.id}) and is NOT in the convergent set ` +
1944
+ `{${[...CONVERGENT_COMMITTED_HOOKS].join(', ')}} — it may carry a non-idempotent side effect. FAILING ` +
1945
+ 'CLOSED: resolve it by hand (confirm whether the side effect completed), then re-run the resume.',
1946
+ );
1947
+ }
1948
+ // no receipt (never ran) OR firing-only + convergent (idempotent recompute) → run it. A
1949
+ // convergent re-run goes through executeHooksFor's quota gate, which counts the prior
1950
+ // 'firing' line — so a convergent hook with max_per_session <= 1 would silently
1951
+ // skipped-quota on replay (a stale projection masquerading as recovered). Enforce the
1952
+ // invariant rather than hope: a convergent hook MUST carry quota > 1 (the V1
1953
+ // compile-active-context ships 500).
1954
+ if (firingOnly && (hook.quota?.max_per_session ?? 0) <= 1) {
1955
+ throw new Error(
1956
+ `resume compile accept: convergent hook "${hook.id}" has max_per_session=${hook.quota?.max_per_session} — ` +
1957
+ 'too tight for a recovery replay (the prior firing line consumes the quota). Raise its quota; failing closed.',
1958
+ );
1959
+ }
1960
+ // Run it, and ENFORCE the gate result exactly as the fresh committed-hook boundary does
1961
+ // (compileAccept throws on committedHooks.gateFailures). A committed GATE hook that fails on
1962
+ // replay halts the reconcile: the canonical write is already landed and cannot roll back, but
1963
+ // compile.accepted must NOT be appended past a refusing gate.
1964
+ const { gateFailures } = await executeHooksFor({ installRoot, hooks: [hook], event: committedEvent });
1965
+ if (gateFailures.length) {
1966
+ const f = gateFailures[0];
1967
+ throw new Error(
1968
+ `resume compile accept: committed gate hook "${f.id}" failed on replay at action_type.committed: ${f.error}` +
1969
+ `${f.stderrTail ? ` — ${f.stderrTail.trim()}` : ''} — recovery must not approve past a gate refusal ` +
1970
+ '(the canonical write is not rolled back; resolve the gate failure by hand).',
1971
+ );
1972
+ }
1973
+ }
1974
+ }
1975
+
1976
+ // Resume a compileAccept that crashed AFTER the move, reconciling only the MISSING post-move
1977
+ // boundaries, in order, idempotently (L7). The draft is GONE, so the caller supplies the
1978
+ // `intent` (from the durable journal — accepted_path, draft_path, approval_source, links_added,
1979
+ // batch_id, rationale) plus the per-boundary `statusCursor`. The cursor keys the committed
1980
+ // boundary on the recorded `action_type.committed` event_id, NEVER by accepted_path (an
1981
+ // update-accept has a prior committed for the same path; by-path would skip the boundary and
1982
+ // re-create the malformed spine, round-10 BLOCKER). The PRE-move `action_type.requested` +
1983
+ // requested gate-hooks are NOT replayed (L7.2): they are pre-canonical-change and already
1984
+ // fired; re-driving them would duplicate `requested` and could let a requested gate-hook refuse
1985
+ // recovery of an already-canonical artifact. Core owns the boundary SEQUENCE (the single
1986
+ // canonical-mutation owner); the caller supplies intent + cursor, not order.
1987
+ export async function resumeCompileAccept({ dir, cwd, intent, statusCursor, session, surface, now, recordBoundary } = {}) {
1988
+ const ctx = reviewCtx({ dir, cwd, session, surface, now });
1989
+ const { installRoot } = ctx;
1990
+ if (!intent || typeof intent.accepted_path !== 'string' || intent.accepted_path === '') {
1991
+ throw new Error('resume compile accept: intent.accepted_path is required (the journal-recorded canonical path).');
1992
+ }
1993
+ const acceptedRel = intent.accepted_path;
1994
+ const draftRel = typeof intent.draft_path === 'string' ? intent.draft_path : null;
1995
+ const cursor = statusCursor && typeof statusCursor === 'object' ? { ...statusCursor } : {};
1996
+
1997
+ // Containment: the journal is operator-owned (hand-editable) trust state, so never trust its
1998
+ // accepted_path blindly — a forged path could fire the projection hook + an audit pair against
1999
+ // an arbitrary location. It must be a canonical compiled/ path with no traversal. (Resume only
2000
+ // APPENDS events; it performs no write, so this guards the audit-forgery surface, not a write.)
2001
+ if (!/^domains\/[a-z0-9][a-z0-9-]*\/compiled\/[a-z][a-z-]*\/[^/]+\.md$/.test(acceptedRel) || acceptedRel.split('/').includes('..')) {
2002
+ throw new Error(`resume compile accept: intent.accepted_path "${acceptedRel}" is not a canonical compiled/ path — refusing.`);
2003
+ }
2004
+
2005
+ // Ground truth: a post-move resume requires the canonical artifact to be present. If it is
2006
+ // missing the move never completed — this is NOT a post-move state; the caller must run a
2007
+ // fresh accept instead of a resume.
2008
+ if (!(await fse.pathExists(join(installRoot, acceptedRel)))) {
2009
+ throw new Error(
2010
+ `resume compile accept: canonical artifact ${acceptedRel} is absent — not a post-move state. Run a fresh accept, not a resume.`,
2011
+ );
2012
+ }
2013
+
2014
+ let registry;
2015
+ try {
2016
+ registry = await fse.readJson(join(installRoot, '.phronesis', 'registry.json'));
2017
+ } catch (err) {
2018
+ throw new Error(`could not read the install registry (.phronesis/registry.json): ${err.message}`);
2019
+ }
2020
+ const hookRes = resolveHooks(registry);
2021
+ if (hookRes.errors.length) {
2022
+ throw new Error(`invalid .phronesis/registry.json (hooks) — the action layer is disabled until it is fixed: ${hookRes.errors.join('; ')}`);
2023
+ }
2024
+
2025
+ const approval = typeof intent.approval_source === 'string' && intent.approval_source !== '' ? intent.approval_source : 'operator:compile-accept';
2026
+ const linksAdded = Number.isInteger(intent.links_added) ? intent.links_added : 0;
2027
+
2028
+ // Boundary: action_type.committed. If the cursor RECORDS a committed event_id, that boundary
2029
+ // already happened — verify the event is in the live stream and reuse it. If the cursor
2030
+ // claims it but the event is ABSENT (archived/purged between crash and resume, or a bad
2031
+ // cursor), do NOT blind-append: a fresh committed would duplicate the one in cold storage.
2032
+ // FAIL CLOSED — the same posture L8 takes for an ambiguous receipt. Only when the cursor does
2033
+ // NOT claim committed do we genuinely append (the moved-but-no-committed boundary).
2034
+ let committedEvent = null;
2035
+ if (cursor.committed && cursor.committed.done && cursor.committed.event_id) {
2036
+ committedEvent = await findCommittedEvent(installRoot, cursor.committed.event_id);
2037
+ if (!committedEvent) {
2038
+ throw new Error(
2039
+ `resume compile accept: the journal records committed event ${cursor.committed.event_id} for ${acceptedRel} ` +
2040
+ 'but it is absent from the live event stream (archived/purged?). FAILING CLOSED to avoid a duplicate ' +
2041
+ 'committed — verify the spine by hand (the committed may be in cold storage) before resuming.',
2042
+ );
2043
+ }
2044
+ // Existence is not enough: the recorded event_id must be the action_type.committed for THIS
2045
+ // compile artifact. The journal is operator-owned (hand-editable) trust state — a stale or
2046
+ // forged cursor pointing at ANOTHER compile's committed (or a non-compile committed) would
2047
+ // let resume reuse it and append compile.accepted for this path atop an unrelated committed —
2048
+ // a malformed spine. Verify action_type + path; FAIL CLOSED on a mismatch (same posture as
2049
+ // the absent-event branch and the accepted_path containment check above).
2050
+ if (committedEvent.payload?.action_type !== 'compile' || committedEvent.payload?.path !== acceptedRel) {
2051
+ throw new Error(
2052
+ `resume compile accept: the journal records committed event ${cursor.committed.event_id} for ${acceptedRel}, ` +
2053
+ `but that event is action_type.committed for ` +
2054
+ `${committedEvent.payload?.action_type ?? '(none)'}/${committedEvent.payload?.path ?? '(no path)'} — ` +
2055
+ 'the cursor does not name THIS artifact (a stale or hand-edited journal). FAILING CLOSED.',
2056
+ );
2057
+ }
2058
+ }
2059
+ if (!committedEvent) {
2060
+ committedEvent = await appendEvent({
2061
+ installRoot,
2062
+ type: 'action_type.committed',
2063
+ session_id: ctx.session,
2064
+ actor: ctx.actor,
2065
+ surface: ctx.surface,
2066
+ payload: { action_type: 'compile', path: acceptedRel, links_updated: linksAdded, approval_source: approval },
2067
+ now: ctx.now,
2068
+ });
2069
+ cursor.committed = { done: true, event_id: committedEvent.id };
2070
+ await recordBoundary?.('committed', { event_id: committedEvent.id });
2071
+ }
2072
+
2073
+ // Boundary: committed-subscribed hooks (the projection refresh) — L8-governed.
2074
+ if (!(cursor.projection && cursor.projection.done)) {
2075
+ await reconcileCommittedHooks({ installRoot, hooks: hookRes.hooks, committedEvent });
2076
+ cursor.projection = { done: true };
2077
+ await recordBoundary?.('projection', {});
2078
+ }
2079
+
2080
+ // Boundary: compile.accepted (with the change-0025 batch correlation).
2081
+ if (!(cursor.accepted && cursor.accepted.done)) {
2082
+ await appendEvent({
2083
+ installRoot,
2084
+ type: 'compile.accepted',
2085
+ session_id: ctx.session,
2086
+ actor: ctx.actor,
2087
+ surface: ctx.surface,
2088
+ payload: compileAcceptedPayload({ draftRel, acceptedRel, batchId: intent.batch_id, rationale: intent.rationale }),
2089
+ now: ctx.now,
2090
+ });
2091
+ cursor.accepted = { done: true };
2092
+ await recordBoundary?.('accepted', {});
2093
+ }
2094
+
2095
+ return {
2096
+ ok: true,
2097
+ resumed: true,
2098
+ draft_path: draftRel,
2099
+ accepted_path: acceptedRel,
2100
+ ...(typeof intent.batch_id === 'string' && intent.batch_id !== '' ? { batch_id: intent.batch_id } : {}),
2101
+ ...(typeof intent.rationale === 'string' && intent.rationale !== '' ? { rationale: intent.rationale } : {}),
2102
+ status_cursor: cursor,
2103
+ };
2104
+ }
2105
+
2106
+ // Discard a draft. Emits compile.rejected after the delete; a failure AFTER the delete
2107
+ // surfaces the "WAS discarded, but the audit failed" guarantee (no missing-draft blind
2108
+ // retry). No canonical state changes, so no committed and no hooks.
2109
+ export async function compileReject({ dir, cwd, draftPath, reason, session, surface, now } = {}) {
2110
+ const ctx = reviewCtx({ dir, cwd, session, surface, now });
2111
+ const { installRoot } = ctx;
2112
+
2113
+ const paths = resolveDraftPaths(installRoot, draftPath);
2114
+ if (paths.error) throw new Error(`compile reject: ${paths.error}`);
2115
+ const { draftRel } = paths;
2116
+
2117
+ const draftAbs = join(installRoot, draftRel);
2118
+ if (!(await fse.pathExists(draftAbs))) {
2119
+ throw new Error(`compile reject: no draft at ${draftRel} (already accepted or rejected, or never staged).`);
2120
+ }
2121
+ await fse.remove(draftAbs);
2122
+
2123
+ try {
2124
+ await appendEvent({
2125
+ installRoot,
2126
+ type: 'compile.rejected',
2127
+ session_id: ctx.session,
2128
+ actor: ctx.actor,
2129
+ surface: ctx.surface,
2130
+ payload: { draft_path: draftRel, ...(reason ? { reason } : {}) },
2131
+ now: ctx.now,
2132
+ });
2133
+ } catch (err) {
2134
+ throw new Error(
2135
+ `compile reject: the draft WAS discarded (${draftRel}), but the compile.rejected audit event failed to append: ${err.message}`,
2136
+ );
2137
+ }
2138
+
2139
+ return { ok: true, draft_path: draftRel, rejected: true };
2140
+ }