phronesis 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/package.json +34 -0
- package/src/act.js +61 -0
- package/src/active-context.js +56 -0
- package/src/adapter.js +386 -0
- package/src/cli.js +1893 -0
- package/src/codex-hooks-schema.js +92 -0
- package/src/compile.js +513 -0
- package/src/doctor.js +303 -0
- package/src/due.js +271 -0
- package/src/eval.js +467 -0
- package/src/event.js +212 -0
- package/src/export.js +136 -0
- package/src/guard.js +89 -0
- package/src/hooks.js +230 -0
- package/src/ingest.js +965 -0
- package/src/init.js +150 -0
- package/src/layout.js +394 -0
- package/src/links.js +83 -0
- package/src/lint.js +58 -0
- package/src/profile.js +116 -0
- package/src/prompts.js +26 -0
- package/src/registry.js +190 -0
- package/src/scaffold.js +134 -0
- package/src/search.js +139 -0
- package/src/skill-discovery.js +117 -0
- package/src/skills-bump.js +885 -0
- package/src/skills.js +355 -0
- package/src/subscription.js +5 -0
- package/src/validate.js +152 -0
- package/templates/claude/settings.json +86 -0
- package/templates/codex/INDEX.md +68 -0
- package/templates/codex/seed/ai-practice/compile-dont-just-capture.md +42 -0
- package/templates/codex/seed/ai-practice/design-for-the-distribution.md +42 -0
- package/templates/codex/seed/ai-practice/from-spec-author-to-outcome-curator.md +43 -0
- package/templates/codex/seed/ai-practice/recall-builds-trust-when-calibrated.md +70 -0
- package/templates/codex/seed/ai-practice/the-eval-is-the-spec.md +42 -0
- package/templates/codex/seed/ai-practice/the-gold-dust-leaves-no-trace.md +72 -0
- package/templates/codex/seed/ai-practice/the-model-is-not-the-moat.md +40 -0
- package/templates/codex/seed/ai-practice/when-generation-is-cheap-judgment-is-the-work.md +39 -0
- package/templates/codex/seed/judgment/contextual-stewardship-is-the-senior-skill.md +50 -0
- package/templates/codex/seed/judgment/decision-quality-is-distinct-from-outcome-quality.md +39 -0
- package/templates/codex/seed/judgment/embrace-reality-ego-and-blind-spots.md +38 -0
- package/templates/codex/seed/judgment/groups-seek-consensus-individuals-seek-truth.md +44 -0
- package/templates/codex/seed/judgment/heresies-are-where-the-edge-lives.md +42 -0
- package/templates/codex/seed/judgment/judgment-compounds-through-honest-reflection.md +38 -0
- package/templates/codex/seed/judgment/phronesis-is-judgment-in-the-particular.md +67 -0
- package/templates/codex/seed/judgment/show-me-the-apparatus.md +40 -0
- package/templates/codex/seed/judgment/small-judgment-gaps-compound-under-leverage.md +37 -0
- package/templates/codex/seed/judgment/taste-is-compressed-judgment.md +38 -0
- package/templates/codex/seed/judgment/trust-taste-only-where-feedback-is-valid.md +42 -0
- package/templates/codex/seed/judgment/weigh-the-reasoning-not-the-recommendation.md +43 -0
- package/templates/codex/seed/leadership/a-managers-output-is-the-teams-output.md +35 -0
- package/templates/codex/seed/leadership/most-team-problems-are-structural.md +40 -0
- package/templates/codex/seed/leadership/next-play.md +36 -0
- package/templates/codex/seed/leadership/psychological-safety.md +39 -0
- package/templates/codex/seed/leadership/right-size-your-teams.md +38 -0
- package/templates/codex/seed/leadership/spend-time-on-high-leverage-activities.md +36 -0
- package/templates/codex/seed/leadership/talent-density-is-the-master-variable.md +39 -0
- package/templates/codex/seed/leadership/under-pressure-calm-is-contagious.md +36 -0
- package/templates/codex/seed/leadership/your-best-alternative-is-your-leverage.md +37 -0
- package/templates/codex/seed/pm/a-problem-well-stated-is-half-solved.md +39 -0
- package/templates/codex/seed/pm/a-real-strategy-makes-planning-trivial.md +40 -0
- package/templates/codex/seed/pm/barrels-and-ammunition.md +39 -0
- package/templates/codex/seed/pm/develop-taste-in-your-beliefs.md +38 -0
- package/templates/codex/seed/pm/hire-for-progress.md +44 -0
- package/templates/codex/seed/pm/jobs-are-stable-solutions-churn.md +39 -0
- package/templates/codex/seed/pm/level-by-the-ambiguity-someone-can-own.md +39 -0
- package/templates/codex/seed/pm/listen-before-you-prescribe.md +38 -0
- package/templates/codex/seed/pm/live-in-the-future-and-build-whats-missing.md +38 -0
- package/templates/codex/seed/pm/match-effort-to-leverage.md +37 -0
- package/templates/codex/seed/pm/not-my-fault-but-my-problem.md +40 -0
- package/templates/codex/seed/pm/pick-the-job-then-refuse-everything-outside-it.md +37 -0
- package/templates/codex/seed/pm/retention-sets-the-ceiling.md +38 -0
- package/templates/codex/seed/pm/scale-with-leaders-not-process.md +38 -0
- package/templates/codex/seed/pm/solution-discovery-over-problem-validation.md +38 -0
- package/templates/codex/seed/pm/tenfold-better-on-a-painful-problem.md +40 -0
- package/templates/codex-surface/README.md +73 -0
- package/templates/codex-surface/hooks/_resolve.sh +36 -0
- package/templates/codex-surface/hooks/recall-guard.sh +30 -0
- package/templates/codex-surface/hooks/session-start.sh +18 -0
- package/templates/codex-surface/hooks/session-sweep-precompact.sh +20 -0
- package/templates/codex-surface/hooks/session-sweep-subagent.sh +23 -0
- package/templates/codex-surface/hooks/session-sweep.sh +19 -0
- package/templates/codex-surface/hooks.json +69 -0
- package/templates/domains/pm/AGENTS.md +111 -0
- package/templates/domains/pm/MEMORY.md +7 -0
- package/templates/domains/pm/USER.md +8 -0
- package/templates/phronesis-hooks/compile-active-context.sh +38 -0
- package/templates/phronesis-hooks/recall-guard.sh +103 -0
- package/templates/phronesis-hooks/session-start.sh +90 -0
- package/templates/phronesis-hooks/session-sweep.sh +140 -0
- package/templates/phronesis-hooks/skill-lifecycle.sh +37 -0
- package/templates/skills/compile/SKILL.md +279 -0
- package/templates/skills/compile/evals/rubric.md +75 -0
- package/templates/skills/decision-log/SKILL.md +156 -0
- package/templates/skills/decision-log/evals/rubric.md +97 -0
- package/templates/skills/extract-gold-dust/SKILL.md +311 -0
- package/templates/skills/extract-gold-dust/evals/rubric.md +52 -0
- package/templates/skills/ingest/SKILL.md +204 -0
- package/templates/skills/ingest/evals/rubric.md +117 -0
- package/templates/skills/lint/SKILL.md +246 -0
- package/templates/skills/lint/evals/rubric.md +139 -0
- package/templates/skills/meeting-prep/SKILL.md +195 -0
- package/templates/skills/meeting-prep/evals/rubric.md +93 -0
- package/templates/skills/onboard/SKILL.md +258 -0
- package/templates/skills/onboard/evals/rubric.md +60 -0
- package/templates/skills/prd-draft/SKILL.md +193 -0
- package/templates/skills/prd-draft/evals/rubric.md +38 -0
- package/templates/skills/prep-extraction/SKILL.md +217 -0
- package/templates/skills/prep-extraction/evals/rubric.md +45 -0
- package/templates/skills/recall/SKILL.md +155 -0
- package/templates/skills/recall/evals/rubric.md +37 -0
- package/templates/skills/research/SKILL.md +247 -0
- package/templates/skills/research/evals/rubric.md +139 -0
- package/templates/skills/stakeholder-update/SKILL.md +163 -0
- package/templates/skills/stakeholder-update/evals/rubric.md +111 -0
- package/vendor/core/package.json +25 -0
- package/vendor/core/src/action-registry.js +459 -0
- package/vendor/core/src/action-writers.js +201 -0
- package/vendor/core/src/actions.js +2140 -0
- package/vendor/core/src/active-context.js +167 -0
- package/vendor/core/src/artifacts.js +301 -0
- package/vendor/core/src/content-digest.js +21 -0
- package/vendor/core/src/contract.js +245 -0
- package/vendor/core/src/event-log.js +338 -0
- package/vendor/core/src/guard.js +398 -0
- package/vendor/core/src/hook-executor.js +527 -0
- package/vendor/core/src/index.js +180 -0
- package/vendor/core/src/ingest.js +286 -0
- package/vendor/core/src/install-root.js +42 -0
- package/vendor/core/src/layout.js +13 -0
- package/vendor/core/src/link-index.js +198 -0
- package/vendor/core/src/lint.js +368 -0
- package/vendor/core/src/schedule.js +535 -0
- package/vendor/core/src/search.js +534 -0
- package/vendor/core/src/session-hooks.js +129 -0
- package/vendor/core/src/skill-lifecycle.js +479 -0
- package/vendor/core/src/skill-registry.js +524 -0
- package/vendor/core/src/subscription.js +84 -0
- package/vendor/core/src/validate-object.js +62 -0
- package/vendor/core/src/workspace-scan.js +125 -0
|
@@ -0,0 +1,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
|
+
}
|