@ngockhoale/ukit 2.6.8 → 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.
@@ -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
+ }
@@ -1156,6 +1156,35 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
1156
1156
  };
1157
1157
  }
1158
1158
 
1159
+ // Derived artifacts (CI-303/CI-305): git-history co-change mining and the
1160
+ // merged code-graph store. Exported for the index CLI/install callers to run
1161
+ // after buildCodeIndex — intentionally NOT inside buildCodeIndex so the core
1162
+ // build's read/write discipline (atomic renames, no secondary artifact reads
1163
+ // on fresh builds) stays untouched. Optional, config-gated, never fatal: a
1164
+ // failure leaves the artifact absent and readers degrade to omitted/empty.
1165
+ export async function buildDerivedIndexArtifacts(absoluteRoot) {
1166
+ const built = { cochange: null, codegraph: null };
1167
+ try {
1168
+ const { loadRuntimeConfig } = await import('../core/runtimeConfig.js');
1169
+ const config = await loadRuntimeConfig(absoluteRoot);
1170
+ const codeIntel = config?.codeIntel ?? {};
1171
+ if (codeIntel.cochange?.enabled !== false) {
1172
+ const { mineCoChanges } = await import('../core/codeintel/cochange.js');
1173
+ built.cochange = await mineCoChanges(absoluteRoot, {
1174
+ maxCommits: codeIntel.cochange?.maxCommits,
1175
+ timeoutMs: codeIntel.cochange?.timeoutMs,
1176
+ });
1177
+ }
1178
+ if (codeIntel.graph?.enabled !== false) {
1179
+ const { buildGraphArtifact } = await import('../core/codeintel/graph.js');
1180
+ built.codegraph = await buildGraphArtifact(absoluteRoot);
1181
+ }
1182
+ } catch {
1183
+ // Derivation is best-effort; core artifacts already landed.
1184
+ }
1185
+ return built;
1186
+ }
1187
+
1159
1188
  async function collectFiles(scanRoots, discovery = null) {
1160
1189
  const budget = discovery ?? createDiscoveryBudget({});
1161
1190
  const result = [];
@@ -11,6 +11,8 @@ export const INDEX_ARTIFACTS = {
11
11
  archetypes: 'archetypes.json',
12
12
  relations: 'relations.json',
13
13
  analogs: 'analogs.json',
14
+ cochange: 'cochange.json',
15
+ codegraph: 'codegraph.json',
14
16
  };
15
17
 
16
18
  export const INDEX_SCHEMA_VERSION = 8;
@@ -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 `ask` decision (stdout JSON) so a human
119
- # decides; exit 2 still refuses the call for harnesses that ignore structured output.
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
- printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
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 2
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
- printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit protected-file guard blocked this edit. Ask the user to modify the protected file manually."}}'
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 2
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
- ? (item.content?.id === projectId)
4042
- : (item.type === 'session' ? item.content?.projectId === projectId : true)
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-v1', {
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
- env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
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
- 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 ${hook_name} cannot prove it safe. UKit defers this to a human decision.\"}}"
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