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,535 @@
1
+ // Schedule registry + cadence primitive (core-0051; change 0042 — RATIFIED 2026-07-04).
2
+ //
3
+ // The V1-shaped PULL layer of the scheduled-invocation contract: a typed, operator-authored
4
+ // schedule registry, a deterministic `phronesis due` read, the session-start due-check
5
+ // extension that emits `schedule.due`, and the completion floor. No daemon, no headless
6
+ // model call, no new action types (the daemon time-watch + auto-run is Layer 2 / V1.5).
7
+ //
8
+ // SEPARATE FAILURE DOMAIN (ratification (a)): declarations live in `.phronesis/schedule.json`,
9
+ // NOT inside `registry.json`. `registry.json` is the strict all-or-nothing action-layer loader
10
+ // (core-0011: present-but-invalid disables the section); a malformed schedule entry must never
11
+ // take the action layer down with it. So this module owns its own load/validate, and a bad
12
+ // schedule is a `validate` hard-fail + a `doctor` warn — never an action-layer outage.
13
+ //
14
+ // DECLARATIONS vs RUNTIME STATE (ratification (d), change 0008 tracked-vs-derived line):
15
+ // - `.phronesis/schedule.json` — durable operator-authored cadence; TRACKED + exported.
16
+ // - `.phronesis/state/schedule-state.json` — mutable `last_completed` per id; GITIGNORED +
17
+ // export-excluded. Losing it degrades honestly: every schedule surfaces as due once and
18
+ // completion rebuilds from operator confirmations — over-surfacing, never a silent skip.
19
+ //
20
+ // Pure core: no CLI, no provider/model endpoint, no network. The zero-API-key guard (D6) is a
21
+ // property of this file — nothing here reaches a model; only an operator session runs a target.
22
+
23
+ import { join, resolve, relative, isAbsolute, sep } from 'node:path';
24
+ import { readFile, writeFile, rename } from 'node:fs/promises';
25
+ import { statSync, realpathSync } from 'node:fs';
26
+ import fse from 'fs-extra';
27
+ import { RE_SLUG } from './contract.js';
28
+ import { appendAuditEntry } from './hook-executor.js';
29
+ import { appendEvent, localDateStamp } from './event-log.js';
30
+
31
+ // The install-relative paths (single source — validators, doctor, export, and the scaffold
32
+ // all key on these two strings so a rename can never half-land).
33
+ export const SCHEDULE_REGISTRY_REL = join('.phronesis', 'schedule.json');
34
+ export const SCHEDULE_STATE_REL = join('.phronesis', 'state', 'schedule-state.json');
35
+
36
+ // The three target kinds (proposal §"schedule registry"). `skill` runs inside an operator
37
+ // session; `cli_verb`/`connector_pull` are deterministic, model-free work.
38
+ export const SCHEDULE_KINDS = Object.freeze(['skill', 'cli_verb', 'connector_pull']);
39
+
40
+ // The only fields a schedule entry may carry. Unknown keys are REFUSED (validate hard-fails):
41
+ // the registry is contract, not a junk drawer — and JSON has no comments, so a `$comment`
42
+ // workaround is forbidden by exactly this refusal (change 0041 / F2).
43
+ const ENTRY_KEYS = new Set(['id', 'kind', 'target', 'cadence', 'unattended', 'domain', 'classification']);
44
+
45
+ // V1 cadence grammar (ratification (b)): SIMPLE DAY INTERVALS only — `1d`/`7d`/`30d`-class.
46
+ // The overdue unit is whole days (`days_overdue`). Hour-grain and cron are deferred + additive
47
+ // (V1's only execution layer is the session-start pull, so a sub-day cadence could not fire
48
+ // more often than sessions anyway). A leading-zero / zero / non-positive interval is invalid.
49
+ const CADENCE_RE = /^[1-9][0-9]*d$/;
50
+
51
+ export function parseCadenceDays(cadence) {
52
+ if (typeof cadence !== 'string' || !CADENCE_RE.test(cadence)) return null;
53
+ const n = Number(cadence.slice(0, -1));
54
+ // Reject overflow: a huge digit string matches the regex but `Number(...)` is `Infinity`
55
+ // (or a precision-losing value), which would pass validation and then make every due check
56
+ // compute `daysBetween - Infinity` ⇒ negative ⇒ silently never due (Codex round-8 F2).
57
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
58
+ }
59
+
60
+ function isPlainObject(v) {
61
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
62
+ }
63
+
64
+ // Strict YYYY-MM-DD calendar validation → UTC ms, or null. A value that MATCHES the regex but
65
+ // is not a real calendar date (`2026-02-31`, `9999-99-99`) must be rejected, not normalized:
66
+ // `Date.UTC` silently rolls `2026-02-31` to Mar 3, which would let CORRUPT runtime state
67
+ // masquerade as a real completion and SILENTLY SKIP a due schedule (Codex round-4 F2). The
68
+ // round-trip check (reconstructed components === input) is what makes corrupt state degrade to
69
+ // "absent" ⇒ over-surface, per the state-loss contract.
70
+ function parseDateStamp(s) {
71
+ if (typeof s !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
72
+ const [y, m, d] = s.split('-').map(Number);
73
+ const utc = Date.UTC(y, m - 1, d);
74
+ const back = new Date(utc);
75
+ if (back.getUTCFullYear() !== y || back.getUTCMonth() !== m - 1 || back.getUTCDate() !== d) return null;
76
+ return utc;
77
+ }
78
+
79
+ // Whole days between two VALIDATED YYYY-MM-DD stamps (UTC date math — both are local stamps, so
80
+ // the difference is exact whole days). Callers must pre-validate with parseDateStamp.
81
+ function daysBetween(fromStamp, toStamp) {
82
+ const toUtc = (s) => {
83
+ const [y, m, d] = s.split('-').map(Number);
84
+ return Date.UTC(y, m - 1, d);
85
+ };
86
+ return Math.round((toUtc(toStamp) - toUtc(fromStamp)) / 86_400_000);
87
+ }
88
+
89
+ // --- Load (tolerant reads; validate owns the hard errors) --------------------------------
90
+
91
+ // Read `.phronesis/schedule.json`. Only a genuinely-ABSENT file (ENOENT) is the honest empty
92
+ // registry — a fresh install ships `{"schedules":[]}` but a hand-deleted file behaves the same.
93
+ // A PRESENT-but-unreadable path (a directory at that name → EISDIR, a permission error → EACCES)
94
+ // is NOT "absent": it returns `{ schedules: [], error }` so `validate` fails closed and
95
+ // `due`/`doctor` surface the fault instead of silently skipping declared schedules (rules.md
96
+ // core-0012: an error-swallowing reader makes a broken file read as empty). A malformed-JSON /
97
+ // wrong-shape file likewise carries an `error`.
98
+ export async function loadScheduleRegistry({ installRoot } = {}) {
99
+ let raw;
100
+ try {
101
+ raw = await readFile(join(installRoot, SCHEDULE_REGISTRY_REL), 'utf8');
102
+ } catch (err) {
103
+ if (err.code === 'ENOENT') return { schedules: [] };
104
+ return { schedules: [], error: `schedule.json is present but unreadable: ${err.message}` };
105
+ }
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(raw);
109
+ } catch (err) {
110
+ return { schedules: [], error: `schedule.json is not valid JSON: ${err.message}` };
111
+ }
112
+ if (!isPlainObject(parsed) || !Array.isArray(parsed.schedules)) {
113
+ return { schedules: [], error: 'schedule.json must be a JSON object of shape { "schedules": [ … ] }' };
114
+ }
115
+ return { schedules: parsed.schedules, raw: parsed };
116
+ }
117
+
118
+ // Read the runtime state file. Missing OR malformed → an empty state (over-surface, never a
119
+ // silent skip — the whole point of the declarations/state split).
120
+ export async function loadScheduleState({ installRoot } = {}) {
121
+ try {
122
+ const raw = await readFile(join(installRoot, SCHEDULE_STATE_REL), 'utf8');
123
+ const parsed = JSON.parse(raw);
124
+ if (isPlainObject(parsed) && isPlainObject(parsed.last_completed)) {
125
+ return { last_completed: { ...parsed.last_completed } };
126
+ }
127
+ } catch {
128
+ // fall through to empty
129
+ }
130
+ return { last_completed: {} };
131
+ }
132
+
133
+ // Atomically persist the runtime state (temp + rename — rules.md core-0003 atomic-write rule;
134
+ // a crash mid-write leaves EITHER the old file or the new, never a torn one). Creates
135
+ // `.phronesis/state/` on first write (the dir is NOT scaffolded — it is gitignored runtime
136
+ // state that exists only once a completion has been recorded).
137
+ export async function writeScheduleState({ installRoot, state } = {}) {
138
+ const abs = join(installRoot, SCHEDULE_STATE_REL);
139
+ await fse.ensureDir(join(installRoot, '.phronesis', 'state'));
140
+ const body = { last_completed: state.last_completed || {} };
141
+ const tmp = `${abs}.partial-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
142
+ await writeFile(tmp, `${JSON.stringify(body, null, 2)}\n`);
143
+ await rename(tmp, abs);
144
+ return body;
145
+ }
146
+
147
+ // --- Validation (the `phronesis validate` schedule pass; FS-aware for connector_pull) -----
148
+
149
+ // Validate `.phronesis/schedule.json`. Returns { ok, errors } — a flat list of specific
150
+ // reasons (rules.md: assert the specific reason). A MISSING registry is valid (empty). Every
151
+ // class of fault is a HARD error (refuse), so a contract-breaking schedule can never be blessed:
152
+ // - top-level shape / unknown top-level key
153
+ // - per-entry: missing/duplicate/non-slug id, unknown kind, empty target, bad cadence,
154
+ // non-boolean unattended, non-slug domain, unknown classification, UNKNOWN FIELD
155
+ // - connector_pull target: absolute path, resolution outside the install root, or a target
156
+ // that is not a regular file with the executable bit (build directive 2026-07-04: each
157
+ // REFUSES — fail-closed, not warn; a schedule that pulls an external account must point at
158
+ // a real, install-owned, runnable target or it does not load).
159
+ export async function validateScheduleRegistry({ installRoot } = {}) {
160
+ let raw;
161
+ try {
162
+ raw = await readFile(join(installRoot, SCHEDULE_REGISTRY_REL), 'utf8');
163
+ } catch (err) {
164
+ if (err.code === 'ENOENT') return { ok: true, errors: [] }; // genuinely absent ⇒ nothing to validate
165
+ // Present-but-unreadable (a directory at the path, a permission error) FAILS CLOSED — never
166
+ // indistinguishable from "no registry" (Codex F3; rules.md core-0012 error-swallowing reader).
167
+ return { ok: false, errors: [`schedule.json is present but unreadable: ${err.message}`] };
168
+ }
169
+ let parsed;
170
+ try {
171
+ parsed = JSON.parse(raw);
172
+ } catch (err) {
173
+ return { ok: false, errors: [`schedule.json is not valid JSON: ${err.message}`] };
174
+ }
175
+ const errors = [];
176
+ if (!isPlainObject(parsed)) {
177
+ return { ok: false, errors: ['schedule.json must be a JSON object of shape { "schedules": [ … ] }'] };
178
+ }
179
+ for (const key of Object.keys(parsed)) {
180
+ if (key !== 'schedules') errors.push(`schedule.json: unknown top-level key "${key}" (only "schedules" is allowed — the registry is contract, not a junk drawer; JSON has no comments)`);
181
+ }
182
+ if (!Array.isArray(parsed.schedules)) {
183
+ errors.push('schedule.json: "schedules" must be an array');
184
+ return { ok: false, errors };
185
+ }
186
+
187
+ const seenIds = new Set();
188
+ parsed.schedules.forEach((entry, i) => {
189
+ const at = `schedule[${i}]`;
190
+ if (!isPlainObject(entry)) {
191
+ errors.push(`${at}: each schedule must be a JSON object`);
192
+ return;
193
+ }
194
+ for (const key of Object.keys(entry)) {
195
+ if (!ENTRY_KEYS.has(key)) errors.push(`${at}: unknown field "${key}" (allowed: ${[...ENTRY_KEYS].join(', ')})`);
196
+ }
197
+ // id — required, unique, kebab-case slug (the same grammar the rest of the substrate uses).
198
+ const id = entry.id;
199
+ if (typeof id !== 'string' || !RE_SLUG.test(id)) {
200
+ errors.push(`${at}: "id" is required and must be a kebab-case slug`);
201
+ } else if (seenIds.has(id)) {
202
+ errors.push(`${at}: duplicate id "${id}" (schedule ids must be unique)`);
203
+ } else {
204
+ seenIds.add(id);
205
+ }
206
+ // kind
207
+ if (!SCHEDULE_KINDS.includes(entry.kind)) {
208
+ errors.push(`${at}${idTag(id)}: "kind" must be one of ${SCHEDULE_KINDS.join(' | ')}`);
209
+ }
210
+ // target
211
+ if (typeof entry.target !== 'string' || entry.target.trim() === '') {
212
+ errors.push(`${at}${idTag(id)}: "target" is required and must be a non-empty string`);
213
+ } else if (entry.kind === 'skill' && !RE_SLUG.test(entry.target)) {
214
+ // A skill target is a skill NAME (a kebab-case slug). Auto-completion matches it exactly
215
+ // against `skill.completed`'s skill_name, so a typo with whitespace ("lint ") or a bad slug
216
+ // would pass a non-empty-string check yet never auto-complete (Codex round-9 F1). Fail it
217
+ // here. (Registration is NOT required — an operator may forward-declare a skill they will
218
+ // install; a slug is the format guarantee.)
219
+ errors.push(`${at}${idTag(id)}: a "skill" target must be a kebab-case skill name (no whitespace, e.g. "lint" or "prd-draft")`);
220
+ } else if (entry.kind === 'connector_pull') {
221
+ const targetErr = connectorPullTargetError(installRoot, entry.target);
222
+ if (targetErr) errors.push(`${at}${idTag(id)}: ${targetErr}`);
223
+ }
224
+ // cadence
225
+ if (parseCadenceDays(entry.cadence) === null) {
226
+ errors.push(`${at}${idTag(id)}: "cadence" must be a simple day interval (e.g. 1d, 7d, 30d) — V1 grammar (hours/cron deferred)`);
227
+ }
228
+ // unattended — optional; boolean only
229
+ if ('unattended' in entry && typeof entry.unattended !== 'boolean') {
230
+ errors.push(`${at}${idTag(id)}: "unattended" must be a boolean (default false)`);
231
+ }
232
+ // domain — optional; slug
233
+ if ('domain' in entry && (typeof entry.domain !== 'string' || !RE_SLUG.test(entry.domain))) {
234
+ errors.push(`${at}${idTag(id)}: "domain" must be a kebab-case domain slug`);
235
+ }
236
+ // classification — optional; V1 supports ONLY `acknowledged`. The `on_schedule_due` posture
237
+ // is fixed acknowledged (specs/hooks.md — batched, dismissible, attributed to the operator),
238
+ // and the due-check surfaces every schedule that way; accepting `silent`/`interactive` would
239
+ // be valid data the runtime silently ignores (Codex round-9 F2). Restrict to what V1 honors;
240
+ // silent/interactive schedule surfacing is a deferred widening.
241
+ if ('classification' in entry && entry.classification !== 'acknowledged') {
242
+ errors.push(`${at}${idTag(id)}: V1 supports only "classification": "acknowledged" for schedules (silent/interactive surfacing is deferred)`);
243
+ }
244
+ });
245
+
246
+ return { ok: errors.length === 0, errors };
247
+ }
248
+
249
+ function idTag(id) {
250
+ return typeof id === 'string' && id ? ` "${id}"` : '';
251
+ }
252
+
253
+ function outsideRoot(root, target) {
254
+ const rel = relative(root, target);
255
+ // Outside when rel is empty (== root itself), is `..`/`../…`, or is absolute. Anchored on
256
+ // `sep` so a legitimately-named file like `..foo` is NOT a false positive.
257
+ return rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
258
+ }
259
+
260
+ // connector_pull target rule (F6 + build directive 2026-07-04). The operator-authored entry IS
261
+ // the allowlist (no bridge registry in V1), so the target must be an install-relative, in-root,
262
+ // runnable executable. Containment is checked BOTH lexically (a clean error on an obvious `../`)
263
+ // AND after realpath resolution of BOTH the target and the install root (Codex F1; rules.md
264
+ // core-0034: realpath both sides) — so an in-root SYMLINK pointing at an executable OUTSIDE the
265
+ // root cannot slip past a purely-lexical check (statSync/realpath follow links). Returns an
266
+ // error STRING (refuse) or null (ok).
267
+ function connectorPullTargetError(installRoot, target) {
268
+ if (isAbsolute(target)) {
269
+ return `connector_pull target "${target}" must be install-relative, not an absolute path`;
270
+ }
271
+ const resolved = resolve(installRoot, target);
272
+ if (outsideRoot(installRoot, resolved)) {
273
+ return `connector_pull target "${target}" resolves outside the install root`;
274
+ }
275
+ // Physical containment: realpath BOTH sides (the install root may itself sit under a symlinked
276
+ // prefix — /tmp→/private/tmp, /var/folders — so normalizing only one side breaks the compare).
277
+ let realTarget;
278
+ try {
279
+ realTarget = realpathSync(resolved);
280
+ } catch {
281
+ return `connector_pull target "${target}" does not exist under the install root`;
282
+ }
283
+ let realRoot;
284
+ try {
285
+ realRoot = realpathSync(installRoot);
286
+ } catch {
287
+ realRoot = installRoot;
288
+ }
289
+ if (outsideRoot(realRoot, realTarget)) {
290
+ return `connector_pull target "${target}" resolves (via a symlink) outside the install root`;
291
+ }
292
+ let st;
293
+ try {
294
+ st = statSync(realTarget);
295
+ } catch {
296
+ return `connector_pull target "${target}" does not exist under the install root`;
297
+ }
298
+ if (!st.isFile() || !(st.mode & 0o111)) {
299
+ return `connector_pull target "${target}" is not an executable file (needs the executable bit)`;
300
+ }
301
+ return null;
302
+ }
303
+
304
+ // --- Due computation (D2 — a PURE read: emits nothing, writes nothing) --------------------
305
+
306
+ // Overdue entries as a deterministic list `{ schedule_id, target, kind, days_overdue }`, sorted
307
+ // by schedule_id. Pure function of (registry, state, clock): the completion source of truth is
308
+ // state.last_completed (advanced by the completion floor + the session-start reconcile). A
309
+ // never-completed entry surfaces once as due with days_overdue 0 (there is no anchor to measure
310
+ // overdue-ness against, and inventing one would be dishonest). Malformed / cadence-less entries
311
+ // are SKIPPED here (validate is the place that fails on them) so `due` degrades, never throws.
312
+ export async function computeScheduleDue({ installRoot, now = new Date() } = {}) {
313
+ // Fail closed (Codex round-2 F1): a registry that would fail `validate` surfaces NOTHING.
314
+ // due/due-check must never return or emit an entry the contract rejects — a bad `kind`, an
315
+ // empty `target`, or a `connector_pull` target outside the root or without the exec bit.
316
+ // Surfacing/emitting those would half-legitimize an invalid declaration; `validate` is the fix
317
+ // path (all-or-nothing, mirroring the validator's own semantics). Pure: validate only reads.
318
+ const { ok } = await validateScheduleRegistry({ installRoot });
319
+ if (!ok) return [];
320
+ const today = localDateStamp(now);
321
+ const { schedules } = await loadScheduleRegistry({ installRoot });
322
+ const { last_completed } = await loadScheduleState({ installRoot });
323
+ const due = [];
324
+ for (const entry of schedules) {
325
+ if (!isPlainObject(entry)) continue;
326
+ const { id, target, kind, cadence } = entry;
327
+ if (typeof id !== 'string' || !id) continue;
328
+ const cadenceDays = parseCadenceDays(cadence);
329
+ if (cadenceDays === null) continue;
330
+ const last = last_completed[id];
331
+ let daysOverdue;
332
+ if (parseDateStamp(last) === null || last > today) {
333
+ // Never completed, state lost, a corrupt/non-calendar date, OR a nonsensical FUTURE
334
+ // completion (clock skew / corruption — you cannot complete work in the future; Codex
335
+ // round-6 F2) ⇒ surface once (over-surface, never a silent skip). A regex-matching-but-
336
+ // invalid or future date is treated as absent, never trusted to suppress a due schedule.
337
+ daysOverdue = 0;
338
+ } else {
339
+ daysOverdue = daysBetween(last, today) - cadenceDays;
340
+ if (daysOverdue < 0) continue; // not yet due
341
+ }
342
+ due.push({ schedule_id: id, target, kind, days_overdue: daysOverdue });
343
+ }
344
+ due.sort((a, b) => (a.schedule_id < b.schedule_id ? -1 : a.schedule_id > b.schedule_id ? 1 : 0));
345
+ return due;
346
+ }
347
+
348
+ // --- Completion (advance last_completed) --------------------------------------------------
349
+
350
+ // The EXPLICIT completion floor (`phronesis due complete <id>`; D4). Surface-independent —
351
+ // required because Codex emits no `skill.completed`, so automatic advancement cannot be the
352
+ // only path. Refuses an id absent from the registry (you cannot complete what you never
353
+ // scheduled). Advances last_completed to the local day, persists, and writes the ordinary audit
354
+ // trail (D4): a `schedule.completed` line to `.phronesis/audit.log` — the general operational
355
+ // audit channel (gitignored/export-excluded, same class as the hook audit). The skill-boundary
356
+ // advance needs no separate audit: the `skill.completed` event IS its trail.
357
+ export async function markScheduleCompleted({ installRoot, scheduleId, actor = 'operator', surface = 'cli', now = new Date(), ranTarget } = {}) {
358
+ if (typeof scheduleId !== 'string' || scheduleId === '') {
359
+ throw new Error('due complete: a schedule id is required.');
360
+ }
361
+ // Fail closed (Codex round-4 F1): never RECORD completion against an invalid registry — a
362
+ // duplicate/ambiguous declaration would poison `last_completed` and then suppress the schedule
363
+ // the operator keeps after fixing the duplicate. `validate` is the fix path.
364
+ const { ok, errors } = await validateScheduleRegistry({ installRoot });
365
+ if (!ok) {
366
+ throw new Error(`due complete: schedule.json is invalid — run \`phronesis validate\` first (${errors[0]}).`);
367
+ }
368
+ const { schedules } = await loadScheduleRegistry({ installRoot });
369
+ const entry = schedules.find((e) => isPlainObject(e) && e.id === scheduleId);
370
+ if (!entry) {
371
+ throw new Error(`due complete: no schedule "${scheduleId}" in .phronesis/schedule.json.`);
372
+ }
373
+ const state = await loadScheduleState({ installRoot });
374
+ const completedOn = localDateStamp(now);
375
+ // AUDIT-FIRST, then persist state (PR #138 P2). If the audit append fails AFTER the state
376
+ // advanced, the schedule silently vanishes from `due` with no audit line — the exact silent
377
+ // skip this file promises never to do. Ordering the audit first makes BOTH failure modes fail
378
+ // in the safe (over-surface) direction: an audit failure leaves state un-advanced (still due);
379
+ // a state-write failure after the audit leaves an audit line for a not-yet-persisted completion
380
+ // (a record of intent) with the schedule still due — never a silent skip.
381
+ await appendAuditEntry(installRoot, {
382
+ event_type: 'schedule.completed',
383
+ schedule_id: scheduleId,
384
+ kind: entry.kind,
385
+ target: entry.target,
386
+ completed_on: completedOn,
387
+ actor,
388
+ surface,
389
+ outcome: 'schedule-completed',
390
+ ...(ranTarget ? { ran_target: ranTarget } : {}),
391
+ });
392
+ state.last_completed[scheduleId] = completedOn;
393
+ await writeScheduleState({ installRoot, state });
394
+ return { schedule_id: scheduleId, last_completed: completedOn };
395
+ }
396
+
397
+ // Automatic sugar for lifecycle-full surfaces (D4): when a `skill.completed` fires, advance
398
+ // every `kind: skill` schedule whose target is that skill — AT THE COMPLETION BOUNDARY, keyed
399
+ // on the completion's own day. Deliberately NOT a scan of the whole event log (Codex F2): a
400
+ // history replay would rebuild completion from arbitrary past events, so a state-file loss could
401
+ // be silently "healed" by a stale `skill.completed` and SUPPRESS the promised one-time
402
+ // resurfacing (proposal: state loss ⇒ every schedule surfaces as due once). Completion is
403
+ // recorded FORWARD from the event, never reconstructed backward. Persists; returns the advanced
404
+ // ids. Codex emits no `skill.completed`, so this never fires there — the explicit
405
+ // `due complete` floor is the surface-independent path.
406
+ export async function advanceSchedulesForSkill({ installRoot, skillName, now = new Date() } = {}) {
407
+ if (typeof skillName !== 'string' || skillName === '') return { advanced: [] };
408
+ // Fail safe (Codex round-4 F1): never mutate state against an invalid registry — no-op until
409
+ // `validate` is clean. (The explicit floor `due complete` refuses louder; this best-effort
410
+ // sugar, called from the guarded lifecycle-close boundary, simply declines.)
411
+ const { ok } = await validateScheduleRegistry({ installRoot });
412
+ if (!ok) return { advanced: [] };
413
+ const { schedules } = await loadScheduleRegistry({ installRoot });
414
+ const matches = schedules.filter(
415
+ (e) =>
416
+ isPlainObject(e) &&
417
+ e.kind === 'skill' &&
418
+ e.target === skillName &&
419
+ typeof e.id === 'string' &&
420
+ e.id &&
421
+ // Domain-SCOPED skill schedules are NOT auto-advanced (Codex round-6 F1): `skill.completed`
422
+ // carries no domain (D4: no new telemetry), so advancing by skill name alone would wrongly
423
+ // complete a DIFFERENT domain's same-named schedule (two `lint` entries scoped pm vs sales).
424
+ // Those use the explicit `due complete <id>` floor, where the operator knows the domain.
425
+ // A domain-AGNOSTIC schedule (no `domain`) auto-advances as before.
426
+ !e.domain,
427
+ );
428
+ if (matches.length === 0) return { advanced: [] };
429
+ const state = await loadScheduleState({ installRoot });
430
+ const completedOn = localDateStamp(now);
431
+ const advanced = [];
432
+ for (const entry of matches) {
433
+ const priorStr = state.last_completed[entry.id];
434
+ // Overwrite unless the prior is a VALID completion already on this exact day. This clears an
435
+ // absent/corrupt prior AND a FUTURE prior (> the completion day — clock skew/corruption),
436
+ // matching computeScheduleDue's future-date policy so a real skill run always un-sticks a
437
+ // schedule instead of leaving a `2099-01-01` value due forever (Codex round-8 F1). At the
438
+ // real-time completion boundary a legitimate prior is always <= the completion day, so this
439
+ // only ever regresses a corrupt future value, never a real later completion.
440
+ if (parseDateStamp(priorStr) === null || priorStr !== completedOn) {
441
+ state.last_completed[entry.id] = completedOn;
442
+ advanced.push(entry.id);
443
+ }
444
+ }
445
+ if (advanced.length) await writeScheduleState({ installRoot, state });
446
+ return { advanced };
447
+ }
448
+
449
+ // --- Session-start due-check extension (D3 — the authoritative `schedule.due` emitter) -----
450
+
451
+ // Compute + emit: one `schedule.due` per due entry through core appendEvent — the SAME
452
+ // authoritative path the V1.5 daemon time-watch will drive, and the reason freehand
453
+ // `event emit schedule.due` is refused (the event's provenance stays with the due-check).
454
+ // Completion is advanced at the skill.completed boundary (advanceSchedulesForSkill) and by the
455
+ // explicit floor — NOT reconstructed here — so this stays a compute-then-emit with no history
456
+ // scan. Returns the due list + events.
457
+ export async function runScheduleDueCheck({
458
+ installRoot,
459
+ session,
460
+ actor = 'agent',
461
+ surface = 'cli',
462
+ now = new Date(),
463
+ } = {}) {
464
+ if (typeof session !== 'string' || session === '') {
465
+ throw new Error('schedule due-check: session is required (a non-empty session id).');
466
+ }
467
+ const due = await computeScheduleDue({ installRoot, now });
468
+ const events = [];
469
+ for (const d of due) {
470
+ events.push(
471
+ await appendEvent({
472
+ installRoot,
473
+ type: 'schedule.due',
474
+ session_id: session,
475
+ actor,
476
+ surface,
477
+ payload: { schedule_id: d.schedule_id, target: d.target, kind: d.kind, days_overdue: d.days_overdue },
478
+ now,
479
+ }),
480
+ );
481
+ }
482
+ return { due, events };
483
+ }
484
+
485
+ // --- validate warn-and-prune + doctor staleness -------------------------------------------
486
+
487
+ // Orphan state entries (a last_completed keyed by an id with no matching declaration) are
488
+ // warn-and-pruned by `phronesis validate` (D1b). Fails SAFE: if the registry is malformed we
489
+ // cannot know the valid id set, so nothing is pruned (never delete state we cannot confirm is
490
+ // orphaned). Returns the pruned ids.
491
+ export async function pruneOrphanScheduleState({ installRoot } = {}) {
492
+ // Never prune against an unreliable declared set (Codex round-2 F2): an entry-level-invalid
493
+ // registry (bad kind, duplicate id, unknown field, bad connector target) can misrepresent
494
+ // which ids are declared, so a real completion could be mis-pruned. Fail SAFE on ANY
495
+ // validation failure, not just parse/shape errors — prune nothing until `validate` is clean.
496
+ const { ok } = await validateScheduleRegistry({ installRoot });
497
+ if (!ok) return { pruned: [] };
498
+ const registry = await loadScheduleRegistry({ installRoot });
499
+ const declared = new Set(registry.schedules.filter(isPlainObject).map((e) => e.id));
500
+ const state = await loadScheduleState({ installRoot });
501
+ const orphans = Object.keys(state.last_completed).filter((id) => !declared.has(id));
502
+ if (orphans.length === 0) return { pruned: [] };
503
+ for (const id of orphans) delete state.last_completed[id];
504
+ try {
505
+ await writeScheduleState({ installRoot, state });
506
+ } catch (err) {
507
+ // A prune is a NICETY (D1b warn-and-prune), not a contract check — a state-write failure
508
+ // (EACCES on a read-only state dir, a full disk) must never crash `phronesis validate`.
509
+ // Return the error so the caller WARNS; the orphan entries simply remain until a writable
510
+ // run cleans them (they cannot cause a wrong due result — an orphan has no declaration).
511
+ return { pruned: [], error: `could not prune ${orphans.length} orphan state entr(ies): ${err.message}` };
512
+ }
513
+ return { pruned: orphans };
514
+ }
515
+
516
+ // Doctor's schedule-staleness warn (D5): entries overdue by MORE THAN `factor` × cadence.
517
+ // Pure read (doctor is byte-identical). A never-completed entry (days_overdue 0) is due, not
518
+ // stale. Returns `{ malformed?, stale: [{ schedule_id, days_overdue, cadence_days }] }`.
519
+ export async function scheduleStaleness({ installRoot, now = new Date(), factor = 2 } = {}) {
520
+ const registry = await loadScheduleRegistry({ installRoot });
521
+ if (registry.error) return { malformed: registry.error, stale: [] };
522
+ const cadenceById = new Map();
523
+ for (const e of registry.schedules) {
524
+ if (isPlainObject(e) && typeof e.id === 'string') cadenceById.set(e.id, parseCadenceDays(e.cadence));
525
+ }
526
+ const due = await computeScheduleDue({ installRoot, now });
527
+ const stale = [];
528
+ for (const d of due) {
529
+ const cadenceDays = cadenceById.get(d.schedule_id);
530
+ if (cadenceDays && d.days_overdue > factor * cadenceDays) {
531
+ stale.push({ schedule_id: d.schedule_id, days_overdue: d.days_overdue, cadence_days: cadenceDays });
532
+ }
533
+ }
534
+ return { stale };
535
+ }