@ngockhoale/ukit 2.6.9 → 2.6.10
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/package.json +1 -1
- package/src/cli/commands/metrics.js +127 -0
- package/src/cli/index.js +7 -0
- package/src/diagnostics/failurePatterns.js +187 -0
- package/src/diagnostics/routeOutcomes.js +146 -0
- package/templates/.claude/hooks/block-dangerous.sh +29 -5
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/protect-files.sh +26 -3
- package/templates/.claude/ukit/index/route-task.mjs +57 -6
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +9 -1
- package/templates/.claude/ukit/runtime/hook-input.sh +26 -3
package/package.json
CHANGED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// `ukit metrics` — read-only telemetry roll-up (SPEC §6, SI-102).
|
|
2
|
+
// Route outcomes + failure patterns (lazy, degrade to n/a) + v2 memory count.
|
|
3
|
+
// Never exits non-zero on missing telemetry; unknown flag → exit 1.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { collectRouteOutcomes } from '../../diagnostics/routeOutcomes.js';
|
|
8
|
+
import { buildRuntimePaths } from '../../core/runtimePaths.js';
|
|
9
|
+
|
|
10
|
+
const HELP_FLAGS = new Set(['--help', '-h', 'help']);
|
|
11
|
+
const KNOWN_FLAGS = new Set(['--json']);
|
|
12
|
+
|
|
13
|
+
const MODE_FIELDS = ['routes', 'joined', 'writeOk', 'writeFail', 'verifyAttempted', 'verifyOk', 'verifyFail', 'rescue', 'stalled'];
|
|
14
|
+
|
|
15
|
+
function printUsage() {
|
|
16
|
+
console.log('Usage: ukit metrics [--json]');
|
|
17
|
+
console.log('');
|
|
18
|
+
console.log('Print a read-only roll-up of UKit telemetry:');
|
|
19
|
+
console.log(' route-outcomes route-audit ↔ exec-ledger join stats per mode/taskType');
|
|
20
|
+
console.log(' failure-patterns mined failure signatures (n/a when unavailable)');
|
|
21
|
+
console.log(' memory v2 record count by status');
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log('Options:');
|
|
24
|
+
console.log(' --json Print the full roll-up object as JSON');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function collectMemorySummary(projectRoot) {
|
|
28
|
+
try {
|
|
29
|
+
const { memoryV2RecordsPath } = buildRuntimePaths(projectRoot);
|
|
30
|
+
const raw = await fs.readFile(memoryV2RecordsPath, 'utf8');
|
|
31
|
+
const doc = JSON.parse(raw);
|
|
32
|
+
const records = Array.isArray(doc) ? doc : (Array.isArray(doc?.records) ? doc.records : []);
|
|
33
|
+
const byStatus = {};
|
|
34
|
+
for (const record of records) {
|
|
35
|
+
const status = record?.status ?? 'unknown';
|
|
36
|
+
byStatus[status] = (byStatus[status] ?? 0) + 1;
|
|
37
|
+
}
|
|
38
|
+
return { total: records.length, byStatus };
|
|
39
|
+
} catch {
|
|
40
|
+
return { total: 0, byStatus: {} };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function collectFailurePatterns(projectRoot) {
|
|
45
|
+
try {
|
|
46
|
+
const mod = await import('../../diagnostics/failurePatterns.js');
|
|
47
|
+
if (typeof mod?.mineFailurePatterns !== 'function') return null;
|
|
48
|
+
return await mod.mineFailurePatterns(projectRoot);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printRouteOutcomes(routeOutcomes) {
|
|
55
|
+
console.log('route-outcomes');
|
|
56
|
+
console.log(` ledgers: ${routeOutcomes.ledgersScanned} audit rows: ${routeOutcomes.auditRowsScanned} joined: ${routeOutcomes.joined} coverage: ${(routeOutcomes.joinCoverage * 100).toFixed(1)}% unmatched audit: ${routeOutcomes.unmatchedAudit} unmatched ledger: ${routeOutcomes.unmatchedLedger}`);
|
|
57
|
+
const header = ['mode', ...MODE_FIELDS];
|
|
58
|
+
console.log(` ${header.join('\t')}`);
|
|
59
|
+
for (const [mode, bucket] of Object.entries(routeOutcomes.byMode)) {
|
|
60
|
+
console.log(` ${[mode, ...MODE_FIELDS.map((field) => bucket[field] ?? 0)].join('\t')}`);
|
|
61
|
+
}
|
|
62
|
+
if (Object.keys(routeOutcomes.byMode).length === 0) {
|
|
63
|
+
console.log(' (no route-audit data)');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function printFailurePatterns(failurePatterns) {
|
|
68
|
+
console.log('failure-patterns');
|
|
69
|
+
if (!failurePatterns) {
|
|
70
|
+
console.log(' n/a');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const patterns = Array.isArray(failurePatterns.patterns) ? failurePatterns.patterns : [];
|
|
74
|
+
if (patterns.length === 0) {
|
|
75
|
+
console.log(' (none)');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const pattern of patterns) {
|
|
79
|
+
console.log(` ${pattern.count}× ${pattern.signature} sessions=${pattern.sessions}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function printMemory(memory) {
|
|
84
|
+
const statusPairs = Object.entries(memory.byStatus).map(([status, count]) => `${status}=${count}`).join(' ');
|
|
85
|
+
console.log(`memory: v2 records=${memory.total}${statusPairs ? ` (${statusPairs})` : ''}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function runMetrics({ projectRoot, argv = [] }) {
|
|
89
|
+
if (argv.some((flag) => HELP_FLAGS.has(flag))) {
|
|
90
|
+
printUsage();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const unknown = argv.filter((flag) => !KNOWN_FLAGS.has(flag));
|
|
94
|
+
if (unknown.length > 0) {
|
|
95
|
+
console.error(`[UKit] Unknown metrics flag(s): ${unknown.join(', ')}`);
|
|
96
|
+
printUsage();
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const routeOutcomes = await collectRouteOutcomes(projectRoot).catch((error) => ({
|
|
102
|
+
ledgersScanned: 0,
|
|
103
|
+
auditRowsScanned: 0,
|
|
104
|
+
joined: 0,
|
|
105
|
+
unmatchedAudit: 0,
|
|
106
|
+
unmatchedLedger: 0,
|
|
107
|
+
byMode: {},
|
|
108
|
+
byTaskType: {},
|
|
109
|
+
joinCoverage: 0,
|
|
110
|
+
error: error?.message ?? String(error),
|
|
111
|
+
}));
|
|
112
|
+
const failurePatterns = await collectFailurePatterns(projectRoot);
|
|
113
|
+
const memory = await collectMemorySummary(projectRoot);
|
|
114
|
+
|
|
115
|
+
const rollup = { routeOutcomes, failurePatterns, memory };
|
|
116
|
+
|
|
117
|
+
if (argv.includes('--json')) {
|
|
118
|
+
console.log(JSON.stringify(rollup, null, 2));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
printRouteOutcomes(routeOutcomes);
|
|
123
|
+
console.log('');
|
|
124
|
+
printFailurePatterns(failurePatterns);
|
|
125
|
+
console.log('');
|
|
126
|
+
printMemory(memory);
|
|
127
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { runStatus } from './commands/status.js';
|
|
|
7
7
|
import { runMemory } from './commands/memory.js';
|
|
8
8
|
import { runUpdate } from './commands/update.js';
|
|
9
9
|
import { runCode } from './commands/code.js';
|
|
10
|
+
import { runMetrics } from './commands/metrics.js';
|
|
10
11
|
|
|
11
12
|
const GLOBAL_FLAGS = new Set(['--help', '-h', '--version', '-v']);
|
|
12
13
|
|
|
@@ -65,6 +66,11 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
|
|
|
65
66
|
return;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
if (command === 'metrics') {
|
|
70
|
+
await runMetrics({ projectRoot, argv: commandArgv });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
if (command === 'update') {
|
|
69
75
|
await runUpdate({ packageVersion, argv: commandArgv });
|
|
70
76
|
return;
|
|
@@ -98,6 +104,7 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
|
|
|
98
104
|
console.log(' code Context compiler surface (peek/search/context/impact)');
|
|
99
105
|
console.log(' status Show UKit runtime status');
|
|
100
106
|
console.log(' memory Inspect shared UKit memory');
|
|
107
|
+
console.log(' metrics Telemetry roll-up (route outcomes, failure patterns, memory)');
|
|
101
108
|
console.log(' update Upgrade the global UKit CLI to the latest version');
|
|
102
109
|
console.log(' version Show UKit version');
|
|
103
110
|
console.log('');
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// failurePatterns.js — advisory failure-pattern mining over exec-ledger receipts (SI-103).
|
|
2
|
+
//
|
|
3
|
+
// What this is for: ledgers already record every verification receipt, but nothing
|
|
4
|
+
// distilled "which commands keep failing". This module scans
|
|
5
|
+
// `.ukit/storage/cache/exec-ledger/*.json`, extracts failed verification receipts,
|
|
6
|
+
// normalizes each command into a deterministic two-token signature, and persists the
|
|
7
|
+
// top patterns to `.ukit/storage/cache/failure-patterns.json` for `ukit metrics`.
|
|
8
|
+
//
|
|
9
|
+
// Contracts (asserted by tests/core/failurePatterns.test.js):
|
|
10
|
+
// * NEVER THROWS. Missing dir, malformed JSON, unreadable files → zeroed/partial
|
|
11
|
+
// result, no exception escapes. Artifact write failure is non-fatal: the computed
|
|
12
|
+
// object is still returned.
|
|
13
|
+
// * EVIDENCE-ONLY / ADVISORY. Zero writes to memory store, MEMORY.md, or routing
|
|
14
|
+
// inputs — the only side effect is the last-result cache artifact.
|
|
15
|
+
// * Deterministic normalization: first two whitespace tokens, lowercased;
|
|
16
|
+
// path-like and numeric tokens collapse to `<arg>`.
|
|
17
|
+
// * Same ledger-dir discipline as SPEC §4: glob `*.json`, skip
|
|
18
|
+
// `gate-crash-counter.json`, `*.journal*`, `*.quarantine*`, bound by mtime.
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs/promises';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
const LEDGER_DIR_REL = path.join('.ukit', 'storage', 'cache', 'exec-ledger');
|
|
24
|
+
const ARTIFACT_REL = path.join('.ukit', 'storage', 'cache', 'failure-patterns.json');
|
|
25
|
+
const NON_LEDGER_FILES = new Set(['gate-crash-counter.json']);
|
|
26
|
+
const MAX_PATTERNS = 10;
|
|
27
|
+
const MAX_EXAMPLES = 3;
|
|
28
|
+
|
|
29
|
+
// Receipt kinds that count as verification-ish evidence.
|
|
30
|
+
const VERIFY_KINDS = new Set(['verify', 'verification']);
|
|
31
|
+
|
|
32
|
+
function isObject(value) {
|
|
33
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isLedgerFilename(name) {
|
|
37
|
+
if (typeof name !== 'string' || !name.endsWith('.json')) return false;
|
|
38
|
+
if (NON_LEDGER_FILES.has(name)) return false;
|
|
39
|
+
if (name.includes('.journal')) return false;
|
|
40
|
+
if (name.includes('.quarantine')) return false;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// First two whitespace tokens, lowercased; path-like or numeric tokens become `<arg>`.
|
|
45
|
+
function normalizeSignature(command) {
|
|
46
|
+
if (typeof command !== 'string') return null;
|
|
47
|
+
const tokens = command.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
48
|
+
if (tokens.length === 0) return null;
|
|
49
|
+
const head = tokens.slice(0, 2).map((token) => {
|
|
50
|
+
if (/^\d+(\.\d+)?$/.test(token)) return '<arg>';
|
|
51
|
+
if (token.includes('/') || token.includes('\\') || token.endsWith('.js')
|
|
52
|
+
|| token.endsWith('.ts') || token.endsWith('.json')) {
|
|
53
|
+
return '<arg>';
|
|
54
|
+
}
|
|
55
|
+
return token;
|
|
56
|
+
});
|
|
57
|
+
return head.join(' ');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Extract failure events from one parsed ledger. A failure event is a receipt with
|
|
61
|
+
// `success === false` on a verification-ish kind carrying a `command`, OR (when the
|
|
62
|
+
// ledger declares `verificationFailed === true`) any receipt carrying a `command`.
|
|
63
|
+
function failureCommands(ledger) {
|
|
64
|
+
if (!isObject(ledger) || !Array.isArray(ledger.receipts)) return [];
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const receipt of ledger.receipts) {
|
|
67
|
+
if (!isObject(receipt) || typeof receipt.command !== 'string') continue;
|
|
68
|
+
const verifyFail = receipt.success === false && VERIFY_KINDS.has(receipt.kind);
|
|
69
|
+
const ledgerFlagged = ledger.verificationFailed === true && receipt.success === false;
|
|
70
|
+
if (verifyFail || ledgerFlagged) {
|
|
71
|
+
out.push({ command: receipt.command, file: receipt.file });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function listLedgerFiles(dir, limit) {
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const files = [];
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (!entry.isFile() || !isLedgerFilename(entry.name)) continue;
|
|
87
|
+
files.push(entry.name);
|
|
88
|
+
}
|
|
89
|
+
const withMtime = await Promise.all(files.map(async (name) => {
|
|
90
|
+
try {
|
|
91
|
+
const stat = await fs.stat(path.join(dir, name));
|
|
92
|
+
return { name, mtimeMs: stat.mtimeMs };
|
|
93
|
+
} catch {
|
|
94
|
+
return { name, mtimeMs: 0 };
|
|
95
|
+
}
|
|
96
|
+
}));
|
|
97
|
+
withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
98
|
+
return withMtime.slice(0, limit).map((f) => f.name);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function writeArtifact(filePath, result) {
|
|
102
|
+
const dir = path.dirname(filePath);
|
|
103
|
+
const tmp = path.join(dir, `.failure-patterns-${process.pid}.tmp`);
|
|
104
|
+
try {
|
|
105
|
+
await fs.mkdir(dir, { recursive: true });
|
|
106
|
+
await fs.writeFile(tmp, JSON.stringify(result, null, 2));
|
|
107
|
+
await fs.rename(tmp, filePath);
|
|
108
|
+
} catch {
|
|
109
|
+
// Non-fatal: artifact is advisory; the computed result is still returned.
|
|
110
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Mine verification-failure patterns from exec-ledger receipts.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} projectRoot repository root containing `.ukit/storage/cache/exec-ledger/`.
|
|
118
|
+
* @param {{ limitLedgers?: number, minCount?: number }} [options]
|
|
119
|
+
* @returns {Promise<{generatedAt: string, ledgersScanned: number,
|
|
120
|
+
* ledgersWithFailures: number,
|
|
121
|
+
* patterns: Array<{signature: string, count: number, sessions: number,
|
|
122
|
+
* commands: string[], files: string[]}>}>} never throws.
|
|
123
|
+
*/
|
|
124
|
+
export async function mineFailurePatterns(projectRoot, { limitLedgers = 500, minCount = 2 } = {}) {
|
|
125
|
+
const result = {
|
|
126
|
+
generatedAt: new Date().toISOString(),
|
|
127
|
+
ledgersScanned: 0,
|
|
128
|
+
ledgersWithFailures: 0,
|
|
129
|
+
patterns: [],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const dir = path.join(projectRoot, LEDGER_DIR_REL);
|
|
134
|
+
const names = await listLedgerFiles(dir, limitLedgers);
|
|
135
|
+
const groups = new Map();
|
|
136
|
+
|
|
137
|
+
for (const name of names) {
|
|
138
|
+
let ledger;
|
|
139
|
+
try {
|
|
140
|
+
ledger = JSON.parse(await fs.readFile(path.join(dir, name), 'utf8'));
|
|
141
|
+
} catch {
|
|
142
|
+
continue; // malformed/unreadable ledger — skip, never throw
|
|
143
|
+
}
|
|
144
|
+
result.ledgersScanned += 1;
|
|
145
|
+
|
|
146
|
+
const failures = failureCommands(ledger);
|
|
147
|
+
if (failures.length === 0) continue;
|
|
148
|
+
result.ledgersWithFailures += 1;
|
|
149
|
+
const sessionId = typeof ledger.sessionId === 'string' ? ledger.sessionId : null;
|
|
150
|
+
|
|
151
|
+
for (const { command, file } of failures) {
|
|
152
|
+
const signature = normalizeSignature(command);
|
|
153
|
+
if (!signature) continue;
|
|
154
|
+
let group = groups.get(signature);
|
|
155
|
+
if (!group) {
|
|
156
|
+
group = { signature, count: 0, sessions: new Set(), commands: [], files: [] };
|
|
157
|
+
groups.set(signature, group);
|
|
158
|
+
}
|
|
159
|
+
group.count += 1;
|
|
160
|
+
if (sessionId) group.sessions.add(sessionId);
|
|
161
|
+
if (group.commands.length < MAX_EXAMPLES && !group.commands.includes(command)) {
|
|
162
|
+
group.commands.push(command);
|
|
163
|
+
}
|
|
164
|
+
if (typeof file === 'string' && group.files.length < MAX_EXAMPLES && !group.files.includes(file)) {
|
|
165
|
+
group.files.push(file);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
result.patterns = [...groups.values()]
|
|
171
|
+
.filter((g) => g.count >= minCount)
|
|
172
|
+
.sort((a, b) => b.count - a.count || a.signature.localeCompare(b.signature))
|
|
173
|
+
.slice(0, MAX_PATTERNS)
|
|
174
|
+
.map((g) => ({
|
|
175
|
+
signature: g.signature,
|
|
176
|
+
count: g.count,
|
|
177
|
+
sessions: g.sessions.size,
|
|
178
|
+
commands: g.commands,
|
|
179
|
+
files: g.files,
|
|
180
|
+
}));
|
|
181
|
+
} catch {
|
|
182
|
+
// Any unexpected failure → return whatever was computed so far.
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await writeArtifact(path.join(projectRoot, ARTIFACT_REL), result);
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Route-outcome join (SPEC §4, SI-102): joins route-audit.json entries to
|
|
2
|
+
// per-request exec-ledger ledgers on requestKey and aggregates outcome stats
|
|
3
|
+
// per executionMode / taskType. Read-only; never throws.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const UNCLASSIFIED = '(unclassified)';
|
|
9
|
+
const LEDGER_EXCLUDE = new Set(['gate-crash-counter.json']);
|
|
10
|
+
|
|
11
|
+
function zeroedResult(extra = {}) {
|
|
12
|
+
return {
|
|
13
|
+
generatedAt: new Date().toISOString(),
|
|
14
|
+
ledgersScanned: 0,
|
|
15
|
+
auditRowsScanned: 0,
|
|
16
|
+
joined: 0,
|
|
17
|
+
unmatchedAudit: 0,
|
|
18
|
+
unmatchedLedger: 0,
|
|
19
|
+
byMode: {},
|
|
20
|
+
byTaskType: {},
|
|
21
|
+
joinCoverage: 0,
|
|
22
|
+
...extra,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function emptyBucket() {
|
|
27
|
+
return {
|
|
28
|
+
routes: 0,
|
|
29
|
+
joined: 0,
|
|
30
|
+
writeOk: 0,
|
|
31
|
+
writeFail: 0,
|
|
32
|
+
verifyAttempted: 0,
|
|
33
|
+
verifyOk: 0,
|
|
34
|
+
verifyFail: 0,
|
|
35
|
+
rescue: 0,
|
|
36
|
+
stalled: 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function listLedgerFiles(ledgerDir, limitLedgers) {
|
|
41
|
+
let dirents;
|
|
42
|
+
try {
|
|
43
|
+
dirents = await fs.readdir(ledgerDir, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const candidates = [];
|
|
48
|
+
for (const dirent of dirents) {
|
|
49
|
+
if (!dirent.isFile()) continue;
|
|
50
|
+
const name = dirent.name;
|
|
51
|
+
if (!name.endsWith('.json')) continue;
|
|
52
|
+
if (LEDGER_EXCLUDE.has(name)) continue;
|
|
53
|
+
if (name.includes('.journal') || name.includes('.quarantine')) continue;
|
|
54
|
+
candidates.push(name);
|
|
55
|
+
}
|
|
56
|
+
const withMtime = await Promise.all(candidates.map(async (name) => {
|
|
57
|
+
try {
|
|
58
|
+
const stat = await fs.stat(path.join(ledgerDir, name));
|
|
59
|
+
return { name, mtimeMs: stat.mtimeMs };
|
|
60
|
+
} catch {
|
|
61
|
+
return { name, mtimeMs: 0 };
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
65
|
+
return withMtime.slice(0, Math.max(0, limitLedgers)).map((entry) => entry.name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readJson(filePath) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function bump(bucket, ledger, auditEntry) {
|
|
77
|
+
bucket.joined += 1;
|
|
78
|
+
if (ledger.writeSucceeded === true) bucket.writeOk += 1;
|
|
79
|
+
else if (ledger.writeAttempted === true) bucket.writeFail += 1;
|
|
80
|
+
if (ledger.verificationAttempted === true) bucket.verifyAttempted += 1;
|
|
81
|
+
if (ledger.verificationSucceeded === true) bucket.verifyOk += 1;
|
|
82
|
+
if (ledger.verificationFailed === true) bucket.verifyFail += 1;
|
|
83
|
+
if (auditEntry.rescueMode != null) bucket.rescue += 1;
|
|
84
|
+
if ((auditEntry.repeatCount ?? 0) >= 2 && ledger.writeSucceeded !== true) {
|
|
85
|
+
bucket.stalled += 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function collectRouteOutcomes(projectRoot, { limitLedgers = 500 } = {}) {
|
|
90
|
+
try {
|
|
91
|
+
const cacheDir = path.join(projectRoot, '.ukit', 'storage', 'cache');
|
|
92
|
+
const auditDoc = await readJson(path.join(cacheDir, 'route-audit.json'));
|
|
93
|
+
const auditEntries = Array.isArray(auditDoc?.entries) ? auditDoc.entries : [];
|
|
94
|
+
|
|
95
|
+
const ledgerDir = path.join(cacheDir, 'exec-ledger');
|
|
96
|
+
const ledgerFiles = await listLedgerFiles(ledgerDir, limitLedgers);
|
|
97
|
+
const ledgers = [];
|
|
98
|
+
for (const name of ledgerFiles) {
|
|
99
|
+
const doc = await readJson(path.join(ledgerDir, name));
|
|
100
|
+
if (doc && typeof doc === 'object') ledgers.push(doc);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = zeroedResult();
|
|
104
|
+
result.ledgersScanned = ledgers.length;
|
|
105
|
+
result.auditRowsScanned = auditEntries.length;
|
|
106
|
+
|
|
107
|
+
const auditByKey = new Map();
|
|
108
|
+
for (const entry of auditEntries) {
|
|
109
|
+
if (entry && typeof entry.requestKey === 'string' && entry.requestKey) {
|
|
110
|
+
auditByKey.set(entry.requestKey, entry);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const ledgerKeys = new Set();
|
|
114
|
+
for (const ledger of ledgers) {
|
|
115
|
+
if (ledger && typeof ledger.requestKey === 'string' && ledger.requestKey) {
|
|
116
|
+
ledgerKeys.add(ledger.requestKey);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const entry of auditByKey.values()) {
|
|
121
|
+
const modeKey = entry.executionMode ?? UNCLASSIFIED;
|
|
122
|
+
const taskKey = entry.taskType ?? UNCLASSIFIED;
|
|
123
|
+
const modeBucket = result.byMode[modeKey] ??= emptyBucket();
|
|
124
|
+
const taskBucket = result.byTaskType[taskKey] ??= emptyBucket();
|
|
125
|
+
modeBucket.routes += 1;
|
|
126
|
+
taskBucket.routes += 1;
|
|
127
|
+
const ledger = ledgers.find((candidate) => candidate.requestKey === entry.requestKey);
|
|
128
|
+
if (!ledger) continue;
|
|
129
|
+
result.joined += 1;
|
|
130
|
+
bump(modeBucket, ledger, entry);
|
|
131
|
+
bump(taskBucket, ledger, entry);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
result.unmatchedAudit = auditByKey.size - result.joined;
|
|
135
|
+
const matchedLedgerKeys = new Set(
|
|
136
|
+
ledgers
|
|
137
|
+
.filter((ledger) => auditByKey.has(ledger?.requestKey))
|
|
138
|
+
.map((ledger) => ledger.requestKey),
|
|
139
|
+
);
|
|
140
|
+
result.unmatchedLedger = ledgerKeys.size - matchedLedgerKeys.size;
|
|
141
|
+
result.joinCoverage = result.joined / Math.max(1, auditByKey.size);
|
|
142
|
+
return result;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return zeroedResult({ error: error?.message ?? String(error) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -52,9 +52,13 @@ else
|
|
|
52
52
|
# path's ukit_emit_input_degraded.
|
|
53
53
|
rm -f "$UKIT_INPUT_FILE"
|
|
54
54
|
UKIT_INPUT_FILE=""
|
|
55
|
+
# TASK-223: same emit shape as ukit_emit_permission_decision — under a
|
|
56
|
+
# direct host the JSON only reaches the permission pipeline on exit 0; the
|
|
57
|
+
# omp chain needs exit 2 (marker exported by hook-chain-runner.mjs).
|
|
55
58
|
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so dangerous-command gate cannot prove it safe. UKit defers this to a human decision."}}'
|
|
56
59
|
echo "BLOCKED: dangerous-command gate could not inspect a truncated/stalled payload; deferred to human." >&2
|
|
57
|
-
exit 2
|
|
60
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
|
|
61
|
+
exit 0
|
|
58
62
|
fi
|
|
59
63
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
60
64
|
fi
|
|
@@ -115,15 +119,35 @@ DANGEROUS_PATTERNS=(
|
|
|
115
119
|
"dd if=/dev/"
|
|
116
120
|
)
|
|
117
121
|
|
|
118
|
-
# Dangerous detections surface as a structured
|
|
119
|
-
#
|
|
122
|
+
# Dangerous detections surface as a structured decision (stdout JSON). TASK-223:
|
|
123
|
+
# emission is centralized in ukit_emit_permission_decision — exit 0 on the direct
|
|
124
|
+
# host (where a non-zero exit would discard the JSON as a bare "hook error"),
|
|
125
|
+
# exit 2 inside the omp chain (whose bridge only parses stdout on code 2).
|
|
126
|
+
# Probe verdict (2026-09-20, spec step 4): this repo runs
|
|
127
|
+
# permissions.defaultMode=bypassPermissions, where an exit-0 `ask` auto-approves —
|
|
128
|
+
# dead for refusals. So the direct host gets `deny` (a proper denied surface, still
|
|
129
|
+
# fail-closed); the omp chain keeps `ask` (the host boundary prompts the human).
|
|
120
130
|
# The raw command and the matched pattern are NEVER echoed — arguments and comments can
|
|
121
131
|
# carry secrets, and the pattern text itself restates the dangerous command.
|
|
122
132
|
emit_dangerous_decision() {
|
|
123
133
|
REASON="$1"
|
|
124
|
-
|
|
134
|
+
if command -v ukit_emit_permission_decision >/dev/null 2>&1; then
|
|
135
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
136
|
+
ukit_emit_permission_decision ask "$REASON"
|
|
137
|
+
else
|
|
138
|
+
ukit_emit_permission_decision deny "$REASON"
|
|
139
|
+
fi
|
|
140
|
+
fi
|
|
141
|
+
# Helper unavailable (pre-install tree): keep the safe inline parity — the
|
|
142
|
+
# direct host gets deny + exit 0, the chain gets ask + exit 2.
|
|
143
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
144
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
|
|
145
|
+
echo "BLOCKED: $REASON" >&2
|
|
146
|
+
exit 2
|
|
147
|
+
fi
|
|
148
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$REASON\"}}"
|
|
125
149
|
echo "BLOCKED: $REASON" >&2
|
|
126
|
-
exit
|
|
150
|
+
exit 0
|
|
127
151
|
}
|
|
128
152
|
|
|
129
153
|
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
|
|
@@ -80,9 +80,12 @@ else
|
|
|
80
80
|
# path's ukit_emit_input_degraded.
|
|
81
81
|
rm -f "$UKIT_INPUT_FILE"
|
|
82
82
|
UKIT_INPUT_FILE=""
|
|
83
|
+
# TASK-223: same emit shape as ukit_emit_permission_decision — exit 0 on the
|
|
84
|
+
# direct host (non-zero discards stdout there), exit 2 inside the omp chain.
|
|
83
85
|
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so context hard-cap gate cannot prove it safe. UKit defers this to a human decision."}}'
|
|
84
86
|
echo "BLOCKED: context hard-cap gate could not inspect a truncated/stalled payload; deferred to human." >&2
|
|
85
|
-
exit 2
|
|
87
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
|
|
88
|
+
exit 0
|
|
86
89
|
fi
|
|
87
90
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
88
91
|
fi
|
|
@@ -52,9 +52,12 @@ else
|
|
|
52
52
|
# path's ukit_emit_input_degraded.
|
|
53
53
|
rm -f "$UKIT_INPUT_FILE"
|
|
54
54
|
UKIT_INPUT_FILE=""
|
|
55
|
+
# TASK-223: same emit shape as ukit_emit_permission_decision — exit 0 on the
|
|
56
|
+
# direct host (non-zero discards stdout there), exit 2 inside the omp chain.
|
|
55
57
|
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so protected-file gate cannot prove it safe. UKit defers this to a human decision."}}'
|
|
56
58
|
echo "BLOCKED: protected-file gate could not inspect a truncated/stalled payload; deferred to human." >&2
|
|
57
|
-
exit 2
|
|
59
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
|
|
60
|
+
exit 0
|
|
58
61
|
fi
|
|
59
62
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
60
63
|
fi
|
|
@@ -112,9 +115,29 @@ if [ -n "$PROTECTED_MATCH" ]; then
|
|
|
112
115
|
# A stderr-only refusal can look like a silent stall when the host does not render
|
|
113
116
|
# hook stderr. Do not expose the path/pattern on stdout: the generic explanation is
|
|
114
117
|
# enough for the user and avoids leaking a potentially sensitive filename.
|
|
115
|
-
|
|
118
|
+
# TASK-223: emit through the centralized helper — exit 0 on the direct host so the
|
|
119
|
+
# structured decision survives (non-zero discards stdout there), exit 2 in the omp
|
|
120
|
+
# chain whose bridge parses stdout only on code 2. Probe verdict (2026-09-20):
|
|
121
|
+
# under bypassPermissions an exit-0 `ask` auto-approves — dead for a protected-file
|
|
122
|
+
# refusal — so the direct host emits `deny`; the chain keeps `ask` for the human.
|
|
123
|
+
__ukit_protect_reason='UKit protected-file guard blocked this edit. Ask the user to modify the protected file manually.'
|
|
124
|
+
echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
|
|
125
|
+
if command -v ukit_emit_permission_decision >/dev/null 2>&1; then
|
|
126
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
127
|
+
ukit_emit_permission_decision ask "$__ukit_protect_reason"
|
|
128
|
+
else
|
|
129
|
+
ukit_emit_permission_decision deny "$__ukit_protect_reason"
|
|
130
|
+
fi
|
|
131
|
+
fi
|
|
132
|
+
# Helper unavailable (pre-install tree): same decision/exit matrix inline.
|
|
133
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
134
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$__ukit_protect_reason\"}}"
|
|
135
|
+
echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
|
|
136
|
+
exit 2
|
|
137
|
+
fi
|
|
138
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$__ukit_protect_reason\"}}"
|
|
116
139
|
echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
|
|
117
|
-
exit
|
|
140
|
+
exit 0
|
|
118
141
|
fi
|
|
119
142
|
|
|
120
143
|
exit 0
|
|
@@ -3872,6 +3872,13 @@ function getMemoryTimestamp(item) {
|
|
|
3872
3872
|
|
|
3873
3873
|
function buildMemorySegments(item) {
|
|
3874
3874
|
const content = item.content ?? {};
|
|
3875
|
+
if (item.type === 'record') {
|
|
3876
|
+
return [
|
|
3877
|
+
{ text: content.recordType, weight: 1 },
|
|
3878
|
+
{ text: content.text, weight: 3 },
|
|
3879
|
+
];
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3875
3882
|
if (item.type === 'project') {
|
|
3876
3883
|
return [
|
|
3877
3884
|
{ text: content.name, weight: 2 },
|
|
@@ -3964,6 +3971,7 @@ async function listMemoryItems(rootDir) {
|
|
|
3964
3971
|
const userMemory = (await readJson(path.join(runtimeRoot, 'user.json'), null)) ?? { preferences: {}, rules: [] };
|
|
3965
3972
|
const projectMemories = await readDirectoryJsonItems(path.join(runtimeRoot, 'projects'));
|
|
3966
3973
|
const sessionMemories = await readDirectoryJsonItems(path.join(runtimeRoot, 'sessions'));
|
|
3974
|
+
const recordMemories = await listMemoryV2RecordItems(runtimeRoot);
|
|
3967
3975
|
|
|
3968
3976
|
return [
|
|
3969
3977
|
{
|
|
@@ -3981,11 +3989,47 @@ async function listMemoryItems(rootDir) {
|
|
|
3981
3989
|
type: 'session',
|
|
3982
3990
|
content: item.content,
|
|
3983
3991
|
})),
|
|
3992
|
+
...recordMemories,
|
|
3984
3993
|
];
|
|
3985
3994
|
}
|
|
3986
3995
|
|
|
3996
|
+
const MEMORY_V2_RECORD_POOL_LIMIT = 20;
|
|
3997
|
+
|
|
3998
|
+
// v2 lane (SI-101): approved records live in a single records.json document.
|
|
3999
|
+
// Missing/malformed store → [] (same never-throw discipline as
|
|
4000
|
+
// readDirectoryJsonItems). Candidates: status 'active' AND (valid_until == null
|
|
4001
|
+
// OR valid_until > now), capped at 20 newest by created_at.
|
|
4002
|
+
async function listMemoryV2RecordItems(runtimeRoot) {
|
|
4003
|
+
const doc = await readJson(path.join(runtimeRoot, 'v2', 'records.json'), null);
|
|
4004
|
+
const records = Array.isArray(doc?.records) ? doc.records : [];
|
|
4005
|
+
const now = Date.now();
|
|
4006
|
+
|
|
4007
|
+
return records
|
|
4008
|
+
.filter((record) => record && typeof record === 'object')
|
|
4009
|
+
.filter((record) => record.status === 'active')
|
|
4010
|
+
.filter((record) => record.valid_until == null || record.valid_until > now)
|
|
4011
|
+
.sort((left, right) => (right.created_at ?? 0) - (left.created_at ?? 0))
|
|
4012
|
+
.slice(0, MEMORY_V2_RECORD_POOL_LIMIT)
|
|
4013
|
+
.map((record) => ({
|
|
4014
|
+
id: `record:${record.id}`,
|
|
4015
|
+
type: 'record',
|
|
4016
|
+
content: {
|
|
4017
|
+
recordType: record.type,
|
|
4018
|
+
text: record.text,
|
|
4019
|
+
projectId: record.project_id,
|
|
4020
|
+
updatedAt: record.created_at,
|
|
4021
|
+
},
|
|
4022
|
+
}));
|
|
4023
|
+
}
|
|
4024
|
+
|
|
3987
4025
|
function buildPreviousContextSnippet(item) {
|
|
3988
4026
|
const content = item.content ?? {};
|
|
4027
|
+
if (item.type === 'record') {
|
|
4028
|
+
const text = String(content.text ?? '').trim();
|
|
4029
|
+
const truncated = text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
4030
|
+
return `[${content.recordType ?? 'record'}] ${truncated}`;
|
|
4031
|
+
}
|
|
4032
|
+
|
|
3989
4033
|
if (item.type === 'project') {
|
|
3990
4034
|
const decisions = compactPhraseList((content.decisions ?? []).map((decision) => decision.what), { limit: 1 });
|
|
3991
4035
|
const rules = compactPhraseList(content.activeRules ?? [], { limit: 1 });
|
|
@@ -4036,11 +4080,18 @@ async function buildPreviousContextSnapshot({ rootDir = process.cwd(), routingCo
|
|
|
4036
4080
|
const queryTokens = tokenize(taskQuery);
|
|
4037
4081
|
const rankedItems = items
|
|
4038
4082
|
.filter((item) => item.type !== 'user')
|
|
4039
|
-
.filter((item) =>
|
|
4040
|
-
item.type === 'project'
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4083
|
+
.filter((item) => {
|
|
4084
|
+
if (item.type === 'project') {
|
|
4085
|
+
return item.content?.id === projectId;
|
|
4086
|
+
}
|
|
4087
|
+
if (item.type === 'session') {
|
|
4088
|
+
return item.content?.projectId === projectId;
|
|
4089
|
+
}
|
|
4090
|
+
if (item.type === 'record') {
|
|
4091
|
+
return item.content?.projectId == null || item.content?.projectId === projectId;
|
|
4092
|
+
}
|
|
4093
|
+
return true;
|
|
4094
|
+
})
|
|
4044
4095
|
.map((item) => ({
|
|
4045
4096
|
item,
|
|
4046
4097
|
score: scoreMemoryItem(item, queryTokens),
|
|
@@ -4060,7 +4111,7 @@ async function buildPreviousContextSnapshot({ rootDir = process.cwd(), routingCo
|
|
|
4060
4111
|
return {
|
|
4061
4112
|
line: rankedItems.map((item) => buildPreviousContextSnippet(item)).join(' | '),
|
|
4062
4113
|
selectedIds: rankedItems.map((item) => item.id),
|
|
4063
|
-
fingerprint: buildCompactMachineKey('route-memory-
|
|
4114
|
+
fingerprint: buildCompactMachineKey('route-memory-v2', {
|
|
4064
4115
|
taskQuery: normalize(taskQuery),
|
|
4065
4116
|
projectId,
|
|
4066
4117
|
items: rankedItems.map((item) => ({
|
|
@@ -102,7 +102,15 @@ async function run(payloadText, scriptPaths) {
|
|
|
102
102
|
deadlineMs: Math.min(childBudgetMs, remainingMs),
|
|
103
103
|
maxBuffer: MAX_BUFFER_BYTES,
|
|
104
104
|
cwd: projectRoot,
|
|
105
|
-
|
|
105
|
+
// TASK-223 (HK-401): mark chain-spawned children so their structured
|
|
106
|
+
// permission decisions keep the omp contract (stdout parsed on exit 2).
|
|
107
|
+
// Direct Claude Code invocations carry no marker and exit 0 instead —
|
|
108
|
+
// a non-zero exit there discards stdout, killing the decision JSON.
|
|
109
|
+
env: {
|
|
110
|
+
...process.env,
|
|
111
|
+
CLAUDE_PROJECT_DIR: projectRoot,
|
|
112
|
+
UKIT_HOOK_CHAIN_RUNNER: '1',
|
|
113
|
+
},
|
|
106
114
|
});
|
|
107
115
|
const failureKind = chainFailureKind(result);
|
|
108
116
|
const code = Number.isFinite(result.code) ? result.code : 1;
|
|
@@ -135,12 +135,35 @@ ukit_input_degraded() {
|
|
|
135
135
|
[ "${UKIT_INPUT_TRUNCATED:-0}" = "1" ] || [ "${UKIT_HOOK_INPUT_STALLED:-0}" = "1" ]
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
# TASK-223 (HK-401): ALL PreToolUse structured permission decisions emit through
|
|
139
|
+
# this one helper. The JSON shape is harness-agnostic, but the exit code is NOT:
|
|
140
|
+
# direct Claude Code (UKIT_HOOK_CHAIN_RUNNER unset) — a non-zero exit means the
|
|
141
|
+
# harness DISCARDS stdout, so a decision JSON + exit 2 is dead text rendered
|
|
142
|
+
# as a bare "hook error" with no human-approval path. Emit the JSON and exit 0
|
|
143
|
+
# so `deny`/`ask` actually reaches the permission pipeline. Under
|
|
144
|
+
# permissions.defaultMode=bypassPermissions `ask` is auto-approved (probe
|
|
145
|
+
# verdict 2026-09-20), so refusal call sites pass `deny` directly — the host
|
|
146
|
+
# surfaces a proper "denied" instead of a hook error, still fail-closed.
|
|
147
|
+
# omp chain (UKIT_HOOK_CHAIN_RUNNER=1, exported by hook-chain-runner.mjs) — the
|
|
148
|
+
# bridge parses hookSpecificOutput ONLY when the child exits 2; exit 0
|
|
149
|
+
# short-circuits to {block:false} and would turn every gate fail-open. Emit
|
|
150
|
+
# the JSON and exit 2 exactly as before.
|
|
151
|
+
# stderr still carries the BLOCKED line on every path — the model needs the
|
|
152
|
+
# block reason regardless of which stdout contract the harness honors.
|
|
153
|
+
ukit_emit_permission_decision() {
|
|
154
|
+
local decision="${1:-ask}" reason="${2:-UKit deferred this tool call to a human decision.}"
|
|
155
|
+
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"${decision}\",\"permissionDecisionReason\":\"${reason}\"}}"
|
|
156
|
+
echo "BLOCKED: ${reason}" >&2
|
|
157
|
+
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
158
|
+
exit 2
|
|
159
|
+
fi
|
|
160
|
+
exit 0
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
ukit_emit_input_degraded() {
|
|
139
164
|
local posture="${1:-advisory}" hook_name="${2:-hook}"
|
|
140
165
|
if [ "$posture" = "failclosed" ]; then
|
|
141
|
-
|
|
142
|
-
echo "BLOCKED: ${hook_name} could not inspect a truncated/stalled payload; deferred to human." >&2
|
|
143
|
-
exit 2
|
|
166
|
+
ukit_emit_permission_decision ask "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so ${hook_name} cannot prove it safe. UKit defers this to a human decision."
|
|
144
167
|
fi
|
|
145
168
|
printf '%s\n' "{\"systemMessage\":\"UKit ${hook_name}: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input.\"}"
|
|
146
169
|
exit 0
|