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