@ngockhoale/ukit 2.0.3 → 2.0.5
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 +27 -0
- package/manifests/platform.full.yaml +11 -0
- package/package.json +1 -1
- package/src/cli/commands/update.js +34 -7
- package/src/core/runtimeConfig.js +6 -6
- package/src/core/update.js +61 -0
- package/src/index/buildIndex.js +107 -15
- package/templates/.claude/agents/handoff-planner.md +1 -1
- package/templates/.claude/commands/ukit/handoff-create.md +1 -1
- package/templates/.claude/commands/ukit/handoff-fullstack.md +13 -3
- package/templates/.claude/commands/ukit/handoff-implement.md +15 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +2 -1
- package/templates/.claude/hooks/context-window-guard.sh +158 -0
- package/templates/.claude/hooks/vision-gate.sh +24 -24
- package/templates/.claude/hooks/vision-router.sh +44 -48
- package/templates/.claude/settings.json +5 -0
- package/templates/.claude/ukit/index/lib/index-core.mjs +61 -14
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +8 -1
- package/templates/.codex/settings.json +1 -1
- package/templates/ukit/storage/config.json +15 -13
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.0.5 - 2026-08-11
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Model IDs refreshed to the Claude 5 family**: `claude-sonnet-4-6` → `claude-sonnet-5`, `claude-opus-4-6` → `claude-opus-5`, across the `code`/`smart` tiers, `orchestration.modelTiers.vision.fallbackModel`, `handoff.defaultModel`/`advisorModel`/`orchestratorModel`, the Codex adapter settings, the `handoff-planner` agent example, and the config's own Vietnamese documentation strings. `claude-haiku-4-5` is unchanged — Haiku 4.5 is still current, so the `lite` tier was already correct. 54 references in live config, source and tests; historical records (`docs/AI_HANDOFF/archive/`, `docs/plans/`, `docs/WORKLOG.md`, `.ukit/storage/backups/`) are deliberately left alone, since rewriting what a past cycle actually ran on would be falsifying the record.
|
|
10
|
+
|
|
11
|
+
## 2.0.4 - 2026-08-11
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`context-window-guard.sh`** (UserPromptSubmit) — measures the session's real context by reading the transcript, and warns at 80% of `compact.hardCapTokens` / raises an ALERT at 100%. It counts only the messages *after* the last `compact_boundary`, so it tracks live context rather than the file's lifetime size (on a real 5.8 MB transcript: 1,444,439 tokens whole-file vs 145,510 measured — the whole-file number is off by 10x and useless). Subagent entries (`isSidechain`) are counted separately, since each carries its own window. **Advisory only — it always exits 0.** A hook that blocked here would strand the user at exactly the moment they can no longer type their way out.
|
|
16
|
+
- **`handoff.maxParallelAgents`** (default `3`) — caps how many implementer agents a handoff wave spawns at once. `handoff-implement` and `handoff-fullstack` now batch each wave against it instead of launching every independent task simultaneously.
|
|
17
|
+
- **`ukit update` now refreshes the current project**, replacing the `ukit update && ukit install` two-step. `--no-install` keeps the old CLI-only behaviour. The refresh is spawned as a **child process** running the newly installed global `bin/ukit`: after `npm install -g`, the running process still holds the old module graph, so an in-process install would redeploy the *old* templates and silently defeat the update. It also refuses to scaffold a directory with no UKit markers (install writes `.claude/`, `CLAUDE.md` and edits `.gitignore` — an unwelcome surprise in a home directory), and never reports an install that did not actually happen.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **`compact.hardCapTokens` default lowered from `220000` to `160000`.** The old default sat *above* a 200k model window, so the gate it guards could never fire in the one situation it exists to prevent — it was dead code shipped as a safety feature. Measured live on a real session, the new guard read 157,952 tokens against this 160k cap, matching the point where the API actually starts rejecting input. The separate 50k/80k advisory phases are unchanged.
|
|
22
|
+
- **The index now covers the whole project instead of a fixed list of directory names.** Discovery scanned `src`/`tests`/`test`/`specs`/`spec`/`__tests__`/`manifests` and fell back to the project root only when *none* of them existed. A partial match was the trap: a repo with a top-level `tests/` but its code in `packages/` or `app/` indexed `tests/` and silently nothing else. Scope now comes from the project's own ignore rules — git's tracked plus untracked-not-ignored files — which is self-tuning per layout and needs no configuration. Non-git projects fall back to walking the root. This also removes noise the old name-based walk could not see: build output a project gitignores, and `.claude/`/`.ukit/`, which `ukit install` writes into every project.
|
|
23
|
+
- **Shell scripts are indexed.** `.sh` joins `.md`/`.json`/`.yaml` as tracked-but-not-parsed, matched by path. Every hook UKit ships was previously invisible to its own index.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **One pasted screenshot could brick `Edit`/`Write` for an entire session on every non-UNIC project.** The vision gate hard-blocks edits until a `ukit-vision-analyst` receipt exists, and it accepted a receipt only if the reported model matched a hardcoded, by-then-stale model ID. That acceptance path is unsatisfiable in practice: an honest agent reports the model it *actually* ran on, so the gate could never be cleared and the only escape was deleting a gitignored marker file by hand. The gate now stands down entirely when the session is off the UNIC gateway — where Claude reads images natively and there is nothing to enforce — and matches `unic-vision` by capability instead of by a pinned version string. Detection failure (`null`) still enforces, so breaking one gitignored file is not a bypass. `tests/handoff/cycle4/vision-gate.test.mjs` gains cases 21b/21c pinning "off-gateway + no receipt is allowed, never a deadlock" and "no fallback model needs to be configured"; the UNIC enforcement cases are untouched and still pass.
|
|
28
|
+
- **The v2.0.2 hard cap could not stop what it was built to stop.** `context-hardcap-gate.sh` is `PreToolUse`, but a context-window rejection happens when the request is *sent* — before any tool call exists to gate. `UserPromptSubmit` is the only event that runs early enough, hence the new guard above. Related: only three modules ever advance `estimatedTotalTokens`, and none of them observe `Read` results, images, or returned subagent reports, so the tracked figure drifts far below reality on exactly the sessions that overflow.
|
|
29
|
+
- **UKit's index could not see what UKit ships — the root cause of three releases' worth of dead features.** Because `src`, `tests` and `manifests` all exist in this repo, `templates/` was never scanned: 0 of 303 shipped files indexed, while the hand-maintained `.claude/` dev mirror sat in plain view. CLAUDE.md mandates an index-first loop, so the AI was routed to `src/` and the mirror, and never learned that `templates/.claude/` is the copy that reaches users. Querying a shipped skill by its own name returned only `src/` files, with no signal that anything was out of scope. Coverage in this repo goes from 136 files to 442, including the 185 `templates/.claude` files and all 17 shipped hooks; the three assets behind the 2.0.0 and 2.0.2 bugs now rank first for their own names.
|
|
30
|
+
- `tests/index/scanScope.test.js` guards the class: code outside the old allowlist is indexed even when a listed directory exists, ignore rules define scope, the non-git fallback still covers the whole project, and the shipped `index-core.mjs` bundle must carry the same discovery logic as `src/` — that bundle is what installed projects actually run, so a fix landing only in `src/` reaches nobody.
|
|
31
|
+
|
|
5
32
|
## 2.0.3 - 2026-08-11
|
|
6
33
|
|
|
7
34
|
### Added
|
|
@@ -1110,6 +1110,17 @@ items:
|
|
|
1110
1110
|
packs:
|
|
1111
1111
|
- core
|
|
1112
1112
|
|
|
1113
|
+
- id: hook-context-window-guard
|
|
1114
|
+
type: hook
|
|
1115
|
+
sourceTemplate: .claude/hooks/context-window-guard.sh
|
|
1116
|
+
targetPath: .claude/hooks/context-window-guard.sh
|
|
1117
|
+
requires: []
|
|
1118
|
+
mergeStrategy: overwrite_with_backup
|
|
1119
|
+
variables: []
|
|
1120
|
+
enabledByDefault: true
|
|
1121
|
+
packs:
|
|
1122
|
+
- core
|
|
1123
|
+
|
|
1113
1124
|
- id: hook-reset-compact-pressure
|
|
1114
1125
|
type: hook
|
|
1115
1126
|
sourceTemplate: .claude/hooks/reset-compact-pressure.sh
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { updateUkit, UKIT_PACKAGE_NAME } from '../../core/update.js';
|
|
1
|
+
import { updateUkit, runInstallAfterUpdate, UKIT_PACKAGE_NAME } from '../../core/update.js';
|
|
2
2
|
|
|
3
|
-
const KNOWN_UPDATE_FLAGS = new Set(['--help', '-h']);
|
|
3
|
+
const KNOWN_UPDATE_FLAGS = new Set(['--help', '-h', '--no-install']);
|
|
4
4
|
|
|
5
|
-
export async function runUpdate({ packageVersion, argv = [] }) {
|
|
5
|
+
export async function runUpdate({ packageVersion, argv = [], cwd = process.cwd() }) {
|
|
6
6
|
if (argv.includes('--help') || argv.includes('-h')) {
|
|
7
|
-
console.log('Usage: ukit update');
|
|
7
|
+
console.log('Usage: ukit update [--no-install]');
|
|
8
8
|
console.log('');
|
|
9
|
-
console.log('Upgrade the globally installed UKit CLI
|
|
10
|
-
console.log(`Equivalent to: npm install -g ${UKIT_PACKAGE_NAME}`);
|
|
9
|
+
console.log('Upgrade the globally installed UKit CLI, then refresh the current project.');
|
|
10
|
+
console.log(`Equivalent to: npm install -g ${UKIT_PACKAGE_NAME} && ukit install`);
|
|
11
|
+
console.log('');
|
|
12
|
+
console.log(' --no-install Only upgrade the CLI; do not refresh the current project.');
|
|
11
13
|
return;
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -23,5 +25,30 @@ export async function runUpdate({ packageVersion, argv = [] }) {
|
|
|
23
25
|
|
|
24
26
|
updateUkit();
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
if (argv.includes('--no-install')) {
|
|
29
|
+
console.log('[UKit] Update complete. Skipped project refresh (--no-install).');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log('[UKit] Refreshing this project: ukit install');
|
|
34
|
+
const { ran, reason } = runInstallAfterUpdate({ projectRoot: cwd });
|
|
35
|
+
|
|
36
|
+
if (ran) {
|
|
37
|
+
console.log('[UKit] Update complete — CLI upgraded and project refreshed.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Never report an install that did not happen. Each case gets the actionable next step.
|
|
42
|
+
if (reason === 'not-a-ukit-project') {
|
|
43
|
+
console.log('[UKit] Update complete. No UKit files here, so nothing was refreshed.');
|
|
44
|
+
console.log('[UKit] Run `ukit install` inside a project to set it up.');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (reason === 'global-bin-not-found') {
|
|
48
|
+
console.log('[UKit] Update complete, but the upgraded CLI could not be located.');
|
|
49
|
+
console.log('[UKit] Run `ukit install` to refresh this project.');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
console.log(`[UKit] CLI upgraded, but the project refresh did not finish (${reason}).`);
|
|
53
|
+
console.log('[UKit] Run `ukit install` to retry.');
|
|
27
54
|
}
|
|
@@ -117,14 +117,14 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
117
117
|
},
|
|
118
118
|
router: {
|
|
119
119
|
enabled: true,
|
|
120
|
-
defaultModel: 'claude-sonnet-
|
|
121
|
-
advisorModel: 'claude-opus-
|
|
120
|
+
defaultModel: 'claude-sonnet-5',
|
|
121
|
+
advisorModel: 'claude-opus-5',
|
|
122
122
|
advisorEnabled: true,
|
|
123
123
|
maxAdvisorCalls: 3,
|
|
124
124
|
},
|
|
125
125
|
orchestration: {
|
|
126
126
|
enabled: true,
|
|
127
|
-
orchestratorModel: 'claude-sonnet-
|
|
127
|
+
orchestratorModel: 'claude-sonnet-5',
|
|
128
128
|
advisorEnabled: true,
|
|
129
129
|
contracts: {
|
|
130
130
|
'tiny-fix': {
|
|
@@ -176,12 +176,12 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
176
176
|
},
|
|
177
177
|
modelTiers: {
|
|
178
178
|
lite: { claudeModel: 'claude-haiku-4-5', genericModel: 'unic-lite' },
|
|
179
|
-
code: { claudeModel: 'claude-sonnet-
|
|
180
|
-
smart: { claudeModel: 'claude-opus-
|
|
179
|
+
code: { claudeModel: 'claude-sonnet-5', genericModel: 'unic-code' },
|
|
180
|
+
smart: { claudeModel: 'claude-opus-5', genericModel: 'unic-smart' },
|
|
181
181
|
vision: {
|
|
182
182
|
claudeModel: 'unic-vision',
|
|
183
183
|
genericModel: 'unic-vision',
|
|
184
|
-
fallbackModel: 'claude-sonnet-
|
|
184
|
+
fallbackModel: 'claude-sonnet-5',
|
|
185
185
|
capabilityTier: true,
|
|
186
186
|
note: 'Capability tier, not a cost tier. Orthogonal to lite/code/smart — never insert into escalation.tierOrder. fallbackModel is used when unicMode is off.',
|
|
187
187
|
},
|
package/src/core/update.js
CHANGED
|
@@ -1,7 +1,68 @@
|
|
|
1
1
|
import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
|
|
3
5
|
export const UKIT_PACKAGE_NAME = '@ngockhoale/ukit';
|
|
4
6
|
|
|
7
|
+
// Markers that a directory is a project someone has already run `ukit install` in.
|
|
8
|
+
// `ukit update` refreshes such a project automatically; it must never scaffold a
|
|
9
|
+
// directory that merely happens to be the shell's cwd (install writes .claude/,
|
|
10
|
+
// CLAUDE.md and edits .gitignore — an unwanted surprise in, say, a home directory).
|
|
11
|
+
const INSTALLED_MARKERS = ['.ukit', '.claude', 'CLAUDE.md', 'AGENTS.md', '.codex'];
|
|
12
|
+
|
|
13
|
+
export function hasUkitInstalled(projectRoot) {
|
|
14
|
+
return INSTALLED_MARKERS.some((marker) => fs.existsSync(path.join(projectRoot, marker)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Absolute path to the globally installed `bin/ukit`, or null if it cannot be resolved.
|
|
19
|
+
*
|
|
20
|
+
* Resolving this matters for correctness, not tidiness: after `npm install -g` the
|
|
21
|
+
* CURRENT process is still running the OLD code off the old module graph. Calling the
|
|
22
|
+
* in-process install would reinstall the OLD templates and silently defeat the update.
|
|
23
|
+
* Only a fresh child process picks up the newly written package.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveGlobalUkitBin({ spawnSync = defaultSpawnSync } = {}) {
|
|
26
|
+
const result = spawnSync('npm', ['root', '-g'], { encoding: 'utf8' });
|
|
27
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const binPath = path.join(result.stdout.trim(), ...UKIT_PACKAGE_NAME.split('/'), 'bin', 'ukit');
|
|
31
|
+
return fs.existsSync(binPath) ? binPath : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run `ukit install` in `projectRoot` using the freshly installed global package.
|
|
36
|
+
* Returns { ran, reason } so the caller can report accurately rather than claim an
|
|
37
|
+
* install that never happened.
|
|
38
|
+
*/
|
|
39
|
+
export function runInstallAfterUpdate({
|
|
40
|
+
projectRoot,
|
|
41
|
+
spawnSync = defaultSpawnSync,
|
|
42
|
+
execPath = process.execPath,
|
|
43
|
+
} = {}) {
|
|
44
|
+
if (!hasUkitInstalled(projectRoot)) {
|
|
45
|
+
return { ran: false, reason: 'not-a-ukit-project' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const binPath = resolveGlobalUkitBin({ spawnSync });
|
|
49
|
+
if (!binPath) {
|
|
50
|
+
return { ran: false, reason: 'global-bin-not-found' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = spawnSync(execPath, [binPath, 'install'], {
|
|
54
|
+
cwd: projectRoot,
|
|
55
|
+
stdio: 'inherit',
|
|
56
|
+
});
|
|
57
|
+
if (result.error) {
|
|
58
|
+
return { ran: false, reason: `install-failed: ${result.error.message}` };
|
|
59
|
+
}
|
|
60
|
+
if (result.status !== 0) {
|
|
61
|
+
return { ran: false, reason: `install-exited-${result.status}` };
|
|
62
|
+
}
|
|
63
|
+
return { ran: true, reason: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
5
66
|
export function updateUkit({ spawnSync = defaultSpawnSync } = {}) {
|
|
6
67
|
const result = spawnSync('npm', ['install', '-g', UKIT_PACKAGE_NAME], {
|
|
7
68
|
stdio: 'inherit',
|
package/src/index/buildIndex.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
4
5
|
|
|
5
6
|
import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, isLikelyTestFilePath, normalizeRelative } from './paths.js';
|
|
6
7
|
import { importsNeedAliasContext, loadImportAliasContextState, resolveImportSpecifier } from './importResolution.js';
|
|
7
8
|
import { clearIndexArtifactCache } from './queryIndex.js';
|
|
8
9
|
import { clearRelatedTestArtifactCache } from './relatedTests.js';
|
|
9
10
|
|
|
10
|
-
const
|
|
11
|
+
const GIT_ENUMERATION_TIMEOUT_MS = 15_000;
|
|
11
12
|
const EXCLUDED_DIR_NAMES = new Set([
|
|
12
13
|
'node_modules',
|
|
13
14
|
'.git',
|
|
@@ -30,7 +31,9 @@ const EXCLUDED_DIR_NAMES = new Set([
|
|
|
30
31
|
]);
|
|
31
32
|
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue']);
|
|
32
33
|
const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
33
|
-
|
|
34
|
+
// `.sh` is tracked but never parsed, like `.md`/`.json`/`.yaml` — it is matched
|
|
35
|
+
// by path only. Without it every shipped hook script was invisible to the index.
|
|
36
|
+
const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.yaml', '.yml', '.md', '.sh']);
|
|
34
37
|
const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
|
|
35
38
|
export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
36
39
|
const INDEX_PARSE_BATCH_SIZE = 8;
|
|
@@ -52,7 +55,7 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
|
52
55
|
previousCodeFileRecords.map((item) => [item.filePath, { mtimeMs: Number(item.mtimeMs ?? -1), size: Number(item.size ?? -1) }]),
|
|
53
56
|
);
|
|
54
57
|
|
|
55
|
-
const discoveredFiles = await
|
|
58
|
+
const discoveredFiles = await discoverProjectFiles(absoluteRoot);
|
|
56
59
|
const sourceFingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
|
|
57
60
|
const styleFilePaths = discoveredFiles
|
|
58
61
|
.map((entry) => {
|
|
@@ -942,22 +945,106 @@ function areBugIndexSnapshotsEqual(previousSnapshot, nextSnapshot) {
|
|
|
942
945
|
&& previousSnapshot.size === nextSnapshot.size;
|
|
943
946
|
}
|
|
944
947
|
|
|
945
|
-
|
|
946
|
-
|
|
948
|
+
/*
|
|
949
|
+
* File discovery is the whole project, always — never a fixed list of "source"
|
|
950
|
+
* directory names.
|
|
951
|
+
*
|
|
952
|
+
* The old rule scanned `src|tests|test|specs|spec|__tests__|manifests` and only
|
|
953
|
+
* fell back to the project root when NONE of them existed. Partial matches were
|
|
954
|
+
* the trap: a repo with a top-level `tests/` but its code in `packages/` indexed
|
|
955
|
+
* `tests/` and silently nothing else. UKit's own repo hit exactly that — `src`,
|
|
956
|
+
* `tests` and `manifests` all exist, so `templates/` (the entire shipped payload)
|
|
957
|
+
* was never indexed, and three releases shipped features that were dead on
|
|
958
|
+
* arrival because the index kept pointing at `src/` and the hand-maintained
|
|
959
|
+
* `.claude/` dev mirror instead.
|
|
960
|
+
*
|
|
961
|
+
* Scope now comes from the project's OWN ignore rules rather than a guess about
|
|
962
|
+
* its layout: git lists tracked files plus untracked-not-ignored ones. That is
|
|
963
|
+
* self-tuning per project and needs no configuration — end users still only run
|
|
964
|
+
* `ukit install`. It also removes noise the name-based walk cannot see: build
|
|
965
|
+
* output a project gitignores, and `.claude/`/`.ukit/`, which `ensureGitignore`
|
|
966
|
+
* writes into every installed project, so UKit's own scaffolding stops competing
|
|
967
|
+
* with the user's real code in query results.
|
|
968
|
+
*
|
|
969
|
+
* Non-git projects fall back to walking the root with the same exclusion rules.
|
|
970
|
+
*/
|
|
971
|
+
async function discoverProjectFiles(rootDir) {
|
|
972
|
+
const gitFiles = await collectGitTrackedFiles(rootDir);
|
|
973
|
+
if (gitFiles) {
|
|
974
|
+
return gitFiles;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return collectFiles([rootDir]);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function runGit(rootDir, args) {
|
|
981
|
+
const result = spawnSync('git', args, {
|
|
982
|
+
cwd: rootDir,
|
|
983
|
+
encoding: 'utf8',
|
|
984
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
985
|
+
timeout: GIT_ENUMERATION_TIMEOUT_MS,
|
|
986
|
+
windowsHide: true,
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return result.stdout;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* @returns {Promise<Array<{absolutePath: string, mtimeMs: number, size: number}>|null>}
|
|
998
|
+
* `null` when this is not a usable git worktree, so the caller can fall back.
|
|
999
|
+
*/
|
|
1000
|
+
async function collectGitTrackedFiles(rootDir) {
|
|
1001
|
+
// `-z` matters beyond separators: it also disables git's path quoting, which
|
|
1002
|
+
// would otherwise mangle non-ASCII filenames.
|
|
1003
|
+
const tracked = runGit(rootDir, ['ls-files', '-z']);
|
|
1004
|
+
if (tracked === null) {
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
const untracked = runGit(rootDir, ['ls-files', '-z', '--others', '--exclude-standard']) ?? '';
|
|
1008
|
+
|
|
1009
|
+
const relativePaths = new Set();
|
|
1010
|
+
for (const entry of `${tracked}${untracked}`.split('\0')) {
|
|
1011
|
+
if (entry) {
|
|
1012
|
+
relativePaths.add(entry);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (relativePaths.size === 0) {
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const candidates = [...relativePaths].filter((relativePath) => {
|
|
1020
|
+
if (!DISCOVERED_EXTENSIONS.has(path.extname(relativePath).toLowerCase())) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
// Git already applied the project's own ignore rules, so dot-directories it
|
|
1024
|
+
// still lists are deliberate (UKit's `templates/.claude/**` is force-added).
|
|
1025
|
+
// All that is left to defend against is a build directory someone committed.
|
|
1026
|
+
return !relativePath.split('/').some((segment) => EXCLUDED_DIR_NAMES.has(segment));
|
|
1027
|
+
});
|
|
947
1028
|
|
|
948
|
-
|
|
949
|
-
const
|
|
1029
|
+
const entries = await Promise.all(candidates.map(async (relativePath) => {
|
|
1030
|
+
const absolutePath = path.join(rootDir, relativePath);
|
|
950
1031
|
try {
|
|
951
|
-
const stat = await fs.stat(
|
|
952
|
-
if (stat.
|
|
953
|
-
|
|
1032
|
+
const stat = await fs.stat(absolutePath);
|
|
1033
|
+
if (!stat.isFile()) {
|
|
1034
|
+
return null;
|
|
954
1035
|
}
|
|
1036
|
+
return {
|
|
1037
|
+
absolutePath,
|
|
1038
|
+
mtimeMs: Math.floor(stat.mtimeMs),
|
|
1039
|
+
size: stat.size,
|
|
1040
|
+
};
|
|
955
1041
|
} catch {
|
|
956
|
-
//
|
|
1042
|
+
// staged deletion, or a symlink pointing outside the worktree
|
|
1043
|
+
return null;
|
|
957
1044
|
}
|
|
958
|
-
}
|
|
1045
|
+
}));
|
|
959
1046
|
|
|
960
|
-
return
|
|
1047
|
+
return entries.filter(Boolean);
|
|
961
1048
|
}
|
|
962
1049
|
|
|
963
1050
|
function shouldSkipDirectory(name) {
|
|
@@ -965,7 +1052,12 @@ function shouldSkipDirectory(name) {
|
|
|
965
1052
|
return true;
|
|
966
1053
|
}
|
|
967
1054
|
|
|
968
|
-
|
|
1055
|
+
// Dot-directories are tool scaffolding, not project source. `.claude/`,
|
|
1056
|
+
// `.codex/` and `.ukit/` matter most: `ukit install` writes them into every
|
|
1057
|
+
// project AND gitignores them, so the git path never lists them either.
|
|
1058
|
+
// Skipping them here keeps both discovery paths agreeing, and stops UKit's own
|
|
1059
|
+
// ~450 scaffolding files from crowding out the user's code in query results.
|
|
1060
|
+
return name.startsWith('.');
|
|
969
1061
|
}
|
|
970
1062
|
|
|
971
1063
|
function createSourceFingerprint(rootDir, discoveredFiles) {
|
|
@@ -1640,7 +1732,7 @@ export async function isIndexStale({
|
|
|
1640
1732
|
|
|
1641
1733
|
const currentSourceFingerprint = createSourceFingerprint(
|
|
1642
1734
|
absoluteRoot,
|
|
1643
|
-
await
|
|
1735
|
+
await discoverProjectFiles(absoluteRoot),
|
|
1644
1736
|
);
|
|
1645
1737
|
return !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, currentSourceFingerprint);
|
|
1646
1738
|
}
|
|
@@ -52,7 +52,7 @@ If zero testable behavior → write `N/A` + explicit justification in each task'
|
|
|
52
52
|
Append this footer to `PLAN.md` — mandatory, checked by a hook before the write is allowed:
|
|
53
53
|
```
|
|
54
54
|
## Planner Report
|
|
55
|
-
PLANNER_MODEL: <your exact model ID — e.g. claude-opus-
|
|
55
|
+
PLANNER_MODEL: <your exact model ID — e.g. claude-opus-5>
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
## Phase 2 — Split into TASK-xxx.md
|
|
@@ -52,7 +52,7 @@ The planner agent does the following (use Step 1 summary — do NOT re-read file
|
|
|
52
52
|
- §5 Verification — exact shell commands executor will run
|
|
53
53
|
- §6 Acceptance — done checklist (prefer verifiable criteria with commands)
|
|
54
54
|
|
|
55
|
-
Append a footer — **mandatory, a hook blocks the
|
|
55
|
+
Append a footer — **mandatory**. The PLAN.md write itself goes through, but a hook then blocks task-file creation and the final push until this footer is present:
|
|
56
56
|
```
|
|
57
57
|
## Planner Report
|
|
58
58
|
PLANNER_MODEL: <your exact model ID>
|
|
@@ -61,7 +61,7 @@ The planner agent does the following (use P1 summary — do NOT re-read files):
|
|
|
61
61
|
- §5 Verification — exact shell commands executor will run
|
|
62
62
|
- §6 Acceptance — done checklist (prefer verifiable criteria with commands)
|
|
63
63
|
|
|
64
|
-
Append a footer — **mandatory, a hook blocks the
|
|
64
|
+
Append a footer — **mandatory**. The PLAN.md write itself goes through, but a hook then blocks task-file creation and the final push until this footer is present:
|
|
65
65
|
```
|
|
66
66
|
## Planner Report
|
|
67
67
|
PLANNER_MODEL: <your exact model ID>
|
|
@@ -127,9 +127,19 @@ Read each `docs/AI_HANDOFF/tasks/TASK-xxx.md` for `Dependencies` field:
|
|
|
127
127
|
- Chain A→B→C = 3 waves of 1 task each (sequential)
|
|
128
128
|
- Independent A, B, C = 1 wave of 3 tasks (parallel)
|
|
129
129
|
|
|
130
|
+
**Batch each wave — mandatory.** Read `handoff.maxParallelAgents` from
|
|
131
|
+
`.ukit/storage/config.json` (default **3**). A wave with more tasks than that is split
|
|
132
|
+
into consecutive batches of at most that many; finish one batch completely (including 3c
|
|
133
|
+
copy-back and worktree deletion) before starting the next.
|
|
134
|
+
|
|
135
|
+
Never spawn a whole wide wave at once. Every background agent carries its own context
|
|
136
|
+
window, and each finished report is injected back into this session — a wave of 20+ is
|
|
137
|
+
the fastest way to blow the orchestrator's own context window, which kills the run and
|
|
138
|
+
leaves worktrees behind. Batching only ever narrows a wave, never reorders across waves.
|
|
139
|
+
|
|
130
140
|
### I3 — Execute wave by wave (code model agents)
|
|
131
141
|
|
|
132
|
-
**Claude Code — MANDATORY, do this before anything else in I3:** for each wave, call the Agent tool once per task (in parallel), each with `subagent_type: "feature-implementer"`. Do NOT implement the tasks yourself in the current session — this step is contracted to the code tier (sonnet/unic-code), which only the spawned agent's frontmatter model guarantees.
|
|
142
|
+
**Claude Code — MANDATORY, do this before anything else in I3:** for each wave, call the Agent tool once per task **in the current batch** (in parallel, at most `handoff.maxParallelAgents` — default 3), each with `subagent_type: "feature-implementer"`. Do NOT implement the tasks yourself in the current session — this step is contracted to the code tier (sonnet/unic-code), which only the spawned agent's frontmatter model guarantees.
|
|
133
143
|
|
|
134
144
|
For each wave:
|
|
135
145
|
|
|
@@ -139,7 +149,7 @@ git worktree add -b handoff/task-xxx .worktrees/task-xxx $BASE
|
|
|
139
149
|
# (omit -b if branch already exists)
|
|
140
150
|
```
|
|
141
151
|
|
|
142
|
-
**3b — Run
|
|
152
|
+
**3b — Run the batch in parallel** — one code-model agent per task in the batch (at most `handoff.maxParallelAgents`), each in its own worktree. Each agent:
|
|
143
153
|
```
|
|
144
154
|
Read docs/AI_HANDOFF/tasks/TASK-xxx.md
|
|
145
155
|
|
|
@@ -33,9 +33,22 @@ Read each `tasks/TASK-xxx.md` for `Dependencies` field:
|
|
|
33
33
|
- Chain A→B→C = 3 waves of 1 task each (sequential, no parallel)
|
|
34
34
|
- Independent A, B, C = 1 wave of 3 tasks (parallel)
|
|
35
35
|
|
|
36
|
+
### Batch each wave — mandatory
|
|
37
|
+
|
|
38
|
+
Read `handoff.maxParallelAgents` from `.ukit/storage/config.json` (default **3**). A wave
|
|
39
|
+
with more tasks than that is split into consecutive batches of at most that many; finish
|
|
40
|
+
one batch completely (including 3c copy-back and worktree deletion) before starting the
|
|
41
|
+
next.
|
|
42
|
+
|
|
43
|
+
Never spawn a whole wide wave at once. Every background agent carries its own context
|
|
44
|
+
window, and each finished report is injected back into this session — a wave of 20+ is
|
|
45
|
+
the fastest way to blow the orchestrator's own context window, which kills the run and
|
|
46
|
+
leaves worktrees behind. Dependencies still take priority: batching only ever narrows a
|
|
47
|
+
wave, never reorders it across waves.
|
|
48
|
+
|
|
36
49
|
## Step 3 — Execute wave by wave
|
|
37
50
|
|
|
38
|
-
For each wave:
|
|
51
|
+
For each wave (and for each batch within it):
|
|
39
52
|
|
|
40
53
|
### 3a — Create worktrees (from `$BASE`, no extra branch)
|
|
41
54
|
```bash
|
|
@@ -45,7 +58,7 @@ git worktree add -b handoff/task-xxx .worktrees/task-xxx $BASE
|
|
|
45
58
|
|
|
46
59
|
### 3b — Run tasks in parallel (one agent/session per task)
|
|
47
60
|
|
|
48
|
-
**Claude Code — MANDATORY, do this before anything else in this wave:** call the Agent tool once per task in the
|
|
61
|
+
**Claude Code — MANDATORY, do this before anything else in this wave:** call the Agent tool once per task **in the current batch** (in parallel, at most `handoff.maxParallelAgents`), each with `subagent_type: "feature-implementer"`. Do NOT implement the tasks yourself in the current session — this step is contracted to the code tier (sonnet/unic-code), which only the spawned agent's frontmatter model guarantees.
|
|
49
62
|
|
|
50
63
|
Each spawned agent works independently in its own worktree — **NO git commit, NO git add**:
|
|
51
64
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: hard-enforce an absolute context token cap (compact.hardCapTokens,
|
|
3
|
-
# default
|
|
3
|
+
# default 160000 — must stay below the model's real context window), separate from the
|
|
4
|
+
# soft/hard advisory pressure phases in
|
|
4
5
|
# compact-threshold.mjs (default soft=50000/hard=80000, which only print a suggestion).
|
|
5
6
|
#
|
|
6
7
|
# Those advisory phases are just injected text — nothing stops the agent from ignoring
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# UserPromptSubmit hook: warn BEFORE the request is sent when the live context is close
|
|
3
|
+
# to the model's real window.
|
|
4
|
+
#
|
|
5
|
+
# Why this exists, and why context-hardcap-gate.sh could not do it:
|
|
6
|
+
# "Your input exceeds the context window of this model" is an API-level rejection. It
|
|
7
|
+
# happens when the request is SENT, before the model emits any tool call. A PreToolUse
|
|
8
|
+
# hook only runs when a tool call is already about to execute, so it is structurally
|
|
9
|
+
# incapable of preventing that error — it can only ever block the next Edit/Write AFTER
|
|
10
|
+
# the context is already oversized. UserPromptSubmit is the one event that runs before
|
|
11
|
+
# the turn, which makes it the only place this check can do any good.
|
|
12
|
+
#
|
|
13
|
+
# Why it measures the transcript instead of compact-pressure.json:
|
|
14
|
+
# estimatedTotalTokens is UKit's own running approximation, advanced only by a couple of
|
|
15
|
+
# runtime modules. Nothing feeds it file Reads, tool results, images, or subagent
|
|
16
|
+
# reports — i.e. the largest contributors — so it can read ~30k while the real context
|
|
17
|
+
# is nearly full. transcript_path is the actual conversation on disk.
|
|
18
|
+
#
|
|
19
|
+
# ADVISORY ONLY — always exit 0, never exit 2. Blocking the prompt would not prevent the
|
|
20
|
+
# overflow (the context is already accumulated) and would strand the user with no way
|
|
21
|
+
# forward, since /compact itself has to send the transcript and fails the same way. A
|
|
22
|
+
# loud early warning is the thing that actually helps: it arrives while compacting or
|
|
23
|
+
# starting a fresh session still works.
|
|
24
|
+
|
|
25
|
+
INPUT=$(cat)
|
|
26
|
+
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
27
|
+
|
|
28
|
+
INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
|
|
32
|
+
// Reading more than this per prompt is not worth the latency. Reading only the TAIL is
|
|
33
|
+
// also the safe direction: if the last compact boundary is older than the tail, we count
|
|
34
|
+
// the whole tail as live context, which over-estimates and warns earlier.
|
|
35
|
+
const MAX_READ_BYTES = 24 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
// Chars-per-token. Calibrated against a real 2843-line transcript: whole-file bytes read
|
|
38
|
+
// ~1.44M "tokens" (constant false alarms), while message content after the last compact
|
|
39
|
+
// boundary read ~145k for a session that did in fact need compacting right after.
|
|
40
|
+
const CHARS_PER_TOKEN = 4;
|
|
41
|
+
|
|
42
|
+
const payload = (() => {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(process.env.INPUT || '');
|
|
45
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
})();
|
|
50
|
+
|
|
51
|
+
const projectRoot = process.env.PROJECT_ROOT;
|
|
52
|
+
const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path.trim() : '';
|
|
53
|
+
if (!transcriptPath) process.exit(0);
|
|
54
|
+
|
|
55
|
+
function readTail(filePath) {
|
|
56
|
+
const stat = fs.statSync(filePath);
|
|
57
|
+
if (stat.size <= MAX_READ_BYTES) {
|
|
58
|
+
return { text: fs.readFileSync(filePath, 'utf8'), truncated: false };
|
|
59
|
+
}
|
|
60
|
+
const fd = fs.openSync(filePath, 'r');
|
|
61
|
+
try {
|
|
62
|
+
const buf = Buffer.allocUnsafe(MAX_READ_BYTES);
|
|
63
|
+
fs.readSync(fd, buf, 0, MAX_READ_BYTES, stat.size - MAX_READ_BYTES);
|
|
64
|
+
return { text: buf.toString('utf8'), truncated: true };
|
|
65
|
+
} finally {
|
|
66
|
+
fs.closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The cap the rest of UKit already agrees on, so one config key tunes both this warning
|
|
71
|
+
// and context-hardcap-gate.sh. Default mirrors compact-threshold.mjs.
|
|
72
|
+
function loadHardCap() {
|
|
73
|
+
try {
|
|
74
|
+
const raw = fs.readFileSync(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8');
|
|
75
|
+
const value = JSON.parse(raw)?.compact?.hardCapTokens;
|
|
76
|
+
if (Number.isFinite(value) && value > 0) return value;
|
|
77
|
+
} catch { /* fall through to the default */ }
|
|
78
|
+
return 160_000;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let text;
|
|
82
|
+
let truncated;
|
|
83
|
+
try {
|
|
84
|
+
({ text, truncated } = readTail(transcriptPath));
|
|
85
|
+
} catch {
|
|
86
|
+
// Transcript unreadable (first prompt of a session, permissions, races). Nothing to
|
|
87
|
+
// measure and nothing worth reporting.
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lines = text.split('\n').filter(Boolean);
|
|
92
|
+
if (truncated) lines.shift(); // a tail read almost certainly split the first line
|
|
93
|
+
|
|
94
|
+
// Everything before the last compact boundary is already summarised away and is NOT in
|
|
95
|
+
// the live context. Counting it is what makes naive file-size estimates useless.
|
|
96
|
+
let start = 0;
|
|
97
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
98
|
+
if (!lines[i].includes('compact_boundary')) continue;
|
|
99
|
+
try {
|
|
100
|
+
const entry = JSON.parse(lines[i]);
|
|
101
|
+
if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
|
|
102
|
+
start = i + 1;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
} catch { /* not a usable boundary line */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let contentChars = 0;
|
|
109
|
+
let sidechainEntries = 0;
|
|
110
|
+
for (let i = start; i < lines.length; i += 1) {
|
|
111
|
+
let entry;
|
|
112
|
+
try {
|
|
113
|
+
entry = JSON.parse(lines[i]);
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// Subagent traffic lives in its own context window, not the main one. Count it
|
|
118
|
+
// separately: it is a leading indicator of the "many teammates at once" blow-up, but
|
|
119
|
+
// adding it to the main estimate would overstate the main window.
|
|
120
|
+
if (entry?.isSidechain) {
|
|
121
|
+
sidechainEntries += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (entry?.type !== 'user' && entry?.type !== 'assistant' && entry?.type !== 'attachment') continue;
|
|
125
|
+
try {
|
|
126
|
+
contentChars += JSON.stringify(entry.message ?? entry.attachment ?? '').length;
|
|
127
|
+
} catch { /* unserialisable entry */ }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const estimatedTokens = Math.round(contentChars / CHARS_PER_TOKEN);
|
|
131
|
+
const hardCap = loadHardCap();
|
|
132
|
+
const ratio = estimatedTokens / hardCap;
|
|
133
|
+
|
|
134
|
+
if (ratio < 0.8) process.exit(0);
|
|
135
|
+
|
|
136
|
+
const pct = Math.round(ratio * 100);
|
|
137
|
+
const lines_out = [];
|
|
138
|
+
if (ratio >= 1) {
|
|
139
|
+
lines_out.push(`UKIT CONTEXT ALERT — live context ~${estimatedTokens.toLocaleString()} tokens, at/over the ${hardCap.toLocaleString()} cap (${pct}%).`);
|
|
140
|
+
lines_out.push('The next few turns risk "Your input exceeds the context window of this model".');
|
|
141
|
+
lines_out.push('Act now: /compact, or finish this thread and start a fresh session.');
|
|
142
|
+
lines_out.push('If /compact itself already failed, the context is too large to summarise —');
|
|
143
|
+
lines_out.push('a new session is the only way out. Work committed to disk is not lost.');
|
|
144
|
+
} else {
|
|
145
|
+
lines_out.push(`UKIT CONTEXT WARNING — live context ~${estimatedTokens.toLocaleString()} tokens (${pct}% of the ${hardCap.toLocaleString()} cap).`);
|
|
146
|
+
lines_out.push('Wrap up or /compact soon, while compacting still works.');
|
|
147
|
+
}
|
|
148
|
+
if (sidechainEntries > 0) {
|
|
149
|
+
lines_out.push(`Note: ${sidechainEntries} subagent entries in this stretch. Each teammate carries its own`);
|
|
150
|
+
lines_out.push('context window, and every finished report is injected back here — running many at');
|
|
151
|
+
lines_out.push('once is the fastest way to overflow this session.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
process.stdout.write(`${lines_out.join('\n')}\n`);
|
|
155
|
+
process.exit(0);
|
|
156
|
+
NODE
|
|
157
|
+
|
|
158
|
+
exit 0
|
|
@@ -82,19 +82,6 @@ function readJsonSafe(filePath) {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
// Best-effort: a receipt whose model equals modelTiers.vision.fallbackModel counts as
|
|
86
|
-
// vision-capable ONLY when unic-gateway.mjs reports unicMode:false (no real UNIC
|
|
87
|
-
// routing, so the analyst actually ran on the fallback model instead of unic-vision).
|
|
88
|
-
// In-process dynamic import — never a subprocess spawn — and never fatal: any failure
|
|
89
|
-
// just means the fallback exception is unavailable and only the literal "unic-vision"
|
|
90
|
-
// model name is accepted, which is the stricter/safer default.
|
|
91
|
-
function loadFallbackModel() {
|
|
92
|
-
const configPath = path.join(projectRoot, '.ukit', 'storage', 'config.json');
|
|
93
|
-
const config = readJsonSafe(configPath);
|
|
94
|
-
const fallback = config?.orchestration?.modelTiers?.vision?.fallbackModel;
|
|
95
|
-
return typeof fallback === 'string' && fallback.trim() ? fallback.trim() : null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
85
|
// Tri-state on purpose: true | false | null(unknown). Returning false on a detection
|
|
99
86
|
// FAILURE would be read as "unicMode is off", which GRANTS the fallback-model exception
|
|
100
87
|
// below — i.e. deleting or breaking one gitignored file would make the gate accept a
|
|
@@ -114,14 +101,11 @@ async function detectUnicModeSafe() {
|
|
|
114
101
|
|
|
115
102
|
// Anchored, not a substring match: "unic-vision-mini" and "not-unic-vision" are different
|
|
116
103
|
// models and must not inherit the real one's capability by sharing a substring.
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
function isVisionCapable(model
|
|
104
|
+
// Only reached when unicMode is true or unknown — off-gateway sessions already exited
|
|
105
|
+
// above — so `unic-vision` is the only model that can legitimately clear the gate here.
|
|
106
|
+
function isVisionCapable(model) {
|
|
120
107
|
if (typeof model !== 'string' || !model.trim()) return false;
|
|
121
|
-
|
|
122
|
-
if (/^unic-vision$/i.test(trimmed)) return true;
|
|
123
|
-
if (unicModeIsOff && fallbackModel && trimmed === fallbackModel) return true;
|
|
124
|
-
return false;
|
|
108
|
+
return /^unic-vision$/i.test(model.trim());
|
|
125
109
|
}
|
|
126
110
|
|
|
127
111
|
const VALID_STATUSES = new Set(['OK', 'NO_IMAGE', 'UNREADABLE']);
|
|
@@ -153,7 +137,7 @@ function findReceipt(sha) {
|
|
|
153
137
|
return null;
|
|
154
138
|
}
|
|
155
139
|
|
|
156
|
-
function checkReceipt(receiptPath
|
|
140
|
+
function checkReceipt(receiptPath) {
|
|
157
141
|
if (!receiptPath || !fs.existsSync(receiptPath)) {
|
|
158
142
|
return { ok: false, reason: 'no analysis receipt found' };
|
|
159
143
|
}
|
|
@@ -161,7 +145,7 @@ function checkReceipt(receiptPath, fallbackModel, unicModeIsOff) {
|
|
|
161
145
|
if (!receipt) {
|
|
162
146
|
return { ok: false, reason: 'receipt is not valid JSON' };
|
|
163
147
|
}
|
|
164
|
-
if (!isVisionCapable(receipt.model
|
|
148
|
+
if (!isVisionCapable(receipt.model)) {
|
|
165
149
|
const modelLabel = typeof receipt.model === 'string' && receipt.model.trim() ? receipt.model.trim() : '(missing)';
|
|
166
150
|
return { ok: false, reason: `receipt model "${modelLabel}" is not vision-capable — treated as absent` };
|
|
167
151
|
}
|
|
@@ -174,9 +158,25 @@ function checkReceipt(receiptPath, fallbackModel, unicModeIsOff) {
|
|
|
174
158
|
|
|
175
159
|
(async () => {
|
|
176
160
|
const now = Date.now();
|
|
177
|
-
const fallbackModel = loadFallbackModel();
|
|
178
161
|
const unicMode = await detectUnicModeSafe();
|
|
179
162
|
|
|
163
|
+
// The gate exists because the UNIC gateway BLINDS the model: unic-code/unic-smart
|
|
164
|
+
// cannot see images there, so an unanalysed image means guessing. Off the gateway
|
|
165
|
+
// Claude reads images natively and there is nothing left to enforce.
|
|
166
|
+
//
|
|
167
|
+
// This early exit is what makes the gate satisfiable at all off-gateway. The previous
|
|
168
|
+
// design tried to cover this case by accepting a receipt whose model string equalled
|
|
169
|
+
// modelTiers.vision.fallbackModel — but the analyst's frontmatter pins `unic-vision`,
|
|
170
|
+
// so off-gateway it runs on whatever model is actually available and self-reports THAT.
|
|
171
|
+
// A hardcoded fallback ID could never match it, so no valid receipt was reachable and
|
|
172
|
+
// one pasted screenshot hard-blocked Edit/Write for the rest of the session.
|
|
173
|
+
//
|
|
174
|
+
// Only a POSITIVE false stands the gate down. null (detection failed) still enforces:
|
|
175
|
+
// breaking one gitignored file must not be a bypass.
|
|
176
|
+
if (unicMode === false) {
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
180
|
const unsatisfied = [];
|
|
181
181
|
for (const markerName of markerNames) {
|
|
182
182
|
const sha = markerName.replace(/^pending-/, '').replace(/\.json$/, '');
|
|
@@ -198,7 +198,7 @@ function checkReceipt(receiptPath, fallbackModel, unicModeIsOff) {
|
|
|
198
198
|
if (now - markerTs > MARKER_MAX_AGE_MS) continue;
|
|
199
199
|
|
|
200
200
|
const receiptPath = findReceipt(sha);
|
|
201
|
-
const result = checkReceipt(receiptPath
|
|
201
|
+
const result = checkReceipt(receiptPath);
|
|
202
202
|
if (!result.ok) {
|
|
203
203
|
unsatisfied.push({ sha, reason: result.reason });
|
|
204
204
|
}
|
|
@@ -44,6 +44,33 @@ const { pathToFileURL } = require('url');
|
|
|
44
44
|
|
|
45
45
|
const promptText = extractPromptText(payload);
|
|
46
46
|
|
|
47
|
+
// Tri-state, same contract as vision-gate.sh: true | false | null(unknown).
|
|
48
|
+
async function detectUnicModeSafe() {
|
|
49
|
+
try {
|
|
50
|
+
const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
|
|
51
|
+
if (!fs.existsSync(gatewayPath)) return null;
|
|
52
|
+
const mod = await import(pathToFileURL(gatewayPath).href);
|
|
53
|
+
if (typeof mod.detectUnicGateway !== 'function') return null;
|
|
54
|
+
return !!mod.detectUnicGateway({ rootDir: projectRoot })?.unicMode;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const unicMode = await detectUnicModeSafe();
|
|
61
|
+
|
|
62
|
+
// Off the UNIC gateway Claude reads images natively, so there is nothing to route and
|
|
63
|
+
// nothing to gate. Exit before marking anything: an armed marker that the gate will
|
|
64
|
+
// stand down on anyway is pure noise, and the advice text used to demand an analyst run
|
|
65
|
+
// whose receipt could never be accepted. Only a POSITIVE false short-circuits; null
|
|
66
|
+
// (detection failed) still routes, matching the gate's fail-safe direction.
|
|
67
|
+
//
|
|
68
|
+
// Cheaper than the old order, too: this replaces an execFileSync node spawn per prompt
|
|
69
|
+
// with one local import on the overwhelmingly common off-gateway path.
|
|
70
|
+
if (unicMode === false) {
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
// URL images are checked first and stripped out so the local-path regex never
|
|
48
75
|
// re-matches the tail of a URL (case c is a hint-only lane — no download here).
|
|
49
76
|
const URL_IMAGE_RE = /https?:\/\/\S+\.(?:png|jpe?g|gif|webp)\b/gi;
|
|
@@ -105,56 +132,29 @@ const { pathToFileURL } = require('url');
|
|
|
105
132
|
return;
|
|
106
133
|
}
|
|
107
134
|
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// accepted model never drift): tell the caller to override the model.
|
|
114
|
-
// 'strict' — gateway module missing/broken, or unicMode false but fallbackModel is
|
|
115
|
-
// unreadable: today's `unic-vision` text (fail-safe default).
|
|
116
|
-
// Never fatal — any error in this block falls back to 'strict' and no note.
|
|
117
|
-
let remedyMode = 'strict';
|
|
118
|
-
let fallbackModel = null;
|
|
135
|
+
// Only unicMode true or null reach here; off-gateway already exited. Both remaining
|
|
136
|
+
// cases get the same strict `unic-vision` remedy, so there is no fallback-model branch
|
|
137
|
+
// to advertise. There must never be one: the analyst self-reports the model it really
|
|
138
|
+
// ran on, so telling it to claim some other ID is asking it to falsify the receipt —
|
|
139
|
+
// which the gate is entitled to reject, and which defeats the point of having a gate.
|
|
119
140
|
let unicNote = '';
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
141
|
+
if (unicMode === true) {
|
|
142
|
+
try {
|
|
143
|
+
const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
|
|
123
144
|
const mod = await import(pathToFileURL(gatewayPath).href);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
remedyMode = 'gateway';
|
|
128
|
-
unicNote = ` (UNIC gateway active — ${result.visionModel} routes through it.)`;
|
|
129
|
-
} else {
|
|
130
|
-
const configPath = path.join(projectRoot, '.ukit', 'storage', 'config.json');
|
|
131
|
-
const configRaw = fs.readFileSync(configPath, 'utf8');
|
|
132
|
-
const config = JSON.parse(configRaw);
|
|
133
|
-
const candidate = config?.orchestration?.modelTiers?.vision?.fallbackModel;
|
|
134
|
-
if (typeof candidate === 'string' && candidate.trim()) {
|
|
135
|
-
fallbackModel = candidate.trim();
|
|
136
|
-
remedyMode = 'fallback';
|
|
137
|
-
}
|
|
138
|
-
}
|
|
145
|
+
const result = mod.detectUnicGateway({ rootDir: projectRoot });
|
|
146
|
+
if (result?.visionModel) {
|
|
147
|
+
unicNote = ` (UNIC gateway active — ${result.visionModel} routes through it.)`;
|
|
139
148
|
}
|
|
149
|
+
} catch {
|
|
150
|
+
unicNote = '';
|
|
140
151
|
}
|
|
141
|
-
} catch {
|
|
142
|
-
remedyMode = 'strict';
|
|
143
|
-
fallbackModel = null;
|
|
144
|
-
unicNote = '';
|
|
145
152
|
}
|
|
146
153
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
: 'Do this before anything else:')
|
|
152
|
-
: (armed > 0
|
|
153
|
-
? 'unic-code / unic-smart cannot read images on this gateway, and Edit/Write is now GATED\nuntil a vision analysis exists. Do this before anything else:'
|
|
154
|
-
: 'unic-code / unic-smart cannot read images on this gateway. Do this before anything else:');
|
|
155
|
-
const agentLine = isFallback
|
|
156
|
-
? ` 2. Agent(subagent_type: "ukit-vision-analyst", model: "${fallbackModel}")`
|
|
157
|
-
: ' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]';
|
|
154
|
+
const reasonLine = armed > 0
|
|
155
|
+
? 'unic-code / unic-smart cannot read images on this gateway, and Edit/Write is now GATED\nuntil a vision analysis exists. Do this before anything else:'
|
|
156
|
+
: 'unic-code / unic-smart cannot read images on this gateway. Do this before anything else:';
|
|
157
|
+
const agentLine = ' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]';
|
|
158
158
|
|
|
159
159
|
const lines = [
|
|
160
160
|
`UKIT VISION ROUTE — image input detected (${cases.join(', ')}).`,
|
|
@@ -164,10 +164,6 @@ const { pathToFileURL } = require('url');
|
|
|
164
164
|
' Pass the ABSOLUTE paths from images[].path as text — a subagent does NOT',
|
|
165
165
|
' inherit image blocks; it can only Read files.',
|
|
166
166
|
];
|
|
167
|
-
if (isFallback) {
|
|
168
|
-
lines.push(` The analyst must self-report "model": "${fallbackModel}" in its receipt —`);
|
|
169
|
-
lines.push(' otherwise the gate will reject it.');
|
|
170
|
-
}
|
|
171
167
|
lines.push(' 3. Continue the real task using the returned description.');
|
|
172
168
|
if (markedUrl > 0) {
|
|
173
169
|
lines.push(' Image URL detected: ukit-vision-analyst downloads it with Bash into');
|
|
@@ -165,6 +165,11 @@
|
|
|
165
165
|
"type": "command",
|
|
166
166
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\"",
|
|
167
167
|
"timeout": 8
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"type": "command",
|
|
171
|
+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-window-guard.sh\"",
|
|
172
|
+
"timeout": 10
|
|
168
173
|
}
|
|
169
174
|
]
|
|
170
175
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
+
const GIT_ENUMERATION_TIMEOUT_MS = 15_000;
|
|
6
7
|
const EXCLUDED_DIR_NAMES = new Set([
|
|
7
8
|
'node_modules',
|
|
8
9
|
'.git',
|
|
@@ -25,7 +26,7 @@ const EXCLUDED_DIR_NAMES = new Set([
|
|
|
25
26
|
]);
|
|
26
27
|
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue']);
|
|
27
28
|
const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
28
|
-
const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.yaml', '.yml', '.md']);
|
|
29
|
+
const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.yaml', '.yml', '.md', '.sh']);
|
|
29
30
|
const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
|
|
30
31
|
const INDEX_SCHEMA_VERSION = 6;
|
|
31
32
|
export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
@@ -131,7 +132,7 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
|
131
132
|
previousCodeFileRecords.map((item) => [item.filePath, { mtimeMs: Number(item.mtimeMs ?? -1), size: Number(item.size ?? -1) }]),
|
|
132
133
|
);
|
|
133
134
|
|
|
134
|
-
const discoveredFiles = await
|
|
135
|
+
const discoveredFiles = await discoverProjectFiles(absoluteRoot);
|
|
135
136
|
const sourceFingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
|
|
136
137
|
const styleFilePaths = discoveredFiles
|
|
137
138
|
.map((entry) => {
|
|
@@ -2699,23 +2700,69 @@ function normalizeTestStem(testBase) {
|
|
|
2699
2700
|
.toLowerCase();
|
|
2700
2701
|
}
|
|
2701
2702
|
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2703
|
+
/*
|
|
2704
|
+
* File discovery is the whole project, always — never a fixed list of "source"
|
|
2705
|
+
* directory names. Scope comes from the project's own ignore rules (git), which
|
|
2706
|
+
* is self-tuning per layout and needs no configuration. Non-git projects fall
|
|
2707
|
+
* back to walking the root with the same exclusion rules.
|
|
2708
|
+
*/
|
|
2709
|
+
async function discoverProjectFiles(rootDir) {
|
|
2710
|
+
const gitFiles = await collectGitTrackedFiles(rootDir);
|
|
2711
|
+
if (gitFiles) return gitFiles;
|
|
2712
|
+
return collectFiles([rootDir]);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
function runGit(rootDir, args) {
|
|
2716
|
+
const result = spawnSync('git', args, {
|
|
2717
|
+
cwd: rootDir,
|
|
2718
|
+
encoding: 'utf8',
|
|
2719
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
2720
|
+
timeout: GIT_ENUMERATION_TIMEOUT_MS,
|
|
2721
|
+
windowsHide: true,
|
|
2722
|
+
});
|
|
2723
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== 'string') return null;
|
|
2724
|
+
return result.stdout;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
async function collectGitTrackedFiles(rootDir) {
|
|
2728
|
+
// `-z` also disables git path quoting, which would mangle non-ASCII filenames.
|
|
2729
|
+
const tracked = runGit(rootDir, ['ls-files', '-z']);
|
|
2730
|
+
if (tracked === null) return null;
|
|
2731
|
+
const untracked = runGit(rootDir, ['ls-files', '-z', '--others', '--exclude-standard']) ?? '';
|
|
2732
|
+
|
|
2733
|
+
const relativePaths = new Set();
|
|
2734
|
+
for (const entry of `${tracked}${untracked}`.split('\0')) {
|
|
2735
|
+
if (entry) relativePaths.add(entry);
|
|
2736
|
+
}
|
|
2737
|
+
if (relativePaths.size === 0) return null;
|
|
2738
|
+
|
|
2739
|
+
const candidates = [...relativePaths].filter((relativePath) => {
|
|
2740
|
+
if (!DISCOVERED_EXTENSIONS.has(path.extname(relativePath).toLowerCase())) return false;
|
|
2741
|
+
// Git already applied the project's own ignore rules, so dot-directories it
|
|
2742
|
+
// still lists are deliberate. Only committed build dirs are left to reject.
|
|
2743
|
+
return !relativePath.split('/').some((segment) => EXCLUDED_DIR_NAMES.has(segment));
|
|
2744
|
+
});
|
|
2745
|
+
|
|
2746
|
+
const entries = await Promise.all(candidates.map(async (relativePath) => {
|
|
2747
|
+
const absolutePath = path.join(rootDir, relativePath);
|
|
2706
2748
|
try {
|
|
2707
|
-
const stat = await fs.stat(
|
|
2708
|
-
if (stat.
|
|
2749
|
+
const stat = await fs.stat(absolutePath);
|
|
2750
|
+
if (!stat.isFile()) return null;
|
|
2751
|
+
return { absolutePath, mtimeMs: Math.floor(stat.mtimeMs), size: stat.size };
|
|
2709
2752
|
} catch {
|
|
2710
|
-
|
|
2753
|
+
return null;
|
|
2711
2754
|
}
|
|
2712
|
-
}
|
|
2713
|
-
|
|
2755
|
+
}));
|
|
2756
|
+
|
|
2757
|
+
return entries.filter(Boolean);
|
|
2714
2758
|
}
|
|
2715
2759
|
|
|
2716
2760
|
function shouldSkipDirectory(name) {
|
|
2717
2761
|
if (EXCLUDED_DIR_NAMES.has(name)) return true;
|
|
2718
|
-
|
|
2762
|
+
// Dot-directories are tool scaffolding, not project source. `ukit install`
|
|
2763
|
+
// writes AND gitignores `.claude/`, `.codex/`, `.ukit/`, so the git path never
|
|
2764
|
+
// lists them either -- skipping them keeps both discovery paths agreeing.
|
|
2765
|
+
return name.startsWith('.');
|
|
2719
2766
|
}
|
|
2720
2767
|
|
|
2721
2768
|
function isLikelyTestFile(filePath) {
|
|
@@ -3147,7 +3194,7 @@ export async function isIndexStale({
|
|
|
3147
3194
|
if (!metaArtifact?.sourceFingerprint) return true;
|
|
3148
3195
|
const currentSourceFingerprint = createSourceFingerprint(
|
|
3149
3196
|
absoluteRoot,
|
|
3150
|
-
await
|
|
3197
|
+
await discoverProjectFiles(absoluteRoot),
|
|
3151
3198
|
);
|
|
3152
3199
|
return !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, currentSourceFingerprint);
|
|
3153
3200
|
}
|
|
@@ -439,7 +439,14 @@ export function buildCompactThresholds(config = {}) {
|
|
|
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
|
|
441
441
|
// the advisory tokenThreshold must never silently raise the cap along with it.
|
|
442
|
-
|
|
442
|
+
//
|
|
443
|
+
// Must stay BELOW the model's real context window or the cap is unreachable: the API
|
|
444
|
+
// rejects the request with "input exceeds the context window" long before an estimate
|
|
445
|
+
// climbing toward a higher number ever trips this. The previous 220_000 default sat
|
|
446
|
+
// above a 200k window, so the gate could never fire on a standard model — it was dead
|
|
447
|
+
// code in exactly the situation it existed to prevent. 160_000 leaves real headroom on
|
|
448
|
+
// a 200k window for the response plus the estimator's own undercount.
|
|
449
|
+
const hardCapTokens = Math.max(1, finiteNumber(config?.compact?.hardCapTokens, 160_000));
|
|
443
450
|
|
|
444
451
|
return {
|
|
445
452
|
softThreshold,
|
|
@@ -264,7 +264,7 @@
|
|
|
264
264
|
"configPath": ".ukit/storage/config.json",
|
|
265
265
|
"modelField": "orchestration.orchestratorModel",
|
|
266
266
|
"advisorField": "orchestration.advisorEnabled",
|
|
267
|
-
"defaultModel": "claude-sonnet-
|
|
267
|
+
"defaultModel": "claude-sonnet-5",
|
|
268
268
|
"internalOnly": true,
|
|
269
269
|
"qualityFirst": true,
|
|
270
270
|
"safeUpwardBias": true,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"compact": {
|
|
10
10
|
"enabled": true,
|
|
11
11
|
"tokenThreshold": 50000,
|
|
12
|
-
"hardCapTokens":
|
|
12
|
+
"hardCapTokens": 160000,
|
|
13
13
|
"hardCapBlock": true,
|
|
14
14
|
"contextRotDetection": true,
|
|
15
15
|
"askBeforeDrop": true,
|
|
@@ -73,14 +73,14 @@
|
|
|
73
73
|
},
|
|
74
74
|
"router": {
|
|
75
75
|
"enabled": true,
|
|
76
|
-
"defaultModel": "claude-sonnet-
|
|
77
|
-
"advisorModel": "claude-opus-
|
|
76
|
+
"defaultModel": "claude-sonnet-5",
|
|
77
|
+
"advisorModel": "claude-opus-5",
|
|
78
78
|
"advisorEnabled": true,
|
|
79
79
|
"maxAdvisorCalls": 3
|
|
80
80
|
},
|
|
81
81
|
"orchestration": {
|
|
82
82
|
"enabled": true,
|
|
83
|
-
"orchestratorModel": "claude-sonnet-
|
|
83
|
+
"orchestratorModel": "claude-sonnet-5",
|
|
84
84
|
"advisorEnabled": true,
|
|
85
85
|
"contracts": {
|
|
86
86
|
"tiny-fix": {
|
|
@@ -139,12 +139,12 @@
|
|
|
139
139
|
},
|
|
140
140
|
"modelTiers": {
|
|
141
141
|
"lite": { "claudeModel": "claude-haiku-4-5", "genericModel": "unic-lite" },
|
|
142
|
-
"code": { "claudeModel": "claude-sonnet-
|
|
143
|
-
"smart": { "claudeModel": "claude-opus-
|
|
142
|
+
"code": { "claudeModel": "claude-sonnet-5", "genericModel": "unic-code" },
|
|
143
|
+
"smart": { "claudeModel": "claude-opus-5", "genericModel": "unic-smart" },
|
|
144
144
|
"vision": {
|
|
145
145
|
"claudeModel": "unic-vision",
|
|
146
146
|
"genericModel": "unic-vision",
|
|
147
|
-
"fallbackModel": "claude-sonnet-
|
|
147
|
+
"fallbackModel": "claude-sonnet-5",
|
|
148
148
|
"capabilityTier": true,
|
|
149
149
|
"note": "Capability tier, not a cost tier. Orthogonal to lite/code/smart — never insert into escalation.tierOrder. fallbackModel is used when unicMode is off."
|
|
150
150
|
}
|
|
@@ -186,12 +186,13 @@
|
|
|
186
186
|
"handoff": {
|
|
187
187
|
"enabled": true,
|
|
188
188
|
"crossTool": true,
|
|
189
|
+
"maxParallelAgents": 3,
|
|
189
190
|
"plan": {
|
|
190
191
|
"requireTestPlan": true,
|
|
191
192
|
"minTestsHappyPath": 1,
|
|
192
193
|
"minTestsEdgeCase": 1,
|
|
193
194
|
"regressionTestRequiredForBugfix": true,
|
|
194
|
-
"smartModelHint": "claude-opus-
|
|
195
|
+
"smartModelHint": "claude-opus-5"
|
|
195
196
|
},
|
|
196
197
|
"executor": {
|
|
197
198
|
"testFirstRequired": true,
|
|
@@ -337,8 +338,8 @@
|
|
|
337
338
|
"doi_reviewer_model": {
|
|
338
339
|
"field": "handoff.reviewer.model",
|
|
339
340
|
"mac_dinh": "unic-smart",
|
|
340
|
-
"y_nghia": "Model dùng cho reviewer agent ở Phase 3. BẮT BUỘC khác model executor để bắt được lỗi mà executor miss. Có thể dùng claude-opus-
|
|
341
|
-
"vi_du": "Nếu executor là unic-code (Kilo Code), set reviewer.model=unic-smart hoặc claude-opus-
|
|
341
|
+
"y_nghia": "Model dùng cho reviewer agent ở Phase 3. BẮT BUỘC khác model executor để bắt được lỗi mà executor miss. Có thể dùng claude-opus-5, unic-smart, hoặc bất kỳ model reasoning mạnh nào.",
|
|
342
|
+
"vi_du": "Nếu executor là unic-code (Kilo Code), set reviewer.model=unic-smart hoặc claude-opus-5. Nếu executor là claude-sonnet, set reviewer thành claude-opus."
|
|
342
343
|
},
|
|
343
344
|
"tat_reviewer_phase": {
|
|
344
345
|
"field": "handoff.reviewer.enabled",
|
|
@@ -369,7 +370,7 @@
|
|
|
369
370
|
"compact": {
|
|
370
371
|
"enabled": "Bật/tắt toàn bộ helper compact của UKit.",
|
|
371
372
|
"tokenThreshold": "Ngưỡng token chung cho runtime compact dùng chung.",
|
|
372
|
-
"hardCapTokens": "Ngưỡng cứng tuyệt đối (mặc định
|
|
373
|
+
"hardCapTokens": "Ngưỡng cứng tuyệt đối (mặc định 160000 token ước lượng). 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 (200k), nếu không API sẽ báo lỗi vượt context trước khi gate kịp chặn.",
|
|
373
374
|
"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. Không có ngoại lệ.",
|
|
374
375
|
"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.",
|
|
375
376
|
"askBeforeDrop": "Giữ thái độ thận trọng trước khi bỏ context quan trọng. Nếu rủi ro thì hand back cho main model.",
|
|
@@ -478,12 +479,13 @@
|
|
|
478
479
|
"handoff": {
|
|
479
480
|
"enabled": "Bật Quality Gate cho handoff: plan có Test Plan, executor test-first, reviewer model khác. Tắt = quay về flow cũ (dễ lọt lỗi vặt).",
|
|
480
481
|
"crossTool": "true nghĩa là handoff truyền qua file (PLAN/INDEX/tasks) chứ không qua in-process subagent — cho phép plan ở Claude Code, execute ở Kilo Code, review ở Claude Code khác model.",
|
|
482
|
+
"maxParallelAgents": "Số agent chạy song song TỐI ĐA trong một wave (mặc định 3). Một wave có nhiều task hơn số này sẽ được chia thành nhiều batch chạy lần lượt. Lý do: mỗi agent nền có context window riêng, và report của agent khi xong sẽ được inject ngược vào session chính — chạy quá nhiều cùng lúc là cách nhanh nhất làm session chính vượt context window. Hạ xuống 2 nếu task nặng; không nên vượt 3.",
|
|
481
483
|
"plan": {
|
|
482
484
|
"requireTestPlan": "Bắt buộc PLAN.md §4 phải có Test Plan trước khi task chuyển ready.",
|
|
483
485
|
"minTestsHappyPath": "Tối thiểu test cho happy path.",
|
|
484
486
|
"minTestsEdgeCase": "Tối thiểu test cho edge case (null/empty/boundary/concurrent…).",
|
|
485
487
|
"regressionTestRequiredForBugfix": "Bug fix phải có regression test fail-trước-fix.",
|
|
486
|
-
"smartModelHint": "Gợi ý model mạnh nhất cho phase plan (ví dụ claude-opus-
|
|
488
|
+
"smartModelHint": "Gợi ý model mạnh nhất cho phase plan (ví dụ claude-opus-5). UKit không tự ép, chỉ ghi hint vào task."
|
|
487
489
|
},
|
|
488
490
|
"executor": {
|
|
489
491
|
"testFirstRequired": "Executor phải viết test trước khi implement (RED → GREEN).",
|
|
@@ -494,7 +496,7 @@
|
|
|
494
496
|
},
|
|
495
497
|
"reviewer": {
|
|
496
498
|
"enabled": "Bật Phase 3 review độc lập. Tắt = bỏ lưới an toàn cuối cùng, không khuyến nghị.",
|
|
497
|
-
"model": "Model reviewer. BẮT BUỘC khác executor model. Mặc định unic-smart; có thể dùng claude-opus-
|
|
499
|
+
"model": "Model reviewer. BẮT BUỘC khác executor model. Mặc định unic-smart; có thể dùng claude-opus-5 hoặc bất kỳ model reasoning mạnh nào.",
|
|
498
500
|
"agent": "Tên reviewer agent (xem .claude/agents/code-reviewer.md).",
|
|
499
501
|
"mustDifferFromExecutor": "Nếu true, reviewer tự refuse khi phát hiện cùng model với executor.",
|
|
500
502
|
"blockOnCritical": "CRITICAL → block handoff cứng. Set false chỉ khi muốn warning mềm.",
|