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.
- package/README.md +56 -6
- package/cli/kit.manifest.yaml +2 -3
- package/package.json +1 -1
- package/payload/docs/README.md +135 -0
- package/payload/docs/ci-wiring.md +25 -0
- package/payload/engine/commands/commit-check.js +49 -0
- package/payload/engine/commands/preflight.js +29 -430
- package/payload/engine/commands/resolve.js +71 -35
- package/payload/engine/commands/reverse-staged.js +30 -0
- package/payload/engine/commands/validate-values.js +19 -3
- package/payload/engine/commands/validate.js +51 -1
- package/payload/engine/commit-check.js +12 -0
- package/payload/engine/lib/commit-snapshot.js +155 -0
- package/payload/engine/lib/coverage.js +1 -0
- package/payload/engine/lib/kit-root.js +25 -3
- package/payload/engine/lib/load-stores.js +14 -0
- package/payload/engine/lib/preflight.js +122 -0
- package/payload/engine/lib/time-verdicts.js +5 -6
- package/payload/engine/lib/verdicts.js +260 -0
- package/payload/engine/reverse-staged.js +12 -0
- package/payload/hooks/pre-commit +7 -7
- package/payload/hooks/reverse-lookup +7 -61
- package/payload/protocol/AGENTS.md +321 -52
- package/payload/protocol/skills/kb-build.md +58 -3
- package/payload/protocol/skills/knowledge-reflect.md +95 -8
- package/payload/wrappers/cursor.mdc +7 -8
- package/payload/wrappers/pointer.md +6 -7
|
@@ -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:
|
|
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
|
|
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.
|
|
274
|
-
*
|
|
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 —
|
|
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
|
+
}
|
package/payload/hooks/pre-commit
CHANGED
|
@@ -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
|
-
#
|
|
12
|
-
#
|
|
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 —
|
|
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/
|
|
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
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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 $?
|