@ngockhoale/ukit 2.2.1 → 2.2.3

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,354 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs/promises';
5
+ import fsSync from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const LEDGER_VERSION = 1;
10
+ const MAX_RECEIPTS = 24;
11
+ const MAX_CONTINUATIONS = 6;
12
+ const IMPLEMENT_MODES = new Set([
13
+ 'tiny-fix',
14
+ 'local-fix',
15
+ 'local-build',
16
+ 'shared-edit',
17
+ 'find-cause',
18
+ ]);
19
+
20
+ function safeSegment(value) {
21
+ return String(value || 'default')
22
+ .trim()
23
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
24
+ .slice(0, 96) || 'default';
25
+ }
26
+
27
+ function firstDefined(values) {
28
+ return values.find((value) => value !== undefined && value !== null);
29
+ }
30
+
31
+ function sessionIdentity(payload = {}) {
32
+ const sessionId = firstDefined([
33
+ payload.session_id,
34
+ payload.sessionId,
35
+ ]);
36
+ if (sessionId) return safeSegment(sessionId);
37
+
38
+ const transcriptPath = firstDefined([
39
+ payload.transcript_path,
40
+ payload.transcriptPath,
41
+ ]);
42
+ if (transcriptPath) {
43
+ return `transcript-${crypto.createHash('sha256').update(String(transcriptPath)).digest('hex').slice(0, 20)}`;
44
+ }
45
+ return 'default';
46
+ }
47
+
48
+ function ledgerPath(projectRoot, payload = {}) {
49
+ return path.join(
50
+ projectRoot,
51
+ '.ukit',
52
+ 'storage',
53
+ 'cache',
54
+ 'exec-ledger',
55
+ `${sessionIdentity(payload)}.json`,
56
+ );
57
+ }
58
+
59
+ async function readJson(filePath, fallback = null) {
60
+ try {
61
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
62
+ } catch {
63
+ return fallback;
64
+ }
65
+ }
66
+
67
+ async function writeJsonAtomic(filePath, value) {
68
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
69
+ const tempPath = `${filePath}.${process.pid}.tmp`;
70
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
71
+ await fs.rename(tempPath, filePath);
72
+ }
73
+
74
+ export async function readRouteState(projectRoot) {
75
+ return readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
76
+ }
77
+
78
+ export async function readExecutionLedger(projectRoot, payload = {}) {
79
+ return readJson(ledgerPath(projectRoot, payload), null);
80
+ }
81
+
82
+ function explicitError(payload = {}) {
83
+ return [
84
+ payload.isError,
85
+ payload.is_error,
86
+ payload.tool_output?.isError,
87
+ payload.tool_output?.is_error,
88
+ payload.tool_result?.isError,
89
+ payload.tool_result?.is_error,
90
+ payload.tool_response?.isError,
91
+ payload.tool_response?.is_error,
92
+ ].some((value) => value === true);
93
+ }
94
+
95
+ function extractExitCode(payload = {}) {
96
+ const candidates = [
97
+ payload.tool_output?.exitCode,
98
+ payload.tool_output?.exit_code,
99
+ payload.tool_result?.details?.exitCode,
100
+ payload.tool_result?.details?.exit_code,
101
+ payload.tool_result?.exitCode,
102
+ payload.tool_result?.exit_code,
103
+ payload.tool_response?.exitCode,
104
+ payload.tool_response?.exit_code,
105
+ payload.exitCode,
106
+ payload.exit_code,
107
+ ];
108
+ for (const value of candidates) {
109
+ const number = Number(value);
110
+ if (Number.isFinite(number)) return number;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function isVerificationCommand(command) {
116
+ const text = String(command || '').trim();
117
+ if (!text) return false;
118
+ return [
119
+ /(?:^|\s)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)/i,
120
+ /(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|lint|typecheck|check|build)(?:\s|$)/i,
121
+ /(?:^|\s)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$)/i,
122
+ /(?:^|\s)node\s+--check(?:\s|$)/i,
123
+ ].some((pattern) => pattern.test(text));
124
+ }
125
+
126
+ function compactReceipt(receipt) {
127
+ const compact = {
128
+ ts: receipt.ts,
129
+ kind: receipt.kind,
130
+ success: receipt.success,
131
+ };
132
+ for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'error']) {
133
+ if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
134
+ compact[key] = receipt[key];
135
+ }
136
+ }
137
+ return compact;
138
+ }
139
+
140
+ function appendReceipt(receipts, receipt) {
141
+ return [...(receipts || []), compactReceipt(receipt)].slice(-MAX_RECEIPTS);
142
+ }
143
+
144
+ function freshLedger(payload, routeState, harness) {
145
+ return {
146
+ version: LEDGER_VERSION,
147
+ sessionKey: sessionIdentity(payload),
148
+ sessionId: payload.session_id || payload.sessionId || null,
149
+ transcriptPath: payload.transcript_path || payload.transcriptPath || null,
150
+ harness,
151
+ requestKey: routeState?.requestKey || null,
152
+ routeFingerprint: routeState?.fingerprint || null,
153
+ sourceSucceeded: false,
154
+ writeAttempted: false,
155
+ writeSucceeded: false,
156
+ verificationAttempted: false,
157
+ verificationSucceeded: false,
158
+ verificationFailed: false,
159
+ receipts: [],
160
+ blocker: null,
161
+ continuationCount: 0,
162
+ updatedAt: Date.now(),
163
+ };
164
+ }
165
+
166
+ export async function recordExecutionReceipt({
167
+ projectRoot,
168
+ payload = {},
169
+ toolName = payload.tool_name,
170
+ harness = 'unknown',
171
+ } = {}) {
172
+ if (!projectRoot || !toolName) return null;
173
+ const routeState = await readRouteState(projectRoot);
174
+ const current = await readExecutionLedger(projectRoot, payload);
175
+ const ledger = !current || current.requestKey !== (routeState?.requestKey || null)
176
+ ? freshLedger(payload, routeState, harness)
177
+ : { ...current, harness: current.harness || harness };
178
+
179
+ const failed = explicitError(payload);
180
+ const exitCode = extractExitCode(payload);
181
+ const success = !failed && (exitCode === null || exitCode === 0);
182
+ const toolInput = payload.tool_input || {};
183
+ const receipt = {
184
+ ts: Date.now(),
185
+ toolName,
186
+ toolUseId: payload.tool_use_id || null,
187
+ success,
188
+ exitCode,
189
+ };
190
+
191
+ if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
192
+ receipt.kind = 'source';
193
+ receipt.file = toolInput.file_path || toolInput.path || null;
194
+ ledger.sourceSucceeded ||= success;
195
+ } else if (toolName === 'Edit' || toolName === 'Write') {
196
+ receipt.kind = 'write';
197
+ receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
198
+ ledger.writeAttempted = true;
199
+ ledger.writeSucceeded ||= success;
200
+ } else if (toolName === 'Bash' && isVerificationCommand(toolInput.command)) {
201
+ receipt.kind = 'verification';
202
+ receipt.command = String(toolInput.command || '').trim();
203
+ ledger.verificationAttempted = true;
204
+ ledger.verificationSucceeded ||= success;
205
+ ledger.verificationFailed ||= !success;
206
+ } else {
207
+ return ledger;
208
+ }
209
+
210
+ ledger.receipts = appendReceipt(ledger.receipts, receipt);
211
+ ledger.updatedAt = Date.now();
212
+ await writeJsonAtomic(ledgerPath(projectRoot, payload), ledger);
213
+ return ledger;
214
+ }
215
+
216
+ function requiredEvidence(state = {}) {
217
+ const routeSummary = state?.routeSummary || {};
218
+ const contractEvidence = routeSummary.executionContract?.completionEvidence;
219
+ if (Array.isArray(contractEvidence) && contractEvidence.length > 0) {
220
+ return [...new Set(contractEvidence)];
221
+ }
222
+ return [...new Set(routeSummary.completionState?.missingEvidence || [])];
223
+ }
224
+
225
+ function evidenceSatisfied(evidence, ledger = {}) {
226
+ if (evidence === 'write-evidence') return ledger.writeSucceeded === true;
227
+ if (evidence === 'verification-evidence') return ledger.verificationSucceeded === true;
228
+ if (evidence === 'impact-evidence') return ledger.sourceSucceeded === true;
229
+ return false;
230
+ }
231
+
232
+ function recoveryInstruction(missingEvidence, ledger = {}) {
233
+ if (missingEvidence.includes('write-evidence')) {
234
+ if (!ledger.sourceSucceeded) {
235
+ return 'Pull one bounded indexed source slice, then make the requested Edit/Write in this continuation.';
236
+ }
237
+ if (ledger.writeAttempted && !ledger.writeSucceeded) {
238
+ return 'Recover from the failed mutation using the current error, then retry the smallest correct Edit/Write.';
239
+ }
240
+ return 'Make the smallest correct Edit/Write now; do not end after more read-only analysis.';
241
+ }
242
+ if (missingEvidence.includes('verification-evidence')) {
243
+ if (ledger.verificationFailed) {
244
+ return 'Use the latest failed verification output, fix the failure, and rerun targeted verification.';
245
+ }
246
+ return 'Run the routed targeted verification now and inspect its result before stopping.';
247
+ }
248
+ return 'Complete the current routed milestone before stopping.';
249
+ }
250
+
251
+ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
252
+ const routeSummary = state?.routeSummary || {};
253
+ const mode = routeSummary.executionMode || routeSummary.approachSelector?.executionMode || null;
254
+ const evidence = requiredEvidence(state);
255
+ if (!IMPLEMENT_MODES.has(mode) || evidence.length === 0 || ledger?.blocker) {
256
+ return { continue: false, missingEvidence: [] };
257
+ }
258
+
259
+ const sameRequest = !ledger?.requestKey || !state?.requestKey || ledger.requestKey === state.requestKey;
260
+ const effectiveLedger = sameRequest ? ledger : {};
261
+ const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger));
262
+ if (missingEvidence.length === 0) {
263
+ return { continue: false, missingEvidence: [] };
264
+ }
265
+
266
+ const continuationCount = Number(effectiveLedger?.continuationCount || 0);
267
+ if (continuationCount >= MAX_CONTINUATIONS) {
268
+ return {
269
+ continue: false,
270
+ capped: true,
271
+ missingEvidence,
272
+ reason: `UKit continuation cap reached with missing evidence: ${missingEvidence.join(', ')}.`,
273
+ };
274
+ }
275
+
276
+ const finalAttempt = continuationCount === MAX_CONTINUATIONS - 1;
277
+ const instruction = recoveryInstruction(missingEvidence, effectiveLedger);
278
+ return {
279
+ continue: true,
280
+ missingEvidence,
281
+ reason: [
282
+ `UKit completion gate: missing ${missingEvidence.join(', ')}.`,
283
+ instruction,
284
+ finalAttempt ? 'Final automatic recovery attempt: finish now or report a concrete blocker with its evidence.' : null,
285
+ ].filter(Boolean).join(' '),
286
+ };
287
+ }
288
+
289
+ export async function incrementContinuation(projectRoot, payload = {}, ledger = null) {
290
+ const current = ledger || await readExecutionLedger(projectRoot, payload) || freshLedger(payload, null, 'unknown');
291
+ const next = {
292
+ ...current,
293
+ continuationCount: Number(current.continuationCount || 0) + 1,
294
+ lastContinuationAt: Date.now(),
295
+ updatedAt: Date.now(),
296
+ };
297
+ await writeJsonAtomic(ledgerPath(projectRoot, payload), next);
298
+ return next;
299
+ }
300
+
301
+ async function readStdin() {
302
+ if (process.stdin.isTTY) return '';
303
+ const chunks = [];
304
+ for await (const chunk of process.stdin) chunks.push(String(chunk));
305
+ return chunks.join('');
306
+ }
307
+
308
+ async function main() {
309
+ const payload = JSON.parse((await readStdin()) || '{}');
310
+ const projectRoot = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
311
+ if (process.argv.includes('--record')) {
312
+ await recordExecutionReceipt({
313
+ projectRoot,
314
+ payload,
315
+ toolName: payload.tool_name,
316
+ harness: process.env.UKIT_HARNESS || 'claude-code',
317
+ });
318
+ return;
319
+ }
320
+ if (process.argv.includes('--evaluate-stop')) {
321
+ const state = await readRouteState(projectRoot);
322
+ const ledger = await readExecutionLedger(projectRoot, payload) || {};
323
+ const result = evaluateCompletion({ state, ledger });
324
+ if (result.continue) {
325
+ await incrementContinuation(projectRoot, payload, ledger);
326
+ process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
327
+ } else if (result.capped) {
328
+ process.stderr.write(`[ukit-completion] ${result.reason}\n`);
329
+ }
330
+ }
331
+ }
332
+
333
+ // Compare real paths: a project under a symlinked root (macOS /tmp -> /private/tmp, or a
334
+ // symlinked work directory) makes import.meta.url resolve to the real path while argv[1]
335
+ // keeps the symlinked spelling. Without realpath the guard silently never runs the CLI.
336
+ function isDirectRun() {
337
+ const argvPath = process.argv[1];
338
+ if (!argvPath) return false;
339
+ const selfPath = fileURLToPath(import.meta.url);
340
+ const resolved = path.resolve(argvPath);
341
+ if (selfPath === resolved) return true;
342
+ try {
343
+ return fsSync.realpathSync(selfPath) === fsSync.realpathSync(resolved);
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+
349
+ if (isDirectRun()) {
350
+ main().catch((error) => {
351
+ process.stderr.write(`[ukit-execution-ledger] ${error?.message || error}\n`);
352
+ process.exitCode = 1;
353
+ });
354
+ }
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+
7
+ const FAIL_CLOSED_SCRIPTS = new Set([
8
+ 'protect-files.sh',
9
+ 'stale-spec-guard.sh',
10
+ 'handoff-model-guard.sh',
11
+ 'vision-gate.sh',
12
+ 'context-hardcap-gate.sh',
13
+ 'block-dangerous.sh',
14
+ 'verification-guard.sh',
15
+ ]);
16
+
17
+ const TOTAL_BUDGET_MS = 10000;
18
+ const CHILD_BUDGET_MS = 4000;
19
+ const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
20
+
21
+ function safeName(value) {
22
+ return String(value || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
23
+ }
24
+
25
+ function recordTiming(projectRoot, payload, timing) {
26
+ try {
27
+ const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ const filePath = path.join(dir, `${safeName(payload?.session_id)}.jsonl`);
30
+ fs.appendFileSync(filePath, `${JSON.stringify(timing)}\n`, 'utf8');
31
+ } catch {
32
+ // Timing telemetry is advisory and must never delay or block a tool call.
33
+ }
34
+ }
35
+
36
+ function run(payloadText, scriptPaths) {
37
+ const payload = JSON.parse(payloadText || '{}');
38
+ const firstScript = scriptPaths[0] || '';
39
+ const projectRoot = firstScript
40
+ ? path.resolve(path.dirname(firstScript), '../..')
41
+ : (payload.cwd || process.cwd());
42
+ const startedAt = Date.now();
43
+ const deadline = startedAt + TOTAL_BUDGET_MS;
44
+ const results = [];
45
+
46
+ for (const scriptPath of scriptPaths) {
47
+ const scriptName = path.basename(scriptPath);
48
+ const remainingMs = deadline - Date.now();
49
+ if (remainingMs <= 0) {
50
+ results.push({
51
+ scriptName,
52
+ code: 1,
53
+ stdout: '',
54
+ stderr: `hook chain exceeded its ${TOTAL_BUDGET_MS}ms total budget`,
55
+ killed: true,
56
+ elapsedMs: 0,
57
+ });
58
+ break;
59
+ }
60
+
61
+ const childStartedAt = Date.now();
62
+ const result = spawnSync(scriptPath, [], {
63
+ cwd: projectRoot,
64
+ env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
65
+ input: payloadText,
66
+ encoding: 'utf8',
67
+ timeout: Math.min(CHILD_BUDGET_MS, remainingMs),
68
+ maxBuffer: MAX_BUFFER_BYTES,
69
+ });
70
+ const code = Number.isFinite(result.status) ? result.status : 1;
71
+ const killed = Boolean(result.signal || result.error?.code === 'ETIMEDOUT');
72
+ const stderr = [result.stderr, result.error?.message].filter(Boolean).join('\n');
73
+ results.push({
74
+ scriptName,
75
+ code,
76
+ stdout: result.stdout || '',
77
+ stderr,
78
+ killed,
79
+ elapsedMs: Date.now() - childStartedAt,
80
+ });
81
+
82
+ if (code === 2 || killed || (code !== 0 && FAIL_CLOSED_SCRIPTS.has(scriptName))) {
83
+ break;
84
+ }
85
+ }
86
+
87
+ const elapsedMs = Date.now() - startedAt;
88
+ recordTiming(projectRoot, payload, {
89
+ ts: Date.now(),
90
+ hookEvent: payload?.hook_event_name || null,
91
+ toolName: payload?.tool_name || null,
92
+ toolUseId: payload?.tool_use_id || null,
93
+ elapsedMs,
94
+ budgetMs: TOTAL_BUDGET_MS,
95
+ scripts: results.map(({ scriptName, code, killed, elapsedMs: scriptElapsedMs }) => ({
96
+ scriptName,
97
+ code,
98
+ killed,
99
+ elapsedMs: scriptElapsedMs,
100
+ })),
101
+ });
102
+
103
+ return { results, elapsedMs, budgetMs: TOTAL_BUDGET_MS };
104
+ }
105
+
106
+ try {
107
+ const [, , payloadText = '{}', ...scriptPaths] = process.argv;
108
+ process.stdout.write(JSON.stringify(run(payloadText, scriptPaths)));
109
+ } catch (error) {
110
+ process.stdout.write(JSON.stringify({
111
+ results: [],
112
+ wrapperError: error?.message || String(error),
113
+ }));
114
+ process.exitCode = 1;
115
+ }
@@ -1,34 +1,62 @@
1
1
  # .omp/RULES.md — sticky always-apply rules
2
2
 
3
3
  omp re-attaches this file near every turn from its native location (`.omp/RULES.md` only, never a
4
- copy elsewhere). It carries the always-apply subset of root `CLAUDE.md` that omp's claude provider
5
- cannot see — that provider only reads `~/.claude/CLAUDE.md` and `<cwd>/.claude/CLAUDE.md`, never root
6
- `CLAUDE.md`. Keep this short: every line here is a per-turn tax.
4
+ copy elsewhere). It carries the always-apply subset of root `AGENTS.md` that must survive even when
5
+ nothing else is loaded. Keep this short: every line here is a per-turn tax.
7
6
 
8
- ## 1. Execution Contract
7
+ ## 1. Classify first, then act
8
+
9
+ - **Trivial** (typo, rename, flag, obvious config) — act directly. No doc reads, no index, no agents.
10
+ - **Simple** (1-2 files, existing pattern) — act directly, pull the smallest useful context.
11
+ - **Non-trivial / risky** (auth, migration, shared runtime, data-loss, flaky) — index-first, then
12
+ activate the matching skill, then verify.
13
+
14
+ Pick the lane before the first tool call. Skipping this is what turns a direct chat into a
15
+ scattershot one.
16
+
17
+ ## 2. Execution Contract
9
18
 
10
19
  For implement/apply/fix requests, continue until the actual edit is made or a real blocker is found.
11
20
  Do not stop after a read-only inspection step (read/grep/glob/search).
12
21
 
13
- ## 2. No "done" after read-only
22
+ ## 3. No "done" after read-only
14
23
 
15
24
  Never say "done", "applied", or "fixed" after a read-only step. Completion wording requires concrete
16
25
  edit/write evidence in the current turn, plus verification when the change is risky.
17
26
 
18
- ## 3. Index-first loop
27
+ ## 4. Index-first loop
19
28
 
20
29
  Check `.cache/index/` freshness first. Then run
21
30
  `node .claude/ukit/index/query-index.mjs "<error|symbol|path>"` and open only the top 1-3 suspect
22
31
  files before widening further. These node scripts work unchanged under omp.
23
32
 
24
- ## 4. Safe Patch
33
+ ## 5. Auto-activate skills
34
+
35
+ Skills live in `.claude/skills/` and omp reads them through its `claude` discovery provider — no
36
+ mirror under `.omp/`. On any non-trivial task, and again after the first tool calls reveal what the
37
+ work really is, read the matching `SKILL.md` without being asked. Users never name skills.
38
+
39
+ ## 6. Delegate to bind a model tier
40
+
41
+ Your own model does not change mid-turn. A tier only applies when work is handed to a task-agent in
42
+ `.omp/agents/`, whose `model:` field (`@lite` / `@code` / `@smart` / `@vision`) resolves through
43
+ `modelRoles` in `.omp/config.yml`.
44
+
45
+ - `tiny-fix` → `@lite` · `local-fix`, `local-build`, `shared-edit`, `find-cause`, `map-impact` →
46
+ `@code` · `review-release` → `@smart`
47
+ - Images: `@vision` only. `@code`/`@smart` cannot see images on the UNIC gateway and must never
48
+ guess at their contents — hand every image to `ukit-vision-analyst` first.
49
+
50
+ Doing everything inline is exactly what makes UKit look like it only has one model. Keep direct
51
+ execution for trivial/simple work; delegate when the tier differs or when a noisy lane would other-
52
+ wise flood this context.
53
+
54
+ ## 7. Safe Patch
25
55
 
26
56
  Prefer unique current-file anchors over line numbers or stale pasted blocks. Never silently merge a
27
57
  stale spec — re-read current source and confirm before applying. Preserve existing BOM and line
28
58
  endings.
29
59
 
30
- ## Model roles (see `.omp/config.yml`)
31
-
32
- `modelRoles` ships UNIC gateway names as the default. A maintainer on a non-UNIC omp provider edits
33
- only the three cost-tier values — `lite`, `code`, `smart` — never `vision`, which stays `unic-vision`
34
- because it is a capability lane, not a cost tier.
60
+ > Maintainer note, not a per-turn rule: `modelRoles` in `.omp/config.yml` ships UNIC gateway names.
61
+ > On a non-UNIC provider, edit only the three cost tiers — `lite`, `code`, `smart` — never `vision`,
62
+ > which stays `unic-vision` because it is a capability lane, not a cost tier.
@@ -1,5 +1,6 @@
1
- # UNIC gateway model names are LITERAL model names, not aliases, and they ship as the
2
- # installed default. Do NOT substitute sonnet/opus/haiku here. See PLAN.md §3 D15.
1
+ # UNIC gateway model names are stable provider-neutral aliases. Their hidden backend
2
+ # models may change during development; orchestration must not branch on vendor names.
3
+ # Do NOT substitute sonnet/opus/haiku here. See PLAN.md §3 D15.
3
4
  #
4
5
  # Non-UNIC omp provider? A maintainer edits the three cost tiers to the values in
5
6
  # orchestration.modelTiers[*].claudeModel (.ukit/storage/config.json) — currently
@@ -79,10 +80,12 @@ memory:
79
80
  # Auto-compact must trigger BEFORE context-hardcap-gate.sh starts blocking tool
80
81
  # calls (compact.hardCapTokens, default 220000). Mirrors the Claude Code value
81
82
  # env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = 180000 in templates/.claude/settings.json.
82
- # TODO(verify): the omp key name below is UNVERIFIED — omp is not installed on the
83
- # planning machine and no omp schema is vendored in this repo. If it is wrong, omp
84
- # ignores the unknown key and the session simply compacts later, with
85
- # context-hardcap-gate.sh + hardCapGraceCalls still acting as the backstop.
86
- # Degrades to "later, harder compact", never to "no gate". See PLAN.md §3 D13.
87
- compact:
88
- autoCompactWindow: 180000 # TODO(verify) key name against a real omp install
83
+ #
84
+ # Key VERIFIED against omp v17.4.2 (2026-08-22): `compaction.thresholdTokens` is a
85
+ # fixed token limit for context maintenance and overrides the percentage threshold
86
+ # when set; omp's own default is -1 ("use compaction.thresholdPercent"). The key this
87
+ # file shipped with in 2.2.1 — `compact.autoCompactWindow` — was a plan-time guess and
88
+ # omp rejects it outright (`omp config get compact.autoCompactWindow` → "Unknown
89
+ # setting"), so auto-compact never moved off omp's default. See PLAN.md §3 D13.
90
+ compaction:
91
+ thresholdTokens: 180000