unknown-knowledge 2.1.0 → 3.0.0-rc.1

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.
@@ -68,6 +68,8 @@
68
68
  * // from either end: resolving a concept surfaces
69
69
  * // its declaring leaves structurally, with no
70
70
  * // dependence on whether any term text matches
71
+ * supersedingLeaves: Map leaf id -> [direct successor identities],
72
+ * // derived from relates.supersedes; never stored
71
73
  * refs: [{ from, type, to, file, path, resolved }], // cross-ref graph
72
74
  * diagnostics: [{ severity, code, file, path, message }],
73
75
  * ok, // true iff no error-severity diagnostic
@@ -1333,6 +1335,17 @@ function buildLeavesByConcept(ctx) {
1333
1335
  return sortedMap(index);
1334
1336
  }
1335
1337
 
1338
+ /** Disposable inverse of the authoritative leaf supersedes edges, one hop. */
1339
+ function buildSupersedingLeaves(ctx) {
1340
+ const index = new Map();
1341
+ for (const ref of ctx.refs) {
1342
+ if (ref.type !== 'relates.supersedes' || !ctx.leaves.has(ref.from) || !ctx.leaves.has(ref.to)) continue;
1343
+ if (!index.has(ref.to)) index.set(ref.to, new Set());
1344
+ index.get(ref.to).add(ref.from);
1345
+ }
1346
+ return sortedMap(new Map([...index].map(([id, successors]) => [id, [...successors].sort(compare)])));
1347
+ }
1348
+
1336
1349
  /** Resolve every collected edge; a miss is an unresolved-ref error. */
1337
1350
  function resolveRefs(ctx) {
1338
1351
  for (const ref of ctx.refs) {
@@ -1422,6 +1435,7 @@ export function loadStores(root) {
1422
1435
  phoenix: sortedMap(ctx.phoenix),
1423
1436
  pointers,
1424
1437
  leavesByConcept,
1438
+ supersedingLeaves: buildSupersedingLeaves(ctx),
1425
1439
  refs: ctx.refs,
1426
1440
  diagnostics: ctx.diagnostics,
1427
1441
  ok: ctx.diagnostics.every((d) => d.severity !== 'error'),
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Preflight orchestration (UCS-953): compute fresh Verdicts, assemble the
3
+ * command result, and optionally append quarantine findings. Verdict rules
4
+ * stay in verdicts.js; this layer owns counts, the numeric exit contract,
5
+ * and logging. It never renders or writes to stdout/stderr.
6
+ */
7
+ import { healthSummary } from './load-stores.js';
8
+ import { computeVerdicts } from './verdicts.js';
9
+ import { EXIT_CODES } from './exit-codes.js';
10
+ import { compare } from './validate-record.js';
11
+ import { createEntry } from './log-entry.js';
12
+ import { timeCheckStatus } from './time-verdicts.js';
13
+
14
+ /** finding.schema.json conceptRef — `consulted` only carries conforming ids. */
15
+ const CONCEPT_REF = /^K-[0-9]+$/;
16
+
17
+ // -------------------------------------------- quarantine findings (KK-13)
18
+
19
+ /**
20
+ * Append one engine-attributed quarantine finding per quarantined concept
21
+ * (capture content policy §3.4: concept ids, codes, and paths only).
22
+ * Returns the root-relative fragment paths, sorted.
23
+ */
24
+ function logQuarantines(root, verdicts, today) {
25
+ const logged = [];
26
+ for (const v of verdicts) {
27
+ if (v.verdict !== 'quarantined') continue;
28
+ const codes = [...new Set(v.evidence.map((e) => e.code))].sort(compare);
29
+ const paths = [...new Set(v.evidence.flatMap((e) => [e.file, e.source]).filter(Boolean))].sort(compare);
30
+ const { file } = createEntry({
31
+ root, log: 'findings', date: today,
32
+ fields: {
33
+ trigger: 'quarantine',
34
+ session: 'engine/preflight.js',
35
+ summary: `preflight quarantined ${v.concept}: ${codes.join(', ')} (${paths.join(', ')})`,
36
+ ...(CONCEPT_REF.test(v.concept) ? { consulted: { concepts: [v.concept] } } : {}),
37
+ },
38
+ });
39
+ logged.push(file);
40
+ }
41
+ return logged.sort(compare);
42
+ }
43
+
44
+ /**
45
+ * @param {object} model freshly loaded Store
46
+ * @param {object} options
47
+ * @param {string} options.repoRoot repo root for source pointers
48
+ * @param {string[] | null} [options.concepts] normalized concept ids
49
+ * @param {string[] | null} [options.leaves] normalized leaf accession ids
50
+ * @param {string | null} [options.today] injected calendar date
51
+ * @param {boolean} [options.log] append quarantine findings (requires today)
52
+ * @returns {{ payload: object, exitCode: number }}
53
+ * @throws when computation or logging cannot complete; a partial logged run
54
+ * may already have appended findings. Let the CLI guard report incompletion.
55
+ */
56
+ export function runPreflight(model, { repoRoot, concepts = null, leaves = null, today = null, log = false }) {
57
+ // Verdict computation owns the store-wide degradation decision; the
58
+ // orchestration projects the returned health into the existing wire shape.
59
+ const { health: fullHealth, storeVerdict, verdicts, leafVerdicts } = computeVerdicts(model, {
60
+ concepts, leaves, repoRoot, today,
61
+ });
62
+ const health = healthSummary(fullHealth);
63
+ const storeErrors = fullHealth.errors
64
+ .map(({ code, file, path, message }) => ({ code, file, path, message }));
65
+
66
+ // Empty/omitted --concepts AND --leaves: store-health-only — exit on the
67
+ // store verdict alone (§7); no per-record check runs, so no per-record
68
+ // verdict exists. Either flag alone selects that flag's records; both
69
+ // select both, because "which concepts" and "which leaves" are two
70
+ // questions and a run may legitimately ask one, the other, or both.
71
+ const wantConcepts = !!concepts && concepts.length > 0;
72
+ const wantLeaves = !!leaves && leaves.length > 0;
73
+ const storeHealthOnly = !wantConcepts && !wantLeaves;
74
+
75
+ // Counted TOGETHER, over both verdict lists. Splitting the counts would let
76
+ // a run exit 0 on clean concepts while a requested leaf was quarantined —
77
+ // the gate reading as clean about the half of the question it liked.
78
+ const all = [...verdicts, ...leafVerdicts];
79
+ const counts = {
80
+ trusted: all.filter((v) => v.verdict === 'trusted').length,
81
+ quarantined: all.filter((v) => v.verdict === 'quarantined').length,
82
+ unknown: all.filter((v) => v.verdict === 'unknown').length,
83
+ // Counted separately (UCS-1150) so a stale leaf is visible in the tally
84
+ // rather than absorbed into a class that means something else. `ok`
85
+ // below still requires trusted === all.length, so a stale leaf gates.
86
+ stale: all.filter((v) => v.verdict === 'stale').length,
87
+ };
88
+ const logged = log ? logQuarantines(model.root, verdicts, today) : null;
89
+
90
+ const ok = storeHealthOnly
91
+ ? storeVerdict === 'trusted'
92
+ : storeVerdict === 'trusted' && counts.trusted === all.length;
93
+ const payload = {
94
+ ok,
95
+ // The mode names what was ASKED. `leaves` and `concepts+leaves` are new
96
+ // (UCS-1149); a run that named only concepts reads exactly as it did
97
+ // before, so no existing consumer sees a shape it did not ask for.
98
+ mode: storeHealthOnly
99
+ ? 'store-health'
100
+ : [wantConcepts ? 'concepts' : null, wantLeaves ? 'leaves' : null].filter(Boolean).join('+'),
101
+ 'store-verdict': storeVerdict,
102
+ 'store-health': health,
103
+ ...(storeErrors.length ? { 'store-errors': storeErrors } : {}),
104
+ counts,
105
+ verdicts,
106
+ // Present only when leaves were asked about, for the same reason `mode`
107
+ // still says `concepts`: a --concepts-only run's JSON is unchanged.
108
+ // `time-check` rides the same condition — leaves are the only records the
109
+ // time facet governs, so a concepts-only run has no time check to report
110
+ // and inventing one would answer a question nobody asked. When leaves
111
+ // ARE asked about, it is always present: a run that computed no freshness
112
+ // verdicts must never look like one that checked and found them fresh.
113
+ ...(wantLeaves ? { 'time-check': timeCheckStatus(today), 'leaf-verdicts': leafVerdicts } : {}),
114
+ ...(logged ? { logged } : {}),
115
+ };
116
+ // Unknown includes store-wide failure and skipped checks. Stale and
117
+ // quarantined are completed findings; only checked success exits cleanly.
118
+ const exitCode = storeVerdict !== 'trusted' || counts.unknown > 0
119
+ ? EXIT_CODES.FAILURE
120
+ : counts.quarantined > 0 || counts.stale > 0 ? EXIT_CODES.FINDINGS : EXIT_CODES.CLEAN;
121
+ return { payload, exitCode };
122
+ }
@@ -231,7 +231,7 @@ export function timeVerdict(record, today) {
231
231
  return {
232
232
  ...base,
233
233
  verdict: TIME_VERDICTS.SKIPPED,
234
- reason: 'skipped — pass --today YYYY-MM-DD to enable time verdicts; diffable output never reads the wall clock (D-012)',
234
+ reason: timeCheckStatus(null),
235
235
  };
236
236
  }
237
237
  if (verified === null) {
@@ -252,7 +252,7 @@ export function timeVerdict(record, today) {
252
252
  age,
253
253
  verdict: TIME_VERDICTS.STALE,
254
254
  stale: true,
255
- reason: `verified ${age} day(s) ago, past the ${limit}-day limit for ${volatility} knowledge — re-verify against the cited sources, or treat the claim as unverified (UCS-1150)`,
255
+ reason: `verified ${age} day(s) ago, past the ${limit}-day limit for ${volatility} knowledge (UCS-1150)`,
256
256
  };
257
257
  }
258
258
  // Only `stable` and `volatile` reach here — `static` returned above and
@@ -270,13 +270,12 @@ export function timeVerdict(record, today) {
270
270
  *
271
271
  * Every projection that can demote on time must SAY whether it computed
272
272
  * verdicts, and it must say so in one wording — a surface that phrased its own
273
- * skip notice would eventually phrase it as silence. Mirrors the audit's
274
- * `checks['stale-last-verified']` string exactly, so a reader who has seen one
275
- * recognizes the other.
273
+ * skip notice would eventually phrase it as silence. Reports the missing
274
+ * input as a fact; recovery wording belongs to the protocol (D-011).
276
275
  *
277
276
  * @param {string|null} today the injected date, or null
278
277
  * @returns {string}
279
278
  */
280
279
  export const timeCheckStatus = (today) => (today
281
280
  ? `checked against --today ${today} (stale after ${VOLATILITY_LIMITS.stable} days for stable, ${VOLATILITY_LIMITS.volatile} for volatile; static never stales)`
282
- : 'skipped — pass --today YYYY-MM-DD to enable; diffable output never reads the wall clock (D-012)');
281
+ : 'skipped — no evaluation date supplied; diffable output never reads the wall clock (D-012)');
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Deterministic Verdict computation (UCS-947, D-011).
3
+ *
4
+ * Joins the validators to a freshly loaded Store. The library owns the
5
+ * store-wide degradation decision for both concepts and leaves, including
6
+ * their promotion and time rules. No results are cached and no logs are
7
+ * written; callers retain rendering, logging, and exit-code handling.
8
+ */
9
+ import { isPrePromotionStatus, leafIdentityOf, leafStage, selectConcepts, selectLeaves, storeHealth } from './load-stores.js';
10
+ import { compare } from './validate-record.js';
11
+ import { runChecks } from '../commands/validate.js';
12
+ import { validateValues } from '../commands/validate-values.js';
13
+ import { TIME_VERDICTS, timeVerdict } from './time-verdicts.js';
14
+
15
+ /**
16
+ * Compute requested Verdicts using the loader's single health model.
17
+ * Empty or omitted selections request store health only, never all records.
18
+ * On an unhealthy Store every requested id degrades to unknown, including
19
+ * ids that could not load. Healthy Stores reject unknown ids through the
20
+ * loader's selectors. Dates are injected; the wall clock is never read.
21
+ *
22
+ * @param {object} model freshly loaded Store; reload after Store edits
23
+ * @param {object} options
24
+ * @param {string} options.repoRoot repo root used to resolve source pointers
25
+ * @param {string[] | null} [options.concepts] normalized concept ids
26
+ * @param {string[] | null} [options.leaves] normalized leaf accession ids
27
+ * @param {string | null} [options.today] injected ISO calendar date
28
+ * @returns {{ health: ReturnType<typeof storeHealth>, storeVerdict: string, verdicts: object[], leafVerdicts: object[] }}
29
+ */
30
+ export function computeVerdicts(model, { repoRoot, concepts = null, leaves = null, today = null }) {
31
+ const health = storeHealth(model);
32
+ const verdicts = concepts?.length
33
+ ? (health.ok ? computeConceptVerdicts(model, concepts, repoRoot) : degradeAll(model, concepts, health.errorCount))
34
+ : [];
35
+ const leafVerdicts = leaves?.length
36
+ ? (health.ok ? computeLeafVerdicts(model, leaves, repoRoot, today) : degradeAllLeaves(model, leaves, today, health.errorCount))
37
+ : [];
38
+ return { health, storeVerdict: health.ok ? 'trusted' : 'unknown', verdicts, leafVerdicts };
39
+ }
40
+
41
+ /**
42
+ * Stable next-action codes, shared by JSON and human output (UCS-954).
43
+ * Conduct is keyed by these codes in protocol/AGENTS.md, never rendered here.
44
+ * Changing a code is a breaking CLI contract change (D-021).
45
+ */
46
+ const NEXT_ACTIONS = Object.freeze({
47
+ trusted: 'proceed',
48
+ quarantined: 'repair-evidence',
49
+ 'unknown-status': 'review-status',
50
+ 'unknown-stage': 'review-stage',
51
+ 'unknown-store': 'repair-store',
52
+ // The Time facet (UCS-1150). A stale leaf is not broken and its checks did
53
+ // run — the action is re-verification against the sources, which is a
54
+ // steward's job rather than a repair.
55
+ stale: 'reverify-leaf',
56
+ // A leaf that asked to be governed by time and gave nothing to measure from.
57
+ // Its verdict can only ever be `undated`, so the fix is the missing field.
58
+ 'unknown-undated': 'supply-verified-date',
59
+ // No --today was injected, so no freshness verdict was computed at all.
60
+ 'unknown-skipped': 'supply-evaluation-date',
61
+ });
62
+
63
+ // ---------------------------------------------------------- verdict joining
64
+
65
+ /**
66
+ * Join both validators' results to the requested concepts — one verdict per
67
+ * concept. Only called on a healthy store (the store-wide degradation path
68
+ * never reaches the validators: their checks would not have run).
69
+ */
70
+ function computeConceptVerdicts(model, ids, repoRoot) {
71
+ const structural = runChecks(model, repoRoot);
72
+ const values = validateValues(model, null, repoRoot); // full run; attribution below
73
+
74
+ return selectConcepts(model, ids).map(({ id, record }) => {
75
+ const status = record.status ?? null;
76
+ // Evidence: error-severity findings from either validator, plus value
77
+ // hard errors (unknown-kind, source-missing, …) — all attributable to
78
+ // this concept, all reasons not to trust it (quarantine, per §4).
79
+ const evidence = [
80
+ ...structural
81
+ .filter((f) => f.id === id && f.severity === 'error')
82
+ .map(({ code, file, path, message }) => ({ check: 'structural', code, severity: 'error', file, path, message })),
83
+ ...values.findings
84
+ .filter((f) => f.concept === id && f.severity === 'error')
85
+ .map(({ code, file, path, source, value, message }) => ({ check: 'value', code, severity: 'error', file, path, ...(source ? { source } : {}), ...(value !== undefined ? { value } : {}), message })),
86
+ ...values.hardErrors
87
+ .filter((e) => e.concept === id)
88
+ .map(({ code, file, path, source, message }) => ({ check: 'value', code, severity: 'hard-error', file, path, ...(source ? { source } : {}), message })),
89
+ ].sort((a, b) => compare(a.check, b.check) || compare(a.path ?? '', b.path ?? '')
90
+ || compare(a.code, b.code) || compare(a.value ?? '', b.value ?? ''));
91
+
92
+ if (evidence.length) {
93
+ return {
94
+ concept: id, status, verdict: 'quarantined',
95
+ reason: `${evidence.length} error-severity check result(s) attributable to this concept — see evidence`,
96
+ 'next-action': NEXT_ACTIONS.quarantined,
97
+ evidence,
98
+ };
99
+ }
100
+ if (isPrePromotionStatus(status)) {
101
+ return {
102
+ concept: id, status, verdict: 'unknown',
103
+ reason: `status "${status}" — structural checks only (§3.5); the value checks were skipped, so nothing certifies the claims`,
104
+ 'next-action': NEXT_ACTIONS['unknown-status'],
105
+ evidence,
106
+ };
107
+ }
108
+ return {
109
+ concept: id, status, verdict: 'trusted',
110
+ reason: 'every attributable check ran clean this run',
111
+ 'next-action': NEXT_ACTIONS.trusted,
112
+ evidence,
113
+ };
114
+ }).sort((a, b) => compare(a.concept, b.concept));
115
+ }
116
+
117
+ /**
118
+ * Leaf verdicts (UCS-1149) — the same three verdicts, computed for leaves.
119
+ *
120
+ * A leaf earns a verdict on the same two questions a concept does, asked of the
121
+ * evidence a leaf actually has:
122
+ *
123
+ * quarantined error-severity structural findings attributable to this leaf —
124
+ * an unminted facet value, a citation with no authority tier, a
125
+ * cross-reference that does not resolve. There is no value-check
126
+ * half: value checks diff a descriptor against source code, and
127
+ * a leaf carries no descriptor. Its evidence is structural only,
128
+ * which is stated rather than silently implied by an empty list.
129
+ * unknown `facets.stage` is missing or pre-promotion — the SAME predicate the
130
+ * concept path calls, so a draft leaf and a draft concept cannot
131
+ * be verdicted differently by two surfaces that both think they
132
+ * are asking one question. This is preflight's half of the
133
+ * draft-stage contract; the resolver's half is the downrank.
134
+ * trusted neither.
135
+ *
136
+ * Attribution is by the finding's `id`, which for a leaf is its identity — its
137
+ * accession (UCS-1142/1147) — so a leaf is matched by the same string the
138
+ * validator names it by, not by a second guess at its id space.
139
+ */
140
+ function computeLeafVerdicts(model, ids, repoRoot, today) {
141
+ const structural = runChecks(model, repoRoot);
142
+
143
+ return selectLeaves(model, ids).map((entry) => {
144
+ const id = entry.identity;
145
+ const stage = leafStage(entry.record);
146
+ const time = timeVerdict(entry.record, today);
147
+ const base = { leaf: id, stage, time };
148
+ const evidence = structural
149
+ .filter((f) => f.id === id && f.severity === 'error')
150
+ .map(({ code, file, path, message }) => ({ check: 'structural', code, severity: 'error', file, path, message }))
151
+ .sort((a, b) => compare(a.path ?? '', b.path ?? '') || compare(a.code, b.code));
152
+
153
+ if (evidence.length) {
154
+ return {
155
+ ...base, verdict: 'quarantined',
156
+ reason: `${evidence.length} error-severity check result(s) attributable to this leaf — see evidence`,
157
+ 'next-action': NEXT_ACTIONS.quarantined,
158
+ evidence,
159
+ };
160
+ }
161
+ if (stage === null || isPrePromotionStatus(stage)) {
162
+ return {
163
+ ...base, verdict: 'unknown',
164
+ reason: stage === null
165
+ ? 'missing review stage (facets.stage) — this leaf has no declared promotion state'
166
+ : `stage "${stage}" — this leaf is pre-promotion; its declared stage does not establish reviewed evidence`,
167
+ 'next-action': NEXT_ACTIONS['unknown-stage'],
168
+ evidence,
169
+ };
170
+ }
171
+ // The Time facet (UCS-1150). Asked AFTER stage, because a leaf that no
172
+ // moderator has promoted is unverified for a reason that outranks its age:
173
+ // re-dating a draft would not make it trusted. A promoted leaf, though, is
174
+ // exactly the one whose freshness is the remaining question.
175
+ //
176
+ // `stale` is its OWN verdict class rather than a mapping onto `unknown` or
177
+ // `quarantined`, and the choice is the ticket's ("trusted/stale verdicts").
178
+ // The existing three each mean something a stale leaf is not: nothing about
179
+ // it is broken (quarantined), and its checks did run and returned a
180
+ // definite answer (unknown). Folding it into either would tell a steward to
181
+ // do the wrong thing — repair evidence that is fine, or pass a flag they
182
+ // already passed — and would make the leaf-verdicts surface dishonest about
183
+ // what it computed. It gates like the others: only trusted reads as clean.
184
+ if (time.stale) {
185
+ return {
186
+ ...base, verdict: 'stale',
187
+ reason: time.reason,
188
+ 'next-action': NEXT_ACTIONS.stale,
189
+ evidence,
190
+ };
191
+ }
192
+ // A leaf under time governance whose freshness could not be computed is
193
+ // NOT trusted. Two ways that happens, and they need different actions: the
194
+ // leaf is missing its date (`undated`), or this run never injected one
195
+ // (`skipped`). Both are unknown-class — a check that never ran is never a
196
+ // silent pass — and each says which fix applies.
197
+ if (time.verdict === TIME_VERDICTS.UNDATED || time.verdict === TIME_VERDICTS.SKIPPED) {
198
+ return {
199
+ ...base, verdict: 'unknown',
200
+ reason: time.reason,
201
+ 'next-action': NEXT_ACTIONS[`unknown-${time.verdict}`],
202
+ evidence,
203
+ };
204
+ }
205
+ return {
206
+ ...base, verdict: 'trusted',
207
+ reason: 'every attributable check ran clean this run',
208
+ 'next-action': NEXT_ACTIONS.trusted,
209
+ evidence,
210
+ };
211
+ }).sort((a, b) => compare(a.leaf, b.leaf));
212
+ }
213
+
214
+ /**
215
+ * Store-wide failure: no check ran — every requested LEAF verdict is unknown.
216
+ *
217
+ * Ids are resolved through `leafIdentityOf`, the same lookup the healthy path's
218
+ * `selectLeaves` uses, so a leaf that IS in the store reports under its own
219
+ * identity whether the store loaded clean or not. An id that resolves to
220
+ * nothing keys on the caller's spelling instead — see below.
221
+ */
222
+ function degradeAllLeaves(model, ids, today, errors) {
223
+ // De-duplicated by IDENTITY, like selectLeaves: naming one leaf twice is one
224
+ // leaf, and emitting two verdict rows for it would have a caller reconciling
225
+ // two answers about a single record. An
226
+ // id that resolves to nothing keys on the caller's spelling instead — on a
227
+ // store this broken the leaf may simply have failed to load, so echoing back
228
+ // what was asked for is more honest than inventing an identity, and two
229
+ // distinct unresolved ids stay two rows.
230
+ const seen = new Set();
231
+ const out = [];
232
+ for (const id of ids) {
233
+ const identity = leafIdentityOf(model, id) ?? id;
234
+ if (seen.has(identity)) continue;
235
+ seen.add(identity);
236
+ const record = model.leaves.get(identity)?.record;
237
+ out.push({
238
+ leaf: identity, stage: leafStage(record),
239
+ // The time verdict travels on the degraded path too, computed from
240
+ // whatever loaded. A key that vanished on a broken store would make a
241
+ // consumer's presence check mean two things at once.
242
+ time: timeVerdict(record, today),
243
+ verdict: 'unknown',
244
+ reason: `store-wide failure: the loader reported ${errors} error(s) — no check ran for any leaf (single health model, PRD §4)`,
245
+ 'next-action': NEXT_ACTIONS['unknown-store'],
246
+ evidence: [],
247
+ });
248
+ }
249
+ return out.sort((a, b) => compare(a.leaf, b.leaf));
250
+ }
251
+
252
+ /** Store-wide failure: no check ran — every requested verdict is unknown. */
253
+ function degradeAll(model, ids, errors) {
254
+ return ids.map((id) => ({
255
+ concept: id, status: model.concepts.get(id)?.record.status ?? null, verdict: 'unknown',
256
+ reason: `store-wide failure: the loader reported ${errors} error(s) — no check ran for any concept (single health model, PRD §4)`,
257
+ 'next-action': NEXT_ACTIONS['unknown-store'],
258
+ evidence: [],
259
+ })).sort((a, b) => compare(a.concept, b.concept));
260
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // Load failures must report failure (2), never findings (1).
3
+ try {
4
+ const [{ boot }, command] = await Promise.all([
5
+ import('./lib/boot.js'),
6
+ import('./commands/reverse-staged.js'),
7
+ ]);
8
+ process.exitCode = await boot('reverse-staged', command);
9
+ } catch (error) {
10
+ process.stderr.write(`reverse-staged: internal failure — the engine could not be loaded\n${error?.stack ?? error}\n`);
11
+ process.exitCode = 2;
12
+ }
@@ -8,11 +8,8 @@
8
8
  # Protocol compliance is a property of the mechanism, not of agent
9
9
  # obedience: the same validator CI runs, run before the commit exists.
10
10
  #
11
- # Not a test seam. The tested surface is the wrapped command
12
- # (`engine/validate.js`, tests/validate.test.js); testing it IS testing
13
- # this hook, because this file adds nothing to test. Wiring is reviewed
14
- # the way the per-IDE wrappers are — read it once, confirm it is a
15
- # pointer, and trust the command it points at.
11
+ # Installed-hook behavior is exercised with real Git commits in
12
+ # tests/commit-gate.test.js; command tests alone do not prove the wiring.
16
13
  #
17
14
  # Wire it (the client's own git config — the kit never writes .git/):
18
15
  # ln -s ../../unknown-knowledge/hooks/pre-commit .git/hooks/pre-commit
@@ -20,11 +17,14 @@
20
17
  # opts in: seeding a hook is not installing one.
21
18
  #
22
19
  # Exit codes are the engine's, propagated verbatim (PRD §5):
23
- # 0 — the store is structurally sound; the commit proceeds.
20
+ # 0 — both whole-store validators are clean; the commit proceeds.
24
21
  # 1 — the check ran and found something; the commit is refused.
25
22
  # 2 — the check never ran; the commit is refused. A check that never
26
23
  # ran is a blocking defect, never a silent pass.
27
24
  #
25
+ # Both validators read one isolated Git index snapshot. Unstaged and untracked
26
+ # evidence cannot affect the result; preparation or cleanup failure exits 2.
27
+ #
28
28
  # KIT_DIR names the seeded root when it is not the default; UK_ROOT names
29
29
  # the repo root when the hook runs from elsewhere. Both are location
30
30
  # arguments, never gate arguments — neither can turn the check off.
@@ -33,5 +33,5 @@ set -u
33
33
  KIT_DIR="${KIT_DIR:-unknown-knowledge}"
34
34
  UK_ROOT="${UK_ROOT:-.}"
35
35
 
36
- node "$UK_ROOT/$KIT_DIR/engine/validate.js" --root "$UK_ROOT"
36
+ node "$UK_ROOT/$KIT_DIR/engine/commit-check.js" --root "$UK_ROOT"
37
37
  exit $?
@@ -1,66 +1,12 @@
1
1
  #!/bin/sh
2
- # unknown-knowledge reverse-lookup hook automatic attribution (UCS-1157).
3
- #
4
- # THIN BY CONSTRUCTION, exactly like the pre-commit hook: it collects the
5
- # touched paths git already knows about and hands them to one engine
6
- # command, exiting with that command's code, unchanged. It reads no leaf,
7
- # joins nothing, and decides nothing `engine/resolve.js --paths` is what
8
- # answers "which leaves and concepts govern this file", and the answer is
9
- # its output, not this script's.
10
- #
11
- # This is the AGENTS.md ACT step (attribute before committing) made
12
- # automatic. An agent can forget to run the reverse lookup; a hook cannot.
13
- # That is the whole point: the protocol rides along invisibly rather than
14
- # depending on anyone remembering it.
15
- #
16
- # Not a test seam. The tested surface is the wrapped command
17
- # (`engine/resolve.js --paths`, tests/resolve.test.js and
18
- # tests/typed-edges.test.js) — this file adds no behavior to test, and the
19
- # wiring is reviewed like the per-IDE wrappers.
20
- #
21
- # Wire it with an EVENT-NAMED symlink — git runs a hook only if its
22
- # filename is one of the events git fires, and `reverse-lookup` is not one
23
- # of them. That matters most under `core.hooksPath` pointed at the seeded
24
- # directory: git looks there for event names only, so this file would sit
25
- # next to `pre-commit` and never fire. Give it an event:
26
- #
27
- # ln -s ../../unknown-knowledge/hooks/reverse-lookup .git/hooks/prepare-commit-msg
28
- #
29
- # (`prepare-commit-msg` runs after the index is staged and before the
30
- # message editor opens, which is when attribution is still actionable. Git
31
- # passes it the message file and source as arguments; this script ignores
32
- # them and reads the staged diff itself.) Under `core.hooksPath` wiring,
33
- # call it explicitly from your `pre-commit` instead. Either way, wiring it
34
- # into git is the client's own act — the seeded artifact is the script.
35
- #
36
- # Exit codes are the engine's, propagated verbatim (PRD §5):
37
- # 0 — the lookup ran (including a zero-hit lookup, which is a real
38
- # answer: nothing in the store governs these files).
39
- # 2 — the lookup never ran.
40
- # `resolve.js` never exits 1: reporting what governs a path is not a
41
- # finding, and this hook is attribution, never a gate. What it prints is
42
- # what the ACT step obliges you to act on.
43
- #
44
- # With no staged changes there is nothing to attribute, so the hook exits
45
- # 0 without invoking the engine — `--paths` with an empty list is a usage
46
- # error (exit 2), and an empty diff is not a failure.
47
- #
48
- # git's own status is checked BEFORE the join. In a pipeline the shell
49
- # reports the LAST command's status, so `git diff | paste` would report
50
- # paste's success even when git failed — leaving PATHS empty and the hook
51
- # exiting 0 on a lookup that never ran. That is the silent pass this whole
52
- # design refuses, so the two steps are separate and git's code is read.
2
+ # Advisory attribution, including prior governance of deletions and renames.
3
+ # Install explicitly as .git/hooks/prepare-commit-msg (an event Git runs).
4
+ # The versioned engine owns Git parsing, isolated snapshots and resolution;
5
+ # actual installed-hook commit tests exercise that behavior end to end.
6
+ # Exit 0: attributed, or no staged changes. Exit 2: attribution failed.
7
+ # Attribution never narrows either whole-store validator or proves a store edit.
53
8
  set -u
54
-
55
9
  KIT_DIR="${KIT_DIR:-unknown-knowledge}"
56
10
  UK_ROOT="${UK_ROOT:-.}"
57
-
58
- STAGED=$(git diff --cached --name-only --diff-filter=ACMR) || {
59
- echo "reverse-lookup: git diff failed — the staged paths could not be read, so the lookup never ran" >&2
60
- exit 2
61
- }
62
- [ -z "$STAGED" ] && exit 0
63
- PATHS=$(printf '%s' "$STAGED" | paste -sd, -)
64
-
65
- node "$UK_ROOT/$KIT_DIR/engine/resolve.js" --paths "$PATHS" --root "$UK_ROOT"
11
+ node "$UK_ROOT/$KIT_DIR/engine/reverse-staged.js" --root "$UK_ROOT"
66
12
  exit $?