@ngockhoale/ukit 2.2.8 → 2.2.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/CHANGELOG.md +79 -0
- package/README.md +30 -0
- package/package.json +1 -1
- package/src/core/compact/contextBudget.js +57 -0
- package/src/core/compact/threshold.js +5 -1
- package/src/core/runtimeConfig.js +2 -1
- package/src/render/buildVariables.js +10 -0
- package/templates/.claude/settings.json +1 -1
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +10 -8
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +40 -6
- package/templates/.omp/RULES.md +6 -0
- package/templates/.omp/config.yml +6 -3
- package/templates/.omp/hooks/pre/ukit-bridge.js +20 -5
- package/templates/docs/AI_HANDOFF/RULES.md +2 -2
- package/templates/ukit/storage/config.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,85 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.2.10 - 2026-08-29
|
|
6
|
+
|
|
7
|
+
User report: long omp (Oh My Pi) sessions go idle roughly 15 minutes into a task — no output, no
|
|
8
|
+
error, no notification, indistinguishable from a hang. Typing `continue` makes the session resume
|
|
9
|
+
and finish correctly (the user's observation, not a measurement — no field reproduction of the
|
|
10
|
+
stall exists on this machine to time it). Three real root causes were found by reading the source,
|
|
11
|
+
not guessed:
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`map-impact` — the mode wide implementation work actually routes to — was never gated by the
|
|
16
|
+
completion loop.** `templates/.claude/ukit/runtime/execution-ledger.mjs:12`'s `IMPLEMENT_MODES`
|
|
17
|
+
set omitted it, while `src/index/taskRouting.js:414` routes exactly this kind of task to
|
|
18
|
+
`map-impact` and `src/index/taskRouting.js:610` gives it the strictest contract
|
|
19
|
+
(`impact-evidence`, `write-evidence`, `verification-evidence`). So the longest tasks declared the
|
|
20
|
+
most evidence and got no gate at all — the session could stop with nothing written and nothing
|
|
21
|
+
verified, silently. `map-impact` is now in `IMPLEMENT_MODES`.
|
|
22
|
+
- **Reaching the continuation cap ended the session with a log line, not a message.** At
|
|
23
|
+
`MAX_CONTINUATIONS` (6, unchanged), `evaluateCompletion` returned `{ capped: true }` and the only
|
|
24
|
+
reaction was `pi.logger.warn` — a log sink, not the transcript. This is also the mechanical reason
|
|
25
|
+
a new prompt (`continue`) appeared to "fix" the stall: a new prompt writes a fresh `requestKey`,
|
|
26
|
+
which resets the per-request continuation count back to zero. The gate now spends exactly one
|
|
27
|
+
final continuation (`finalNotice`) instructing the model to state what is unfinished and stop, and
|
|
28
|
+
persists a one-shot `notified` flag on the ledger so the notice cannot refire.
|
|
29
|
+
- **The post-compact resume was delivered only to the next user turn, and every UKit→omp message was
|
|
30
|
+
`display: false`.** After an omp auto-compaction, the `docs/AI_HANDOFF/RUN.md` resume cursor was
|
|
31
|
+
sent with `deliverAs: 'nextTurn'` only — if compaction ended the agent loop, the cursor waited for
|
|
32
|
+
a human to type something. Separately, every message the bridge built hardcoded `display: false`,
|
|
33
|
+
so UKit had no user-visible channel in omp at all, for context or for stop notices.
|
|
34
|
+
`runSessionCompact` now sends the resume as `steer` (reaches a still-running turn) *and*
|
|
35
|
+
`nextTurn` (unchanged fallback), and `runSessionStop` now sends one `display: true` notice
|
|
36
|
+
whenever it stops with evidence still missing — including the previously-silent
|
|
37
|
+
`review-release` / ungated-mode case.
|
|
38
|
+
|
|
39
|
+
**Two premises behind the third fix are unverified — omp is not installed in this development
|
|
40
|
+
environment.** Whether omp still accepts a `steer` message after a compaction, and whether omp
|
|
41
|
+
honours `display: true` on a bridge message, were not and could not be reproduced or measured here.
|
|
42
|
+
Both changes are additive: if either premise is false, the `nextTurn` delivery and the model's own
|
|
43
|
+
`finalNotice` message (from the second fix) still reach the user, so the worst case is unchanged
|
|
44
|
+
from today's behaviour, never worse. See `docs/STATUS.md` for the open thread and the evidence that
|
|
45
|
+
would confirm the continuation-cap path directly.
|
|
46
|
+
|
|
47
|
+
- **`templates/.omp/RULES.md` gains one sticky rule**: ending a turn with no output is a defect, not
|
|
48
|
+
a pause — a model-side backstop for whatever a code gate cannot reach.
|
|
49
|
+
|
|
50
|
+
## 2.2.9 - 2026-08-28
|
|
51
|
+
|
|
52
|
+
Retuning UKit's context budget used to mean editing five scattered places and hand-syncing two
|
|
53
|
+
of them. It is now **three numbers in one file**, and everything else is derived at install time.
|
|
54
|
+
|
|
55
|
+
The shipped defaults also move up for a 1M context window: compaction is recommended at 150,000
|
|
56
|
+
estimated tokens (was 50,000) and the hard cap sits at 500,000 — half the window (was 220,000).
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **`compact.tokenThreshold` / `compact.hardCapTokens` / `compact.autoCompactWindowRatio` in
|
|
61
|
+
`.ukit/storage/config.json` are now the only knobs.** New `src/core/compact/contextBudget.js`
|
|
62
|
+
reads them and derives the rest, so the defaults in `runtimeConfig.js` and
|
|
63
|
+
`compact/threshold.js` no longer carry their own copies of the number. Edit the three values,
|
|
64
|
+
rerun `ukit install`, and the whole budget follows. Documented in README →
|
|
65
|
+
*Retuning The Context Budget (maintainers)*, including a window→cap sizing table, because the
|
|
66
|
+
new 500,000 default sits **above** a 200k or 256k model's window, where the gate could never fire.
|
|
67
|
+
- **New defaults:** `tokenThreshold` 50,000 → **150,000**; `hardCapTokens` 220,000 → **500,000**;
|
|
68
|
+
auto-compact fires at **70%** of the cap → 350,000.
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- **omp auto-compact had silently drifted out of sync.** `.omp/config.yml`'s
|
|
73
|
+
`compaction.thresholdTokens` was still 180,000 while Claude Code's
|
|
74
|
+
`CLAUDE_CODE_AUTO_COMPACT_WINDOW` had moved on — the two were separate hand-maintained numbers
|
|
75
|
+
with nothing pinning them together. Both are now rendered from the same derived value at
|
|
76
|
+
install time, so **omp and Claude Code auto-compact at the same point** and cannot drift apart
|
|
77
|
+
again.
|
|
78
|
+
- **`ompDocsSync > historical records are untouched by this cycle` failed on every new plan doc.**
|
|
79
|
+
The guard compared *all* changes under `docs/plans/` and friends against an old cycle's base
|
|
80
|
+
commit, so the first plan added afterwards (2.2.8's) tripped it permanently. Narrowed to
|
|
81
|
+
`--diff-filter=MDR`: rewriting, deleting or renaming a historical record still fails; adding a
|
|
82
|
+
new plan does not.
|
|
83
|
+
|
|
5
84
|
## 2.2.8 - 2026-08-28
|
|
6
85
|
|
|
7
86
|
UKit already picked the right files to open, but the step *after* that — actually entering the
|
package/README.md
CHANGED
|
@@ -114,6 +114,36 @@ For maintainers, the runtime is inspectable with:
|
|
|
114
114
|
|
|
115
115
|
Normal teammates should still only need **`ukit install`**.
|
|
116
116
|
|
|
117
|
+
## Retuning The Context Budget (maintainers)
|
|
118
|
+
|
|
119
|
+
Switching to a model with a different context window is a **three-number edit in one file**:
|
|
120
|
+
`templates/ukit/storage/config.json` → `compact`.
|
|
121
|
+
|
|
122
|
+
| Key | Default | What it means |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| `tokenThreshold` | `150000` | Soft advisory. Past this UKit starts recommending compaction. |
|
|
125
|
+
| `hardCapTokens` | `500000` | Absolute ceiling — 50% of a 1M window. `context-hardcap-gate` blocks Edit/Write/Bash here. |
|
|
126
|
+
| `autoCompactWindowRatio` | `0.7` | Auto-compact fires at this fraction of the cap (→ `350000`). Must be `< 1`. |
|
|
127
|
+
|
|
128
|
+
Everything else is derived — do **not** hand-edit these:
|
|
129
|
+
|
|
130
|
+
- `.claude/settings.json` → `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW`
|
|
131
|
+
- `.omp/config.yml` → `compaction.thresholdTokens`
|
|
132
|
+
- the code-side defaults in `src/core/runtimeConfig.js` and `src/core/compact/threshold.js`
|
|
133
|
+
|
|
134
|
+
They are all rendered/read from the three keys above via `src/core/compact/contextBudget.js`.
|
|
135
|
+
Edit the three numbers, rerun **`ukit install`**, and Claude Code and omp both follow.
|
|
136
|
+
|
|
137
|
+
`hardCapTokens` must stay **below the model's real context window**, otherwise the API rejects
|
|
138
|
+
the request before the gate can ever fire. Sizing at 50% of the window keeps room for the
|
|
139
|
+
response plus the estimator's own undercount:
|
|
140
|
+
|
|
141
|
+
| Model window | `hardCapTokens` | derived auto-compact |
|
|
142
|
+
| --- | --- | --- |
|
|
143
|
+
| 1M | `500000` | `350000` |
|
|
144
|
+
| 256k | `128000` | `89600` |
|
|
145
|
+
| 200k | `100000` | `70000` |
|
|
146
|
+
|
|
117
147
|
## Installer Behavior
|
|
118
148
|
|
|
119
149
|
- idempotent install (safe to rerun)
|
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
// The shipped compact.tokenThreshold / compact.hardCapTokens / compact.autoCompactWindowRatio
|
|
5
|
+
// trio is the ONLY place the context budget is written down. Every other number — the
|
|
6
|
+
// code-side defaults, CLAUDE_CODE_AUTO_COMPACT_WINDOW in .claude/settings.json, and
|
|
7
|
+
// compaction.thresholdTokens in .omp/config.yml — is read or derived from here, so retuning
|
|
8
|
+
// UKit for a different model context window is a three-number edit in
|
|
9
|
+
// templates/ukit/storage/config.json and nothing else.
|
|
10
|
+
const SHIPPED_CONFIG_URL = new URL('../../../templates/ukit/storage/config.json', import.meta.url);
|
|
11
|
+
|
|
12
|
+
// Only reachable if the package ships without its own templates. Deliberately conservative:
|
|
13
|
+
// a 200k model survives these, a 1M model merely compacts earlier than it had to.
|
|
14
|
+
const EMERGENCY_BUDGET = {
|
|
15
|
+
tokenThreshold: 50_000,
|
|
16
|
+
hardCapTokens: 160_000,
|
|
17
|
+
autoCompactWindowRatio: 0.7,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Auto-compact must land strictly below hardCapTokens so the client heals itself before
|
|
21
|
+
// context-hardcap-gate starts refusing Edit/Write/Bash; a ratio >= 1 would invert that and is
|
|
22
|
+
// treated as a typo rather than honoured.
|
|
23
|
+
export function deriveAutoCompactWindow(hardCapTokens, ratio) {
|
|
24
|
+
const safeRatio = Number.isFinite(ratio) && ratio > 0 && ratio < 1
|
|
25
|
+
? ratio
|
|
26
|
+
: EMERGENCY_BUDGET.autoCompactWindowRatio;
|
|
27
|
+
return Math.round(hardCapTokens * safeRatio);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let cachedBudget = null;
|
|
31
|
+
|
|
32
|
+
export function loadShippedCompactBudget() {
|
|
33
|
+
if (cachedBudget) {
|
|
34
|
+
return cachedBudget;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let compact = EMERGENCY_BUDGET;
|
|
38
|
+
try {
|
|
39
|
+
compact = JSON.parse(readFileSync(fileURLToPath(SHIPPED_CONFIG_URL), 'utf8')).compact ?? {};
|
|
40
|
+
} catch {
|
|
41
|
+
compact = EMERGENCY_BUDGET;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tokenThreshold = Number.isFinite(compact.tokenThreshold)
|
|
45
|
+
? compact.tokenThreshold
|
|
46
|
+
: EMERGENCY_BUDGET.tokenThreshold;
|
|
47
|
+
const hardCapTokens = Number.isFinite(compact.hardCapTokens)
|
|
48
|
+
? compact.hardCapTokens
|
|
49
|
+
: EMERGENCY_BUDGET.hardCapTokens;
|
|
50
|
+
|
|
51
|
+
cachedBudget = {
|
|
52
|
+
tokenThreshold,
|
|
53
|
+
hardCapTokens,
|
|
54
|
+
autoCompactWindow: deriveAutoCompactWindow(hardCapTokens, compact.autoCompactWindowRatio),
|
|
55
|
+
};
|
|
56
|
+
return cachedBudget;
|
|
57
|
+
}
|
|
@@ -2,6 +2,7 @@ import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
|
2
2
|
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
3
3
|
import { buildCompactMachineKey, compressLine, estimateTokenCount } from '../token/index.js';
|
|
4
4
|
import { compactContextBlock } from './index.js';
|
|
5
|
+
import { loadShippedCompactBudget } from './contextBudget.js';
|
|
5
6
|
|
|
6
7
|
const DEFAULT_MAX_PROMPT_ENTRIES = 12;
|
|
7
8
|
const DEFAULT_MAX_OUTPUT_ENTRIES = 12;
|
|
@@ -427,7 +428,10 @@ function computeEstimatedTotalTokens({
|
|
|
427
428
|
}
|
|
428
429
|
|
|
429
430
|
export function buildCompactThresholds(config = {}) {
|
|
430
|
-
const softThreshold = Math.max(
|
|
431
|
+
const softThreshold = Math.max(
|
|
432
|
+
1,
|
|
433
|
+
finiteNumber(config?.compact?.tokenThreshold, loadShippedCompactBudget().tokenThreshold),
|
|
434
|
+
);
|
|
431
435
|
const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
|
|
432
436
|
const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
|
|
433
437
|
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { buildRuntimePaths } from './runtimePaths.js';
|
|
5
|
+
import { loadShippedCompactBudget } from './compact/contextBudget.js';
|
|
5
6
|
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
7
8
|
const { version: PACKAGE_VERSION } = require('../../package.json');
|
|
@@ -70,7 +71,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
70
71
|
},
|
|
71
72
|
compact: {
|
|
72
73
|
enabled: true,
|
|
73
|
-
tokenThreshold:
|
|
74
|
+
tokenThreshold: loadShippedCompactBudget().tokenThreshold,
|
|
74
75
|
contextRotDetection: true,
|
|
75
76
|
askBeforeDrop: true,
|
|
76
77
|
agentContext: {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { loadShippedCompactBudget } from '../core/compact/contextBudget.js';
|
|
2
|
+
|
|
1
3
|
const CODEGRAPH_SECTION = `
|
|
2
4
|
## CodeGraph Integration (active)
|
|
3
5
|
|
|
@@ -12,7 +14,15 @@ To disable: rerun \`ukit install\` without \`--with-codegraph\`.
|
|
|
12
14
|
`;
|
|
13
15
|
|
|
14
16
|
export function buildTemplateVariables({ projectContext, stackContext, packageVersion, providerContext, withCodegraph = false }) {
|
|
17
|
+
// Derived, never hand-written: .claude/settings.json is read by Claude Code itself and so
|
|
18
|
+
// cannot consult .ukit/storage/config.json at runtime. Baking the window in here keeps
|
|
19
|
+
// compact.hardCapTokens the single number that retunes both.
|
|
20
|
+
const { autoCompactWindow } = loadShippedCompactBudget();
|
|
21
|
+
|
|
15
22
|
return {
|
|
23
|
+
compact: {
|
|
24
|
+
autoCompactWindow: String(autoCompactWindow),
|
|
25
|
+
},
|
|
16
26
|
project: {
|
|
17
27
|
name: projectContext.project.name,
|
|
18
28
|
root: projectContext.project.root,
|
|
@@ -434,7 +434,7 @@ function computeEstimatedTotalTokens({
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
export function buildCompactThresholds(config = {}) {
|
|
437
|
-
const softThreshold = Math.max(1, finiteNumber(config?.compact?.tokenThreshold,
|
|
437
|
+
const softThreshold = Math.max(1, finiteNumber(config?.compact?.tokenThreshold, 150_000));
|
|
438
438
|
const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
|
|
439
439
|
const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
|
|
440
440
|
// Deliberately NOT coupled to hardThreshold: this is an absolute ceiling, so raising
|
|
@@ -442,13 +442,15 @@ export function buildCompactThresholds(config = {}) {
|
|
|
442
442
|
//
|
|
443
443
|
// Must stay BELOW the model's real context window or the cap is unreachable: the API
|
|
444
444
|
// rejects the request with "input exceeds the context window" long before an estimate
|
|
445
|
-
// climbing toward a higher number ever trips this.
|
|
446
|
-
//
|
|
447
|
-
// model this default sits ABOVE the window, so the gate can never fire and is
|
|
448
|
-
// in exactly the situation it exists to prevent — set compact.hardCapTokens to
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
|
|
445
|
+
// climbing toward a higher number ever trips this. 500_000 is half of a 1M window, which
|
|
446
|
+
// leaves ample headroom for the response plus the estimator's own undercount. On any
|
|
447
|
+
// smaller model this default sits ABOVE the window, so the gate can never fire and is
|
|
448
|
+
// dead code in exactly the situation it exists to prevent — set compact.hardCapTokens to
|
|
449
|
+
// 100_000 (200k model) or 128_000 (256k model) for those. Both
|
|
450
|
+
// env.CLAUDE_CODE_AUTO_COMPACT_WINDOW and omp's compaction.thresholdTokens are rendered
|
|
451
|
+
// from this number at install time (70% of it), so the client auto-compacts before the
|
|
452
|
+
// gate blocks tools without anyone hand-syncing a second value.
|
|
453
|
+
const hardCapTokens = Math.max(1, finiteNumber(config?.compact?.hardCapTokens, 500_000));
|
|
452
454
|
|
|
453
455
|
return {
|
|
454
456
|
softThreshold,
|
|
@@ -15,6 +15,7 @@ const IMPLEMENT_MODES = new Set([
|
|
|
15
15
|
'local-build',
|
|
16
16
|
'shared-edit',
|
|
17
17
|
'find-cause',
|
|
18
|
+
'map-impact',
|
|
18
19
|
]);
|
|
19
20
|
|
|
20
21
|
function safeSegment(value) {
|
|
@@ -159,6 +160,7 @@ function freshLedger(payload, routeState, harness) {
|
|
|
159
160
|
receipts: [],
|
|
160
161
|
blocker: null,
|
|
161
162
|
continuationCount: 0,
|
|
163
|
+
notified: false,
|
|
162
164
|
updatedAt: Date.now(),
|
|
163
165
|
};
|
|
164
166
|
}
|
|
@@ -252,24 +254,48 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
252
254
|
const routeSummary = state?.routeSummary || {};
|
|
253
255
|
const mode = routeSummary.executionMode || routeSummary.approachSelector?.executionMode || null;
|
|
254
256
|
const evidence = requiredEvidence(state);
|
|
255
|
-
if (
|
|
256
|
-
return { continue: false, missingEvidence: [] };
|
|
257
|
+
if (ledger?.blocker) {
|
|
258
|
+
return { continue: false, notify: false, missingEvidence: [] };
|
|
259
|
+
}
|
|
260
|
+
if (evidence.length === 0) {
|
|
261
|
+
return { continue: false, notify: false, missingEvidence: [] };
|
|
257
262
|
}
|
|
258
263
|
|
|
259
264
|
const sameRequest = !ledger?.requestKey || !state?.requestKey || ledger.requestKey === state.requestKey;
|
|
260
265
|
const effectiveLedger = sameRequest ? ledger : {};
|
|
261
266
|
const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger));
|
|
262
267
|
if (missingEvidence.length === 0) {
|
|
263
|
-
return { continue: false, missingEvidence: [] };
|
|
268
|
+
return { continue: false, notify: false, missingEvidence: [] };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const gated = IMPLEMENT_MODES.has(mode);
|
|
272
|
+
if (!gated) {
|
|
273
|
+
return {
|
|
274
|
+
continue: false,
|
|
275
|
+
notify: true,
|
|
276
|
+
missingEvidence,
|
|
277
|
+
reason: `UKit completion gate: missing ${missingEvidence.join(', ')}. This mode does not auto-continue; tell the user what is unfinished.`,
|
|
278
|
+
};
|
|
264
279
|
}
|
|
265
280
|
|
|
266
281
|
const continuationCount = Number(effectiveLedger?.continuationCount || 0);
|
|
267
282
|
if (continuationCount >= MAX_CONTINUATIONS) {
|
|
283
|
+
if (effectiveLedger?.notified === true) {
|
|
284
|
+
return {
|
|
285
|
+
continue: false,
|
|
286
|
+
capped: true,
|
|
287
|
+
notify: true,
|
|
288
|
+
missingEvidence,
|
|
289
|
+
reason: `UKit continuation cap reached with missing evidence: ${missingEvidence.join(', ')}.`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
268
292
|
return {
|
|
269
|
-
continue:
|
|
293
|
+
continue: true,
|
|
294
|
+
finalNotice: true,
|
|
295
|
+
notify: true,
|
|
270
296
|
capped: true,
|
|
271
297
|
missingEvidence,
|
|
272
|
-
reason: `UKit
|
|
298
|
+
reason: `UKit stopping with unfinished work: ${missingEvidence.join(', ')}. Tell the user what is unfinished and stop; do not continue further.`,
|
|
273
299
|
};
|
|
274
300
|
}
|
|
275
301
|
|
|
@@ -298,6 +324,13 @@ export async function incrementContinuation(projectRoot, payload = {}, ledger =
|
|
|
298
324
|
return next;
|
|
299
325
|
}
|
|
300
326
|
|
|
327
|
+
export async function markNotified(projectRoot, payload = {}, ledger = null) {
|
|
328
|
+
const current = ledger || await readExecutionLedger(projectRoot, payload) || freshLedger(payload, null, 'unknown');
|
|
329
|
+
const next = { ...current, notified: true, updatedAt: Date.now() };
|
|
330
|
+
await writeJsonAtomic(ledgerPath(projectRoot, payload), next);
|
|
331
|
+
return next;
|
|
332
|
+
}
|
|
333
|
+
|
|
301
334
|
async function readStdin() {
|
|
302
335
|
if (process.stdin.isTTY) return '';
|
|
303
336
|
const chunks = [];
|
|
@@ -322,7 +355,8 @@ async function main() {
|
|
|
322
355
|
const ledger = await readExecutionLedger(projectRoot, payload) || {};
|
|
323
356
|
const result = evaluateCompletion({ state, ledger });
|
|
324
357
|
if (result.continue) {
|
|
325
|
-
await
|
|
358
|
+
if (result.finalNotice) await markNotified(projectRoot, payload, ledger);
|
|
359
|
+
else await incrementContinuation(projectRoot, payload, ledger);
|
|
326
360
|
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
|
|
327
361
|
} else if (result.capped) {
|
|
328
362
|
process.stderr.write(`[ukit-completion] ${result.reason}\n`);
|
package/templates/.omp/RULES.md
CHANGED
|
@@ -57,6 +57,12 @@ Prefer unique current-file anchors over line numbers or stale pasted blocks. Nev
|
|
|
57
57
|
stale spec — re-read current source and confirm before applying. Preserve existing BOM and line
|
|
58
58
|
endings.
|
|
59
59
|
|
|
60
|
+
## 8. Never end a turn silently
|
|
61
|
+
|
|
62
|
+
Ending a turn with no output is a defect, not a pause. If you stop before the work is finished, the
|
|
63
|
+
last thing you emit is one short line naming what is unfinished and what you need. Silence is never
|
|
64
|
+
a status — the user cannot distinguish it from a crash.
|
|
65
|
+
|
|
60
66
|
> Maintainer note, not a per-turn rule: `modelRoles` in `.omp/config.yml` ships UNIC gateway names.
|
|
61
67
|
> On a non-UNIC provider, edit only the three cost tiers — `lite`, `code`, `smart` — never `vision`,
|
|
62
68
|
> which stays `unic-vision` because it is a capability lane, not a cost tier.
|
|
@@ -78,8 +78,11 @@ memory:
|
|
|
78
78
|
backend: off
|
|
79
79
|
|
|
80
80
|
# Auto-compact must trigger BEFORE context-hardcap-gate.sh starts blocking tool
|
|
81
|
-
# calls (compact.hardCapTokens, default
|
|
82
|
-
# env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
|
|
81
|
+
# calls (compact.hardCapTokens, default 500000). Rendered at install time from the
|
|
82
|
+
# same derived number as env.CLAUDE_CODE_AUTO_COMPACT_WINDOW in
|
|
83
|
+
# templates/.claude/settings.json (70% of hardCapTokens), so omp and Claude Code
|
|
84
|
+
# cannot drift apart and retuning both is a single edit to
|
|
85
|
+
# templates/ukit/storage/config.json.
|
|
83
86
|
#
|
|
84
87
|
# Key VERIFIED against omp v17.4.2 (2026-08-22): `compaction.thresholdTokens` is a
|
|
85
88
|
# fixed token limit for context maintenance and overrides the percentage threshold
|
|
@@ -88,4 +91,4 @@ memory:
|
|
|
88
91
|
# omp rejects it outright (`omp config get compact.autoCompactWindow` → "Unknown
|
|
89
92
|
# setting"), so auto-compact never moved off omp's default. See PLAN.md §3 D13.
|
|
90
93
|
compaction:
|
|
91
|
-
thresholdTokens:
|
|
94
|
+
thresholdTokens: {{compact.autoCompactWindow}}
|
|
@@ -12,6 +12,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
12
12
|
import {
|
|
13
13
|
evaluateCompletion,
|
|
14
14
|
incrementContinuation,
|
|
15
|
+
markNotified,
|
|
15
16
|
readExecutionLedger,
|
|
16
17
|
readRouteState,
|
|
17
18
|
recordExecutionReceipt,
|
|
@@ -337,18 +338,18 @@ function textFromContent(content) {
|
|
|
337
338
|
.join('\n');
|
|
338
339
|
}
|
|
339
340
|
|
|
340
|
-
function hookContextMessage(content) {
|
|
341
|
+
function hookContextMessage(content, { display = false } = {}) {
|
|
341
342
|
return {
|
|
342
343
|
customType: 'ukit-hook-context',
|
|
343
344
|
content,
|
|
344
|
-
display
|
|
345
|
+
display,
|
|
345
346
|
};
|
|
346
347
|
}
|
|
347
348
|
|
|
348
|
-
function sendContext(pi, context, deliverAs) {
|
|
349
|
+
function sendContext(pi, context, deliverAs, { display = false } = {}) {
|
|
349
350
|
const content = context.filter(Boolean).join('\n').trim();
|
|
350
351
|
if (!content || typeof pi.sendMessage !== 'function') return;
|
|
351
|
-
pi.sendMessage(hookContextMessage(content), { deliverAs });
|
|
352
|
+
pi.sendMessage(hookContextMessage(content, { display }), { deliverAs });
|
|
352
353
|
}
|
|
353
354
|
|
|
354
355
|
export async function runToolCall(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
@@ -469,6 +470,9 @@ export async function runSessionCompact(pi, event, { projectRoot, context: exten
|
|
|
469
470
|
const metadata = runtimeMetadata(event, extensionContext);
|
|
470
471
|
const payload = buildHookPayload('SessionStart', { ...metadata, source: 'compact' });
|
|
471
472
|
const result = await runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
|
|
473
|
+
// TODO(verify) PLAN.md §3 D3: whether omp still accepts a 'steer' message after a compaction
|
|
474
|
+
// is unverified. If it drops steer, the 'nextTurn' copy below preserves prior behavior.
|
|
475
|
+
sendContext(pi, result.context, 'steer');
|
|
472
476
|
sendContext(pi, result.context, 'nextTurn');
|
|
473
477
|
return undefined;
|
|
474
478
|
}
|
|
@@ -490,12 +494,23 @@ export async function runSessionStop(
|
|
|
490
494
|
const evaluation = evaluateCompletion({ state, ledger });
|
|
491
495
|
if (!evaluation.continue) {
|
|
492
496
|
if (evaluation.capped) pi.logger?.warn?.(`[UKit] ${evaluation.reason}`);
|
|
497
|
+
if (Array.isArray(evaluation.missingEvidence) && evaluation.missingEvidence.length > 0) {
|
|
498
|
+
const notice = [
|
|
499
|
+
`[UKit] Stopping with unfinished work: ${evaluation.missingEvidence.join(', ')}.`,
|
|
500
|
+
evaluation.reason,
|
|
501
|
+
].filter(Boolean).join(' ');
|
|
502
|
+
// TODO(verify) PLAN.md §3 D4: whether omp honours display:true on a sendMessage payload
|
|
503
|
+
// is unverified (omp is not installed). If display is ignored, the finalNotice
|
|
504
|
+
// continuation below (the model's own message) is the channel guaranteed to render.
|
|
505
|
+
sendContext(pi, [notice], 'nextTurn', { display: true });
|
|
506
|
+
}
|
|
493
507
|
return undefined;
|
|
494
508
|
}
|
|
495
509
|
|
|
496
510
|
if (suppliedLedger === undefined) {
|
|
497
511
|
try {
|
|
498
|
-
await
|
|
512
|
+
if (evaluation.finalNotice) await markNotified(projectRoot, payload, ledger);
|
|
513
|
+
else await incrementContinuation(projectRoot, payload, ledger);
|
|
499
514
|
} catch (error) {
|
|
500
515
|
pi.logger?.warn?.(`[UKit] continuation bookkeeping failed open: ${error?.message || error}`);
|
|
501
516
|
}
|
|
@@ -79,8 +79,8 @@ Next: <bước kế tiếp chính xác>
|
|
|
79
79
|
- Subagent ghi **full log vào task file trên đĩa**, chỉ trả về orchestrator ≤10 dòng (executor) / ≤6 dòng (reviewer). Paste log ngược lại orchestrator là nguyên nhân số 1 làm run chết vì hết context.
|
|
80
80
|
- Hết mỗi wave: commit, ghi cursor, **collapse** wave đó còn 1 dòng/task trong bộ nhớ làm việc, rồi chạy tiếp.
|
|
81
81
|
- Yêu cầu `/compact` **chỉ** được đặt ở cuối command, giữa 2 cycle. Giữa cycle thì tuyệt đối không — state đã nằm hết ở git + `INDEX.md` + `RUN.md` nên compact ở ranh giới cycle không mất gì.
|
|
82
|
-
- Vượt `compact.hardCapTokens` (mặc định
|
|
83
|
-
- Không hook nào gọi được `/compact` — đó là lệnh client-only. Nhưng từ 2.1.3, settings mặc định đặt `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW =
|
|
82
|
+
- Vượt `compact.hardCapTokens` (mặc định 500k = 50% của context window 1M) mà `RUN.md` còn run dở: `context-hardcap-gate` cho thêm `compact.hardCapGraceCalls` (mặc định 10) tool call rồi mới chặn cứng. **Grace đó chỉ để hạ cánh** — hoàn tất edit đang dở, commit, ghi cursor, push. Không mở task mới, không đọc thêm file, không spawn agent. Hết grace là chặn thật; budget chỉ reset khi ước lượng token thực sự giảm (có compact thật), không reset theo wave.
|
|
83
|
+
- Không hook nào gọi được `/compact` — đó là lệnh client-only. Nhưng từ 2.1.3, settings mặc định đặt `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW` = 70% của `hardCapTokens` (mặc định 350000 < 500k), render lúc install, nên **client tự auto-compact trước khi gate chặn**. Đường thường: auto-compact chạy → `handoff-resume.sh` replay cursor → chạy tiếp, không cần người gõ gì. Grace window ở trên chỉ còn là lưới an toàn.
|
|
84
84
|
- Sửa một trong hai số đó thì phải giữ `autoCompactWindow < hardCapTokens`. Đảo thứ tự là deadlock: gate chặn tool trước → transcript ngừng lớn → ngưỡng auto-compact không bao giờ tới. `tests/core/autoCompactWindow.test.js` khóa bất biến này.
|
|
85
85
|
|
|
86
86
|
### Git
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
},
|
|
9
9
|
"compact": {
|
|
10
10
|
"enabled": true,
|
|
11
|
-
"tokenThreshold":
|
|
12
|
-
"hardCapTokens":
|
|
11
|
+
"tokenThreshold": 150000,
|
|
12
|
+
"hardCapTokens": 500000,
|
|
13
|
+
"autoCompactWindowRatio": 0.7,
|
|
13
14
|
"hardCapBlock": true,
|
|
14
15
|
"hardCapGraceCalls": 10,
|
|
15
16
|
"contextRotDetection": true,
|
|
@@ -394,7 +395,8 @@
|
|
|
394
395
|
"compact": {
|
|
395
396
|
"enabled": "Bật/tắt toàn bộ helper compact của UKit.",
|
|
396
397
|
"tokenThreshold": "Ngưỡng token chung cho runtime compact dùng chung.",
|
|
397
|
-
"hardCapTokens": "Ngưỡng cứng tuyệt đối (mặc định
|
|
398
|
+
"hardCapTokens": "Ngưỡng cứng tuyệt đối (mặc định 500000 token ước lượng = 50% của context window 1M). Chạm/vượt ngưỡng này thì context coi như quá dài — không phải gợi ý nữa, là bắt buộc. PHẢI thấp hơn context window thật của model, nếu không API sẽ báo lỗi vượt context trước khi gate kịp chặn — model 200k thì hạ xuống 100000, model 256k thì 128000. Cả env.CLAUDE_CODE_AUTO_COMPACT_WINDOW (.claude/settings.json) lẫn compaction.thresholdTokens (.omp/config.yml) đều được SUY RA từ số này khi chạy ukit install, nên chỉ cần sửa ở đây rồi cài lại là auto-compact của cả Claude Code và omp đổi theo.",
|
|
399
|
+
"autoCompactWindowRatio": "Auto-compact chạy ở bao nhiêu phần của hardCapTokens (mặc định 0.7 → 350000). Phải < 1 để client tự compact TRƯỚC khi context-hardcap-gate chặn tool; số càng nhỏ thì compact càng sớm và càng nhiều đệm an toàn.",
|
|
398
400
|
"hardCapBlock": "Nếu true, hook context-hardcap-gate chặn cứng Edit/Write/Bash (exit 2) khi vượt hardCapTokens, cho tới khi có compact thật (PreCompact) reset lại bộ đếm.",
|
|
399
401
|
"hardCapGraceCalls": "Số tool call được phép chạy tiếp sau khi vượt hardCapTokens KHI docs/AI_HANDOFF/RUN.md còn run dở (mặc định 10). Dùng để run kịp commit + ghi cursor + push rồi mới bị chặn, thay vì chết giữa lúc đang Edit. Hết grace là chặn cứng như cũ. Budget tính theo mỗi đợt vượt cap, chỉ reset khi ước lượng token thật sự giảm (có compact thật) — không reset theo wave.",
|
|
400
402
|
"contextRotDetection": "Phát hiện context quá dài/dễ mục để giữ lại state quan trọng trước khi AI nhớ sai.",
|