knosky 0.4.1 → 0.6.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.
@@ -0,0 +1,189 @@
1
+ // KnoSky protocol config schema, loader, and validator (SAT-426 / KSV2-P1).
2
+ // Pure Node stdlib, ESM — no third-party dependencies.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // DEFAULTS — the canonical safe config for every knosky project.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** @type {Readonly<KnoskyConfig>} */
11
+ export const DEFAULTS = Object.freeze({
12
+ knosky_protocol: '1.0',
13
+ telemetry: false,
14
+ absolute_paths: false,
15
+ fail_on_secret: true,
16
+ allow_excerpts: false,
17
+ max_excerpt_chars: 0,
18
+ categories: [],
19
+ ignore: [],
20
+ });
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // parseConfigYaml — minimal YAML-subset parser, no deps.
24
+ // Supports: `key: value` lines, booleans, integers, bare/quoted strings,
25
+ // block lists (`- item` lines following a `key:` line with no inline value).
26
+ // Ignores blank lines and # comments.
27
+ // Throws on any line it cannot interpret for a known key.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Parse a minimal YAML subset used by `.knosky/config.yml`.
32
+ *
33
+ * @param {string} text Raw file contents.
34
+ * @returns {Record<string, unknown>} Parsed key/value map.
35
+ */
36
+ export function parseConfigYaml(text) {
37
+ const result = {};
38
+ const lines = text.split(/\r?\n/);
39
+ let i = 0;
40
+
41
+ while (i < lines.length) {
42
+ const raw = lines[i];
43
+ // Strip inline comment and trim
44
+ const line = raw.replace(/#.*$/, '').trimEnd();
45
+ i++;
46
+
47
+ if (!line.trim()) continue; // blank / comment-only
48
+
49
+ // Must be a `key: [value]` line at the top level (no leading spaces)
50
+ const kvMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)/);
51
+ if (!kvMatch) {
52
+ throw new Error(`parseConfigYaml: unexpected line: ${JSON.stringify(raw)}`);
53
+ }
54
+ const key = kvMatch[1];
55
+ const rest = kvMatch[2].trim();
56
+
57
+ if (rest === '') {
58
+ // Possibly a block list — collect ` - item` lines that follow
59
+ const items = [];
60
+ while (i < lines.length) {
61
+ const next = lines[i].replace(/#.*$/, '');
62
+ if (/^\s*-\s/.test(next)) {
63
+ items.push(next.replace(/^\s*-\s*/, '').trim());
64
+ i++;
65
+ } else {
66
+ break;
67
+ }
68
+ }
69
+ result[key] = items;
70
+ } else {
71
+ result[key] = parseScalar(rest, key);
72
+ }
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Parse a scalar YAML value: boolean, integer, or string (bare or quoted).
80
+ *
81
+ * @param {string} raw The raw value string (already trimmed).
82
+ * @param {string} key The key name, used for error messages.
83
+ * @returns {boolean|number|string}
84
+ */
85
+ function parseScalar(raw, key) {
86
+ if (raw === 'true') return true;
87
+ if (raw === 'false') return false;
88
+
89
+ // Integer
90
+ if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
91
+
92
+ // Double-quoted string
93
+ if (raw.startsWith('"') && raw.endsWith('"')) return raw.slice(1, -1);
94
+
95
+ // Single-quoted string
96
+ if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
97
+
98
+ // Bare string — must not contain characters that signal unhandled YAML
99
+ if (/^[A-Za-z0-9._\-/ ]+$/.test(raw)) return raw;
100
+
101
+ throw new Error(`parseConfigYaml: cannot parse value for key "${key}": ${JSON.stringify(raw)}`);
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // loadConfig — read + parse `.knosky/config.yml`, merge over DEFAULTS.
106
+ // Fail-closed: throws on parse error; returns DEFAULTS copy if file absent.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Load `.knosky/config.yml` from `root` and merge it over {@link DEFAULTS}.
111
+ * Returns a plain object (not frozen). Throws if the file exists but is
112
+ * malformed.
113
+ *
114
+ * @param {string} root Absolute (or relative) project root.
115
+ * @returns {Record<string, unknown>} Merged config object.
116
+ */
117
+ export function loadConfig(root) {
118
+ const cfgPath = path.join(root, '.knosky', 'config.yml');
119
+
120
+ let text;
121
+ try {
122
+ text = fs.readFileSync(cfgPath, 'utf8');
123
+ } catch (err) {
124
+ if (err.code === 'ENOENT') {
125
+ // File absent — return a safe copy of defaults
126
+ return { ...DEFAULTS, categories: [], ignore: [] };
127
+ }
128
+ throw err; // permission error, etc — still fail-closed
129
+ }
130
+
131
+ // File exists: parse (throws on malformed — never silently fall back)
132
+ const parsed = parseConfigYaml(text);
133
+
134
+ // Deep-merge: parsed wins per-key; arrays are replaced, not concatenated.
135
+ return { ...DEFAULTS, categories: [], ignore: [], ...parsed };
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // validateConfig — collect all constraint violations, return { ok, errors }.
140
+ // ---------------------------------------------------------------------------
141
+
142
+ const BOOLEAN_KEYS = ['telemetry', 'absolute_paths', 'fail_on_secret', 'allow_excerpts'];
143
+
144
+ /**
145
+ * Validate a config object against the KnoSky protocol schema.
146
+ * Collects every violation; `ok` is true only when `errors` is empty.
147
+ *
148
+ * @param {Record<string, unknown>} cfg Config object (e.g. from {@link loadConfig}).
149
+ * @returns {{ ok: boolean, errors: string[] }}
150
+ */
151
+ export function validateConfig(cfg) {
152
+ const errors = [];
153
+ const knownKeys = new Set(Object.keys(DEFAULTS));
154
+
155
+ // knosky_protocol must be the string "1.0"
156
+ if (cfg.knosky_protocol !== '1.0') {
157
+ errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(cfg.knosky_protocol)}`);
158
+ }
159
+
160
+ // Boolean fields
161
+ for (const k of BOOLEAN_KEYS) {
162
+ if (typeof cfg[k] !== 'boolean') {
163
+ errors.push(`${k} must be a boolean, got: ${JSON.stringify(cfg[k])}`);
164
+ }
165
+ }
166
+
167
+ // max_excerpt_chars must be integer >= 0
168
+ const mec = cfg.max_excerpt_chars;
169
+ if (!Number.isInteger(mec) || mec < 0) {
170
+ errors.push(`max_excerpt_chars must be an integer >= 0, got: ${JSON.stringify(mec)}`);
171
+ }
172
+
173
+ // categories and ignore must be arrays of strings
174
+ for (const k of ['categories', 'ignore']) {
175
+ const val = cfg[k];
176
+ if (!Array.isArray(val) || val.some(v => typeof v !== 'string')) {
177
+ errors.push(`${k} must be an array of strings, got: ${JSON.stringify(val)}`);
178
+ }
179
+ }
180
+
181
+ // Unknown keys
182
+ for (const k of Object.keys(cfg)) {
183
+ if (!knownKeys.has(k)) {
184
+ errors.push(`unknown config key: ${k}`);
185
+ }
186
+ }
187
+
188
+ return { ok: errors.length === 0, errors };
189
+ }
@@ -0,0 +1,13 @@
1
+ // Shared, dependency-free constants used by more than one core module.
2
+ // Kept in its own file (no imports, no side effects) so any core/*.mjs can
3
+ // import from it without creating a circular dependency between modules.
4
+
5
+ // Upper bound for a plausible ledger sequence (git commit count). 1 billion
6
+ // is far beyond any real repo's commit count and far below
7
+ // Number.MAX_SAFE_INTEGER (~9e15), so it rejects implausible/attacker-
8
+ // supplied values (e.g. 1e100) with no precision-loss risk of its own.
9
+ // Enforced independently at two layers — core/freshness.mjs's
10
+ // extractLedgerSeq (structural validation) and core/ledger.mjs's
11
+ // checkAndAdvance (the persisted guard itself) — both import this single
12
+ // source of truth so the two layers cannot silently diverge.
13
+ export const MAX_PLAUSIBLE_LEDGER_SEQ = 1_000_000_000;
@@ -0,0 +1,111 @@
1
+ // Cross-repo graph utilities (SAT-448): edge detection, boundary extraction, and rendering metadata.
2
+ // Pure logic — no filesystem access, no new deps. Works on the in-memory city/byId structures
3
+ // produced by retrieve.load() or any equivalent.
4
+ //
5
+ // A node's *repo* identity is determined by provenance.repo when present, falling back to
6
+ // provenance.store (e.g. "fs", "gh:owner/repo") so existing city data stays fully compatible —
7
+ // single-repo graphs have no cross-repo edges because every node shares the same store value.
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // repoOf
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Return the repository identity of a node.
15
+ * Preference order:
16
+ * 1. node.provenance.repo — explicit multi-repo label (new field, optional)
17
+ * 2. node.provenance.store — always present; used as the identity when repo is absent
18
+ * (works correctly for single-repo graphs: all nodes share one store → no cross edges)
19
+ *
20
+ * Returns null if provenance is absent or malformed.
21
+ *
22
+ * @param {object} node
23
+ * @returns {string|null}
24
+ */
25
+ export function repoOf(node) {
26
+ if (!node || typeof node !== 'object') return null;
27
+ const prov = node.provenance;
28
+ if (!prov || typeof prov !== 'object') return null;
29
+ if (typeof prov.repo === 'string' && prov.repo.length > 0) return prov.repo;
30
+ if (typeof prov.store === 'string' && prov.store.length > 0) return prov.store;
31
+ return null;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // isCrossRepoEdge
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Return true when the edge (fromId → toId) crosses a repo boundary.
40
+ * An edge is cross-repo when repoOf(fromNode) !== repoOf(toNode) AND both are non-null.
41
+ * Edges where either endpoint is missing or has no repo identity are treated as same-repo
42
+ * (conservative: no false positives).
43
+ *
44
+ * @param {Map<string, object>} byId — node map from retrieve.load()
45
+ * @param {string} fromId
46
+ * @param {string} toId
47
+ * @returns {boolean}
48
+ */
49
+ export function isCrossRepoEdge(byId, fromId, toId) {
50
+ const src = byId.get(fromId);
51
+ const dst = byId.get(toId);
52
+ if (!src || !dst) return false;
53
+ const r1 = repoOf(src);
54
+ const r2 = repoOf(dst);
55
+ if (r1 === null || r2 === null) return false;
56
+ return r1 !== r2;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // getCrossRepoEdges
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Walk every node's `links` array and return all edges that cross a repo boundary.
65
+ *
66
+ * @param {object} ctx — { city: { nodes: [] }, byId: Map }
67
+ * @returns {Array<{ from: string, to: string, fromRepo: string, toRepo: string }>}
68
+ */
69
+ export function getCrossRepoEdges(ctx) {
70
+ const edges = [];
71
+ for (const node of (ctx.city.nodes || [])) {
72
+ if (!Array.isArray(node.links)) continue;
73
+ const fromRepo = repoOf(node);
74
+ if (fromRepo === null) continue;
75
+ for (const toId of node.links) {
76
+ const dst = ctx.byId.get(toId);
77
+ if (!dst) continue;
78
+ const toRepo = repoOf(dst);
79
+ if (toRepo === null) continue;
80
+ if (fromRepo !== toRepo) {
81
+ edges.push({ from: node.id, to: toId, fromRepo, toRepo });
82
+ }
83
+ }
84
+ }
85
+ return edges;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // getRepoBoundaries
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Return a summary of every distinct repo present in the graph and its node membership.
94
+ *
95
+ * @param {object} ctx — { city: { nodes: [] }, byId: Map }
96
+ * @returns {Array<{ repo: string, nodeIds: string[], count: number }>}
97
+ * Sorted by count desc, then repo asc.
98
+ */
99
+ export function getRepoBoundaries(ctx) {
100
+ /** @type {Map<string, string[]>} */
101
+ const byRepo = new Map();
102
+ for (const node of (ctx.city.nodes || [])) {
103
+ const r = repoOf(node);
104
+ if (r === null) continue;
105
+ if (!byRepo.has(r)) byRepo.set(r, []);
106
+ byRepo.get(r).push(node.id);
107
+ }
108
+ return [...byRepo.entries()]
109
+ .map(([repo, nodeIds]) => ({ repo, nodeIds, count: nodeIds.length }))
110
+ .sort((a, b) => b.count - a.count || String(a.repo).localeCompare(String(b.repo)));
111
+ }
@@ -0,0 +1,161 @@
1
+ // KnoSky destination parser (KSV2-R2) — extended prefix set for kc_route.
2
+ // Pure Node stdlib + internal imports only. ESM. No new deps.
3
+ import { search } from './retrieve.mjs';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Internal helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Resolve a single node by id "fs:<rest>" OR by provenance.ref === <rest>.
11
+ * Also accepts a fully-qualified id (already has "fs:" prefix).
12
+ * @param {object} ctx
13
+ * @param {string} idOrRef
14
+ * @returns {object|null}
15
+ */
16
+ function resolveOne(ctx, idOrRef) {
17
+ // Try constructing the canonical node id
18
+ const byConstructed = ctx.byId.get('fs:' + idOrRef);
19
+ if (byConstructed) return byConstructed;
20
+ // Try the raw string as-is (already fully qualified, e.g. "fs:src/a.js")
21
+ const byRaw = ctx.byId.get(idOrRef);
22
+ if (byRaw) return byRaw;
23
+ // Fallback: linear scan for provenance.ref match
24
+ return ctx.city.nodes.find(n => n.provenance && n.provenance.ref === idOrRef) || null;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // parseDestination — main export
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Resolve the destination string to an initial set of matching nodes.
33
+ * Returns { matched: Node[], matchStrength: 'direct'|'folder'|'district'|'edges'|'chain'|'keyword' }
34
+ *
35
+ * Supported prefixes (ALL metadata-only, no filesystem access):
36
+ * file:<idOrRef> — node by id "fs:<rest>" OR by provenance.ref === rest; 'direct'
37
+ * folder:<prefix> — nodes whose provenance.ref starts with prefix; 'folder'
38
+ * district:<name> — nodes whose category equals name (case-insensitive) or
39
+ * whose category label matches; 'district'
40
+ * importsOf:<idOrRef> — out-edge targets of that node (node.links through ctx.byId); 'edges'
41
+ * depChainTo:<idOrRef> — nodes that TRANSITIVELY import/reach the target via
42
+ * reverse-edge BFS; bounded (max depth 6, max 500 visited,
43
+ * visited-set prevents cycles); 'chain'
44
+ * <anything else> — keyword fallback via search(); 'keyword'
45
+ *
46
+ * @param {object} ctx — retrieve context {city:{nodes,categories}, byId:Map}
47
+ * @param {string} destination
48
+ * @param {number} limit — passed through to keyword search
49
+ * @returns {{ matched: object[], matchStrength: string }}
50
+ */
51
+ export function parseDestination(ctx, destination, limit) {
52
+ const dest = String(destination || '').trim();
53
+
54
+ // ------------------------------------------------------------------
55
+ // file:<idOrRef>
56
+ // ------------------------------------------------------------------
57
+ if (dest.startsWith('file:')) {
58
+ const rest = dest.slice('file:'.length);
59
+ const node = resolveOne(ctx, rest);
60
+ return { matched: node ? [node] : [], matchStrength: 'direct' };
61
+ }
62
+
63
+ // ------------------------------------------------------------------
64
+ // folder:<prefix>
65
+ // ------------------------------------------------------------------
66
+ if (dest.startsWith('folder:')) {
67
+ const rest = dest.slice('folder:'.length);
68
+ // Normalise: ensure trailing slash for prefix match (unless rest already ends with /)
69
+ const prefix = rest.endsWith('/') ? rest : rest + '/';
70
+ const matched = ctx.city.nodes.filter(n => {
71
+ const ref = n.provenance && n.provenance.ref;
72
+ return typeof ref === 'string' && (ref.startsWith(prefix) || ref === rest);
73
+ });
74
+ return { matched, matchStrength: 'folder' };
75
+ }
76
+
77
+ // ------------------------------------------------------------------
78
+ // district:<name>
79
+ // ------------------------------------------------------------------
80
+ if (dest.startsWith('district:')) {
81
+ const name = dest.slice('district:'.length).toLowerCase();
82
+ // Build label->id map from city.categories
83
+ const labelToId = new Map();
84
+ for (const cat of (ctx.city.categories || [])) {
85
+ if (cat.label) labelToId.set(String(cat.label).toLowerCase(), String(cat.id).toLowerCase());
86
+ }
87
+ const resolvedCatId = labelToId.get(name) || null;
88
+ const matched = ctx.city.nodes.filter(n => {
89
+ const cat = String(n.category || '').toLowerCase();
90
+ return cat === name || (resolvedCatId !== null && cat === resolvedCatId);
91
+ });
92
+ return { matched, matchStrength: 'district' };
93
+ }
94
+
95
+ // ------------------------------------------------------------------
96
+ // importsOf:<idOrRef>
97
+ // ------------------------------------------------------------------
98
+ if (dest.startsWith('importsOf:')) {
99
+ const rest = dest.slice('importsOf:'.length);
100
+ const node = resolveOne(ctx, rest);
101
+ if (!node) return { matched: [], matchStrength: 'edges' };
102
+ const matched = (node.links || []).map(tid => ctx.byId.get(tid)).filter(Boolean);
103
+ return { matched, matchStrength: 'edges' };
104
+ }
105
+
106
+ // ------------------------------------------------------------------
107
+ // depChainTo:<idOrRef>
108
+ // Bounded reverse-edge BFS: finds all nodes that transitively import the target.
109
+ // Safety invariants:
110
+ // - visited Set prevents revisiting nodes (handles cycles A→B→A)
111
+ // - max depth 6 per BFS level
112
+ // - hard cap of 500 nodes visited (includes the target seed)
113
+ // ------------------------------------------------------------------
114
+ if (dest.startsWith('depChainTo:')) {
115
+ const rest = dest.slice('depChainTo:'.length);
116
+ const target = resolveOne(ctx, rest);
117
+ if (!target) return { matched: [], matchStrength: 'chain' };
118
+ const targetId = target.id;
119
+
120
+ // Build reverse-edge index: nodeId -> Set<callerId>
121
+ // (which nodes have this id in their links)
122
+ const importedBy = new Map();
123
+ for (const n of ctx.city.nodes) {
124
+ for (const link of (n.links || [])) {
125
+ if (!importedBy.has(link)) importedBy.set(link, new Set());
126
+ importedBy.get(link).add(n.id);
127
+ }
128
+ }
129
+
130
+ // BFS — each queue entry carries { id, depth }
131
+ const visited = new Set();
132
+ visited.add(targetId);
133
+ const queue = [{ id: targetId, depth: 0 }];
134
+
135
+ while (queue.length > 0 && visited.size < 500) {
136
+ const { id, depth } = queue.shift();
137
+ if (depth >= 6) continue;
138
+ const parents = importedBy.get(id);
139
+ if (!parents) continue;
140
+ for (const pid of parents) {
141
+ if (!visited.has(pid)) {
142
+ visited.add(pid);
143
+ queue.push({ id: pid, depth: depth + 1 });
144
+ if (visited.size >= 500) break;
145
+ }
146
+ }
147
+ }
148
+
149
+ // Return callers of target, not the target itself
150
+ visited.delete(targetId);
151
+ const matched = [...visited].map(id => ctx.byId.get(id)).filter(Boolean);
152
+ return { matched, matchStrength: 'chain' };
153
+ }
154
+
155
+ // ------------------------------------------------------------------
156
+ // no recognized prefix — keyword fallback
157
+ // ------------------------------------------------------------------
158
+ const hits = search(ctx, dest, { limit });
159
+ const matched = hits.map(h => ctx.byId.get(h.id)).filter(Boolean);
160
+ return { matched, matchStrength: 'keyword' };
161
+ }
@@ -0,0 +1,68 @@
1
+ // KnoSky HITL escalation gate (D-166 / SAT-467) — any P0/critical or ambiguous
2
+ // review result must surface to Paul, never proceed silently.
3
+ // D-166 (SAT-466): zero-P0/critical + all reviewers succeeded → auto-proceed-to-publish.
4
+ // Pure function: no I/O, no external dependencies, safe to import anywhere.
5
+
6
+ /**
7
+ * Decide whether a review result must be escalated to Paul, or may auto-publish.
8
+ *
9
+ * Rules (D-166 hitl gate, SAT-467 / SAT-466):
10
+ * - One or more CRITICAL findings → P0, escalate immediately.
11
+ * - All reviewers failed → ambiguous (no result at all), escalate.
12
+ * - Any reviewer failed (degraded) → ambiguous (result is incomplete), escalate.
13
+ * - Zero criticals + zero failures + at least one reviewer ran
14
+ * → canAutoPublish (D-166 pre-authorized path).
15
+ *
16
+ * @param {object} opts
17
+ * @param {object[]} [opts.criticals] CRITICAL findings ({ sev, file, hint, role }).
18
+ * @param {object[]} [opts.failed] Failed reviewer records ({ role, err, ok: false }).
19
+ * @param {number} [opts.total] Total reviewer count dispatched this run.
20
+ * @returns {{ escalate: boolean, reasons: string[], paulMessage: string, canAutoPublish: boolean }}
21
+ * escalate — true iff the result must block and surface to Paul.
22
+ * reasons — human-readable list explaining why escalation was triggered
23
+ * (empty array when escalate === false).
24
+ * paulMessage — the block line to embed in the PR review body;
25
+ * non-empty iff escalate === true.
26
+ * canAutoPublish — DATA ONLY, not an authorization by itself (D-173): true iff a clean
27
+ * review (zero criticals, zero reviewer failures, ≥1 reviewer ran)
28
+ * would satisfy D-166's pre-authorized-publish condition. Callers must
29
+ * NOT treat this as license to take an autonomous action (e.g. approving
30
+ * a PR, publishing a release) on its own -- it exists so a separately
31
+ * gated, narrowly-scoped release workflow can consume it for the actual
32
+ * SAT-437/D-166 publish decision. The general per-PR review script
33
+ * deliberately does NOT act on it -- see that script's own comments.
34
+ */
35
+ export function shouldEscalateToPaul({ criticals = [], failed = [], total = 0 } = {}) {
36
+ const reasons = [];
37
+
38
+ // P0 / critical findings — must never pass silently
39
+ if (criticals.length > 0) {
40
+ reasons.push(`${criticals.length} CRITICAL finding(s)`);
41
+ }
42
+
43
+ const allFailed = total > 0 && failed.length === total;
44
+
45
+ if (allFailed) {
46
+ // No reviewer returned a result — outcome is unknowable (ambiguous)
47
+ reasons.push('all reviewers failed (no result — ambiguous)');
48
+ } else if (failed.length > 0) {
49
+ // At least one reviewer failed — remaining results are incomplete (ambiguous)
50
+ reasons.push(`${failed.length} of ${total} reviewer(s) failed (degraded — ambiguous)`);
51
+ }
52
+
53
+ const escalate = reasons.length > 0;
54
+
55
+ // D-166 (SAT-466): pre-authorized auto-publish path — zero criticals, all reviewers
56
+ // succeeded, and at least one reviewer actually ran. Do NOT auto-publish when total
57
+ // is 0 (no-reviewer synthetic run) — that would be a silent pass with no evidence.
58
+ const canAutoPublish = !escalate && total > 0 && failed.length === 0;
59
+
60
+ return {
61
+ escalate,
62
+ reasons,
63
+ paulMessage: escalate
64
+ ? 'Merge blocked until CRITICAL items are resolved or dismissed by Paul.'
65
+ : '',
66
+ canAutoPublish,
67
+ };
68
+ }