@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, Antigravity, OpenAI Codex, and OpenCode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 to the latest published version.');
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
- console.log('[UKit] Update complete. Run `ukit --version` to confirm the new version.');
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-4-6',
121
- advisorModel: 'claude-opus-4-6',
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-4-6',
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-4-6', genericModel: 'unic-code' },
180
- smart: { claudeModel: 'claude-opus-4-6', genericModel: 'unic-smart' },
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-4-6',
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
  },
@@ -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',
@@ -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 SOURCE_DIRS = ['src', 'tests', 'test', 'specs', 'spec', '__tests__', 'manifests'];
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
- const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.yaml', '.yml', '.md']);
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 collectFiles(await resolveScanRoots(absoluteRoot));
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
- async function resolveScanRoots(rootDir) {
946
- const concreteRoots = [];
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
- for (const relativeDir of SOURCE_DIRS) {
949
- const dirPath = path.join(rootDir, relativeDir);
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(dirPath);
952
- if (stat.isDirectory()) {
953
- concreteRoots.push(dirPath);
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
- // ignore
1042
+ // staged deletion, or a symlink pointing outside the worktree
1043
+ return null;
957
1044
  }
958
- }
1045
+ }));
959
1046
 
960
- return concreteRoots.length > 0 ? concreteRoots : [rootDir];
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
- return name.startsWith('.') && name !== '.claude' && name !== '.codex' && name !== '.antigravity';
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 collectFiles(await resolveScanRoots(absoluteRoot)),
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-4-6>
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 write without it**:
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 write without it**:
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 tasks in parallel** — one code-model agent per task, each in its own worktree. Each agent:
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 wave (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.
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 220000), separate from the soft/hard advisory pressure phases in
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
- // ukit-vision-analyst's frontmatter pins the exact name `unic-vision`; anything else goes
118
- // through the fallbackModel path, which requires a positive unicMode:false detection.
119
- function isVisionCapable(model, fallbackModel, unicModeIsOff) {
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
- const trimmed = model.trim();
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, fallbackModel, unicModeIsOff) {
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, fallbackModel, unicModeIsOff)) {
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, fallbackModel, unicMode === false);
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
- // TASK-008: branch the remedy on whether unic-vision is actually invokable for THIS
109
- // session. `remedyMode` is tri-state and defaults to the strict/fail-safe variant:
110
- // 'gateway' — unicMode true: today's `unic-vision` text (unchanged).
111
- // 'fallback' — unicMode false + a readable orchestration.modelTiers.vision.fallbackModel
112
- // (same config path vision-gate.sh reads, so the advertised model and the
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
- try {
121
- const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
122
- if (fs.existsSync(gatewayPath)) {
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
- if (typeof mod.detectUnicGateway === 'function') {
125
- const result = mod.detectUnicGateway({ rootDir: projectRoot });
126
- if (result?.unicMode) {
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 isFallback = remedyMode === 'fallback';
148
- const reasonLine = isFallback
149
- ? (armed > 0
150
- ? 'Edit/Write is now GATED until a vision analysis exists. Do this before anything else:'
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 SOURCE_DIRS = ['src', 'tests', 'test', 'specs', 'spec', '__tests__', 'manifests'];
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 collectFiles(await resolveScanRoots(absoluteRoot));
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
- async function resolveScanRoots(rootDir) {
2703
- const concreteRoots = [];
2704
- for (const relativeDir of SOURCE_DIRS) {
2705
- const dirPath = path.join(rootDir, relativeDir);
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(dirPath);
2708
- if (stat.isDirectory()) concreteRoots.push(dirPath);
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
- // ignore
2753
+ return null;
2711
2754
  }
2712
- }
2713
- return concreteRoots.length > 0 ? concreteRoots : [rootDir];
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
- return name.startsWith('.') && name !== '.claude' && name !== '.codex' && name !== '.antigravity';
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 collectFiles(await resolveScanRoots(absoluteRoot)),
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
- const hardCapTokens = Math.max(1, finiteNumber(config?.compact?.hardCapTokens, 220_000));
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-4-6",
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": 220000,
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-4-6",
77
- "advisorModel": "claude-opus-4-6",
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-4-6",
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-4-6", "genericModel": "unic-code" },
143
- "smart": { "claudeModel": "claude-opus-4-6", "genericModel": "unic-smart" },
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-4-6",
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-4-6"
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-4-6, unic-smart, hoặc bất kỳ model reasoning mạnh nào.",
341
- "vi_du": "Nếu executor là unic-code (Kilo Code), set reviewer.model=unic-smart hoặc claude-opus-4-6. Nếu executor là claude-sonnet, set reviewer thành 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 220000 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.",
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-4-6). UKit không tự ép, chỉ ghi hint vào task."
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-4-6 hoặc bất kỳ model reasoning mạnh nào.",
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.",