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,534 @@
1
+ // Two-stage retrieval — Stage 1 in-process BM25 + Stage 2 typed link walk
2
+ // (core-0014 / T5.2; specs/compile-retrieve-loop.md §"Two-stage retrieval" + §"The V1
3
+ // retrieval surface contract"; specs/tech-stack.md §"Search stack"; change 0014).
4
+ //
5
+ // Library-first (specs/context-server.md §"V1.0 preparation"): every retrieval
6
+ // primitive lives HERE — the CLI parses and formats only, and the V1.5 context server's
7
+ // `recall()` imports these same functions. The BM25 engine is `lunr` (the spec's
8
+ // "`flexsearch`/`lunr`-class" family): its scoring is actual BM25, it exposes the
9
+ // numeric `score` the surface contract requires, and its stemmer + position metadata
10
+ // drive recall and snippet extraction. Known limits: exact-token + stemmed matching
11
+ // (no fuzzy/prefix by default); the in-memory index is rebuilt from the snapshot at
12
+ // load (lunr indexes are immutable — irrelevant under read-time loading).
13
+ //
14
+ // The persisted machine index (.phronesis/index/objects.json) is a PARSE CACHE —
15
+ // derived, gitignored, export-stripped, rebuildable (change 0008) — never a freshness
16
+ // authority and never source of truth. Freshness is READ-TIME verify-and-repair
17
+ // (change 0014 R1): every load stats the live object file set, re-parses only
18
+ // changed/new files (mtimeMs+ctimeMs+size quick-check — ctime is kernel-set on every
19
+ // write and userspace cannot restore it, so mtime-preserving tools like `rsync -a` /
20
+ // `tar -xp` / `cp -p` still trigger repair; the residual hole is a same-size edit
21
+ // inside one ctime tick, unreachable in practice, and `phronesis index rebuild` is
22
+ // the recovery),
23
+ // drops deleted ones, and persists the repaired snapshot atomically (temp + rename,
24
+ // partial removed on failure). Out-of-band edits — the operator in any editor — are a
25
+ // first-class write path under the plain-files promise; an index refreshed only on
26
+ // `act` commits would go silently stale on exactly the false-negative class the
27
+ // validate-search gate exists to catch. The V1.5 daemon's at-rest watch ADDS
28
+ // push-freshness for long-running consumers; it is not load-bearing for correctness.
29
+ //
30
+ // A search-path persist failure degrades to a stderr-visible warning, never a refusal:
31
+ // the snapshot is a cache, and retrieval must keep working in a read-only checkout
32
+ // (an exported, re-opened installation). An EXPLICIT `phronesis index rebuild` that
33
+ // cannot write its artifact fails loudly — that command's whole job is the artifact.
34
+ //
35
+ // Concurrency: same one-writer-per-installation posture as the event log + audit log.
36
+ // Concurrent loads may race the persist; the atomic rename means readers only ever see
37
+ // a complete snapshot, and the loser's work is re-derived on the next load.
38
+
39
+ import { join, dirname } from 'node:path';
40
+ import { mkdir, readFile, writeFile, rename, rm, stat } from 'node:fs/promises';
41
+ import { randomBytes } from 'node:crypto';
42
+ import lunr from 'lunr';
43
+ import { listArtifactFiles, readArtifactRecord } from './artifacts.js';
44
+ import { contextFor, listRetrievableObjectFiles, readObjectRecord, walkMarkdown } from './workspace-scan.js';
45
+ import { buildLinkIndexFromRecords } from './link-index.js';
46
+ import { resolveInstall } from './install-root.js';
47
+ import { resolveDomain } from './actions.js';
48
+
49
+ const SNAPSHOT_VERSION = 1;
50
+ const DEFAULT_LIMIT = 10; // the context-server recall(query, limit = 10) default
51
+ const DEFAULT_HOPS = 1; // the spec's "related decisions, people involved, sources they cite" = the 1-hop neighborhood
52
+ const SNIPPET_BEFORE = 60; // chars of context kept before the first body match
53
+ const SNIPPET_AFTER = 90; // chars kept after it
54
+
55
+ export function indexDir(installRoot) {
56
+ return join(installRoot, '.phronesis', 'index');
57
+ }
58
+
59
+ export function snapshotPath(installRoot) {
60
+ return join(indexDir(installRoot), 'objects.json');
61
+ }
62
+
63
+ // --- Snapshot: parse, verify-and-repair, persist ---------------------------------------
64
+
65
+ // Parse one typed-object file into a snapshot doc — the shared readObjectRecord (ONE
66
+ // file→record parse for the link index and this snapshot; the two-consumers rule) plus
67
+ // the stat fields the freshness quick-check keys on. Returns null for non-objects and
68
+ // unparseable files — the index is best-effort over a possibly-imperfect workspace
69
+ // (the validator flags bad objects; the index routes around them). Null-parses are not
70
+ // recorded, so a persistently broken file is re-attempted each load: correctness over
71
+ // micro-cost, and the validator is the surface that names the problem.
72
+ async function parseDoc(root, rel) {
73
+ let st;
74
+ try {
75
+ st = await stat(join(root, rel));
76
+ } catch {
77
+ return null; // vanished mid-scan — the next load sees the final state
78
+ }
79
+ const record = await readObjectRecord(root, rel);
80
+ if (!record) return null;
81
+ const { ctx, fm, body } = record;
82
+ return {
83
+ path: rel,
84
+ layer: ctx.layer,
85
+ domain: ctx.domain,
86
+ codexKind: ctx.codexKind,
87
+ type: fm.type ?? null,
88
+ name: fm.name ?? null,
89
+ title: fm.title ?? null,
90
+ status: fm.status ?? null,
91
+ exposure: fm.exposure ?? null,
92
+ stance: fm.stance ?? null,
93
+ links: Array.isArray(fm.links) ? fm.links : [],
94
+ body,
95
+ mtimeMs: st.mtimeMs,
96
+ ctimeMs: st.ctimeMs,
97
+ size: st.size,
98
+ };
99
+ }
100
+
101
+ // A cached doc is only trusted if its load-bearing fields hold their contract shape:
102
+ // `path`/`mtimeMs`/`size` drive the freshness quick-check, `body` feeds the BM25 index
103
+ // and snippets, `links` feeds the graph. Envelope-level checks alone (version + "docs
104
+ // is an array") would trust a `null` doc (crash) or a `body: null` whose mtime/size
105
+ // still match disk — a SILENTLY re-persisted stale entry hiding a live file's content,
106
+ // the exact false-negative class this module exists to kill. The gate condition must
107
+ // match the invariant, not a convenient type test (the rules.md null/presence family).
108
+ function isValidDoc(d) {
109
+ return (
110
+ d !== null &&
111
+ typeof d === 'object' &&
112
+ !Array.isArray(d) &&
113
+ typeof d.path === 'string' &&
114
+ typeof d.mtimeMs === 'number' &&
115
+ typeof d.ctimeMs === 'number' &&
116
+ typeof d.size === 'number' &&
117
+ typeof d.body === 'string' &&
118
+ Array.isArray(d.links)
119
+ );
120
+ }
121
+
122
+ // Read the persisted snapshot. Absent, corrupt, version-unknown, or carrying ANY
123
+ // malformed doc all yield null — a cache miss is a full re-parse, never a crash and
124
+ // never a trusted lie (the snapshot is derived state; rebuilding it is always safe).
125
+ async function readSnapshot(root) {
126
+ let raw;
127
+ try {
128
+ raw = await readFile(snapshotPath(root), 'utf8');
129
+ } catch {
130
+ return null;
131
+ }
132
+ let parsed;
133
+ try {
134
+ parsed = JSON.parse(raw);
135
+ } catch {
136
+ return null;
137
+ }
138
+ if (
139
+ parsed === null ||
140
+ typeof parsed !== 'object' ||
141
+ parsed.schema_version !== SNAPSHOT_VERSION ||
142
+ !Array.isArray(parsed.docs) ||
143
+ !parsed.docs.every(isValidDoc)
144
+ ) {
145
+ return null;
146
+ }
147
+ return parsed;
148
+ }
149
+
150
+ // The read-time freshness core (S3): reconcile the snapshot against the live object
151
+ // file set. Unchanged files (same mtimeMs + size) keep their cached parse; changed and
152
+ // new files re-parse; files no longer on disk drop. Per-file checks run concurrently
153
+ // (1000 serial stats would put CI's slow disk on the every-search path — the same
154
+ // hardware-bound cost class the perf split exists for). Returns the current doc set
155
+ // plus whether anything differed from the snapshot (deciding whether to re-persist).
156
+ async function verifyAndRepair(root, snapshot) {
157
+ const cached = new Map((snapshot?.docs ?? []).map((d) => [d.path, d]));
158
+ // Retrieval set excludes the quarantined seed library (workspace-scan.js): seeds are
159
+ // discoverable via codex/INDEX.md + lint, never the search index.
160
+ const files = await listRetrievableObjectFiles(root);
161
+ let changed = snapshot === null;
162
+
163
+ const checked = await Promise.all(
164
+ files.map(async (rel) => {
165
+ if (!contextFor(rel)) return null; // not a typed object (codex/INDEX.md)
166
+ const prior = cached.get(rel);
167
+ if (prior) {
168
+ let st;
169
+ try {
170
+ st = await stat(join(root, rel));
171
+ } catch {
172
+ return { rel, doc: null }; // vanished between listing and stat
173
+ }
174
+ if (prior.mtimeMs === st.mtimeMs && prior.ctimeMs === st.ctimeMs && prior.size === st.size) {
175
+ return { rel, doc: prior, reused: true };
176
+ }
177
+ }
178
+ return { rel, doc: await parseDoc(root, rel) };
179
+ }),
180
+ );
181
+
182
+ const docs = [];
183
+ for (const entry of checked) {
184
+ if (entry === null) continue;
185
+ cached.delete(entry.rel);
186
+ if (!entry.reused) changed = true; // new, modified, or newly unparseable
187
+ if (entry.doc) docs.push(entry.doc);
188
+ }
189
+ if (cached.size > 0) changed = true; // deletions
190
+ docs.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
191
+ return { docs, changed };
192
+ }
193
+
194
+ // Atomic persist (the core-0003 artifact rule): write a pid+random temp beside the
195
+ // destination, rename only after success, remove the partial on any failure.
196
+ async function persistSnapshot(root, docs) {
197
+ const file = snapshotPath(root);
198
+ await mkdir(dirname(file), { recursive: true }); // created on demand — never scaffolded
199
+ const tmp = `${file}.partial-${process.pid}-${randomBytes(4).toString('hex')}`;
200
+ try {
201
+ await writeFile(tmp, JSON.stringify({ schema_version: SNAPSHOT_VERSION, docs }));
202
+ await rename(tmp, file);
203
+ } catch (err) {
204
+ await rm(tmp, { force: true });
205
+ throw err;
206
+ }
207
+ }
208
+
209
+ // --- Stage 1: the BM25 index over snapshot docs -----------------------------------------
210
+
211
+ // `type` is deliberately NOT an indexed field: it is a returned facet. Indexing it
212
+ // would make every ontology noun ("decision", "project", "person") match every object
213
+ // of that type, flooding results with score-tied noise and making the honest negative
214
+ // (`empty_result: true`) unreachable for exactly the vocabulary operators use most —
215
+ // found by core-0014's adversarial review, pinned by the type-noun test.
216
+ function buildLunrIndex(docs) {
217
+ return lunr(function () {
218
+ this.ref('path');
219
+ this.field('title', { boost: 8 });
220
+ this.field('name', { boost: 6 });
221
+ this.field('body');
222
+ this.metadataWhitelist = ['position'];
223
+ for (const d of docs) {
224
+ this.add({
225
+ path: d.path,
226
+ title: d.title ?? '',
227
+ name: d.name ?? '',
228
+ body: d.body,
229
+ });
230
+ }
231
+ });
232
+ }
233
+
234
+ // Free-text queries go through lunr.tokenizer + lunr.trimmer + Query.term — NEVER
235
+ // idx.search(raw): the query-string parser assigns syntax to ':'/'~'/'^'/'-', so an
236
+ // ordinary operator question ("what's next: the plan?") would throw QueryParseError.
237
+ // Two pipeline facts decide this shape (both bitten in this packet's own build):
238
+ // Index.query DOES run each clause through the search pipeline (stemmer) — pre-stemming
239
+ // here would double-stem ("decision"→"decis"→"deci", silent recall loss) — but the
240
+ // TRIMMER lives only in the build pipeline, so punctuation must be stripped here or
241
+ // "plan?" never matches the indexed "plan".
242
+ function lunrHits(index, queryStr) {
243
+ const terms = lunr
244
+ .tokenizer(String(queryStr ?? ''))
245
+ .map((token) => lunr.trimmer(token).toString())
246
+ .filter((term) => term !== '');
247
+ if (terms.length === 0) return []; // nothing queryable — the honest negative
248
+ return index.query((q) => {
249
+ for (const term of terms) q.term(term);
250
+ });
251
+ }
252
+
253
+ // Extract a readable snippet around the first body match. Positions refer to the
254
+ // ORIGINAL body string, so the window is cut first and whitespace collapsed after.
255
+ // Title/name-only matches fall back to the body's opening line(s).
256
+ function makeSnippet(body, matchData) {
257
+ let pos = null;
258
+ for (const term of Object.keys(matchData?.metadata ?? {})) {
259
+ const positions = matchData.metadata[term]?.body?.position;
260
+ if (Array.isArray(positions) && positions.length > 0) {
261
+ if (pos === null || positions[0][0] < pos[0]) pos = positions[0];
262
+ }
263
+ }
264
+ let raw;
265
+ let leading = false;
266
+ let trailing = false;
267
+ if (pos === null) {
268
+ raw = body.slice(0, SNIPPET_BEFORE + SNIPPET_AFTER);
269
+ trailing = body.length > raw.length;
270
+ } else {
271
+ const start = Math.max(0, pos[0] - SNIPPET_BEFORE);
272
+ const end = Math.min(body.length, pos[0] + pos[1] + SNIPPET_AFTER);
273
+ raw = body.slice(start, end);
274
+ leading = start > 0;
275
+ trailing = end < body.length;
276
+ }
277
+ const collapsed = raw.replace(/\s+/g, ' ').trim();
278
+ if (collapsed === '') return '';
279
+ return `${leading ? '… ' : ''}${collapsed}${trailing ? ' …' : ''}`;
280
+ }
281
+
282
+ // --- The search index --------------------------------------------------------------------
283
+
284
+ export class SearchIndex {
285
+ constructor({ root, scope, docs, scopedDocs, lunrIndex, linkIndex, allPaths, persistError }) {
286
+ this.root = root;
287
+ this.scope = scope; // { domains: [slug…], codex: true, all_domains: bool }
288
+ this.docs = docs; // every typed object in the install (unscoped)
289
+ this.scopedDocs = scopedDocs;
290
+ this.lunrIndex = lunrIndex;
291
+ this.linkIndex = linkIndex; // built over scopedDocs — Stage 2 walks the SCOPED graph
292
+ this.allPaths = allPaths; // install-wide path set, for the outside-scope relabel
293
+ this.persistError = persistError; // non-null when the cache write failed (degraded, not fatal)
294
+ this.byPath = new Map(scopedDocs.map((d) => [d.path, d]));
295
+ }
296
+
297
+ get size() {
298
+ return this.scopedDocs.length;
299
+ }
300
+
301
+ #result(hit) {
302
+ const doc = this.byPath.get(hit.ref);
303
+ return {
304
+ path: doc.path,
305
+ layer: doc.layer,
306
+ domain: doc.domain,
307
+ type: doc.type,
308
+ name: doc.name,
309
+ title: doc.title,
310
+ score: hit.score,
311
+ snippet: makeSnippet(doc.body, hit.matchData),
312
+ exposure: doc.exposure,
313
+ status: doc.status,
314
+ };
315
+ }
316
+
317
+ // The library IS the contract surface (S1) — the context server calls these methods
318
+ // directly, so the semantics live here, not in CLI parse. A bad limit/hops must
319
+ // refuse: silently clamping limit 0 to an empty result would emit `empty_result:
320
+ // true` — a machine-checkable claim that "no workspace state matched" — when state
321
+ // matched (a lying honest negative, found by the adversarial review).
322
+ #checkLimit(limit) {
323
+ if (!Number.isInteger(limit) || limit < 1) {
324
+ throw new Error(`limit must be a positive integer (got ${JSON.stringify(limit)})`);
325
+ }
326
+ }
327
+
328
+ // Stage 1 — the `phronesis search` surface (S5 envelope).
329
+ search(queryStr, { limit = DEFAULT_LIMIT } = {}) {
330
+ this.#checkLimit(limit);
331
+ const t0 = performance.now();
332
+ const results = lunrHits(this.lunrIndex, queryStr)
333
+ .slice(0, limit)
334
+ .map((hit) => this.#result(hit));
335
+ return {
336
+ query: String(queryStr ?? ''),
337
+ scope: this.scope,
338
+ results,
339
+ empty_result: results.length === 0,
340
+ latency_ms: Math.round(performance.now() - t0),
341
+ };
342
+ }
343
+
344
+ // Stage 1+2 — the `phronesis query` surface: each BM25 seed expands through typed
345
+ // links; `sources` are the raw-form leaves of that result's subgraph (the cited raw
346
+ // sources). An edge to an object that EXISTS outside the scope is relabeled
347
+ // `outside-scope` (it is real, just not traversed) — distinct from `unresolved`.
348
+ // Raw leaves are NEVER scope-relabeled, by decision: a raw source is the citing
349
+ // object's provenance, and the citing object is in scope — hiding a cross-domain
350
+ // raw citation would hide where an in-scope claim came from.
351
+ query(queryStr, { limit = DEFAULT_LIMIT, hops = DEFAULT_HOPS } = {}) {
352
+ this.#checkLimit(limit);
353
+ if (!Number.isInteger(hops) || hops < 0) {
354
+ throw new Error(`hops must be a non-negative integer (got ${JSON.stringify(hops)})`);
355
+ }
356
+ const t0 = performance.now();
357
+ const results = lunrHits(this.lunrIndex, queryStr)
358
+ .slice(0, limit)
359
+ .map((hit) => {
360
+ const result = this.#result(hit);
361
+ const subgraph = this.linkIndex.walk(result.path, { hops, direction: 'both' });
362
+ for (const node of subgraph.nodes) {
363
+ if (node.kind === 'unresolved' && this.allPaths.has(node.path)) {
364
+ node.kind = 'outside-scope';
365
+ }
366
+ }
367
+ result.subgraph = subgraph;
368
+ result.sources = subgraph.nodes.filter((n) => n.kind === 'raw').map((n) => n.path);
369
+ return result;
370
+ });
371
+ return {
372
+ query: String(queryStr ?? ''),
373
+ scope: this.scope,
374
+ hops,
375
+ results,
376
+ empty_result: results.length === 0,
377
+ latency_ms: Math.round(performance.now() - t0),
378
+ };
379
+ }
380
+ }
381
+
382
+ // --- Loading -----------------------------------------------------------------------------
383
+
384
+ // Search reads ONLY the registry's `domains` key (scope resolution). Deliberate: the
385
+ // action layer's all-or-nothing registry policy protects a permission lattice; retrieval
386
+ // is read-only and permission-free, so a broken `action_types` section must not take
387
+ // search down with it. But the registry IS domain truth here exactly as it is on the
388
+ // action spine — a registry that does not parse, or parses to a non-object, refuses
389
+ // with the real reason (never a misleading "domain not registered").
390
+ async function readDomainsRegistry(root) {
391
+ let raw;
392
+ try {
393
+ raw = await readFile(join(root, '.phronesis', 'registry.json'), 'utf8');
394
+ } catch (err) {
395
+ throw new Error(`cannot read .phronesis/registry.json: ${err.message}`);
396
+ }
397
+ let parsed;
398
+ try {
399
+ parsed = JSON.parse(raw);
400
+ } catch {
401
+ throw new Error('.phronesis/registry.json is not valid JSON — cannot resolve the domain scope.');
402
+ }
403
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
404
+ throw new Error('.phronesis/registry.json is malformed (expected an object) — cannot resolve the domain scope.');
405
+ }
406
+ return parsed;
407
+ }
408
+
409
+ // Both scope shapes filter by the REGISTERED domain list — `--all-domains` widens to
410
+ // every registered domain, never to "whatever directories exist on disk". A stray
411
+ // unregistered domains/{slug}/ tree stays invisible exactly as it is to the action
412
+ // spine; a flag that widens scope widens the LOCATION SET, not the registration check
413
+ // (the core-0006 --dir lesson, applied to scope — found by the adversarial review).
414
+ function filterScope(docs, domains) {
415
+ return docs.filter((d) => {
416
+ // The shared layer is always in scope — but only the operator's EARNED codex.
417
+ // codex/seed/ is quarantined out of retrieval until adopted (compile-retrieve-loop
418
+ // §"Seed-vs-personal quarantine"): this surface feeds the guard's pre-answer
419
+ // packets as the operator's own prior state, and unadopted seed surfacing there is
420
+ // the named folklore-drift failure mode. Seed discoverability belongs to
421
+ // codex/INDEX.md and the T5.4 lint, not to retrieval; an explicit seed-browse mode
422
+ // would be its own future contract. Found by the PR codex review (P1).
423
+ if (d.layer === 'codex') return d.codexKind === 'personal';
424
+ return domains.includes(d.domain);
425
+ });
426
+ }
427
+
428
+ // Load the search index for an installation: resolve the install (marker-checked --dir
429
+ // or walk-up — fail-closed, the core-0006 rule), verify-and-repair the snapshot (S3),
430
+ // scope it (S4), and build the in-memory Stage 1 + Stage 2 indexes.
431
+ export async function loadSearchIndex({
432
+ dir,
433
+ installRoot,
434
+ cwd = process.cwd(),
435
+ domain,
436
+ allDomains = false,
437
+ includeArtifacts = false,
438
+ persist = true,
439
+ } = {}) {
440
+ let root = installRoot;
441
+ if (!root) {
442
+ const resolved = resolveInstall({ dir, cwd });
443
+ if (resolved.error) throw new Error(resolved.error);
444
+ root = resolved.root;
445
+ }
446
+
447
+ // Domain truth resolves BEFORE any snapshot write: a command that refuses on a
448
+ // corrupt/unresolvable registry must not mutate even derived state on the way out
449
+ // (the marker-check posture, applied to the cache — PR codex review P3).
450
+ const registry = await readDomainsRegistry(root);
451
+ const registered = Array.isArray(registry.domains) ? [...registry.domains].sort() : [];
452
+ let scope;
453
+ if (allDomains) {
454
+ scope = { domains: registered, codex: true, all_domains: true };
455
+ } else {
456
+ const slug = resolveDomain({ installRoot: root, registry, domain, cwd });
457
+ scope = { domains: [slug], codex: true, all_domains: false };
458
+ }
459
+
460
+ const { docs, changed } = await verifyAndRepair(root, await readSnapshot(root));
461
+
462
+ let persistError = null;
463
+ if (persist && changed) {
464
+ try {
465
+ await persistSnapshot(root, docs);
466
+ } catch (err) {
467
+ persistError = err.message; // degraded cache, working retrieval (read-only checkout)
468
+ }
469
+ }
470
+
471
+ // Artifacts are excluded from the persisted machine index by contract. An explicit
472
+ // request reads them directly into this in-memory invocation only, so a normal
473
+ // search can never accidentally pick up a PRD and a rebuild remains compiled+codex.
474
+ const artifactDocs = includeArtifacts
475
+ ? (await Promise.all((await listArtifactFiles(root)).map((rel) => readArtifactRecord(root, rel)))).filter(Boolean)
476
+ : [];
477
+ const searchableDocs = [...docs, ...artifactDocs];
478
+ const scopedDocs = filterScope(searchableDocs, scope.domains);
479
+ const records = scopedDocs.map((d) => ({
480
+ rel: d.path,
481
+ ctx: { layer: d.layer, domain: d.domain, codexKind: d.codexKind, name: d.name, typeDir: null },
482
+ fm: {
483
+ type: d.type,
484
+ name: d.name,
485
+ title: d.title,
486
+ status: d.status,
487
+ exposure: d.exposure,
488
+ stance: d.stance,
489
+ links: d.links,
490
+ },
491
+ }));
492
+
493
+ return new SearchIndex({
494
+ root,
495
+ scope,
496
+ docs: searchableDocs,
497
+ scopedDocs,
498
+ lunrIndex: buildLunrIndex(scopedDocs),
499
+ linkIndex: buildLinkIndexFromRecords(records, root),
500
+ // The outside-scope relabel universe is REGISTERED domains + codex (seed
501
+ // included: a link to seed resolves to real install content that retrieval
502
+ // quarantines — "outside-scope" is the honest label). An unregistered stray tree
503
+ // is NOT in the universe, so links into it stay "unresolved": the registry is
504
+ // domain truth here exactly as on the action spine, and leaf labels must not
505
+ // advertise phantom trees as scoped-out install state (PR codex review P2).
506
+ // Seeds are quarantined out of searchableDocs (they never enter the search/link
507
+ // index — workspace-scan listRetrievableObjectFiles), so their paths are added
508
+ // back into the relabel universe directly: a link INTO a seed is real, immutable,
509
+ // quarantined content — "outside-scope", never the typo signal "unresolved" (T5.4).
510
+ allPaths: new Set([
511
+ ...searchableDocs.filter((d) => d.layer === 'codex' || registered.includes(d.domain)).map((d) => d.path),
512
+ ...(await walkMarkdown(root, join(root, 'codex', 'seed'))).filter(
513
+ (rel) => contextFor(rel)?.codexKind === 'seed',
514
+ ),
515
+ ]),
516
+ persistError,
517
+ });
518
+ }
519
+
520
+ // `phronesis index rebuild` (change 0008): regenerate the machine index from
521
+ // compiled/ + codex/, ignoring any existing snapshot. Unlike the search-path cache
522
+ // write, a rebuild that cannot persist FAILS — the artifact is this command's job.
523
+ export async function rebuildSearchIndex({ dir, installRoot, cwd = process.cwd() } = {}) {
524
+ let root = installRoot;
525
+ if (!root) {
526
+ const resolved = resolveInstall({ dir, cwd });
527
+ if (resolved.error) throw new Error(resolved.error);
528
+ root = resolved.root;
529
+ }
530
+ const t0 = performance.now();
531
+ const { docs } = await verifyAndRepair(root, null);
532
+ await persistSnapshot(root, docs);
533
+ return { root, objects: docs.length, ms: Math.round(performance.now() - t0) };
534
+ }
@@ -0,0 +1,129 @@
1
+ // Session-boundary hook support (core-0013; change 0010 D4; packet decisions H5/H7/H8).
2
+ //
3
+ // Two deterministic halves of the Tier-1 (Claude Code) session hooks:
4
+ //
5
+ // due-check (session start) — computes due `review_date`s across every domain's
6
+ // decisions and emits `outcome.due` per due decision through core appendEvent: the V1
7
+ // authoritative emitter per the ratified 0010 delta (event-log.md `outcome.due` row;
8
+ // the daemon's date-watch takes over at V1.5). Re-emission per session start is the
9
+ // spec's own UX — review surfaces "at next session start" — so there is no dedup.
10
+ //
11
+ // sweep status (session end/compact) — answers "has this session emitted its capture
12
+ // sweep marker?". The marker is `session.end` with a `sweep` payload object (H5:
13
+ // existing vocabulary, no invented type); `sweep.candidates: 0` is the honest
14
+ // negative. The JUDGMENT half of the sweep ("what should have been stored?") is the
15
+ // model's, prompted by the scaffolded Stop hook — a shell script cannot judge, so the
16
+ // deterministic half is exactly this: force the question, verify the marker.
17
+
18
+ import { join, sep } from 'node:path';
19
+ import { readFile } from 'node:fs/promises';
20
+ import yaml from 'js-yaml';
21
+ import { walkMarkdown, splitFrontmatter } from './workspace-scan.js';
22
+ import { DATE_RE } from './contract.js';
23
+ import { appendEvent, readEvents, localDateStamp } from './event-log.js';
24
+
25
+ function isPlainObject(v) {
26
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
27
+ }
28
+
29
+ // Days between two YYYY-MM-DD stamps (UTC date math — both are local-calendar stamps,
30
+ // so the difference is exact whole days).
31
+ function daysBetween(fromStamp, toStamp) {
32
+ const toUtc = (s) => {
33
+ const [y, m, d] = s.split('-').map(Number);
34
+ return Date.UTC(y, m - 1, d);
35
+ };
36
+ return Math.round((toUtc(toStamp) - toUtc(fromStamp)) / 86_400_000);
37
+ }
38
+
39
+ // Every decision whose review obligation is open and due: `review_date <= today` with no
40
+ // `outcome_status` (a recorded outcome ends the obligation). Scans ALL domains — the
41
+ // session-start surface is install-wide. Non-parsing files are skipped: the due-check is
42
+ // a read-only reporter, and `phronesis validate` owns contract enforcement.
43
+ export async function computeDueDecisions({ installRoot, now = new Date() } = {}) {
44
+ const today = localDateStamp(now);
45
+ const due = [];
46
+ const files = (await walkMarkdown(installRoot, join(installRoot, 'domains'))).filter((p) => {
47
+ const parts = p.split(sep);
48
+ return parts[2] === 'compiled' && parts[3] === 'decisions';
49
+ });
50
+ for (const relPath of files.sort()) {
51
+ let fm;
52
+ try {
53
+ const content = await readFile(join(installRoot, relPath), 'utf8');
54
+ const yamlSource = splitFrontmatter(content);
55
+ if (yamlSource === null) continue;
56
+ fm = yaml.load(yamlSource, { schema: yaml.JSON_SCHEMA });
57
+ } catch {
58
+ continue;
59
+ }
60
+ if (!isPlainObject(fm)) continue;
61
+ if (typeof fm.review_date !== 'string' || !DATE_RE.test(fm.review_date)) continue;
62
+ // Only a RECORDED outcome ends the review obligation: a non-empty string is what
63
+ // record_outcome writes. `outcome_status: null` (or any junk value) must not
64
+ // silently kill the obligation — null is present-and-invalid, never "recorded"
65
+ // (rules.md; the validator owns flagging the junk itself).
66
+ if (typeof fm.outcome_status === 'string' && fm.outcome_status.trim() !== '') continue;
67
+ if (fm.review_date > today) continue;
68
+ due.push({
69
+ decision_path: relPath,
70
+ review_date: fm.review_date,
71
+ days_overdue: daysBetween(fm.review_date, today),
72
+ title: typeof fm.title === 'string' ? fm.title : fm.name,
73
+ });
74
+ }
75
+ return due;
76
+ }
77
+
78
+ // Compute + emit: one `outcome.due` per due decision, through the authoritative core
79
+ // path (H3 — the same appendEvent the V1.5 daemon will drive). Returns the due list and
80
+ // the stored events.
81
+ export async function runDueCheck({
82
+ installRoot,
83
+ session,
84
+ actor = 'agent',
85
+ surface = 'cli',
86
+ now = new Date(),
87
+ } = {}) {
88
+ if (typeof session !== 'string' || session === '') {
89
+ throw new Error('due-check: session is required (a non-empty session id).');
90
+ }
91
+ const due = await computeDueDecisions({ installRoot, now });
92
+ const events = [];
93
+ for (const d of due) {
94
+ events.push(
95
+ await appendEvent({
96
+ installRoot,
97
+ type: 'outcome.due',
98
+ session_id: session,
99
+ actor,
100
+ surface,
101
+ payload: { decision_path: d.decision_path, days_overdue: d.days_overdue },
102
+ now,
103
+ }),
104
+ );
105
+ }
106
+ return { due, events };
107
+ }
108
+
109
+ // Has this session emitted its sweep marker (`session.end` with a `sweep` payload
110
+ // object)? A marker only counts when `sweep.candidates` is a non-negative integer —
111
+ // the count IS the sweep's answer, and `{ sweep: {} }` answering the Stop gate would
112
+ // be an invalid marker satisfying the very check the model is held to (fail closed).
113
+ // Returns the latest marker's count so callers can report the honest negative
114
+ // distinctly from a candidate-bearing sweep.
115
+ export async function sweepStatus({ installRoot, session } = {}) {
116
+ if (typeof session !== 'string' || session === '') {
117
+ throw new Error('sweep-status: session is required (a non-empty session id).');
118
+ }
119
+ const markers = (await readEvents({ installRoot, type: 'session.end' })).filter(
120
+ (ev) =>
121
+ ev.session_id === session &&
122
+ isPlainObject(ev.payload) &&
123
+ isPlainObject(ev.payload.sweep) &&
124
+ Number.isInteger(ev.payload.sweep.candidates) &&
125
+ ev.payload.sweep.candidates >= 0,
126
+ );
127
+ if (!markers.length) return { swept: false };
128
+ return { swept: true, candidates: markers[markers.length - 1].payload.sweep.candidates };
129
+ }