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,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
|
+
}
|