phronesis 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +66 -0
  2. package/package.json +34 -0
  3. package/src/act.js +61 -0
  4. package/src/active-context.js +56 -0
  5. package/src/adapter.js +386 -0
  6. package/src/cli.js +1893 -0
  7. package/src/codex-hooks-schema.js +92 -0
  8. package/src/compile.js +513 -0
  9. package/src/doctor.js +303 -0
  10. package/src/due.js +271 -0
  11. package/src/eval.js +467 -0
  12. package/src/event.js +212 -0
  13. package/src/export.js +136 -0
  14. package/src/guard.js +89 -0
  15. package/src/hooks.js +230 -0
  16. package/src/ingest.js +965 -0
  17. package/src/init.js +150 -0
  18. package/src/layout.js +394 -0
  19. package/src/links.js +83 -0
  20. package/src/lint.js +58 -0
  21. package/src/profile.js +116 -0
  22. package/src/prompts.js +26 -0
  23. package/src/registry.js +190 -0
  24. package/src/scaffold.js +134 -0
  25. package/src/search.js +139 -0
  26. package/src/skill-discovery.js +117 -0
  27. package/src/skills-bump.js +885 -0
  28. package/src/skills.js +355 -0
  29. package/src/subscription.js +5 -0
  30. package/src/validate.js +152 -0
  31. package/templates/claude/settings.json +86 -0
  32. package/templates/codex/INDEX.md +68 -0
  33. package/templates/codex/seed/ai-practice/compile-dont-just-capture.md +42 -0
  34. package/templates/codex/seed/ai-practice/design-for-the-distribution.md +42 -0
  35. package/templates/codex/seed/ai-practice/from-spec-author-to-outcome-curator.md +43 -0
  36. package/templates/codex/seed/ai-practice/recall-builds-trust-when-calibrated.md +70 -0
  37. package/templates/codex/seed/ai-practice/the-eval-is-the-spec.md +42 -0
  38. package/templates/codex/seed/ai-practice/the-gold-dust-leaves-no-trace.md +72 -0
  39. package/templates/codex/seed/ai-practice/the-model-is-not-the-moat.md +40 -0
  40. package/templates/codex/seed/ai-practice/when-generation-is-cheap-judgment-is-the-work.md +39 -0
  41. package/templates/codex/seed/judgment/contextual-stewardship-is-the-senior-skill.md +50 -0
  42. package/templates/codex/seed/judgment/decision-quality-is-distinct-from-outcome-quality.md +39 -0
  43. package/templates/codex/seed/judgment/embrace-reality-ego-and-blind-spots.md +38 -0
  44. package/templates/codex/seed/judgment/groups-seek-consensus-individuals-seek-truth.md +44 -0
  45. package/templates/codex/seed/judgment/heresies-are-where-the-edge-lives.md +42 -0
  46. package/templates/codex/seed/judgment/judgment-compounds-through-honest-reflection.md +38 -0
  47. package/templates/codex/seed/judgment/phronesis-is-judgment-in-the-particular.md +67 -0
  48. package/templates/codex/seed/judgment/show-me-the-apparatus.md +40 -0
  49. package/templates/codex/seed/judgment/small-judgment-gaps-compound-under-leverage.md +37 -0
  50. package/templates/codex/seed/judgment/taste-is-compressed-judgment.md +38 -0
  51. package/templates/codex/seed/judgment/trust-taste-only-where-feedback-is-valid.md +42 -0
  52. package/templates/codex/seed/judgment/weigh-the-reasoning-not-the-recommendation.md +43 -0
  53. package/templates/codex/seed/leadership/a-managers-output-is-the-teams-output.md +35 -0
  54. package/templates/codex/seed/leadership/most-team-problems-are-structural.md +40 -0
  55. package/templates/codex/seed/leadership/next-play.md +36 -0
  56. package/templates/codex/seed/leadership/psychological-safety.md +39 -0
  57. package/templates/codex/seed/leadership/right-size-your-teams.md +38 -0
  58. package/templates/codex/seed/leadership/spend-time-on-high-leverage-activities.md +36 -0
  59. package/templates/codex/seed/leadership/talent-density-is-the-master-variable.md +39 -0
  60. package/templates/codex/seed/leadership/under-pressure-calm-is-contagious.md +36 -0
  61. package/templates/codex/seed/leadership/your-best-alternative-is-your-leverage.md +37 -0
  62. package/templates/codex/seed/pm/a-problem-well-stated-is-half-solved.md +39 -0
  63. package/templates/codex/seed/pm/a-real-strategy-makes-planning-trivial.md +40 -0
  64. package/templates/codex/seed/pm/barrels-and-ammunition.md +39 -0
  65. package/templates/codex/seed/pm/develop-taste-in-your-beliefs.md +38 -0
  66. package/templates/codex/seed/pm/hire-for-progress.md +44 -0
  67. package/templates/codex/seed/pm/jobs-are-stable-solutions-churn.md +39 -0
  68. package/templates/codex/seed/pm/level-by-the-ambiguity-someone-can-own.md +39 -0
  69. package/templates/codex/seed/pm/listen-before-you-prescribe.md +38 -0
  70. package/templates/codex/seed/pm/live-in-the-future-and-build-whats-missing.md +38 -0
  71. package/templates/codex/seed/pm/match-effort-to-leverage.md +37 -0
  72. package/templates/codex/seed/pm/not-my-fault-but-my-problem.md +40 -0
  73. package/templates/codex/seed/pm/pick-the-job-then-refuse-everything-outside-it.md +37 -0
  74. package/templates/codex/seed/pm/retention-sets-the-ceiling.md +38 -0
  75. package/templates/codex/seed/pm/scale-with-leaders-not-process.md +38 -0
  76. package/templates/codex/seed/pm/solution-discovery-over-problem-validation.md +38 -0
  77. package/templates/codex/seed/pm/tenfold-better-on-a-painful-problem.md +40 -0
  78. package/templates/codex-surface/README.md +73 -0
  79. package/templates/codex-surface/hooks/_resolve.sh +36 -0
  80. package/templates/codex-surface/hooks/recall-guard.sh +30 -0
  81. package/templates/codex-surface/hooks/session-start.sh +18 -0
  82. package/templates/codex-surface/hooks/session-sweep-precompact.sh +20 -0
  83. package/templates/codex-surface/hooks/session-sweep-subagent.sh +23 -0
  84. package/templates/codex-surface/hooks/session-sweep.sh +19 -0
  85. package/templates/codex-surface/hooks.json +69 -0
  86. package/templates/domains/pm/AGENTS.md +111 -0
  87. package/templates/domains/pm/MEMORY.md +7 -0
  88. package/templates/domains/pm/USER.md +8 -0
  89. package/templates/phronesis-hooks/compile-active-context.sh +38 -0
  90. package/templates/phronesis-hooks/recall-guard.sh +103 -0
  91. package/templates/phronesis-hooks/session-start.sh +90 -0
  92. package/templates/phronesis-hooks/session-sweep.sh +140 -0
  93. package/templates/phronesis-hooks/skill-lifecycle.sh +37 -0
  94. package/templates/skills/compile/SKILL.md +279 -0
  95. package/templates/skills/compile/evals/rubric.md +75 -0
  96. package/templates/skills/decision-log/SKILL.md +156 -0
  97. package/templates/skills/decision-log/evals/rubric.md +97 -0
  98. package/templates/skills/extract-gold-dust/SKILL.md +311 -0
  99. package/templates/skills/extract-gold-dust/evals/rubric.md +52 -0
  100. package/templates/skills/ingest/SKILL.md +204 -0
  101. package/templates/skills/ingest/evals/rubric.md +117 -0
  102. package/templates/skills/lint/SKILL.md +246 -0
  103. package/templates/skills/lint/evals/rubric.md +139 -0
  104. package/templates/skills/meeting-prep/SKILL.md +195 -0
  105. package/templates/skills/meeting-prep/evals/rubric.md +93 -0
  106. package/templates/skills/onboard/SKILL.md +258 -0
  107. package/templates/skills/onboard/evals/rubric.md +60 -0
  108. package/templates/skills/prd-draft/SKILL.md +193 -0
  109. package/templates/skills/prd-draft/evals/rubric.md +38 -0
  110. package/templates/skills/prep-extraction/SKILL.md +217 -0
  111. package/templates/skills/prep-extraction/evals/rubric.md +45 -0
  112. package/templates/skills/recall/SKILL.md +155 -0
  113. package/templates/skills/recall/evals/rubric.md +37 -0
  114. package/templates/skills/research/SKILL.md +247 -0
  115. package/templates/skills/research/evals/rubric.md +139 -0
  116. package/templates/skills/stakeholder-update/SKILL.md +163 -0
  117. package/templates/skills/stakeholder-update/evals/rubric.md +111 -0
  118. package/vendor/core/package.json +25 -0
  119. package/vendor/core/src/action-registry.js +459 -0
  120. package/vendor/core/src/action-writers.js +201 -0
  121. package/vendor/core/src/actions.js +2140 -0
  122. package/vendor/core/src/active-context.js +167 -0
  123. package/vendor/core/src/artifacts.js +301 -0
  124. package/vendor/core/src/content-digest.js +21 -0
  125. package/vendor/core/src/contract.js +245 -0
  126. package/vendor/core/src/event-log.js +338 -0
  127. package/vendor/core/src/guard.js +398 -0
  128. package/vendor/core/src/hook-executor.js +527 -0
  129. package/vendor/core/src/index.js +180 -0
  130. package/vendor/core/src/ingest.js +286 -0
  131. package/vendor/core/src/install-root.js +42 -0
  132. package/vendor/core/src/layout.js +13 -0
  133. package/vendor/core/src/link-index.js +198 -0
  134. package/vendor/core/src/lint.js +368 -0
  135. package/vendor/core/src/schedule.js +535 -0
  136. package/vendor/core/src/search.js +534 -0
  137. package/vendor/core/src/session-hooks.js +129 -0
  138. package/vendor/core/src/skill-lifecycle.js +479 -0
  139. package/vendor/core/src/skill-registry.js +524 -0
  140. package/vendor/core/src/subscription.js +84 -0
  141. package/vendor/core/src/validate-object.js +62 -0
  142. package/vendor/core/src/workspace-scan.js +125 -0
@@ -0,0 +1,92 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const ROOT_FIELDS = new Set(['hooks']);
4
+ const EVENT_ENTRY_FIELDS = new Set(['matcher', 'hooks']);
5
+ const HANDLER_FIELDS = new Set(['type', 'command', 'commandWindows', 'timeout', 'statusMessage', 'async']);
6
+
7
+ function isRecord(value) {
8
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
9
+ }
10
+
11
+ function unknownFields(value, allowed) {
12
+ return Object.keys(value).filter((key) => !allowed.has(key));
13
+ }
14
+
15
+ function pushUnknown(errors, path, keys, expected) {
16
+ if (keys.length) {
17
+ errors.push(`${path} has unknown field(s): ${keys.join(', ')}; expected only ${expected}`);
18
+ }
19
+ }
20
+
21
+ export function validateCodexHooksSchema(config, { source = 'hooks.json' } = {}) {
22
+ const errors = [];
23
+
24
+ if (!isRecord(config)) {
25
+ return {
26
+ ok: false,
27
+ errors: [`${source} root must be a JSON object with a single "hooks" field`],
28
+ };
29
+ }
30
+
31
+ pushUnknown(errors, `${source} root`, unknownFields(config, ROOT_FIELDS), '"hooks"');
32
+ if (!Object.hasOwn(config, 'hooks')) {
33
+ errors.push(`${source} root is missing required field "hooks"`);
34
+ return { ok: false, errors };
35
+ }
36
+ if (!isRecord(config.hooks)) {
37
+ errors.push(`${source}.hooks must be an object mapping event names to hook entries`);
38
+ return { ok: false, errors };
39
+ }
40
+
41
+ for (const [eventName, entries] of Object.entries(config.hooks)) {
42
+ const eventPath = `${source}.hooks.${eventName}`;
43
+ if (!Array.isArray(entries)) {
44
+ errors.push(`${eventPath} must be an array of event entries`);
45
+ continue;
46
+ }
47
+
48
+ entries.forEach((entry, entryIndex) => {
49
+ const entryPath = `${eventPath}[${entryIndex}]`;
50
+ if (!isRecord(entry)) {
51
+ errors.push(`${entryPath} must be an object`);
52
+ return;
53
+ }
54
+
55
+ pushUnknown(errors, entryPath, unknownFields(entry, EVENT_ENTRY_FIELDS), '"matcher", "hooks"');
56
+ if (!Object.hasOwn(entry, 'hooks')) {
57
+ errors.push(`${entryPath} is missing required field "hooks"`);
58
+ return;
59
+ }
60
+ if (!Array.isArray(entry.hooks)) {
61
+ errors.push(`${entryPath}.hooks must be an array of command handlers`);
62
+ return;
63
+ }
64
+
65
+ entry.hooks.forEach((handler, handlerIndex) => {
66
+ const handlerPath = `${entryPath}.hooks[${handlerIndex}]`;
67
+ if (!isRecord(handler)) {
68
+ errors.push(`${handlerPath} must be an object`);
69
+ return;
70
+ }
71
+ pushUnknown(
72
+ errors,
73
+ handlerPath,
74
+ unknownFields(handler, HANDLER_FIELDS),
75
+ '"type", "command", "commandWindows", "timeout", "statusMessage", "async"',
76
+ );
77
+ });
78
+ });
79
+ }
80
+
81
+ return { ok: errors.length === 0, errors };
82
+ }
83
+
84
+ export function validateCodexHooksFile(filePath, { source = filePath } = {}) {
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(readFileSync(filePath, 'utf8'));
88
+ } catch (err) {
89
+ return { ok: false, errors: [`${source} is not valid JSON: ${err.message}`] };
90
+ }
91
+ return validateCodexHooksSchema(parsed, { source });
92
+ }
package/src/compile.js ADDED
@@ -0,0 +1,513 @@
1
+ // `phronesis compile accept|reject <draft>` (core-0017 H9) — the operator review
2
+ // primitives over a compile candidate (.phronesis/drafts/…). Plumbing, not porcelain: the
3
+ // batch-approval UX (`phronesis compile review`, security.md) is T7.5; T5.1 ships the
4
+ // per-draft primitives + their compile.accepted/rejected events. Every check, move, event,
5
+ // and the "WAS moved, but the audit failed" failure shape live behind compileAccept /
6
+ // compileReject in @phronesis/core, so the CLI and any direct caller are the same action.
7
+ //
8
+ // accept's approval signal is the env PHRONESIS_APPROVAL_SOURCE (the same channel as
9
+ // `phronesis act`), recorded verbatim as approval_source on action_type.committed —
10
+ // acceptance IS the operator approval. Core messages are self-describing, so the CLI
11
+ // prints them as-is rather than double-prefixing.
12
+
13
+ import { join } from 'node:path';
14
+ import { existsSync } from 'node:fs';
15
+ import { readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
16
+ import fse from 'fs-extra';
17
+ import {
18
+ DEFAULT_DOMAIN,
19
+ compileAccept,
20
+ compileReject,
21
+ digestFile,
22
+ readEvents,
23
+ resolveInstall,
24
+ resumeCompileAccept,
25
+ } from '@phronesis/core';
26
+
27
+ export async function runCompileAccept({ draftPath, session, surface, dir } = {}) {
28
+ if (!draftPath) {
29
+ console.error('compile accept: name the <draft_path> to promote (a .phronesis/drafts/… candidate).');
30
+ process.exitCode = 1;
31
+ return null;
32
+ }
33
+ try {
34
+ // actor is NOT threaded — accept events are operator-driven by contract (the core
35
+ // hard-sets it). The approval signal still travels on PHRONESIS_APPROVAL_SOURCE.
36
+ const result = await compileAccept({
37
+ dir,
38
+ cwd: process.cwd(),
39
+ draftPath,
40
+ session,
41
+ surface,
42
+ approvalSource: process.env.PHRONESIS_APPROVAL_SOURCE || undefined,
43
+ });
44
+ console.log(JSON.stringify(result));
45
+ process.exitCode = 0;
46
+ return result;
47
+ } catch (err) {
48
+ console.error(err.message);
49
+ process.exitCode = 1;
50
+ return null;
51
+ }
52
+ }
53
+
54
+ // --- `phronesis compile review` (core-0029 PR2 / T7.5) -------------------------------
55
+ // The batch-approval UX over the EXISTING per-draft accept/reject moves (L3): no new event
56
+ // type, no second canonical-mutation path. Approve-all stamps one shared batch_id + rationale
57
+ // (change 0025) so the batch is one logical approval queryable by batch_id (L5).
58
+ // Suspicious-payload-flagged drafts are FAIL-CLOSED excluded from approve-all (L4): a draft is
59
+ // batch-eligible only if its compile.candidate event is provably-clean AND unique. A durable
60
+ // batch journal (written before iterating, updated as each post-move boundary completes)
61
+ // makes a mid-batch crash recoverable: --resume re-drives the resumable compileAccept,
62
+ // reconciling every missing post-move boundary in order (L7/L8).
63
+
64
+ const REVIEW_JOURNAL = 'compile-review-journal.json';
65
+
66
+ function journalPath(installRoot) {
67
+ return join(installRoot, '.phronesis', REVIEW_JOURNAL);
68
+ }
69
+
70
+ async function readJournal(installRoot) {
71
+ const p = journalPath(installRoot);
72
+ if (!existsSync(p)) return null;
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(await readFile(p, 'utf8'));
76
+ } catch {
77
+ return { __corrupt: true };
78
+ }
79
+ // A structurally-invalid-but-parseable journal ({}, [], 42, {planned:null}, a truncated
80
+ // then-flushed write) must be caught here, not crash the resume loop with a TypeError.
81
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.planned) || typeof parsed.cursors !== 'object' || parsed.cursors === null) {
82
+ return { __corrupt: true };
83
+ }
84
+ return parsed;
85
+ }
86
+
87
+ async function writeJournal(installRoot, journal) {
88
+ await fse.ensureDir(join(installRoot, '.phronesis'));
89
+ // Atomic write: a torn/half-flushed journal (a crash mid-write) is unrecoverable bookkeeping
90
+ // and would be quarantined as __corrupt on the next resume. Serialize to a temp sibling, then
91
+ // rename into place — rename is atomic on the same filesystem, so a crash leaves EITHER the
92
+ // prior journal or the complete new one, never a partial file.
93
+ const target = journalPath(installRoot);
94
+ const tmp = `${target}.tmp`;
95
+ await writeFile(tmp, `${JSON.stringify(journal, null, 2)}\n`);
96
+ await rename(tmp, target);
97
+ }
98
+
99
+ async function clearJournal(installRoot) {
100
+ await rm(journalPath(installRoot), { force: true });
101
+ }
102
+
103
+ // Walk .phronesis/drafts/{domain}/*/*.md — every pending compile candidate for the domain.
104
+ export async function listPendingDrafts(installRoot, domain) {
105
+ const base = join(installRoot, '.phronesis', 'drafts', domain);
106
+ if (!existsSync(base)) return [];
107
+ const out = [];
108
+ for (const typeDir of (await readdir(base, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
109
+ if (!typeDir.isDirectory()) continue;
110
+ const typePath = join(base, typeDir.name);
111
+ for (const f of (await readdir(typePath)).sort()) {
112
+ if (f.endsWith('.md')) out.push(`.phronesis/drafts/${domain}/${typeDir.name}/${f}`);
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // L4 — fail-closed classification from the compile.candidate EVENT (the flag is reliably in
119
+ // the event on both create + update). A draft is batch-eligible ONLY if its compile.candidate
120
+ // is provably clean. Re-drafting an unaccepted object, or updating an already-canonical one,
121
+ // LEGITIMATELY appends a new compile.candidate while overwriting the draft file (the documented
122
+ // idempotent re-draft, actions.js) — so multiple candidates for a path are the NORMAL steady
123
+ // state, not an anomaly. The draft file on disk was produced by the MOST RECENT candidate, so
124
+ // classify on THAT one: its suspicious_payload flag is the authoritative provenance of the
125
+ // current draft. A draft with NO candidate at all (missing / archived / purged) is not provably
126
+ // clean → individual review. Absence of a flag is never assumed clean for a missing event.
127
+ // (This corrects the packet's literal "exactly one candidate" mechanism — which would quarantine
128
+ // every re-draft/update — while preserving the spec's only requirement: flagged drafts are
129
+ // excluded from approve-all, security.md:55.)
130
+ export async function classifyDrafts(installRoot, draftRels, candidates) {
131
+ const all = candidates || (await readEvents({ installRoot, type: 'compile.candidate' }));
132
+ const eligible = [];
133
+ const excluded = [];
134
+ for (const draftRel of draftRels) {
135
+ const matches = all.filter((e) => e?.payload?.draft_path === draftRel);
136
+ if (matches.length === 0) {
137
+ excluded.push({ draftRel, reason: 'no compile.candidate event (missing / archived / purged) — not provably clean' });
138
+ continue;
139
+ }
140
+ const latest = matches[matches.length - 1]; // readEvents is chronological
141
+ if (latest.payload.suspicious_payload !== undefined) {
142
+ excluded.push({ draftRel, reason: `suspicious_payload flagged: ${String(latest.payload.suspicious_payload).slice(0, 160)}` });
143
+ continue;
144
+ }
145
+ // change 0027 (D3): bind batch eligibility to the REVIEWED bytes. The latest candidate must
146
+ // carry a content_digest AND the on-disk draft's sha-256 must equal it. Fail-closed (the L4
147
+ // posture): a missing digest (a legacy/unstamped latest candidate) or a mismatch (the draft
148
+ // was edited after its clean candidate — the review-session-scale TOCTOU) is NOT provably
149
+ // clean → individual review, never approve-all. Re-hashed via the SAME digestFile the handler
150
+ // stamped with, so handler and verifier read the bytes identically.
151
+ const expected = latest.payload.content_digest;
152
+ if (typeof expected !== 'string' || expected === '') {
153
+ excluded.push({ draftRel, reason: 'latest compile.candidate has no content_digest — not provably clean (change 0027)' });
154
+ continue;
155
+ }
156
+ let actual;
157
+ try {
158
+ actual = await digestFile(join(installRoot, draftRel));
159
+ } catch (err) {
160
+ excluded.push({ draftRel, reason: `draft bytes could not be read to verify content_digest (${err.code || err.message}) — not provably clean` });
161
+ continue;
162
+ }
163
+ if (actual !== expected) {
164
+ excluded.push({ draftRel, reason: 'draft bytes changed since its clean candidate (content_digest mismatch) — not provably clean' });
165
+ continue;
166
+ }
167
+ eligible.push(draftRel);
168
+ }
169
+ return { eligible, excluded };
170
+ }
171
+
172
+ function makeBatchId({ session, now }) {
173
+ const stamp = (now || new Date()).toISOString().replace(/[:.]/g, '-');
174
+ return `compile-batch-${session}-${stamp}`;
175
+ }
176
+
177
+ // A best-effort durability hook handed to compileAccept / resumeCompileAccept: it records the
178
+ // per-draft intent (at the 'moved' boundary, when the draft is gone) + the per-boundary status
179
+ // cursor (incl. the committed event_id) into the journal, persisting after each boundary so a
180
+ // crash leaves a recoverable receipt.
181
+ function makeRecordBoundary(installRoot, draftRel, journal) {
182
+ return async (boundary, info) => {
183
+ const c = journal.cursors[draftRel] || {};
184
+ if (boundary === 'moved') {
185
+ c.moved = true;
186
+ c.intent = info;
187
+ } else if (boundary === 'committed') {
188
+ c.committed = { done: true, event_id: info.event_id };
189
+ } else if (boundary === 'projection') {
190
+ c.projection = { done: true };
191
+ } else if (boundary === 'accepted') {
192
+ c.accepted = { done: true };
193
+ }
194
+ journal.cursors[draftRel] = c;
195
+ // The in-memory cursor (above) is authoritative for THIS run's clear/remaining logic; the
196
+ // disk write is best-effort crash-durability for --resume. A journal write failure must
197
+ // NEVER abort the canonical mutation it is only bookkeeping for — swallow + warn.
198
+ try {
199
+ await writeJournal(installRoot, journal);
200
+ } catch (err) {
201
+ console.error(`compile review: warning — could not persist the batch journal (${err.message}); resume after a crash may be degraded.`);
202
+ }
203
+ };
204
+ }
205
+
206
+ function remainingDrafts(journal) {
207
+ return journal.planned.filter((d) => !journal.cursors[d]?.accepted?.done);
208
+ }
209
+
210
+ export async function runCompileReview({
211
+ domain = DEFAULT_DOMAIN,
212
+ dir,
213
+ session,
214
+ surface = 'cli',
215
+ rationale,
216
+ approveAll = false,
217
+ resume = false,
218
+ json = false,
219
+ now,
220
+ } = {}) {
221
+ const installed = resolveInstall({ dir, cwd: process.cwd() });
222
+ if (installed.error) {
223
+ console.error(installed.error);
224
+ process.exitCode = 1;
225
+ return null;
226
+ }
227
+ const installRoot = installed.root;
228
+ if (typeof session !== 'string' || session === '') {
229
+ console.error('compile review: --session <id> is required (the review session).');
230
+ process.exitCode = 1;
231
+ return null;
232
+ }
233
+
234
+ if (resume) return resumeReview({ installRoot, session, surface, json, now });
235
+
236
+ const draftRels = await listPendingDrafts(installRoot, domain);
237
+ const { eligible, excluded } = await classifyDrafts(installRoot, draftRels);
238
+
239
+ if (!approveAll) {
240
+ const plan = { domain, batch_eligible: eligible, individual_review: excluded };
241
+ if (json) console.log(JSON.stringify(plan, null, 2));
242
+ else printPlan(plan);
243
+ return plan;
244
+ }
245
+
246
+ // Never overwrite an existing batch journal with a fresh approve-all. A COMPLETED batch clears
247
+ // its journal (below), so a journal present here means a prior batch crashed/failed mid-way and
248
+ // still holds the ONLY recovery receipt for its half-moved drafts (their intent + status
249
+ // cursor). Blindly writing a new journal would destroy it, orphaning canonical artifacts with
250
+ // no path back to a clean spine. Refuse and point the operator at --resume (or a hand
251
+ // resolution of a corrupt journal). A true two-process race between this check and the write
252
+ // below is still possible (a cross-process lock is out of V1 scope); this closes the common
253
+ // "retry/second batch before --resume" footgun.
254
+ const existing = await readJournal(installRoot);
255
+ if (existing) {
256
+ if (existing.__corrupt) {
257
+ console.error(
258
+ `compile review: a batch journal exists but is corrupt (${journalPath(installRoot)}) — resolve it by hand before starting a new batch.`,
259
+ );
260
+ process.exitCode = 1;
261
+ return null;
262
+ }
263
+ const pending = remainingDrafts(existing);
264
+ if (pending.length) {
265
+ console.error(
266
+ `compile review: a prior batch (${existing.batch_id}) left ${pending.length} unreconciled draft(s) in the journal — ` +
267
+ 'run `phronesis compile review --resume` to finish it (or resolve it by hand) before starting a new batch; ' +
268
+ 'starting fresh would destroy the only recovery receipt.',
269
+ );
270
+ process.exitCode = 1;
271
+ return null;
272
+ }
273
+ // A stale but fully-reconciled journal (a clearJournal that never landed) — safe to clear.
274
+ await clearJournal(installRoot);
275
+ }
276
+
277
+ if (typeof rationale !== 'string' || rationale.trim() === '') {
278
+ console.error('compile review --approve-all requires --rationale "<why>" (recorded on every batch compile.accepted).');
279
+ process.exitCode = 1;
280
+ return null;
281
+ }
282
+ if (!eligible.length) {
283
+ const out = { batch_id: null, accepted: [], excluded };
284
+ if (json) console.log(JSON.stringify(out, null, 2));
285
+ else console.log(`compile review: no batch-eligible drafts under ${domain} (${excluded.length} routed to individual review).`);
286
+ return out;
287
+ }
288
+
289
+ const batchId = makeBatchId({ session, now });
290
+ // Write the journal BEFORE iterating (L7.1) — the plan is durable before the first move.
291
+ // Record the batch's start time (created_ts) so resume's out-of-band check can tell THIS batch's
292
+ // terminal events from a STALE prior review of the same — REUSED — draft path (Codex round-3 F2;
293
+ // the resumable-mutation rule: reconcile by recorded identity, never a non-unique key like a path).
294
+ const journal = { batch_id: batchId, rationale, domain, session, surface, created_ts: (now || new Date()).toISOString(), planned: [...eligible], cursors: {} };
295
+ await writeJournal(installRoot, journal);
296
+
297
+ const accepted = [];
298
+ const failed = [];
299
+ for (const draftRel of eligible) {
300
+ // change 0027 (F2/round-2): re-verify the draft IMMEDIATELY BEFORE promoting it. Eligibility was
301
+ // computed once for the whole batch; a draft changed after classification but before its turn —
302
+ // a concurrent agent, or a misbehaving mutation hook firing on an earlier accept — must not ride
303
+ // the batch on a stale check. classifyDrafts RE-READS the candidate event stream AND the draft
304
+ // file FRESH here (NO reused stream): the fresh read catches both a mutated draft file (digest
305
+ // mismatch) AND a newly-appended FLAGGED re-draft candidate with identical bytes (a stale stream
306
+ // missed the flag — reservation blocks freehand CLI emission, not the real compile action/a hook).
307
+ const recheck = await classifyDrafts(installRoot, [draftRel]);
308
+ if (!recheck.eligible.includes(draftRel)) {
309
+ const reason = recheck.excluded.find((e) => e.draftRel === draftRel)?.reason || 'no longer provably clean at promotion';
310
+ failed.push({ draftRel, error: `draft changed since classification — not promoted (${reason})` });
311
+ continue;
312
+ }
313
+ const recordBoundary = makeRecordBoundary(installRoot, draftRel, journal);
314
+ try {
315
+ const r = await compileAccept({
316
+ dir,
317
+ cwd: process.cwd(),
318
+ draftPath: draftRel,
319
+ session,
320
+ surface,
321
+ approvalSource: process.env.PHRONESIS_APPROVAL_SOURCE || undefined,
322
+ now,
323
+ batchId,
324
+ rationale,
325
+ recordBoundary,
326
+ });
327
+ accepted.push({ draftRel, accepted_path: r.accepted_path });
328
+ } catch (err) {
329
+ // A caught error leaves this draft in the journal (its cursor reflects how far it got);
330
+ // continue the batch best-effort, and --resume reconciles the rest.
331
+ failed.push({ draftRel, error: err.message });
332
+ }
333
+ }
334
+
335
+ const remaining = remainingDrafts(journal);
336
+ if (!remaining.length) await clearJournal(installRoot);
337
+ const out = { batch_id: batchId, rationale, accepted, failed, excluded, remaining };
338
+ if (json) console.log(JSON.stringify(out, null, 2));
339
+ else printReviewResult(out);
340
+ if (failed.length) process.exitCode = 1;
341
+ return out;
342
+ }
343
+
344
+ async function resumeReview({ installRoot, session, surface, json, now }) {
345
+ const journal = await readJournal(installRoot);
346
+ if (!journal || journal.__corrupt) {
347
+ console.error(
348
+ journal?.__corrupt
349
+ ? `compile review --resume: the batch journal (${journalPath(installRoot)}) is corrupt — resolve it by hand.`
350
+ : 'compile review --resume: no batch journal to resume.',
351
+ );
352
+ // Both the corrupt AND the missing case are non-zero: --resume is an explicit request to
353
+ // finish a batch, so "there was nothing to resume" is a failed request, not a success —
354
+ // automation must not read it as "nothing left to do, all good".
355
+ process.exitCode = 1;
356
+ return null;
357
+ }
358
+ // Faithful attribution: resumed events carry the ORIGINAL batch's session + surface, not the
359
+ // resume invocation's. The batch is one logical unit (batch_id + session); a --resume from a
360
+ // different --session must not split its events across audit sessions. The terminal events are
361
+ // pre-read ONCE for the draft-gone out-of-band check; the L4 re-check below RE-READS the candidate
362
+ // stream FRESH per draft, so a FLAGGED re-draft candidate appended mid-resume is not missed
363
+ // (Codex round-2 — a once-loaded stream would let a stale clean candidate promote a flagged draft).
364
+ const batchSurface = journal.surface || surface;
365
+ const batchSession = journal.session || session;
366
+ const acceptedEvents = await readEvents({ installRoot, type: 'compile.accepted' });
367
+ const rejectedEvents = await readEvents({ installRoot, type: 'compile.rejected' });
368
+ // Draft paths are REUSED across re-drafts/updates of one object, so a terminal event for this path
369
+ // may be STALE (a prior review), NOT proof of THIS batch's out-of-band resolution. Count a terminal
370
+ // event only if it is at/after the batch's recorded start (created_ts) — the operation identity the
371
+ // resumable-mutation rule requires (Codex round-3 F2): keying on the non-unique path alone would let
372
+ // a stale prior accept clear a live orphan's journal, silently dropping its missing audit spine. A
373
+ // journal MUST carry created_ts to do this safely; missing it (a hand-corrupted or pre-fix journal)
374
+ // is UNVERIFIABLE → no out-of-band resolution (fail closed to manual repair), NEVER the old
375
+ // path-only false-skip (Codex round-4: don't fall back to a non-identity check). NOTE: a wall-clock
376
+ // ts is a coarse identity; the robust form (a recorded terminal-event-id baseline + batch_id match)
377
+ // is the flagged operation-identity provenance follow-up — this interim catches the common
378
+ // stale-prior (normal past ts) case and fails closed on the rest.
379
+ const watermark = typeof journal.created_ts === 'string' ? journal.created_ts : null;
380
+ const afterStart = (e) => watermark !== null && typeof e?.ts === 'string' && e.ts >= watermark;
381
+ const resolvedOutOfBand = (rel) =>
382
+ acceptedEvents.some((e) => e?.payload?.draft_path === rel && afterStart(e)) ||
383
+ rejectedEvents.some((e) => e?.payload?.draft_path === rel && afterStart(e));
384
+ const accepted = [];
385
+ const failed = [];
386
+ for (const draftRel of journal.planned) {
387
+ const cursor = journal.cursors[draftRel] || {};
388
+ if (cursor.accepted?.done) {
389
+ accepted.push({ draftRel, skipped: 'already complete (no-op)' });
390
+ continue;
391
+ }
392
+ const recordBoundary = makeRecordBoundary(installRoot, draftRel, journal);
393
+ try {
394
+ if (cursor.moved && cursor.intent) {
395
+ const r = await resumeCompileAccept({
396
+ dir: installRoot,
397
+ intent: cursor.intent,
398
+ statusCursor: cursor,
399
+ session: batchSession,
400
+ surface: batchSurface,
401
+ now,
402
+ recordBoundary,
403
+ });
404
+ accepted.push({ draftRel, resumed: true, accepted_path: r.accepted_path });
405
+ } else if (!existsSync(join(installRoot, draftRel))) {
406
+ // The draft file is gone but the cursor never recorded a `moved` boundary. TWO very
407
+ // different states hide here, and the filesystem alone cannot tell them apart:
408
+ // (1) genuinely resolved OUT-OF-BAND — a manual accept/reject between the crash and this
409
+ // resume — PROVEN by a terminal compile.accepted/compile.rejected event for the path;
410
+ // (2) the move SUCCEEDED but the best-effort 'moved' journal write failed before the
411
+ // crash — the canonical artifact is ORPHANED with no committed spine. Marking THIS
412
+ // "done" would clear the journal and silently leave canonical state without its audit
413
+ // boundaries. So only (1) is "done"; (2) FAILS CLOSED to manual repair. Never infer
414
+ // "done" from the missing draft alone — require a terminal event to prove it.
415
+ if (resolvedOutOfBand(draftRel)) {
416
+ accepted.push({ draftRel, skipped: 'resolved out-of-band (terminal event present)' });
417
+ if (!cursor.accepted) journal.cursors[draftRel] = { ...cursor, accepted: { done: true } };
418
+ continue;
419
+ }
420
+ failed.push({
421
+ draftRel,
422
+ error:
423
+ 'draft gone but NO terminal compile.accepted/compile.rejected event and no recorded "moved" cursor — ' +
424
+ 'an orphaned post-move state (the canonical artifact may exist without its committed spine). FAILING ' +
425
+ 'CLOSED: resolve it by hand before resuming (do not start a fresh batch — that would lose this receipt).',
426
+ });
427
+ continue;
428
+ } else {
429
+ // The draft is still present → a fresh accept. RE-RUN L4 first: a suspicious
430
+ // compile.candidate could have been appended for its path between plan-write and the
431
+ // crash. Never fresh-accept a now-unclean draft on resume (the fail-closed gate must hold
432
+ // across the crash boundary, not just at plan time).
433
+ const { eligible } = await classifyDrafts(installRoot, [draftRel]);
434
+ if (!eligible.includes(draftRel)) {
435
+ failed.push({ draftRel, error: 'no longer provably clean on resume (L4) — routed to individual review' });
436
+ continue;
437
+ }
438
+ const r = await compileAccept({
439
+ dir: installRoot,
440
+ cwd: process.cwd(),
441
+ draftPath: draftRel,
442
+ session: batchSession,
443
+ surface: batchSurface,
444
+ approvalSource: process.env.PHRONESIS_APPROVAL_SOURCE || undefined,
445
+ now,
446
+ batchId: journal.batch_id,
447
+ rationale: journal.rationale,
448
+ recordBoundary,
449
+ });
450
+ accepted.push({ draftRel, accepted_path: r.accepted_path });
451
+ }
452
+ } catch (err) {
453
+ failed.push({ draftRel, error: err.message });
454
+ }
455
+ }
456
+ const remaining = remainingDrafts(journal);
457
+ if (!remaining.length) await clearJournal(installRoot);
458
+ const out = { batch_id: journal.batch_id, resumed: true, accepted, failed, remaining };
459
+ if (json) console.log(JSON.stringify(out, null, 2));
460
+ else printReviewResult(out);
461
+ if (failed.length) process.exitCode = 1;
462
+ return out;
463
+ }
464
+
465
+ function printPlan({ domain, batch_eligible, individual_review }) {
466
+ console.log(`compile review — ${domain}`);
467
+ console.log(`batch-eligible (provably-clean): ${batch_eligible.length}`);
468
+ for (const d of batch_eligible) console.log(` + ${d}`);
469
+ console.log(`individual review (fail-closed — flagged / not provably clean): ${individual_review.length}`);
470
+ for (const e of individual_review) console.log(` ! ${e.draftRel} — ${e.reason}`);
471
+ console.log('\nrun with --approve-all --rationale "<why>" to approve the clean set as one batch.');
472
+ }
473
+
474
+ function printReviewResult(out) {
475
+ if (out.batch_id) console.log(`batch ${out.batch_id}${out.resumed ? ' (resumed)' : ''}`);
476
+ console.log(`accepted: ${out.accepted.length}`);
477
+ for (const a of out.accepted) console.log(` + ${a.draftRel}${a.resumed ? ' (resumed)' : ''}${a.skipped ? ` (${a.skipped})` : ''}`);
478
+ if (out.excluded?.length) {
479
+ console.log(`individual review (excluded from batch): ${out.excluded.length}`);
480
+ for (const e of out.excluded) console.log(` ! ${e.draftRel} — ${e.reason}`);
481
+ }
482
+ if (out.failed?.length) {
483
+ console.log(`failed (left in the journal — re-run with --resume): ${out.failed.length}`);
484
+ for (const f of out.failed) console.log(` x ${f.draftRel} — ${f.error}`);
485
+ }
486
+ if (out.remaining?.length) console.log(`remaining (resumable): ${out.remaining.length}`);
487
+ }
488
+
489
+ export async function runCompileReject({ draftPath, reason, session, surface, dir } = {}) {
490
+ if (!draftPath) {
491
+ console.error('compile reject: name the <draft_path> to discard (a .phronesis/drafts/… candidate).');
492
+ process.exitCode = 1;
493
+ return null;
494
+ }
495
+ try {
496
+ // actor is NOT threaded — reject events are operator-driven by contract (core hard-set).
497
+ const result = await compileReject({
498
+ dir,
499
+ cwd: process.cwd(),
500
+ draftPath,
501
+ reason,
502
+ session,
503
+ surface,
504
+ });
505
+ console.log(JSON.stringify(result));
506
+ process.exitCode = 0;
507
+ return result;
508
+ } catch (err) {
509
+ console.error(err.message);
510
+ process.exitCode = 1;
511
+ return null;
512
+ }
513
+ }