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
package/src/doctor.js ADDED
@@ -0,0 +1,303 @@
1
+ // `phronesis doctor [--dir <install>] [--json]` (core-0038 / change 0030) — a READ-ONLY install-
2
+ // health overview that COMPOSES existing diagnostics into a one-screen OK / warn / fail report. It
3
+ // makes NO writes to the install and emits NO events: the install (incl. .phronesis/events/) is
4
+ // byte-identical after a run — validate-doctor's byte-identical proof is the guard. It REPORTS; it
5
+ // never fixes (remediation is core-0039 / the operator).
6
+ //
7
+ // READ-ONLY is unconditional, even on a MODIFIED install (Codex round-1 F1): doctor never EXECUTES
8
+ // install-owned code. The Codex-adapter check is STRUCTURAL only (codexAdapterReport structuralOnly:
9
+ // true — pure file reads, no hook spawning); the full behavioral probe (which execs the install's
10
+ // hook scripts) stays in `phronesis adapter test codex`, a deliberate operator self-check.
11
+ //
12
+ // Classification (D4): fail (exit non-zero) — no/invalid/unreadable install, a malformed registry /
13
+ // domains list, an invalid skill registry (the install is genuinely broken). warn (exit 0) —
14
+ // incomplete hook wiring, a non-compliant Codex adapter (Codex is the SECONDARY surface; Claude Code
15
+ // is primary/always-on), USER.md placeholders, pending drafts. Exit is non-zero IFF a fail tripped;
16
+ // no warn check ever fails the command. Every check is guarded — a throw becomes a fail/warn check,
17
+ // never a crash (F2/F3 fail-closed).
18
+
19
+ import { join } from 'node:path';
20
+ import { existsSync, statSync, readFileSync } from 'node:fs';
21
+ import { readFile, readdir } from 'node:fs/promises';
22
+ import fse from 'fs-extra';
23
+ import { resolveInstall, resolveHooks, RE_SLUG, validateScheduleRegistry, scheduleStaleness } from '@phronesis/core';
24
+ import { registryJson } from './layout.js';
25
+ import { skillsStatusReport } from './skills.js';
26
+ import { codexAdapterReport } from './adapter.js';
27
+
28
+ // The fixed check id set (D6) — the --json contract asserts on exactly these.
29
+ export const DOCTOR_CHECK_IDS = ['install_root', 'domain', 'skills', 'hooks', 'adapter_codex', 'user_profile', 'pending_drafts', 'schedule'];
30
+
31
+ function isExecutable(p) {
32
+ try {
33
+ const st = statSync(p);
34
+ // A runnable hook script must be a REGULAR file with an exec bit — a directory carries the
35
+ // exec bit too (0755) and would otherwise read as a runnable script (Codex round-3 F1).
36
+ return st.isFile() && Boolean(st.mode & 0o111);
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function isFileParseableJson(p) {
43
+ try {
44
+ if (!statSync(p).isFile()) return false;
45
+ JSON.parse(readFileSync(p, 'utf8'));
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ // Build the structured doctor report — PURE w.r.t. the install (read-only). Returns the D6 shape:
53
+ // { root, checks:[{ id, status, detail }], summary:{ ok, warn, fail }, exit }.
54
+ export async function doctorReport({ dir } = {}) {
55
+ const checks = [];
56
+ const add = (id, status, detail) => checks.push({ id, status, detail });
57
+ // Run a check body; turn ANY throw into a check of `failClass` (never a crash — F2/F3).
58
+ const guarded = async (id, failClass, fn) => {
59
+ try {
60
+ const [status, detail] = await fn();
61
+ add(id, status, detail);
62
+ } catch (err) {
63
+ add(id, failClass, `${id} check errored: ${err.message}`);
64
+ }
65
+ };
66
+
67
+ // install_root — the only short-circuit: nothing else resolves without a root.
68
+ const installed = resolveInstall({ dir });
69
+ if (installed.error) {
70
+ add('install_root', 'fail', installed.error);
71
+ return finalize(null, checks);
72
+ }
73
+ const root = installed.root;
74
+ add('install_root', 'ok', root);
75
+
76
+ // Shared registry read — a present-but-corrupt registry.json (resolveInstall passes on existence)
77
+ // fails the registry-derived checks, never crashes (F3).
78
+ let registry = null;
79
+ let registryError = null;
80
+ try {
81
+ registry = await fse.readJson(join(root, '.phronesis', 'registry.json'));
82
+ } catch (err) {
83
+ registryError = err.message;
84
+ }
85
+
86
+ // Validated domain list (F2): a non-empty array of kebab-case SLUGs (RE_SLUG — the shared grammar
87
+ // a domain dir is named by). A non-slug (`../outside`, `pm/evil`, ` pm `, null, {}) is malformed —
88
+ // it would otherwise crash or path-traverse join(root,'domains',slug,...) in the dependent loops.
89
+ // `domains` is the safe slug-only subset the dependent (hooks / user_profile) loops iterate.
90
+ let domains = [];
91
+ let domainsValid = false;
92
+ if (!registryError) {
93
+ const d = registry?.domains;
94
+ const isSlug = (x) => typeof x === 'string' && RE_SLUG.test(x);
95
+ domainsValid = Array.isArray(d) && d.length > 0 && d.every(isSlug);
96
+ domains = Array.isArray(d) ? d.filter(isSlug) : [];
97
+ }
98
+
99
+ // domain
100
+ if (registryError) {
101
+ add('domain', 'fail', `registry.json unreadable: ${registryError}`);
102
+ } else if (!domainsValid) {
103
+ add('domain', 'fail', `malformed registry.domains (expected a non-empty array of domain slugs): ${JSON.stringify(registry?.domains)}`);
104
+ } else {
105
+ add('domain', 'ok', `registered: ${domains.join(', ')}`);
106
+ }
107
+
108
+ // skills — the PURE report (no print / no exit-code leak, D2/D3); a load error is fail.
109
+ await guarded('skills', 'fail', async () => {
110
+ const sr = await skillsStatusReport({ dir: root });
111
+ if (sr.status === 'ok') {
112
+ const n = Object.keys(sr.skills).length;
113
+ return ['ok', `${n} skill(s) registered${sr.unregistered.length ? ` · ${sr.unregistered.length} unregistered dir(s): ${sr.unregistered.join(', ')}` : ''}`];
114
+ }
115
+ return ['fail', sr.errors.slice(0, 3).join('; ').slice(0, 400) || 'skill registry invalid'];
116
+ });
117
+
118
+ // hooks — presence of the in-pipeline hook scripts + the surface configs (incomplete is warn, D4).
119
+ await guarded('hooks', 'warn', () => hookWiring(root, registry, registryError, domains));
120
+
121
+ // adapter_codex — the STRUCTURAL-only report (read-only: NO hook execution, F1); the PURE report
122
+ // (no exit-code leak, D2). Codex is the secondary surface → never fail (D4).
123
+ await guarded('adapter_codex', 'warn', async () => {
124
+ const ar = codexAdapterReport({ dir: root, structuralOnly: true });
125
+ if (ar.error) return ['warn', `could not run the Codex adapter check: ${ar.error}`];
126
+ return [
127
+ ar.report.compliant ? 'ok' : 'warn',
128
+ ar.report.compliant
129
+ ? 'Codex adapter structurally wired (run `phronesis adapter test codex` for the full behavioral check)'
130
+ : 'Codex adapter not fully wired — run `phronesis adapter test codex` (Codex is the secondary surface; Claude Code is primary/always-on)',
131
+ ];
132
+ });
133
+
134
+ // user_profile — warn if any domain's USER.md still carries [Name]/[Role] (D4).
135
+ await guarded('user_profile', 'warn', () => userProfile(root, domains));
136
+
137
+ // pending_drafts — warn with the count under .phronesis/drafts/ (D4).
138
+ await guarded('pending_drafts', 'warn', () => pendingDrafts(root));
139
+
140
+ // schedule (core-0051 / change 0042) — a malformed schedule registry OR entries overdue by
141
+ // > 2× cadence are WARN, never fail: the schedule is a SEPARATE failure domain (a bad one
142
+ // does not disable the action layer — ratification (a)), and `phronesis validate` is where the
143
+ // hard contract check lives. Read-only (validateScheduleRegistry + scheduleStaleness are pure).
144
+ await guarded('schedule', 'warn', () => scheduleHealth(root));
145
+
146
+ return finalize(root, checks);
147
+ }
148
+
149
+ function finalize(root, checks) {
150
+ const summary = { ok: 0, warn: 0, fail: 0 };
151
+ for (const c of checks) summary[c.status] += 1;
152
+ const exit = summary.fail > 0 ? 1 : 0;
153
+ return { root, checks, summary, exit };
154
+ }
155
+
156
+ async function hookWiring(root, registry, registryError, domains) {
157
+ if (registryError) {
158
+ return ['fail', `registry.json unreadable (${registryError}) — cannot verify hook wiring`];
159
+ }
160
+ // Compose the REAL hook validator (D3 — don't re-implement a shallow check). An INVALID hooks
161
+ // section (a non-array, an unknown key, a v0-registry, a malformed entry) DISABLES the action
162
+ // layer (actions.js: the act pipeline refuses a hooks section it cannot parse), so it is a FAIL —
163
+ // the same class as an invalid skill registry — not a mere wiring warn (Codex round-2 F1).
164
+ const hookRes = resolveHooks(registry);
165
+ if (hookRes.errors.length) {
166
+ return ['fail', `invalid hooks registry (the action layer is disabled until fixed): ${hookRes.errors.slice(0, 3).join('; ').slice(0, 300)}`];
167
+ }
168
+ const missing = [];
169
+ const hooks = Array.isArray(registry.hooks) ? registry.hooks : [];
170
+ // The default reinforcement subscription (the post-compile active-context projection refresh) must
171
+ // be present AND match the SCAFFOLDED contract (Codex round-4 F2): an id-only check would pass a
172
+ // drifted/dead subscription (wrong event / filter / command) while reporting healthy. Compose the
173
+ // contract from layout.js (the single source init writes) — comparing the functional fields
174
+ // (subscribes_to / filter / command), not quota/classification an operator may legitimately tune.
175
+ const scaffoldDefault = registryJson(['pm']).hooks.find((h) => h.id === 'compile-active-context');
176
+ const defaultHook = hooks.find((h) => h && h.id === 'compile-active-context');
177
+ if (!defaultHook) {
178
+ missing.push('the default compile-active-context hook subscription is absent');
179
+ } else if (scaffoldDefault) {
180
+ if (JSON.stringify(defaultHook.subscribes_to) !== JSON.stringify(scaffoldDefault.subscribes_to)) missing.push('compile-active-context.subscribes_to drifted from the scaffolded wiring');
181
+ if (JSON.stringify(defaultHook.filter) !== JSON.stringify(scaffoldDefault.filter)) missing.push('compile-active-context.filter drifted from the scaffolded wiring');
182
+ if (defaultHook.command !== scaffoldDefault.command) missing.push('compile-active-context.command drifted from the scaffolded wiring');
183
+ }
184
+ for (const h of hooks) {
185
+ const cmd = h && typeof h.command === 'string' ? h.command : null;
186
+ if (!cmd) {
187
+ missing.push('a hook subscription has no command');
188
+ continue;
189
+ }
190
+ const p = join(root, cmd);
191
+ if (!existsSync(p)) missing.push(`${cmd} (missing)`);
192
+ else if (!isExecutable(p)) missing.push(`${cmd} (not executable)`);
193
+ }
194
+ // The Claude Code surface config must be a regular file with parseable JSON (Codex round-4 F3) —
195
+ // a directory or malformed JSON at that path is not a usable config.
196
+ const claudeCfg = join(root, '.claude', 'settings.json');
197
+ if (!existsSync(claudeCfg)) missing.push('.claude/settings.json (Claude Code surface config) missing');
198
+ else if (!isFileParseableJson(claudeCfg)) missing.push('.claude/settings.json is not a readable JSON file (a directory or malformed)');
199
+ for (const slug of domains) {
200
+ // domains is pre-validated to string slugs (F2) — safe to join.
201
+ if (!existsSync(join(root, 'domains', slug, '.codex', 'hooks.json'))) missing.push(`domains/${slug}/.codex/hooks.json (Codex surface config) missing`);
202
+ }
203
+ if (missing.length) return ['warn', `incomplete hook wiring: ${missing.join('; ')}`];
204
+ return ['ok', 'hooks registry valid; in-pipeline scripts present + executable; .claude + .codex surface configs present'];
205
+ }
206
+
207
+ async function userProfile(root, domains) {
208
+ const slugs = domains.length ? domains : ['pm']; // best-effort when no valid domains resolved
209
+ const unfilled = [];
210
+ for (const slug of slugs) {
211
+ try {
212
+ const text = await readFile(join(root, 'domains', slug, 'USER.md'), 'utf8');
213
+ if (text.includes('[Name]') || text.includes('[Role]')) unfilled.push(slug);
214
+ } catch (err) {
215
+ unfilled.push(`${slug} (USER.md unreadable: ${err.code || err.message})`);
216
+ }
217
+ }
218
+ if (unfilled.length) {
219
+ return ['warn', `USER.md still has [Name]/[Role] placeholders for: ${unfilled.join(', ')} — fill it in (or re-run \`phronesis init\` with --name/--role)`];
220
+ }
221
+ return ['ok', 'USER.md personalized (no [Name]/[Role] placeholders)'];
222
+ }
223
+
224
+ async function scheduleHealth(root) {
225
+ const v = await validateScheduleRegistry({ installRoot: root });
226
+ if (!v.ok) {
227
+ return ['warn', `.phronesis/schedule.json invalid (${v.errors.length} issue(s)) — run \`phronesis validate\`: ${v.errors.slice(0, 2).join('; ').slice(0, 300)}`];
228
+ }
229
+ const warns = [];
230
+ const { stale } = await scheduleStaleness({ installRoot: root });
231
+ if (stale.length) {
232
+ const detail = stale.map((s) => `${s.schedule_id} (${s.days_overdue}d overdue, ${s.cadence_days}d cadence)`).join(', ');
233
+ warns.push(`${stale.length} schedule(s) overdue by > 2× cadence: ${detail} — run \`phronesis due\``);
234
+ }
235
+ // Runtime-state gitignore gap (Codex round-9 F3): a fresh scaffold ignores `.phronesis/state/`,
236
+ // but an install created BEFORE this feature and upgraded to this CLI has an older .gitignore.
237
+ // Once completion state is written there it becomes git-trackable. Export is safe regardless
238
+ // (the exclude globs are code-driven), so this is a WARN — flag it once state actually exists.
239
+ if (existsSync(join(root, '.phronesis', 'state')) && !gitignoreCoversState(root)) {
240
+ warns.push('runtime schedule state (.phronesis/state/) exists but is not in .gitignore — an install predating this feature; add `.phronesis/state/` so completion state stays out of git (`phronesis export` already excludes it)');
241
+ }
242
+ if (warns.length) return ['warn', warns.join('; ')];
243
+ return ['ok', 'schedule registry valid; no entries overdue by > 2× cadence'];
244
+ }
245
+
246
+ function gitignoreCoversState(root) {
247
+ try {
248
+ return readFileSync(join(root, '.gitignore'), 'utf8').includes('.phronesis/state/');
249
+ } catch {
250
+ return false; // no .gitignore ⇒ not covered
251
+ }
252
+ }
253
+
254
+ async function pendingDrafts(root) {
255
+ const base = join(root, '.phronesis', 'drafts');
256
+ if (!existsSync(base)) return ['ok', 'no pending compile drafts'];
257
+ let count = 0;
258
+ for (const domainEnt of await readdir(base, { withFileTypes: true })) {
259
+ if (!domainEnt.isDirectory()) continue;
260
+ const domainDir = join(base, domainEnt.name);
261
+ for (const typeEnt of await readdir(domainDir, { withFileTypes: true })) {
262
+ if (!typeEnt.isDirectory()) continue;
263
+ for (const f of await readdir(join(domainDir, typeEnt.name))) {
264
+ if (f.endsWith('.md')) count += 1;
265
+ }
266
+ }
267
+ }
268
+ if (count > 0) return ['warn', `${count} pending compile draft(s) under .phronesis/drafts/ — run \`phronesis compile review\``];
269
+ return ['ok', 'no pending compile drafts'];
270
+ }
271
+
272
+ // --- CLI wrapper: print (human or json) + set the exit code (D4). doctor's exit is its OWN —
273
+ // neither skillsStatusReport nor codexAdapterReport sets process.exitCode (they are pure), so no
274
+ // composed surface's exit leaks here.
275
+ export async function runDoctor({ dir, json = false } = {}) {
276
+ const report = await doctorReport({ dir });
277
+ if (json) {
278
+ console.log(JSON.stringify(report, null, 2));
279
+ } else {
280
+ printHuman(report);
281
+ }
282
+ process.exitCode = report.exit;
283
+ return report;
284
+ }
285
+
286
+ function printHuman(report) {
287
+ const mark = (s) => (s === 'ok' ? '✓' : s === 'warn' ? '!' : '✗');
288
+ console.log(`phronesis doctor — ${report.root || '(no install)'}`);
289
+ console.log('');
290
+ for (const c of report.checks) {
291
+ console.log(` ${mark(c.status)} ${c.id}${c.detail ? ` — ${c.detail}` : ''}`);
292
+ }
293
+ console.log('');
294
+ const { ok, warn, fail } = report.summary;
295
+ console.log(` ${ok} ok · ${warn} warn · ${fail} fail`);
296
+ console.log(
297
+ report.exit === 0
298
+ ? warn
299
+ ? ' Install is healthy (warnings above are informational, not blocking).'
300
+ : ' Install is healthy.'
301
+ : ' Install has FAIL-level issues above — resolve them before dogfooding.',
302
+ );
303
+ }
package/src/due.js ADDED
@@ -0,0 +1,271 @@
1
+ // `phronesis due [--json]`, `phronesis due complete <id>`, and
2
+ // `phronesis due run <id>` (core-0051/core-0058 / change 0042).
3
+ // CLI glue only (the boundary rule): the registry load, the pure due computation, and the
4
+ // completion floor live in @phronesis/core (schedule.js). This module resolves the install,
5
+ // formats, and sets the exit code.
6
+ //
7
+ // `phronesis due` is a PURE READ (D2): it computes overdue entries from the registry + the
8
+ // runtime state + the clock, prints them, and MUTATES NOTHING, EMITS NOTHING. The
9
+ // `schedule.due` events are the session-start due-check's job (`phronesis hooks due-check`),
10
+ // so the event's provenance stays with the authoritative emitter.
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import { realpathSync, statSync } from 'node:fs';
14
+ import { isAbsolute, relative, resolve as resolvePath, sep } from 'node:path';
15
+ import {
16
+ resolveInstall,
17
+ computeScheduleDue,
18
+ validateScheduleRegistry,
19
+ loadScheduleRegistry,
20
+ markScheduleCompleted,
21
+ } from '@phronesis/core';
22
+ import { run as runCliDispatch } from './cli.js';
23
+
24
+ function installOrFail(dir) {
25
+ const r = resolveInstall({ dir });
26
+ if (r.error) {
27
+ console.error(r.error);
28
+ process.exitCode = 1;
29
+ return null;
30
+ }
31
+ return r.root;
32
+ }
33
+
34
+ function overduePhrase(daysOverdue) {
35
+ return daysOverdue === 0 ? 'due now' : `${daysOverdue} day(s) overdue`;
36
+ }
37
+
38
+ function isPlainObject(v) {
39
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
40
+ }
41
+
42
+ function cliVerbArgs(target) {
43
+ return target.trim().split(/\s+/).filter(Boolean);
44
+ }
45
+
46
+ function outsideRoot(root, target) {
47
+ const rel = relative(root, target);
48
+ return rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
49
+ }
50
+
51
+ export function resolveConnectorPullRunTarget(installRoot, target) {
52
+ if (isAbsolute(target)) {
53
+ return { error: `connector_pull target "${target}" must be install-relative, not an absolute path` };
54
+ }
55
+ const resolved = resolvePath(installRoot, target);
56
+ if (outsideRoot(installRoot, resolved)) {
57
+ return { error: `connector_pull target "${target}" resolves outside the install root` };
58
+ }
59
+ let realTarget;
60
+ try {
61
+ realTarget = realpathSync(resolved);
62
+ } catch {
63
+ return { error: `connector_pull target "${target}" does not exist under the install root` };
64
+ }
65
+ let realRoot;
66
+ try {
67
+ realRoot = realpathSync(installRoot);
68
+ } catch {
69
+ realRoot = installRoot;
70
+ }
71
+ if (outsideRoot(realRoot, realTarget)) {
72
+ return { error: `connector_pull target "${target}" resolves (via a symlink) outside the install root` };
73
+ }
74
+ let st;
75
+ try {
76
+ st = statSync(realTarget);
77
+ } catch {
78
+ return { error: `connector_pull target "${target}" does not exist under the install root` };
79
+ }
80
+ if (!st.isFile() || !(st.mode & 0o111)) {
81
+ return { error: `connector_pull target "${target}" is not an executable file (needs the executable bit)` };
82
+ }
83
+ return { abs: realTarget };
84
+ }
85
+
86
+ async function runConnectorPull({ installRoot, target }) {
87
+ const resolved = resolveConnectorPullRunTarget(installRoot, target);
88
+ if (resolved.error) throw new Error(resolved.error);
89
+ return new Promise((resolve) => {
90
+ const child = spawn(resolved.abs, [], {
91
+ cwd: installRoot,
92
+ env: process.env,
93
+ stdio: ['ignore', 'inherit', 'inherit'],
94
+ });
95
+ child.on('error', (err) => {
96
+ console.error(`due run: failed to execute connector_pull target "${target}": ${err.message}`);
97
+ resolve(1);
98
+ });
99
+ child.on('close', (code, signal) => {
100
+ if (signal) {
101
+ console.error(`due run: connector_pull target "${target}" terminated by signal ${signal}.`);
102
+ resolve(1);
103
+ } else {
104
+ resolve(code ?? 1);
105
+ }
106
+ });
107
+ });
108
+ }
109
+
110
+ // Pure read: registry + clock → overdue list. No events, no writes.
111
+ export async function runDue({ dir, json = false } = {}) {
112
+ const installRoot = installOrFail(dir);
113
+ if (!installRoot) return null;
114
+ try {
115
+ // Fail closed: a registry that would fail `validate` surfaces NOTHING and exits non-zero —
116
+ // never a partial/invalid due list (Codex round-2 F1). `validate` is the fix path.
117
+ const { ok, errors } = await validateScheduleRegistry({ installRoot });
118
+ if (!ok) {
119
+ console.error(
120
+ `schedule.json is invalid — run \`phronesis validate\` to fix it (not surfacing schedules until then). First issue: ${errors[0]}`,
121
+ );
122
+ if (json) console.log('[]');
123
+ process.exitCode = 1;
124
+ return [];
125
+ }
126
+ const due = await computeScheduleDue({ installRoot });
127
+ if (json) {
128
+ console.log(JSON.stringify(due));
129
+ process.exitCode = 0;
130
+ return due;
131
+ }
132
+ if (!due.length) {
133
+ console.log('No schedule entries due.');
134
+ process.exitCode = 0;
135
+ return due;
136
+ }
137
+ console.log(`Phronesis schedule: ${due.length} item(s) due.`);
138
+ for (const d of due) {
139
+ console.log(`\n ${d.schedule_id} — ${d.kind}:${d.target} — ${overduePhrase(d.days_overdue)}`);
140
+ if (d.kind === 'cli_verb' || d.kind === 'connector_pull') {
141
+ console.log(` run now: phronesis due run ${d.schedule_id}`);
142
+ }
143
+ console.log(` mark done: phronesis due complete ${d.schedule_id}`);
144
+ }
145
+ process.exitCode = 0;
146
+ return due;
147
+ } catch (err) {
148
+ console.error(`due: ${err.message}`);
149
+ process.exitCode = 1;
150
+ return null;
151
+ }
152
+ }
153
+
154
+ // Operator-invoked executor for deterministic schedule kinds (core-0058). `kind: skill`
155
+ // remains session work: run the skill in the operator session, then use the explicit floor.
156
+ export async function runDueRun({ dir, scheduleId } = {}) {
157
+ const installRoot = installOrFail(dir);
158
+ if (!installRoot) return null;
159
+ if (!scheduleId) {
160
+ console.error('due run: a schedule <id> is required (e.g. `phronesis due run gmail-pull-daily`).');
161
+ process.exitCode = 1;
162
+ return null;
163
+ }
164
+ try {
165
+ const { ok, errors } = await validateScheduleRegistry({ installRoot });
166
+ if (!ok) {
167
+ console.error(`due run: schedule.json is invalid — run \`phronesis validate\` first (${errors[0]}).`);
168
+ process.exitCode = 1;
169
+ return null;
170
+ }
171
+ const { schedules } = await loadScheduleRegistry({ installRoot });
172
+ const entry = schedules.find((e) => isPlainObject(e) && e.id === scheduleId);
173
+ if (!entry) {
174
+ console.error(`due run: no schedule "${scheduleId}" in .phronesis/schedule.json.`);
175
+ process.exitCode = 1;
176
+ return null;
177
+ }
178
+ if (entry.kind === 'skill') {
179
+ console.error(
180
+ `due run: schedule "${scheduleId}" is kind: skill. Skills run in operator sessions; run the skill, then \`phronesis due complete ${scheduleId}\`.`,
181
+ );
182
+ process.exitCode = 1;
183
+ return null;
184
+ }
185
+ if (entry.kind !== 'cli_verb' && entry.kind !== 'connector_pull') {
186
+ console.error(`due run: schedule "${scheduleId}" has unsupported kind "${entry.kind}".`);
187
+ process.exitCode = 1;
188
+ return null;
189
+ }
190
+
191
+ const priorCwd = process.cwd();
192
+ let code = 0;
193
+ if (entry.kind === 'connector_pull') {
194
+ code = await runConnectorPull({ installRoot, target: entry.target });
195
+ } else {
196
+ const args = cliVerbArgs(entry.target);
197
+ if (!args.length) {
198
+ console.error(`due run: schedule "${scheduleId}" has an empty cli_verb target.`);
199
+ process.exitCode = 1;
200
+ return null;
201
+ }
202
+ if (args[0] === 'due') {
203
+ console.error(
204
+ `due run: schedule "${scheduleId}" has a cli_verb target that invokes \`phronesis due\`; run schedule completion explicitly instead.`,
205
+ );
206
+ process.exitCode = 1;
207
+ return null;
208
+ }
209
+ process.exitCode = 0;
210
+ try {
211
+ process.chdir(installRoot);
212
+ await runCliDispatch(args);
213
+ code = process.exitCode ?? 0;
214
+ } finally {
215
+ process.chdir(priorCwd);
216
+ }
217
+ }
218
+
219
+ if (code !== 0) {
220
+ console.error(`due run: target for "${scheduleId}" exited non-zero (${code}); schedule not completed.`);
221
+ process.exitCode = code || 1;
222
+ return null;
223
+ }
224
+
225
+ const result = await markScheduleCompleted({
226
+ installRoot,
227
+ scheduleId,
228
+ actor: process.env.PHRONESIS_ACTOR || 'operator',
229
+ surface: 'cli',
230
+ ranTarget: entry.target,
231
+ });
232
+ console.log(`Ran "${result.schedule_id}" (${entry.kind}:${entry.target}) and marked complete (${result.last_completed}).`);
233
+ process.exitCode = 0;
234
+ return result;
235
+ } catch (err) {
236
+ console.error(`due run: ${err.message}`);
237
+ process.exitCode = 1;
238
+ return null;
239
+ }
240
+ }
241
+
242
+ // The explicit, surface-independent completion floor (D4): advance last_completed for <id>.
243
+ // Required because Codex emits no `skill.completed`, so automatic advancement cannot be the
244
+ // only path. Refuses an id absent from the registry.
245
+ export async function runDueComplete({ dir, scheduleId } = {}) {
246
+ const installRoot = installOrFail(dir);
247
+ if (!installRoot) return null;
248
+ if (!scheduleId) {
249
+ console.error('due complete: a schedule <id> is required (e.g. `phronesis due complete lint-weekly`).');
250
+ process.exitCode = 1;
251
+ return null;
252
+ }
253
+ try {
254
+ const result = await markScheduleCompleted({
255
+ installRoot,
256
+ scheduleId,
257
+ // `due complete` is operator-invoked (packet D4) → attribute the audit trail to the
258
+ // operator by default; a surface adapter that runs it on the operator's behalf overrides
259
+ // via PHRONESIS_ACTOR (Codex round-7 F2).
260
+ actor: process.env.PHRONESIS_ACTOR || 'operator',
261
+ surface: 'cli',
262
+ });
263
+ console.log(`Marked "${result.schedule_id}" complete (${result.last_completed}).`);
264
+ process.exitCode = 0;
265
+ return result;
266
+ } catch (err) {
267
+ console.error(err.message);
268
+ process.exitCode = 1;
269
+ return null;
270
+ }
271
+ }