knosky 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +149 -93
  2. package/CREDITS.md +14 -14
  3. package/LICENSE.md +76 -76
  4. package/LIMITATIONS.md +33 -23
  5. package/PRIVACY.md +30 -30
  6. package/README.md +170 -117
  7. package/SECURITY.md +78 -46
  8. package/action/post-comment.mjs +94 -89
  9. package/action.yml +62 -62
  10. package/bin/knosky.mjs +279 -105
  11. package/core/CONTRACT.md +70 -70
  12. package/core/append-only-checkpoint.mjs +215 -0
  13. package/core/audit-writer.mjs +317 -0
  14. package/core/benchmark-results.mjs +225 -225
  15. package/core/bundle.mjs +178 -178
  16. package/core/churn.mjs +23 -23
  17. package/core/ci.mjs +268 -268
  18. package/core/comparison.mjs +189 -189
  19. package/core/config.mjs +189 -189
  20. package/core/constants.mjs +13 -13
  21. package/core/contract.mjs +123 -123
  22. package/core/cross-repo.mjs +111 -111
  23. package/core/decision-codes.mjs +92 -0
  24. package/core/destination.mjs +161 -161
  25. package/core/district-classification.mjs +111 -0
  26. package/core/doctor-scorecard.mjs +369 -0
  27. package/core/domain-store.mjs +347 -0
  28. package/core/edges.mjs +43 -43
  29. package/core/escalate.mjs +68 -68
  30. package/core/freshness.mjs +198 -194
  31. package/core/fs-indexer.mjs +218 -218
  32. package/core/key-store.mjs +348 -348
  33. package/core/layout.mjs +46 -46
  34. package/core/ledger.mjs +176 -141
  35. package/core/local-ipc-identity.mjs +500 -0
  36. package/core/lod.mjs +155 -155
  37. package/core/mode-b.mjs +410 -0
  38. package/core/multi-model-benchmark.mjs +405 -405
  39. package/core/net-lockdown.mjs +421 -0
  40. package/core/onboarding.mjs +223 -223
  41. package/core/operator-auth.mjs +317 -0
  42. package/core/overlays.mjs +45 -45
  43. package/core/policy-lattice.mjs +142 -0
  44. package/core/pr-comment.mjs +198 -198
  45. package/core/protocol-spec.mjs +460 -460
  46. package/core/provenance.mjs +320 -0
  47. package/core/retrieve.mjs +63 -63
  48. package/core/route.mjs +304 -304
  49. package/core/schema.mjs +275 -275
  50. package/core/signing-tiers.mjs +1265 -0
  51. package/core/swarm-bench.mjs +106 -0
  52. package/core/swarm-coordinator.mjs +867 -0
  53. package/core/trust-root-rekey.mjs +410 -0
  54. package/mcp/server.mjs +264 -108
  55. package/package.json +56 -46
  56. package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
  57. package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
  58. package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
  59. package/renderer/art/kenney/sheet_allCars.xml +545 -545
  60. package/renderer/build-rich.mjs +43 -43
  61. package/renderer/city.template.html +808 -808
  62. package/ssot/decision-codes.json +133 -0
  63. package/ssot/ladder-l0-l3.md +232 -0
  64. package/ssot/tool-menu.json +130 -0
@@ -0,0 +1,347 @@
1
+ // Local trust domain state for Mode B + early L3 identity (DEC-109/106).
2
+ // Loads/creates .knosky agents, leases, and optional policy rules.
3
+
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ renameSync,
10
+ openSync,
11
+ fsyncSync,
12
+ closeSync,
13
+ unlinkSync,
14
+ chmodSync,
15
+ } from 'node:fs';
16
+ import { join, dirname } from 'node:path';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { DENY, ALLOW, NOT_APPLICABLE } from './policy-lattice.mjs';
19
+ import { DEFAULT_CLASS, loadClass, CLASS_BLOCKED, CLASS_RESTRICTED, CLASS_CONFIDENTIAL } from './district-classification.mjs';
20
+ import {
21
+ assertOperator,
22
+ assertOperatorQuorum,
23
+ operatorCount,
24
+ sanitizeClasses,
25
+ ELEVATED,
26
+ } from './operator-auth.mjs';
27
+
28
+ /**
29
+ * Resolve domain root (.knosky dir) from city path or explicit env.
30
+ * @param {string} [cityPath]
31
+ * @param {string} [explicitDomain]
32
+ */
33
+ export function resolveDomainRoot(cityPath, explicitDomain) {
34
+ if (explicitDomain) return explicitDomain;
35
+ if (process.env.KC_DOMAIN) return process.env.KC_DOMAIN;
36
+ if (cityPath) {
37
+ // city often lives at <repo>/.knosky/city-data.json
38
+ const d = dirname(cityPath);
39
+ if (d.endsWith('.knosky') || d.replace(/\\/g, '/').endsWith('/.knosky')) return d;
40
+ return join(d, '.knosky');
41
+ }
42
+ return join(process.cwd(), '.knosky');
43
+ }
44
+
45
+ function atomicWrite(path, obj) {
46
+ mkdirSync(dirname(path), { recursive: true });
47
+ const tmp = path + '.tmp';
48
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
49
+ try {
50
+ const fd = openSync(tmp, 'r');
51
+ try {
52
+ fsyncSync(fd);
53
+ } finally {
54
+ closeSync(fd);
55
+ }
56
+ } catch {
57
+ /* ignore */
58
+ }
59
+ renameSync(tmp, path);
60
+ }
61
+
62
+ function readJson(path, fallback) {
63
+ if (!existsSync(path)) return fallback;
64
+ try {
65
+ return JSON.parse(readFileSync(path, 'utf8'));
66
+ } catch {
67
+ throw new Error(`corrupt domain file: ${path}`);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * @param {string} domainRoot
73
+ */
74
+ export function ensureDomainLayout(domainRoot) {
75
+ mkdirSync(domainRoot, { recursive: true });
76
+ // Best-effort owner-only domain dir (Unix). Windows ACLs are out of process scope;
77
+ // local disk write capability is the trust boundary for policy.json / leases.
78
+ try {
79
+ chmodSync(domainRoot, 0o700);
80
+ } catch {
81
+ /* ignore — platform may not support POSIX mode bits */
82
+ }
83
+ mkdirSync(join(domainRoot, 'audit'), { recursive: true });
84
+ const agentsPath = join(domainRoot, 'agents.json');
85
+ const leasesPath = join(domainRoot, 'leases.json');
86
+ const policyPath = join(domainRoot, 'policy.json');
87
+
88
+ if (!existsSync(agentsPath)) {
89
+ atomicWrite(agentsPath, {
90
+ v: 1,
91
+ agents: {
92
+ // bootstrapped solo agent for local Meta — encoded as optional
93
+ },
94
+ });
95
+ }
96
+ if (!existsSync(leasesPath)) {
97
+ atomicWrite(leasesPath, { v: 1, leases: {} });
98
+ }
99
+ if (!existsSync(policyPath)) {
100
+ // Default: Team-ish open for public/internal, deny restricted+ without allowlist
101
+ atomicWrite(policyPath, {
102
+ v: 1,
103
+ profile: 'team',
104
+ require_identity: true,
105
+ default_class_effect: {
106
+ public: 'ALLOW',
107
+ internal: 'ALLOW',
108
+ restricted: 'DENY',
109
+ confidential: 'DENY',
110
+ blocked: 'DENY',
111
+ },
112
+ // agentId -> allowed classes
113
+ agent_class_allow: {},
114
+ });
115
+ }
116
+ return { agentsPath, leasesPath, policyPath };
117
+ }
118
+
119
+ /**
120
+ * Load domain into memory structures.
121
+ * @param {string} domainRoot
122
+ */
123
+ export function loadDomain(domainRoot) {
124
+ const paths = ensureDomainLayout(domainRoot);
125
+ const agentsDoc = readJson(paths.agentsPath, { v: 1, agents: {} });
126
+ const leasesDoc = readJson(paths.leasesPath, { v: 1, leases: {} });
127
+ const policy = readJson(paths.policyPath, { v: 1, require_identity: true });
128
+
129
+ /** @type {Map<string, any>} */
130
+ const leaseStore = new Map();
131
+ for (const [leaseId, rec] of Object.entries(leasesDoc.leases || {})) {
132
+ leaseStore.set(leaseId, { leaseId, ...rec });
133
+ }
134
+
135
+ return {
136
+ domainRoot,
137
+ paths,
138
+ agents: agentsDoc.agents || {},
139
+ leaseStore,
140
+ leasesDoc,
141
+ policy,
142
+ saveLeases() {
143
+ const leases = {};
144
+ for (const [id, rec] of leaseStore.entries()) {
145
+ const { leaseId, ...rest } = rec;
146
+ leases[id] = rest;
147
+ }
148
+ atomicWrite(paths.leasesPath, { v: 1, leases });
149
+ },
150
+ saveAgents() {
151
+ atomicWrite(paths.agentsPath, { v: 1, agents: this.agents });
152
+ },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Register an agent and mint an active lease (local trust domain).
158
+ *
159
+ * Authorization (Rule 3 — security-critical policy mutation):
160
+ * - Elevated classes (restricted/confidential) require **two distinct operators**
161
+ * (operatorToken + operatorToken2). Single-op cannot unilaterally elevate.
162
+ * - Once any operator exists: plain public/internal join still needs **one**
163
+ * operator token (domain is secured).
164
+ * - Open first-run solo (no operators): only public/internal may self-register.
165
+ *
166
+ * @param {ReturnType<typeof loadDomain>} domain
167
+ * @param {{ agentId: string, role?: string, classes?: string[] }} agent
168
+ * @param {{ operatorToken?: string, operatorToken2?: string }} [opts]
169
+ * @returns {{ ok:true, agentId:string, leaseId:string }
170
+ * |{ ok:false, reason:string, next_action?:string }}
171
+ */
172
+ export function registerAgentWithLease(domain, agent, opts = {}) {
173
+ const agentId = agent?.agentId;
174
+ if (!agentId || typeof agentId !== 'string') {
175
+ return { ok: false, reason: 'agentId_required' };
176
+ }
177
+
178
+ const requested = Array.isArray(agent.classes) ? agent.classes.map(String) : ['public', 'internal'];
179
+ const elevatedWanted = requested.some((c) => ELEVATED.includes(c));
180
+ const secured = operatorCount(domain.domainRoot) > 0;
181
+ const token = opts.operatorToken || process.env.KC_OPERATOR_TOKEN;
182
+ const token2 = opts.operatorToken2 || process.env.KC_OPERATOR_TOKEN_2;
183
+ let allowElevated = false;
184
+
185
+ if (elevatedWanted) {
186
+ // Quorum of 2 distinct operators — single bootstrap token cannot elevate.
187
+ const q = assertOperatorQuorum(domain.domainRoot, token, token2);
188
+ if (!q.ok) {
189
+ return {
190
+ ok: false,
191
+ reason: q.reason || 'operator_quorum_required_for_elevated_classes',
192
+ next_action:
193
+ q.next_action ||
194
+ 'Elevated classes require two distinct operator tokens (--operator-token and --operator-token-2)',
195
+ };
196
+ }
197
+ allowElevated = true;
198
+ } else if (secured) {
199
+ const auth = assertOperator(domain.domainRoot, token);
200
+ if (!auth.ok) {
201
+ return {
202
+ ok: false,
203
+ reason: 'operator_required_domain_secured',
204
+ next_action:
205
+ auth.reason === 'missing_operator_token'
206
+ ? 'Pass operatorToken / KC_OPERATOR_TOKEN (bootstrap via knosky agent-register --bootstrap-operator)'
207
+ : `Operator auth failed: ${auth.reason}`,
208
+ };
209
+ }
210
+ }
211
+
212
+ const classes = sanitizeClasses(requested, { allowElevated });
213
+ if (elevatedWanted && !allowElevated) {
214
+ return {
215
+ ok: false,
216
+ reason: 'operator_quorum_required_for_elevated_classes',
217
+ next_action: 'Elevated district classes require two distinct operator tokens',
218
+ };
219
+ }
220
+
221
+ domain.agents[agentId] = {
222
+ agentId,
223
+ role: agent.role || 'coder',
224
+ classes,
225
+ created_at: new Date().toISOString(),
226
+ };
227
+ domain.saveAgents();
228
+
229
+ if (!domain.policy.agent_class_allow) domain.policy.agent_class_allow = {};
230
+ domain.policy.agent_class_allow[agentId] = classes;
231
+ try {
232
+ atomicWrite(domain.paths.policyPath, domain.policy);
233
+ } catch (err) {
234
+ return { ok: false, reason: `policy_persist_failed: ${err.message || err}` };
235
+ }
236
+
237
+ const leaseId = `lease_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
238
+ const rec = {
239
+ leaseId,
240
+ agentId,
241
+ status: 'active',
242
+ created_at: new Date().toISOString(),
243
+ expires_at: null,
244
+ };
245
+ domain.leaseStore.set(leaseId, rec);
246
+ domain.saveLeases();
247
+ return { ok: true, agentId, leaseId };
248
+ }
249
+
250
+ /**
251
+ * Build lattice rule functions from domain policy for a subject.
252
+ * Subject: { agentId, node?, class?, tool }
253
+ */
254
+ export function policyRulesFromDomain(policy) {
255
+ const requireId = policy.require_identity !== false;
256
+ const effects = policy.default_class_effect || {};
257
+ const allowMap = policy.agent_class_allow || {};
258
+
259
+ return [
260
+ // identity present?
261
+ (subject) => {
262
+ if (!requireId) return NOT_APPLICABLE;
263
+ if (!subject || !subject.agentId) return DENY;
264
+ return ALLOW;
265
+ },
266
+ // class effect — DENY is sticky for elevated classes; non-elevated allowlist may only grant
267
+ // classes that default is not DENY OR agent was listed AND class is public|internal.
268
+ (subject) => {
269
+ const cls = subject?.class || DEFAULT_CLASS;
270
+ const eff = effects[cls];
271
+ if (eff === 'DENY') {
272
+ // agent_class_allow must NOT override DENY for restricted/confidential without those classes
273
+ // already granted at registration time via quorum. At evaluation, listed classes can pass
274
+ // only if the allowlist includes that exact class (set only under quorum at register time).
275
+ const allowed = subject?.agentId ? allowMap[subject.agentId] : null;
276
+ if (Array.isArray(allowed) && allowed.includes(cls) && !ELEVATED.includes(cls)) {
277
+ return ALLOW;
278
+ }
279
+ if (Array.isArray(allowed) && allowed.includes(cls) && ELEVATED.includes(cls)) {
280
+ // Elevated classes listed at register time (quorum-minted) may ALLOW
281
+ return ALLOW;
282
+ }
283
+ return DENY;
284
+ }
285
+ if (eff === 'ALLOW') return ALLOW;
286
+ return NOT_APPLICABLE;
287
+ },
288
+ // per-agent allowlist can expand public/internal when default is N/A
289
+ (subject) => {
290
+ if (!subject?.agentId) return NOT_APPLICABLE;
291
+ const allowed = allowMap[subject.agentId];
292
+ if (!Array.isArray(allowed)) return NOT_APPLICABLE;
293
+ const cls = subject.class || DEFAULT_CLASS;
294
+ if (allowed.includes(cls)) return ALLOW;
295
+ return NOT_APPLICABLE;
296
+ },
297
+ // blocked class always deny
298
+ (subject) => {
299
+ const cls = subject?.class || DEFAULT_CLASS;
300
+ if (cls === CLASS_BLOCKED) return DENY;
301
+ return NOT_APPLICABLE;
302
+ },
303
+ ];
304
+ }
305
+
306
+ /**
307
+ * Infer a representative class for a route payload (best/most open among hits).
308
+ * Unclassified nodes default to `internal` for Team profile routing (policy can still deny),
309
+ * so greenfield indexes without district_class metadata remain usable under Mode B.
310
+ */
311
+ export function inferRouteClass(cityCtx, routeDoc) {
312
+ try {
313
+ const entries = [].concat(routeDoc?.route || [], routeDoc?.alternates || []);
314
+ if (!entries.length) return CLASS_INTERNAL_FALLBACK();
315
+
316
+ let best = CLASS_BLOCKED;
317
+ let saw = false;
318
+ for (const e of entries) {
319
+ const id = e.id || (e.path ? `fs:${e.path}` : null);
320
+ if (!id || !cityCtx?.byId) continue;
321
+ const node = typeof cityCtx.byId.get === 'function' ? cityCtx.byId.get(id) : cityCtx.byId[id];
322
+ if (!node) continue;
323
+ saw = true;
324
+ const cls = loadClass(node);
325
+ // prefer least restrictive for "representative open class" of the destination set
326
+ if (rank(cls) < rank(best)) best = cls;
327
+ }
328
+ if (!saw) return CLASS_INTERNAL_FALLBACK();
329
+ // If everything unresolved stays blocked, treat as internal for team usability
330
+ if (best === CLASS_BLOCKED) return CLASS_INTERNAL_FALLBACK();
331
+ return best;
332
+ } catch {
333
+ return CLASS_INTERNAL_FALLBACK();
334
+ }
335
+ }
336
+
337
+ function CLASS_INTERNAL_FALLBACK() {
338
+ return 'internal';
339
+ }
340
+
341
+ function rank(cls) {
342
+ const order = ['public', 'internal', 'restricted', 'confidential', 'blocked'];
343
+ const i = order.indexOf(cls);
344
+ return i < 0 ? 99 : i;
345
+ }
346
+
347
+ export { CLASS_RESTRICTED, CLASS_CONFIDENTIAL, DEFAULT_CLASS };
package/core/edges.mjs CHANGED
@@ -1,44 +1,44 @@
1
- // File-level import edges (D-155): file-to-file ONLY. Import specifiers are read transiently
2
- // and discarded after resolution; nothing but the resolved file-to-file edge is ever stored.
3
- // No symbol names, no raw import lines, no body text. Reviewer rule: only file paths survive.
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
-
7
- const PATTERNS = [
8
- /^\s*import\s+[^'"]*?from\s*['"]([^'"]+)['"]/, // js/ts: import x from 'y'
9
- /^\s*import\s*['"]([^'"]+)['"]/, // js/ts: import 'y'
10
- /\brequire\(\s*['"]([^'"]+)['"]\s*\)/, // js: require('y')
11
- /^\s*export\s+[^'"]*?from\s*['"]([^'"]+)['"]/, // js/ts: export ... from 'y'
12
- /^\s*from\s+([.\w/]+)\s+import\b/, // python: from x import
13
- /^\s*#include\s*["<]([^">]+)[">]/, // c/c++: #include "y"
14
- ];
15
-
16
- // Read up to 8KB / 150 lines, scan for import specifiers, then drop the buffer. Returns specifier strings only.
17
- export function extractImportSpecifiers(filePath) {
18
- let txt = '';
19
- try { const fd = fs.openSync(filePath, 'r'); const buf = Buffer.alloc(8192); const n = fs.readSync(fd, buf, 0, 8192, 0); fs.closeSync(fd); txt = buf.slice(0, n).toString('utf8'); }
20
- catch { return []; }
21
- const lines = txt.split(/\r?\n/);
22
- txt = '';
23
- const specs = new Set();
24
- for (let i = 0; i < lines.length && i < 150; i++) {
25
- for (const re of PATTERNS) { const m = lines[i].match(re); if (m && m[1]) { specs.add(m[1]); break; } }
26
- }
27
- return [...specs];
28
- }
29
-
30
- const EXTS = ['', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php', '.c', '.h', '.cpp', '.hpp', '.cs', '.kt', '.swift'];
31
-
32
- // Resolve a relative specifier to an indexed repo file (rel path). Returns the target rel or null.
33
- // Only relative/internal specifiers resolve; bare package names return null (external = no edge).
34
- export function resolveSpec(fileRel, spec, relSet) {
35
- if (!spec || !(spec.startsWith('.') || spec.startsWith('/'))) return null;
36
- const baseDir = path.posix.dirname(fileRel);
37
- const target = path.posix.normalize(path.posix.join(spec.startsWith('/') ? '.' : baseDir, spec)).replace(/^\.\//, '');
38
- for (const e of EXTS) {
39
- if (relSet.has(target + e)) return target + e;
40
- const idx = path.posix.join(target, 'index' + (e || '.js'));
41
- if (relSet.has(idx)) return idx;
42
- }
43
- return null;
1
+ // File-level import edges (D-155): file-to-file ONLY. Import specifiers are read transiently
2
+ // and discarded after resolution; nothing but the resolved file-to-file edge is ever stored.
3
+ // No symbol names, no raw import lines, no body text. Reviewer rule: only file paths survive.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ const PATTERNS = [
8
+ /^\s*import\s+[^'"]*?from\s*['"]([^'"]+)['"]/, // js/ts: import x from 'y'
9
+ /^\s*import\s*['"]([^'"]+)['"]/, // js/ts: import 'y'
10
+ /\brequire\(\s*['"]([^'"]+)['"]\s*\)/, // js: require('y')
11
+ /^\s*export\s+[^'"]*?from\s*['"]([^'"]+)['"]/, // js/ts: export ... from 'y'
12
+ /^\s*from\s+([.\w/]+)\s+import\b/, // python: from x import
13
+ /^\s*#include\s*["<]([^">]+)[">]/, // c/c++: #include "y"
14
+ ];
15
+
16
+ // Read up to 8KB / 150 lines, scan for import specifiers, then drop the buffer. Returns specifier strings only.
17
+ export function extractImportSpecifiers(filePath) {
18
+ let txt = '';
19
+ try { const fd = fs.openSync(filePath, 'r'); const buf = Buffer.alloc(8192); const n = fs.readSync(fd, buf, 0, 8192, 0); fs.closeSync(fd); txt = buf.slice(0, n).toString('utf8'); }
20
+ catch { return []; }
21
+ const lines = txt.split(/\r?\n/);
22
+ txt = '';
23
+ const specs = new Set();
24
+ for (let i = 0; i < lines.length && i < 150; i++) {
25
+ for (const re of PATTERNS) { const m = lines[i].match(re); if (m && m[1]) { specs.add(m[1]); break; } }
26
+ }
27
+ return [...specs];
28
+ }
29
+
30
+ const EXTS = ['', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php', '.c', '.h', '.cpp', '.hpp', '.cs', '.kt', '.swift'];
31
+
32
+ // Resolve a relative specifier to an indexed repo file (rel path). Returns the target rel or null.
33
+ // Only relative/internal specifiers resolve; bare package names return null (external = no edge).
34
+ export function resolveSpec(fileRel, spec, relSet) {
35
+ if (!spec || !(spec.startsWith('.') || spec.startsWith('/'))) return null;
36
+ const baseDir = path.posix.dirname(fileRel);
37
+ const target = path.posix.normalize(path.posix.join(spec.startsWith('/') ? '.' : baseDir, spec)).replace(/^\.\//, '');
38
+ for (const e of EXTS) {
39
+ if (relSet.has(target + e)) return target + e;
40
+ const idx = path.posix.join(target, 'index' + (e || '.js'));
41
+ if (relSet.has(idx)) return idx;
42
+ }
43
+ return null;
44
44
  }
package/core/escalate.mjs CHANGED
@@ -1,68 +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
- }
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
+ }