@ngockhoale/ukit 2.2.11 → 2.2.14
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 +87 -0
- package/package.json +1 -1
- package/src/cli/commands/install.js +5 -0
- package/src/core/repairBrokenHooks.js +240 -0
- package/src/core/runInstallPipeline.js +10 -0
- package/src/index/buildIndex.js +15 -2
- package/src/index/paths.js +1 -1
- package/src/index/routeCatalog.js +1 -1
- package/src/index/taskRouting.js +5 -5
- package/templates/.claude/hooks/context-hardcap-gate.sh +18 -2
- package/templates/.claude/hooks/context-window-guard.sh +1 -1
- package/templates/.claude/hooks/handoff-model-guard.sh +99 -29
- package/templates/.claude/hooks/skill-router.sh +54 -6
- package/templates/.claude/ukit/index/lib/index-core.mjs +35 -23
- package/templates/.claude/ukit/index/route-catalog.mjs +1 -1
- package/templates/.claude/ukit/index/route-task.mjs +52 -6
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +97 -2
- package/templates/ukit/storage/config.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,93 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.2.14 - 2026-09-05
|
|
6
|
+
|
|
7
|
+
The context hard-cap gate no longer bricks healthy sessions with a phantom token count. The
|
|
8
|
+
tracker behind `compact.hardCapTokens` accumulates every prompt and tool output across a
|
|
9
|
+
session, and it only reset on the PreCompact hook — which fires for a manual `/compact` but not
|
|
10
|
+
for every harness auto-compact path (SDK/VSCode being the known gap). A session whose live
|
|
11
|
+
context had been compacted down to almost nothing could therefore reach a tracked 528k against
|
|
12
|
+
the 500k cap: Read/Grep stayed open but every Edit/Write/Bash was refused, and the user saw the
|
|
13
|
+
assistant freeze with no error. Every real compaction — manual or automatic — now also leaves a
|
|
14
|
+
`compact_boundary` entry in the session transcript, so `compact-threshold.mjs` rebase the
|
|
15
|
+
counter from that record (`syncCompactPressureStateWithTranscript`, stamped
|
|
16
|
+
`lastCompactBoundaryAt` so each boundary resets exactly once). Both the prompt-time hook and
|
|
17
|
+
`context-hardcap-gate.sh` sync before acting, and the reset is persisted so the gate re-arms
|
|
18
|
+
instead of disabling itself.
|
|
19
|
+
|
|
20
|
+
When the cap is genuinely reached, the model is now ordered to tell the user instead of going
|
|
21
|
+
quiet. The hard-phase prompt warning and the gate's block message both lead with an explicit
|
|
22
|
+
directive — tell the user "gõ /compact ngay" as the first sentence of the reply, since typing
|
|
23
|
+
"continue" alone hits the same block — and the advisory soft phase tells the model to surface
|
|
24
|
+
`/compact` at the next natural pause. `context-window-guard.sh`'s overflow directive likewise
|
|
25
|
+
demands the compact instruction as the first sentence rather than a buried suggestion.
|
|
26
|
+
|
|
27
|
+
Vietnamese concrete bug reports route to the debug lane again. Signals such as `lỗi`, `sửa`,
|
|
28
|
+
`đăng nhập`, `đang ở đâu` were written inside `\b(...)\b`, but JavaScript `\b` is ASCII-only and
|
|
29
|
+
those words have diacritic edges, so the patterns could never match — "app bị lỗi khi đăng nhập,
|
|
30
|
+
sửa giúp tôi" fell through to informational lanes while the identical English prompt routed
|
|
31
|
+
correctly. All Vietnamese intent patterns in the router and catalog now use explicit
|
|
32
|
+
`(?<![A-Za-z0-9_])` / `(?![A-Za-z0-9_])` edges; regression tests cover the debug-lane prompt,
|
|
33
|
+
the open-ended status question, and hook-level state.
|
|
34
|
+
|
|
35
|
+
## 2.2.13 - 2026-09-05
|
|
36
|
+
|
|
37
|
+
Question-shaped prompts no longer get blocked by the completion gate. "Đây là lỗi gì?",
|
|
38
|
+
"model bạn đang dùng là model gì", or "what is this error?" route to the informational lane and
|
|
39
|
+
answer freely; only prompts that actually ask for a change (fix/sửa/build/…) still owe write and
|
|
40
|
+
verification evidence before claiming done. Two layered causes: the Vietnamese question pattern
|
|
41
|
+
only matched adjacent `là gì` (missing "là model gì" / "là lỗi gì"), and the `\blỗi` veto never
|
|
42
|
+
fired at all — JavaScript `\b` word boundaries don't exist around diacritic characters. A
|
|
43
|
+
`strongQuestion` signal (an explicit `?`, a leading interrogative, or the Vietnamese pattern) now
|
|
44
|
+
overrides error-wording vetoes, while "error when I run tests" and "why is build failing?" still
|
|
45
|
+
route to investigation.
|
|
46
|
+
|
|
47
|
+
The source-code index understands SQL from the canonical source, not just the installed mirror.
|
|
48
|
+
`CREATE TABLE/VIEW/FUNCTION/PROCEDURE/INDEX/TYPE` and `ALTER TABLE` statements are extracted as
|
|
49
|
+
symbols with schema-qualified names captured by their bare object name (`qas.fn_rpt_billing` →
|
|
50
|
+
`fn_rpt_billing`), and `.sql` files are tracked and code-indexed. The schema bump had previously
|
|
51
|
+
landed only in the runtime mirror; `src/index/` now carries the same v8 schema so the mirror-parity
|
|
52
|
+
test holds and the `scripts/index` lane benefits too.
|
|
53
|
+
|
|
54
|
+
The handoff model-tier guard closes an under-block found while scenario-testing it. Once a real
|
|
55
|
+
`EXECUTOR_MODEL` or `REVIEWER_MODEL` was on disk, an in-place rewrite could silently demote the
|
|
56
|
+
executor to a lite tier or set executor == reviewer; validation now also fires when an
|
|
57
|
+
already-real model line changes value (same-model rewrites and upward escalation stay free). The
|
|
58
|
+
push-time backstop was unaffected — this moves the rejection to write time, where it belongs.
|
|
59
|
+
|
|
60
|
+
`handoff.maxParallelAgents` template default is now 2, matching the operator setting; the config
|
|
61
|
+
help prose no longer claims a default of 12 one line above a configured 2.
|
|
62
|
+
|
|
63
|
+
## 2.2.12 - 2026-09-04
|
|
64
|
+
|
|
65
|
+
`ukit install` and `ukit update` now clean up broken Claude Code hook registrations automatically.
|
|
66
|
+
|
|
67
|
+
A hook whose stdout contains a `hookSpecificOutput` object without the required `hookEventName`
|
|
68
|
+
field fails Claude Code's schema validation on **every** tool call, surfacing as a repeating
|
|
69
|
+
`PreToolUse:<Tool> hook error — Hook JSON output validation failed`. Such a hook has no effect
|
|
70
|
+
either way, since its output is discarded — it produces nothing but noise.
|
|
71
|
+
|
|
72
|
+
New `src/core/repairBrokenHooks.js` detects that shape generically (not keyed to any one vendor)
|
|
73
|
+
across user settings, project settings, and `settings.local.json`, expanding
|
|
74
|
+
`$CLAUDE_PROJECT_DIR`/`$HOME`/`~` in hook commands and also catching hooks inlined into
|
|
75
|
+
`settings.json` instead of a script file. Offending registrations are removed after a
|
|
76
|
+
`.ukit-backup` restore point is written next to each settings file. Detection matches only real
|
|
77
|
+
key assignments, so prose mentions of the field in a hook's own comments or docstrings never
|
|
78
|
+
trigger a removal.
|
|
79
|
+
|
|
80
|
+
Wired into the install pipeline; since `ukit update` re-runs `ukit install`, both commands are
|
|
81
|
+
covered with no new flags or commands to learn — teammates still only need `ukit install`.
|
|
82
|
+
|
|
83
|
+
Scope is deliberate: UKit removes the *registration* only. An unregistered hook script never
|
|
84
|
+
executes, so that is sufficient, and UKit does not delete the script file or uninstall the tool
|
|
85
|
+
that wrote it. When the owning tool can be identified from the hook source, the install report
|
|
86
|
+
names it and notes that it may re-register on activation, leaving permanent removal as a human
|
|
87
|
+
decision.
|
|
88
|
+
|
|
89
|
+
Known real-world instance: VSCode extension `tjcg.auto-accept-claude-code` v0.5.0 hardcodes two
|
|
90
|
+
such hook templates and rewrites its hook file on activation.
|
|
91
|
+
|
|
5
92
|
## 2.2.11 - 2026-08-29
|
|
6
93
|
|
|
7
94
|
Same-day follow-up to 2.2.10. The two premises that release shipped as unverified were checked
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildPathConfig } from '../../core/paths.js';
|
|
2
2
|
import { runInstallPipeline } from '../../core/runInstallPipeline.js';
|
|
3
|
+
import { formatRepairReport } from '../../core/repairBrokenHooks.js';
|
|
3
4
|
import { buildCodeIndex } from '../../index/buildIndex.js';
|
|
4
5
|
import { installIndexRefreshHooks } from '../../index/gitHooks.js';
|
|
5
6
|
import fs from 'node:fs/promises';
|
|
@@ -238,6 +239,10 @@ export async function runInstall({ packageRoot, projectRoot, packageVersion, arg
|
|
|
238
239
|
.join(', ');
|
|
239
240
|
console.log(`[UKit] Providers: ${providerStatus}, all=${result.providerContext.allSupported}`);
|
|
240
241
|
|
|
242
|
+
for (const line of formatRepairReport(result.hookRepair ?? { removals: [], files: [] })) {
|
|
243
|
+
console.log(`[UKit] ${line}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
241
246
|
const docsLabels = [
|
|
242
247
|
'docs/PROJECT.md',
|
|
243
248
|
'docs/MEMORY.md',
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Claude Code rejects any hook whose stdout carries a `hookSpecificOutput` object
|
|
6
|
+
// without `hookEventName` inside it, and surfaces it as a per-tool-call
|
|
7
|
+
// "Hook JSON output validation failed" error. A hook in that state never has any
|
|
8
|
+
// effect - its output is discarded - so removing its registration only removes noise.
|
|
9
|
+
//
|
|
10
|
+
// Detection is deliberately generic rather than keyed to one known-bad extension:
|
|
11
|
+
// any tool that ships this shape produces the same failure.
|
|
12
|
+
|
|
13
|
+
// Only real key assignments count. Prose mentions of the field inside docstrings
|
|
14
|
+
// and comments are common in hook SDKs and must not trigger a removal.
|
|
15
|
+
const HOOK_OUTPUT_KEY = /["']hookSpecificOutput["']\s*\]?\s*[:=]/g;
|
|
16
|
+
|
|
17
|
+
// How far past the key to look for the sibling field. Generous enough for a
|
|
18
|
+
// formatted object literal, short enough not to reach an unrelated later block.
|
|
19
|
+
const SIBLING_SCAN_WINDOW = 400;
|
|
20
|
+
|
|
21
|
+
// Paths in a hook command string, e.g. "$CLAUDE_PROJECT_DIR/.claude/hooks/x.sh".
|
|
22
|
+
const SCRIPT_PATH = /["']?((?:\/|~|\$\{?[A-Z_]+\}?\/)[^"'\s]+\.(?:sh|ps1|py|js|mjs|cjs|bash|zsh))["']?/g;
|
|
23
|
+
|
|
24
|
+
// e.g. "# Auto-Accept Extension: PreToolUse hook" -> "Auto-Accept Extension"
|
|
25
|
+
const OWNER_HINT = /^[#/\s]*([A-Z][\w-]*(?:\s+[\w-]+)*\s+Extension)\b/m;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* True when `source` assigns hookSpecificOutput without a nearby hookEventName.
|
|
29
|
+
*/
|
|
30
|
+
export function hasBrokenHookShape(source) {
|
|
31
|
+
if (typeof source !== 'string') {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
HOOK_OUTPUT_KEY.lastIndex = 0;
|
|
36
|
+
let match = HOOK_OUTPUT_KEY.exec(source);
|
|
37
|
+
while (match) {
|
|
38
|
+
const window = source.slice(match.index, match.index + SIBLING_SCAN_WINDOW);
|
|
39
|
+
if (!window.includes('hookEventName')) {
|
|
40
|
+
HOOK_OUTPUT_KEY.lastIndex = 0;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
match = HOOK_OUTPUT_KEY.exec(source);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function expandPath(rawPath, { projectRoot, homeDir }) {
|
|
50
|
+
return rawPath
|
|
51
|
+
.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, projectRoot)
|
|
52
|
+
.replace(/\$\{?HOME-?\}?/g, homeDir)
|
|
53
|
+
.replace(/^~/, homeDir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractScriptPaths(command, { projectRoot, homeDir }) {
|
|
57
|
+
const paths = [];
|
|
58
|
+
SCRIPT_PATH.lastIndex = 0;
|
|
59
|
+
let match = SCRIPT_PATH.exec(command);
|
|
60
|
+
while (match) {
|
|
61
|
+
paths.push(expandPath(match[1], { projectRoot, homeDir }));
|
|
62
|
+
match = SCRIPT_PATH.exec(command);
|
|
63
|
+
}
|
|
64
|
+
return paths;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readFileIfExists(filePath) {
|
|
68
|
+
try {
|
|
69
|
+
return await fs.readFile(filePath, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Inspect one registered hook command. Returns null when it is fine, or
|
|
77
|
+
* { source, ownerHint } describing where the broken shape was found.
|
|
78
|
+
*/
|
|
79
|
+
async function inspectHookCommand(command, { projectRoot, homeDir }) {
|
|
80
|
+
if (typeof command !== 'string' || !command) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const scriptPath of extractScriptPaths(command, { projectRoot, homeDir })) {
|
|
85
|
+
const contents = await readFileIfExists(scriptPath);
|
|
86
|
+
if (contents !== null && hasBrokenHookShape(contents)) {
|
|
87
|
+
return { source: scriptPath, ownerHint: OWNER_HINT.exec(contents)?.[1] ?? null };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Some tools inline the whole hook body into settings.json instead of a script.
|
|
92
|
+
if (hasBrokenHookShape(command)) {
|
|
93
|
+
return { source: '<inline command>', ownerHint: null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Drop broken hook entries from one parsed settings object.
|
|
101
|
+
* Mutates `settings` and returns the removals found.
|
|
102
|
+
*/
|
|
103
|
+
async function stripBrokenHooks(settings, { projectRoot, homeDir }) {
|
|
104
|
+
const removals = [];
|
|
105
|
+
const events = settings?.hooks;
|
|
106
|
+
if (!events || typeof events !== 'object' || Array.isArray(events)) {
|
|
107
|
+
return removals;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const [event, groups] of Object.entries(events)) {
|
|
111
|
+
if (!Array.isArray(groups)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const keptGroups = [];
|
|
116
|
+
for (const group of groups) {
|
|
117
|
+
if (!group || typeof group !== 'object') {
|
|
118
|
+
keptGroups.push(group);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const hooks = Array.isArray(group.hooks) ? group.hooks : null;
|
|
123
|
+
if (!hooks) {
|
|
124
|
+
keptGroups.push(group);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const keptHooks = [];
|
|
129
|
+
for (const hook of hooks) {
|
|
130
|
+
const finding = await inspectHookCommand(hook?.command, { projectRoot, homeDir });
|
|
131
|
+
if (finding) {
|
|
132
|
+
removals.push({ event, matcher: group.matcher ?? '', ...finding });
|
|
133
|
+
} else {
|
|
134
|
+
keptHooks.push(hook);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A group that was already empty stays as-is; only drop groups we emptied.
|
|
139
|
+
if (keptHooks.length === 0 && hooks.length > 0) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
group.hooks = keptHooks;
|
|
144
|
+
keptGroups.push(group);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (keptGroups.length > 0) {
|
|
148
|
+
events[event] = keptGroups;
|
|
149
|
+
} else {
|
|
150
|
+
delete events[event];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return removals;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function backupPathFor(settingsPath) {
|
|
158
|
+
return `${settingsPath}.ukit-backup`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Remove Claude Code hook registrations that would fail schema validation on every
|
|
163
|
+
* tool call, from the user-level and project-level settings files.
|
|
164
|
+
*
|
|
165
|
+
* Registration removal alone is enough: an unregistered script is never executed.
|
|
166
|
+
* The script file itself is left on disk untouched - it belongs to whichever tool
|
|
167
|
+
* wrote it, and deleting another tool's files is not UKit's call.
|
|
168
|
+
*
|
|
169
|
+
* @returns {Promise<{removals: Array, files: string[]}>}
|
|
170
|
+
*/
|
|
171
|
+
export async function repairBrokenHooks({ projectRoot, homeDir = os.homedir() } = {}) {
|
|
172
|
+
const candidates = [
|
|
173
|
+
path.join(homeDir, '.claude', 'settings.json'),
|
|
174
|
+
path.join(projectRoot, '.claude', 'settings.json'),
|
|
175
|
+
path.join(projectRoot, '.claude', 'settings.local.json'),
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const removals = [];
|
|
179
|
+
const files = [];
|
|
180
|
+
|
|
181
|
+
for (const settingsPath of candidates) {
|
|
182
|
+
const raw = await readFileIfExists(settingsPath);
|
|
183
|
+
if (raw === null) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let settings;
|
|
188
|
+
try {
|
|
189
|
+
settings = JSON.parse(raw);
|
|
190
|
+
} catch {
|
|
191
|
+
// Malformed settings are not ours to rewrite.
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const found = await stripBrokenHooks(settings, { projectRoot, homeDir });
|
|
196
|
+
if (found.length === 0) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Keep a restore point before touching a file the user did not ask us to edit.
|
|
201
|
+
await fs.writeFile(backupPathFor(settingsPath), raw, 'utf8');
|
|
202
|
+
await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
203
|
+
|
|
204
|
+
files.push(settingsPath);
|
|
205
|
+
removals.push(...found.map((entry) => ({ ...entry, settingsPath })));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { removals, files };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Human-readable lines for the install/update report. Empty array when nothing was repaired.
|
|
213
|
+
*/
|
|
214
|
+
export function formatRepairReport({ removals, files }) {
|
|
215
|
+
if (!removals || removals.length === 0) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const lines = [
|
|
220
|
+
`Removed ${removals.length} broken Claude Code hook registration(s).`,
|
|
221
|
+
'They emitted hookSpecificOutput without hookEventName, so every tool call failed',
|
|
222
|
+
'schema validation and the hook never actually ran.',
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
for (const removal of removals) {
|
|
226
|
+
const owner = removal.ownerHint ? ` (${removal.ownerHint})` : '';
|
|
227
|
+
lines.push(` - ${removal.event}: ${removal.source}${owner}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const owners = [...new Set(removals.map((entry) => entry.ownerHint).filter(Boolean))];
|
|
231
|
+
if (owners.length > 0) {
|
|
232
|
+
lines.push(
|
|
233
|
+
`Warning: ${owners.join(', ')} rewrites its hook on activation, so this can come back.`,
|
|
234
|
+
'Uninstall that extension to stop it for good - UKit will not remove it for you.',
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
lines.push(`Backup: ${files.map((file) => `${file}.ukit-backup`).join(', ')}`);
|
|
239
|
+
return lines;
|
|
240
|
+
}
|
|
@@ -12,6 +12,7 @@ import { summarizeDiff, toDiffRows } from './report.js';
|
|
|
12
12
|
import { writeInstallMetadata } from './metadata.js';
|
|
13
13
|
import { cleanupLegacyPaths, migrateLegacyRuntimeRoot } from './migrateLegacy.js';
|
|
14
14
|
import { ensureGitignore } from './ensureGitignore.js';
|
|
15
|
+
import { repairBrokenHooks } from './repairBrokenHooks.js';
|
|
15
16
|
import { cleanupEmptyParents, readJsonIfExists, removeFileOrLinkOnly, resolveProjectRelativePath } from './fileOps.js';
|
|
16
17
|
|
|
17
18
|
const AUTO_PRUNE_OBSOLETE_PREFIXES = [
|
|
@@ -316,6 +317,13 @@ export async function runInstallPipeline({
|
|
|
316
317
|
}
|
|
317
318
|
|
|
318
319
|
await ensureGitignore(pathConfig.projectRoot);
|
|
320
|
+
|
|
321
|
+
// A hook that emits hookSpecificOutput without hookEventName fails Claude Code's
|
|
322
|
+
// schema check on every tool call, so it only ever produces noise. Teammates are
|
|
323
|
+
// not expected to know that - `ukit install` (and `ukit update`, which re-runs
|
|
324
|
+
// install) clears it for them. Backups are written next to each settings file.
|
|
325
|
+
const hookRepair = await repairBrokenHooks({ projectRoot: pathConfig.projectRoot });
|
|
326
|
+
|
|
319
327
|
await pruneObsoleteManagedPaths({
|
|
320
328
|
installMetaData,
|
|
321
329
|
projectRoot: pathConfig.projectRoot,
|
|
@@ -350,6 +358,7 @@ export async function runInstallPipeline({
|
|
|
350
358
|
summary,
|
|
351
359
|
rows: toDiffRows(diffResults),
|
|
352
360
|
writes,
|
|
361
|
+
hookRepair,
|
|
353
362
|
};
|
|
354
363
|
}
|
|
355
364
|
|
|
@@ -361,5 +370,6 @@ export async function runInstallPipeline({
|
|
|
361
370
|
summary: plannedSummary,
|
|
362
371
|
rows: toDiffRows(diffResults),
|
|
363
372
|
writes: [],
|
|
373
|
+
hookRepair: { removals: [], files: [] },
|
|
364
374
|
};
|
|
365
375
|
}
|
package/src/index/buildIndex.js
CHANGED
|
@@ -29,11 +29,11 @@ const EXCLUDED_DIR_NAMES = new Set([
|
|
|
29
29
|
'.venv',
|
|
30
30
|
'venv',
|
|
31
31
|
]);
|
|
32
|
-
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue']);
|
|
32
|
+
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql']);
|
|
33
33
|
const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
34
34
|
// `.sh` is tracked but never parsed, like `.md`/`.json`/`.yaml` — it is matched
|
|
35
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']);
|
|
36
|
+
const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql', '.json', '.yaml', '.yml', '.md', '.sh']);
|
|
37
37
|
const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
|
|
38
38
|
export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
39
39
|
const INDEX_PARSE_BATCH_SIZE = 8;
|
|
@@ -729,6 +729,19 @@ export function extractSymbols(filePath, content) {
|
|
|
729
729
|
}
|
|
730
730
|
};
|
|
731
731
|
|
|
732
|
+
if (filePath.endsWith('.sql')) {
|
|
733
|
+
// Capture the object name, skipping an optional schema qualifier (qas.fn_rpt_billing → fn_rpt_billing).
|
|
734
|
+
const sqlIdent = '(?:(?:["`]?[A-Za-z_][A-Za-z0-9_$]*["`]?)\\s*\\.\\s*)?["`]?([A-Za-z_][A-Za-z0-9_$]*)["`]?';
|
|
735
|
+
addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'table');
|
|
736
|
+
addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?VIEW\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'view');
|
|
737
|
+
addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?FUNCTION\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'function');
|
|
738
|
+
addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?PROCEDURE\\s+${sqlIdent}`, 'gi'), 'procedure');
|
|
739
|
+
addMatches(new RegExp(`CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'index');
|
|
740
|
+
addMatches(new RegExp(`CREATE\\s+(?:TYPE|DOMAIN|SEQUENCE|MATERIALIZED\\s+VIEW)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'type');
|
|
741
|
+
addMatches(new RegExp(`ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'table');
|
|
742
|
+
return dedupeBy(symbols, (item) => `${item.filePath}:${item.type}:${item.name}`);
|
|
743
|
+
}
|
|
744
|
+
|
|
732
745
|
addMatches(/export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'function');
|
|
733
746
|
addMatches(/export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'class');
|
|
734
747
|
addMatches(/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\(/g, 'callable');
|
package/src/index/paths.js
CHANGED
|
@@ -234,7 +234,7 @@ export const ROUTE_CATALOG = [
|
|
|
234
234
|
contextMode: 'standalone',
|
|
235
235
|
signals: [
|
|
236
236
|
{ type: 'prompt', regex: /\b(what(?:'s| is)? next|next steps?|project status|current status|where are we|continue(?: from)?(?: last session)?|roadmap|status\.md|task queue|tasks\.md|next queued task|pick next task|work from tasks)\b/i, score: 7 },
|
|
237
|
-
{ type: 'prompt', regex:
|
|
237
|
+
{ type: 'prompt', regex: /(?<![A-Za-z0-9_])(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project|task tiếp theo|việc tiếp theo trong tasks)(?![A-Za-z0-9_])/i, score: 7 },
|
|
238
238
|
],
|
|
239
239
|
},
|
|
240
240
|
{
|
package/src/index/taskRouting.js
CHANGED
|
@@ -960,7 +960,7 @@ function hasHandoffSignal(lower, raw) {
|
|
|
960
960
|
|| /\b(ai handoff|handoff phase|handoff mode|clear handoff|update handoff|start handoff|handoff clear)\b/.test(lower)
|
|
961
961
|
|| /\b(brainstorm|idea dump|ideas?).{0,80}\b(handoff|tasks?|taskify|split|breakdown)\b/.test(lower)
|
|
962
962
|
|| /\b(handoff|tasks?|taskify|split|breakdown).{0,80}\b(brainstorm|idea dump|ideas?)\b/.test(lower)
|
|
963
|
-
||
|
|
963
|
+
|| /(?<![A-Za-z0-9_])(gom|chia|tach|tách|lên|len|xóa|clear|dọn).{0,80}(?<![A-Za-z0-9_])(idea|ý tưởng|y tuong|task|công việc|cong viec|handoff)(?![A-Za-z0-9_])/.test(raw)
|
|
964
964
|
|| /\b(bàn giao|ban giao).{0,80}\b(ai|task|công việc|cong viec)\b/.test(raw);
|
|
965
965
|
}
|
|
966
966
|
|
|
@@ -980,7 +980,7 @@ function hasStatusUpdateSignal(lower, raw) {
|
|
|
980
980
|
function hasOpenEndedStatusSignal(lower, raw) {
|
|
981
981
|
return /\b(what next|what is next|what's next|next step|next steps|project status|current status|where are we|continue|continue from last session|roadmap|status\.md|task queue|tasks\.md|next queued task|pick next task|work from tasks)\b/.test(lower)
|
|
982
982
|
|| /\b(lam gi tiep|buoc tiep theo|tiep theo lam gi|lam tiep|dang o dau|trang thai project|tinh trang project|task tiep theo|viec tiep theo trong tasks)\b/.test(lower)
|
|
983
|
-
||
|
|
983
|
+
|| /(?<![A-Za-z0-9_])(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project|task tiếp theo|việc tiếp theo trong tasks)(?![A-Za-z0-9_])/.test(raw);
|
|
984
984
|
}
|
|
985
985
|
|
|
986
986
|
function hasTaskQueueNextSignal(lower, raw) {
|
|
@@ -994,7 +994,7 @@ function hasConcreteTaskSignal(lower, raw, targetFile, { taskQueueNext = false }
|
|
|
994
994
|
}
|
|
995
995
|
|
|
996
996
|
return /\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|implement|build|create|add|ship|deliver|refactor|integrate|integration|scaffold|feature|update|change|modify|inject|apply|review|audit|diff|pr feedback|code review|auth|login|api|endpoint|test|spec)\b/.test(lower)
|
|
997
|
-
||
|
|
997
|
+
|| /(?<![A-Za-z0-9_])(sửa|fix|lỗi|bug|debug|implement|cài|thêm|review|kiểm tra|soát|đăng nhập)(?![A-Za-z0-9_])/.test(raw);
|
|
998
998
|
}
|
|
999
999
|
|
|
1000
1000
|
function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = false } = {}) {
|
|
@@ -1017,11 +1017,11 @@ function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = fal
|
|
|
1017
1017
|
|
|
1018
1018
|
return mentionsTargetDoc(lower, targetName)
|
|
1019
1019
|
|| /\b(edit|write|improve|document|docs?|readme|changelog|worklog|memory|code map|template|wording|copy|grammar|format|structure|heading|section|handoff notes?)\b/.test(lower)
|
|
1020
|
-
||
|
|
1020
|
+
|| /(?<![A-Za-z0-9_])(câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu|ghi chú bàn giao)(?![A-Za-z0-9_])/.test(raw);
|
|
1021
1021
|
}
|
|
1022
1022
|
|
|
1023
1023
|
return /\b(template|wording|edit|copy|grammar|format|structure|heading|section|docs?)\b/.test(lower)
|
|
1024
|
-
||
|
|
1024
|
+
|| /(?<![A-Za-z0-9_])(mẫu|câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu)(?![A-Za-z0-9_])/.test(raw);
|
|
1025
1025
|
}
|
|
1026
1026
|
|
|
1027
1027
|
function hasExplicitStatusUpdateSignal(lower, raw) {
|
|
@@ -100,7 +100,21 @@ function readRunCursor() {
|
|
|
100
100
|
const mod = await import(pathToFileURL(thresholdModulePath).href);
|
|
101
101
|
const pressurePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'compact-pressure.json');
|
|
102
102
|
const rawState = readJsonSafe(pressurePath, null);
|
|
103
|
-
|
|
103
|
+
|
|
104
|
+
// A compaction the PreCompact hook missed (SDK/VSCode auto-compact) leaves the tracked
|
|
105
|
+
// counter counting history that was already summarised away — the exact failure that
|
|
106
|
+
// bricks a healthy session at this cap. The transcript's own compact_boundary entries
|
|
107
|
+
// are the reliable record of real compactions, so sync before deciding, and persist the
|
|
108
|
+
// reset so it re-arms exactly once per boundary instead of disabling the gate.
|
|
109
|
+
let state = mod.buildCompactPressureState(rawState, config);
|
|
110
|
+
try {
|
|
111
|
+
const synced = await mod.syncCompactPressureStateWithTranscript(rawState, config, payload.transcript_path);
|
|
112
|
+
if (synced.reset) {
|
|
113
|
+
state = await mod.writeCompactPressureState(projectRoot, synced.state, config);
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
state = mod.buildCompactPressureState(rawState, config);
|
|
117
|
+
}
|
|
104
118
|
const thresholds = mod.buildCompactThresholds(config);
|
|
105
119
|
|
|
106
120
|
const gracePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hardcap-grace.json');
|
|
@@ -140,6 +154,7 @@ function readRunCursor() {
|
|
|
140
154
|
`An unfinished run is in flight (Phase: ${run.phase}${run.cursor ? `, Cursor: ${run.cursor}` : ''}), so this call is allowed instead of stranding it mid-edit.`,
|
|
141
155
|
'Spend the remaining grace on LANDING, not on new work: finish the current edit, commit, update docs/AI_HANDOFF/RUN.md, push.',
|
|
142
156
|
'Do NOT start a new task, open new files, or spawn agents. When grace runs out the gate blocks hard.',
|
|
157
|
+
'Tell the user in your reply that context is over the cap and they should run /compact as soon as this run lands.',
|
|
143
158
|
'After the user runs /compact, the SessionStart resume hook replays the cursor and the run continues automatically.',
|
|
144
159
|
].join('\n') + '\n',
|
|
145
160
|
);
|
|
@@ -150,12 +165,13 @@ function readRunCursor() {
|
|
|
150
165
|
|
|
151
166
|
const lines = [
|
|
152
167
|
`BLOCKED (context hard cap): estimated context ~${state.estimatedTotalTokens} tokens >= hard cap ${thresholds.hardCapTokens}.`,
|
|
168
|
+
'FIRST ACTION IN YOUR REPLY — tell the user plainly, do not just stop silently: "Context đã đầy — hãy gõ /compact ngay (run /compact now)".',
|
|
169
|
+
'Typing "continue" alone will hit this same block; only /compact (or a new session) resets the counter. After compacting, resume the interrupted task.',
|
|
153
170
|
'This is an absolute ceiling (compact.hardCapTokens), separate from the soft/hard advisory phases — those were apparently not followed.',
|
|
154
171
|
`Edit/Write/Bash refused (tool_name=${toolName}) until real compaction happens.`,
|
|
155
172
|
run
|
|
156
173
|
? 'The unfinished-run grace window (compact.hardCapGraceCalls) is already exhausted — progress should be committed and the cursor written by now.'
|
|
157
174
|
: 'No unfinished run in docs/AI_HANDOFF/RUN.md, so there is no grace window to spend.',
|
|
158
|
-
'Remedy (any one): run /compact, or start a new session (SessionStart resets the counter), or delete .ukit/storage/cache/compact-pressure.json.',
|
|
159
175
|
'Do not work around this by summarizing inline and continuing, and do not reach for a non-gated write tool.',
|
|
160
176
|
];
|
|
161
177
|
process.stderr.write(`${lines.join('\n')}\n`);
|
|
@@ -211,7 +211,7 @@ if (withinCooldown) {
|
|
|
211
211
|
lines_out.push('ACTION THIS TURN, before starting new investigation/subagents/pipeline phases:');
|
|
212
212
|
lines_out.push('1) Persist current progress now (update docs/STATUS.md, e.g. via the update-status skill) so nothing is lost.');
|
|
213
213
|
lines_out.push('2) If the remaining work still has multiple steps or pipeline phases left, split what remains into bounded docs/AI_HANDOFF tasks instead of continuing in this one thread — smaller phases keep each turn under the cap.');
|
|
214
|
-
lines_out.push('3) Otherwise,
|
|
214
|
+
lines_out.push('3) Otherwise, make the user\'s next action the FIRST sentence of your reply so it cannot be missed: "Context sắp đầy — gõ /compact ngay (run /compact now)", or wrap up and start a fresh session — work already committed/persisted to disk is not lost. Do not keep working silently until tools start being refused.');
|
|
215
215
|
if (sidechainEntries > 0) {
|
|
216
216
|
lines_out.push(`Note: ${sidechainEntries} subagent entries in this stretch — each teammate carries its own context window, and every finished report is injected back here, so running many at once is the fastest way to overflow this session. Avoid spawning more until context drops back under the cap.`);
|
|
217
217
|
}
|
|
@@ -60,6 +60,20 @@ function extractField(text, name) {
|
|
|
60
60
|
return matches[matches.length - 1][1].trim();
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Phase-section detection and model extraction must run on visible content only:
|
|
64
|
+
// task/plan templates carry `## Executor Report` / `## Reviewer Verdict` inside HTML
|
|
65
|
+
// comments, and matching those made plain task creation fail with reviewer/executor
|
|
66
|
+
// model errors (the Phase 2 misfire).
|
|
67
|
+
function stripHtmlComments(text) {
|
|
68
|
+
return String(text || '').replace(/<!--[\s\S]*?-->/g, '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isPlaceholderValue(value) {
|
|
72
|
+
const raw = String(value || '').trim();
|
|
73
|
+
if (!raw || /^[<(]/.test(raw)) return true;
|
|
74
|
+
return ['-','--','tbd','todo','n/a','na','none','unknown','pending'].includes(raw.toLowerCase());
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
if (toolName === 'Write' || toolName === 'Edit') {
|
|
64
78
|
const filePath = String(input.file_path || '');
|
|
65
79
|
if (!filePath) process.exit(0);
|
|
@@ -84,9 +98,11 @@ if (toolName === 'Write' || toolName === 'Edit') {
|
|
|
84
98
|
: currentContent.replace(oldStr, newStr);
|
|
85
99
|
}
|
|
86
100
|
const newContent = resultingContent();
|
|
101
|
+
const newVisible = stripHtmlComments(newContent);
|
|
102
|
+
const currentVisible = stripHtmlComments(currentContent);
|
|
87
103
|
|
|
88
|
-
if (isPlan && /## Planner Report/.test(
|
|
89
|
-
const plannerModel = extractField(
|
|
104
|
+
if (isPlan && /## Planner Report/.test(newVisible)) {
|
|
105
|
+
const plannerModel = extractField(newVisible, 'PLANNER_MODEL');
|
|
90
106
|
if (!plannerModel || /^unknown$/i.test(plannerModel)) {
|
|
91
107
|
block('PLANNER_MODEL missing/unknown in PLAN.md. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) and self-report its model.');
|
|
92
108
|
}
|
|
@@ -102,42 +118,96 @@ if (toolName === 'Write' || toolName === 'Edit') {
|
|
|
102
118
|
const taskId = taskMatch[1];
|
|
103
119
|
const planPath = path.join(projectRoot, 'docs/AI_HANDOFF/PLAN.md');
|
|
104
120
|
|
|
105
|
-
const
|
|
121
|
+
const hasExecutorReport = /## Executor Report/.test(newVisible);
|
|
122
|
+
const hasReviewerVerdict = /## Reviewer Verdict/.test(newVisible);
|
|
123
|
+
const isFreshTaskFile = !fileExists;
|
|
106
124
|
if (isFreshTaskFile) {
|
|
107
125
|
const planContent = fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
|
|
108
|
-
const plannerModel = extractField(planContent, 'PLANNER_MODEL');
|
|
126
|
+
const plannerModel = extractField(stripHtmlComments(planContent), 'PLANNER_MODEL');
|
|
109
127
|
if (!plannerModel || tierOf(plannerModel) !== 'smart') {
|
|
110
128
|
block(`Cannot create ${taskId}.md — PLAN.md has no valid smart-tier PLANNER_MODEL yet. Run planning via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) first.`);
|
|
111
129
|
}
|
|
112
130
|
}
|
|
113
131
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
// A section already on disk only counts as validated when its model is real —
|
|
133
|
+
// otherwise a placeholder skeleton created at Phase 2 could be silently promoted
|
|
134
|
+
// to a "real" report without ever passing tier validation.
|
|
135
|
+
const currentExecutorReal = /## Executor Report/.test(currentVisible)
|
|
136
|
+
&& !isPlaceholderValue(extractField(currentVisible, 'EXECUTOR_MODEL'));
|
|
137
|
+
if (hasExecutorReport && !currentExecutorReal) {
|
|
138
|
+
const executorModel = extractField(newVisible, 'EXECUTOR_MODEL');
|
|
139
|
+
if (isPlaceholderValue(executorModel) || /^unknown$/i.test(executorModel)) {
|
|
140
|
+
if (!isFreshTaskFile) {
|
|
141
|
+
block(`${taskId}: EXECUTOR_MODEL missing/placeholder. Implementation must run via Agent tool subagent_type: "feature-implementer" (sonnet/unic-code) and self-report its model.`);
|
|
142
|
+
}
|
|
143
|
+
// Else: skeleton sections copied from a task template at creation time — the
|
|
144
|
+
// real Executor Report is appended in Phase 3 and validated there.
|
|
145
|
+
} else {
|
|
146
|
+
if (tierOf(executorModel) === 'vision') {
|
|
147
|
+
block(`${taskId}: EXECUTOR_MODEL "${executorModel}" ${VISION_LANE_MESSAGE}`);
|
|
148
|
+
}
|
|
149
|
+
if (tierOf(executorModel) === 'lite') {
|
|
150
|
+
block(`${taskId}: EXECUTOR_MODEL "${executorModel}" is lite tier. Implementation must run on at least sonnet/unic-code, never haiku/unic-lite.`);
|
|
151
|
+
}
|
|
118
152
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
153
|
+
} else if (hasExecutorReport && currentExecutorReal) {
|
|
154
|
+
// An in-place rewrite of an already-real model line must not silently demote the
|
|
155
|
+
// tier or collide with the reviewer; the push-time check would catch it, but the
|
|
156
|
+
// violating report should never be accepted onto disk in the first place.
|
|
157
|
+
const previousExecutorModel = extractField(currentVisible, 'EXECUTOR_MODEL');
|
|
158
|
+
const executorModel = extractField(newVisible, 'EXECUTOR_MODEL');
|
|
159
|
+
if (executorModel && previousExecutorModel && executorModel !== previousExecutorModel) {
|
|
160
|
+
if (tierOf(executorModel) === 'vision') {
|
|
161
|
+
block(`${taskId}: EXECUTOR_MODEL "${executorModel}" ${VISION_LANE_MESSAGE}`);
|
|
162
|
+
}
|
|
163
|
+
if (tierOf(executorModel) === 'lite') {
|
|
164
|
+
block(`${taskId}: EXECUTOR_MODEL "${executorModel}" is lite tier. Implementation must run on at least sonnet/unic-code, never haiku/unic-lite.`);
|
|
165
|
+
}
|
|
166
|
+
const verdictModel = extractField(newVisible, 'REVIEWER_MODEL');
|
|
167
|
+
if (!isPlaceholderValue(verdictModel) && verdictModel.toLowerCase() === executorModel.toLowerCase()) {
|
|
168
|
+
block(`${taskId}: EXECUTOR_MODEL ("${executorModel}") matches REVIEWER_MODEL ("${verdictModel}") — reviewer must differ from executor.`);
|
|
169
|
+
}
|
|
124
170
|
}
|
|
125
171
|
}
|
|
126
172
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
173
|
+
const currentReviewerReal = /## Reviewer Verdict/.test(currentVisible)
|
|
174
|
+
&& !isPlaceholderValue(extractField(currentVisible, 'REVIEWER_MODEL'));
|
|
175
|
+
if (hasReviewerVerdict && !currentReviewerReal) {
|
|
176
|
+
const executorModel = extractField(currentVisible, 'EXECUTOR_MODEL') || extractField(newVisible, 'EXECUTOR_MODEL');
|
|
177
|
+
const reviewerModel = extractField(newVisible, 'REVIEWER_MODEL');
|
|
178
|
+
if (isPlaceholderValue(reviewerModel) || /^unknown$/i.test(reviewerModel)) {
|
|
179
|
+
if (!isFreshTaskFile) {
|
|
180
|
+
block(`${taskId}: REVIEWER_MODEL missing/placeholder. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart) and self-report its model.`);
|
|
181
|
+
}
|
|
182
|
+
// Else: skeleton Reviewer Verdict on a freshly created task file — Phase 4
|
|
183
|
+
// appends the real verdict and it is validated there.
|
|
184
|
+
} else {
|
|
185
|
+
if (tierOf(reviewerModel) === 'vision') {
|
|
186
|
+
block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" ${VISION_LANE_MESSAGE}`);
|
|
187
|
+
}
|
|
188
|
+
if (tierOf(reviewerModel) !== 'smart') {
|
|
189
|
+
block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" is not strong/opus tier. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart).`);
|
|
190
|
+
}
|
|
191
|
+
if (executorModel && reviewerModel && executorModel.toLowerCase() === reviewerModel.toLowerCase()) {
|
|
192
|
+
block(`${taskId}: REVIEWER_MODEL ("${reviewerModel}") matches EXECUTOR_MODEL — reviewer must differ from executor. Re-run review through a different subagent/model.`);
|
|
193
|
+
}
|
|
138
194
|
}
|
|
139
|
-
|
|
140
|
-
|
|
195
|
+
} else if (hasReviewerVerdict && currentReviewerReal) {
|
|
196
|
+
// Mirror of the executor in-place rule: a real reviewer verdict must not be
|
|
197
|
+
// rewritten onto a weaker tier or onto the executor's own model.
|
|
198
|
+
const previousReviewerModel = extractField(currentVisible, 'REVIEWER_MODEL');
|
|
199
|
+
const reviewerModel = extractField(newVisible, 'REVIEWER_MODEL');
|
|
200
|
+
if (reviewerModel && previousReviewerModel && reviewerModel !== previousReviewerModel) {
|
|
201
|
+
if (tierOf(reviewerModel) === 'vision') {
|
|
202
|
+
block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" ${VISION_LANE_MESSAGE}`);
|
|
203
|
+
}
|
|
204
|
+
if (tierOf(reviewerModel) !== 'smart') {
|
|
205
|
+
block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" is not strong/opus tier. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart).`);
|
|
206
|
+
}
|
|
207
|
+
const executorModel = extractField(newVisible, 'EXECUTOR_MODEL');
|
|
208
|
+
if (!isPlaceholderValue(executorModel) && executorModel.toLowerCase() === reviewerModel.toLowerCase()) {
|
|
209
|
+
block(`${taskId}: REVIEWER_MODEL ("${reviewerModel}") matches EXECUTOR_MODEL — reviewer must differ from executor. Re-run review through a different subagent/model.`);
|
|
210
|
+
}
|
|
141
211
|
}
|
|
142
212
|
}
|
|
143
213
|
}
|
|
@@ -160,7 +230,7 @@ if (toolName === 'Bash') {
|
|
|
160
230
|
for (const taskId of taskIds) {
|
|
161
231
|
const taskPath = path.join(projectRoot, `docs/AI_HANDOFF/tasks/${taskId}.md`);
|
|
162
232
|
if (!fs.existsSync(taskPath)) continue;
|
|
163
|
-
const text = fs.readFileSync(taskPath, 'utf8');
|
|
233
|
+
const text = stripHtmlComments(fs.readFileSync(taskPath, 'utf8'));
|
|
164
234
|
if (!text.includes('## Reviewer Verdict')) continue; // not yet reviewed, not this push's concern
|
|
165
235
|
|
|
166
236
|
// A documented, human-approved exception (e.g. an opus-tier executor escalation left no
|
|
@@ -172,10 +242,10 @@ if (toolName === 'Bash') {
|
|
|
172
242
|
const executorModel = extractField(text, 'EXECUTOR_MODEL');
|
|
173
243
|
const reviewerModel = extractField(text, 'REVIEWER_MODEL');
|
|
174
244
|
|
|
175
|
-
if (
|
|
245
|
+
if (isPlaceholderValue(executorModel) || /^unknown$/i.test(executorModel)) problems.push(`${taskId}: EXECUTOR_MODEL missing/placeholder`);
|
|
176
246
|
else if (tierOf(executorModel) === 'lite') problems.push(`${taskId}: EXECUTOR_MODEL "${executorModel}" is lite tier`);
|
|
177
247
|
|
|
178
|
-
if (
|
|
248
|
+
if (isPlaceholderValue(reviewerModel) || /^unknown$/i.test(reviewerModel)) problems.push(`${taskId}: REVIEWER_MODEL missing/placeholder`);
|
|
179
249
|
else if (tierOf(reviewerModel) !== 'smart') problems.push(`${taskId}: REVIEWER_MODEL "${reviewerModel}" is not opus/unic-smart tier`);
|
|
180
250
|
|
|
181
251
|
if (executorModel && reviewerModel && executorModel.toLowerCase() === reviewerModel.toLowerCase()) {
|
|
@@ -331,7 +331,7 @@ const { pathToFileURL } = require('url');
|
|
|
331
331
|
function hasOpenEndedStatusSignal(lower, raw) {
|
|
332
332
|
return /\b(what next|what is next|what's next|next step|next steps|project status|current status|where are we|continue|continue from last session|roadmap|status\.md|task queue|tasks\.md|next queued task|pick next task|work from tasks)\b/.test(lower)
|
|
333
333
|
|| /\b(lam gi tiep|buoc tiep theo|tiep theo lam gi|lam tiep|dang o dau|trang thai project|tinh trang project|task tiep theo|viec tiep theo trong tasks)\b/.test(lower)
|
|
334
|
-
||
|
|
334
|
+
|| /(?<![A-Za-z0-9_])(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project|task tiếp theo|việc tiếp theo trong tasks)(?![A-Za-z0-9_])/.test(raw);
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
function hasTaskQueueNextSignal(lower, raw) {
|
|
@@ -345,7 +345,7 @@ const { pathToFileURL } = require('url');
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
return /\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|implement|build|create|add|ship|deliver|refactor|integrate|integration|scaffold|feature|update|change|modify|inject|apply|review|audit|diff|pr feedback|code review|auth|login|api|endpoint|test|spec)\b/.test(lower)
|
|
348
|
-
||
|
|
348
|
+
|| /(?<![A-Za-z0-9_])(sửa|fix|lỗi|bug|debug|implement|cài|thêm|review|kiểm tra|soát|đăng nhập)(?![A-Za-z0-9_])/.test(raw);
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = false } = {}) {
|
|
@@ -368,11 +368,11 @@ const { pathToFileURL } = require('url');
|
|
|
368
368
|
|
|
369
369
|
return mentionsTargetDoc(lower, targetName)
|
|
370
370
|
|| /\b(edit|write|improve|document|docs?|readme|changelog|worklog|memory|code map|template|wording|copy|grammar|format|structure|heading|section|handoff notes?)\b/.test(lower)
|
|
371
|
-
||
|
|
371
|
+
|| /(?<![A-Za-z0-9_])(câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu|ghi chú bàn giao)(?![A-Za-z0-9_])/.test(raw);
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
return /\b(template|wording|edit|copy|grammar|format|structure|heading|section|docs?)\b/.test(lower)
|
|
375
|
-
||
|
|
375
|
+
|| /(?<![A-Za-z0-9_])(mẫu|câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu)(?![A-Za-z0-9_])/.test(raw);
|
|
376
376
|
}
|
|
377
377
|
|
|
378
378
|
function hasExplicitStatusUpdateSignal(lower, raw) {
|
|
@@ -1473,6 +1473,48 @@ const { pathToFileURL } = require('url');
|
|
|
1473
1473
|
};
|
|
1474
1474
|
}
|
|
1475
1475
|
|
|
1476
|
+
function isInformationalPrompt({
|
|
1477
|
+
promptText = '',
|
|
1478
|
+
commandText = '',
|
|
1479
|
+
scores = null,
|
|
1480
|
+
} = {}) {
|
|
1481
|
+
if (String(commandText || '').trim()) return false;
|
|
1482
|
+
const raw = String(promptText || '').toLowerCase().trim();
|
|
1483
|
+
if (!raw) return false;
|
|
1484
|
+
const questionSignal = raw.includes('?')
|
|
1485
|
+
|| /\b(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
|
|
1486
|
+
|| /(là\s+(?:[^\s]+\s+){0,2}gì|thế nào|như thế nào|bao nhiêu|khi nào|bao giờ|ở đâu|nghĩa là|được không|không\b)/.test(raw);
|
|
1487
|
+
// '?' or a leading/Vietnamese interrogative — required to overrule error wording.
|
|
1488
|
+
const strongQuestion = raw.includes('?')
|
|
1489
|
+
|| /^(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
|
|
1490
|
+
|| /(là\s+(?:[^\s]+\s+){0,2}gì|thế nào|như thế nào|bao nhiêu|khi nào|bao giờ|ở đâu)/.test(raw);
|
|
1491
|
+
if (scores) {
|
|
1492
|
+
if (
|
|
1493
|
+
scores.implementSignal
|
|
1494
|
+
|| scores.reviewSignal
|
|
1495
|
+
|| scores.debugSignal
|
|
1496
|
+
|| scores.impactSignal
|
|
1497
|
+
|| scores.smallFixSignal
|
|
1498
|
+
|| scores.directTransformSignal
|
|
1499
|
+
) {
|
|
1500
|
+
return false;
|
|
1501
|
+
}
|
|
1502
|
+
if (scores.failureSignal && !strongQuestion) {
|
|
1503
|
+
return false;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
const implementWords = /(?<![A-Za-z0-9_])(implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|sửa|thêm|tạo|xóa|đổi|thay thế|cập nhật|viết|build|chạy|cài)(?![A-Za-z0-9_])/.test(raw);
|
|
1507
|
+
if (implementWords) return false;
|
|
1508
|
+
const investigationWords = /\b(why|debug|triage|root cause|investigate|tại sao)\b/.test(raw);
|
|
1509
|
+
if (investigationWords) return false;
|
|
1510
|
+
// \b never matches around 'lỗi' (diacritics are not \w), so test it as a plain
|
|
1511
|
+
// substring: an error report is informational only when phrased as a question.
|
|
1512
|
+
if (raw.includes('lỗi') && !strongQuestion) return false;
|
|
1513
|
+
const reviewWords = /\b(review|audit|verify)\b/.test(raw);
|
|
1514
|
+
if (reviewWords) return false;
|
|
1515
|
+
return questionSignal;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1476
1518
|
function deriveExecutionMode({
|
|
1477
1519
|
promptText = '',
|
|
1478
1520
|
commandText = '',
|
|
@@ -1495,6 +1537,10 @@ const { pathToFileURL } = require('url');
|
|
|
1495
1537
|
&& (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
|
|
1496
1538
|
const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
|
|
1497
1539
|
|
|
1540
|
+
if (isInformationalPrompt({ promptText, commandText, scores })) {
|
|
1541
|
+
return 'informational';
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1498
1544
|
if ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal) {
|
|
1499
1545
|
return 'review-release';
|
|
1500
1546
|
}
|
|
@@ -1778,7 +1824,7 @@ const { pathToFileURL } = require('url');
|
|
|
1778
1824
|
}
|
|
1779
1825
|
|
|
1780
1826
|
function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
|
|
1781
|
-
if (!executionMode) {
|
|
1827
|
+
if (!executionMode || executionMode === 'informational') {
|
|
1782
1828
|
return null;
|
|
1783
1829
|
}
|
|
1784
1830
|
|
|
@@ -2331,8 +2377,9 @@ const { pathToFileURL } = require('url');
|
|
|
2331
2377
|
const now = Date.now();
|
|
2332
2378
|
const debounceMs = 10 * 60 * 1000;
|
|
2333
2379
|
const previousTs = Number(previous?.ts || 0);
|
|
2380
|
+
const informationalNoTargetPrompt = isInformationalPrompt({ promptText, commandText }) && !filePath;
|
|
2334
2381
|
|
|
2335
|
-
if (active.length === 0) {
|
|
2382
|
+
if (active.length === 0 || informationalNoTargetPrompt) {
|
|
2336
2383
|
const lastExplicitUserPromptText = promptText.trim()
|
|
2337
2384
|
|| previous?.routingContext?.lastExplicitUserPromptText
|
|
2338
2385
|
|| '';
|
|
@@ -2343,6 +2390,7 @@ const { pathToFileURL } = require('url');
|
|
|
2343
2390
|
targetFile: filePath,
|
|
2344
2391
|
contextIntent: '',
|
|
2345
2392
|
taskType: '',
|
|
2393
|
+
...(informationalNoTargetPrompt ? { executionMode: 'informational' } : {}),
|
|
2346
2394
|
};
|
|
2347
2395
|
const previousContext = buildPreviousContextSnapshot({
|
|
2348
2396
|
projectRoot,
|
|
@@ -24,11 +24,11 @@ const EXCLUDED_DIR_NAMES = new Set([
|
|
|
24
24
|
'.venv',
|
|
25
25
|
'venv',
|
|
26
26
|
]);
|
|
27
|
-
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue']);
|
|
27
|
+
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql']);
|
|
28
28
|
const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
29
|
-
const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.yaml', '.yml', '.md', '.sh']);
|
|
29
|
+
const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql', '.json', '.yaml', '.yml', '.md', '.sh']);
|
|
30
30
|
const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
|
|
31
|
-
export const INDEX_SCHEMA_VERSION =
|
|
31
|
+
export const INDEX_SCHEMA_VERSION = 8;
|
|
32
32
|
export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
33
33
|
const INDEX_PARSE_BATCH_SIZE = 8;
|
|
34
34
|
const MAX_IMPORTER_HOPS = 2;
|
|
@@ -1810,26 +1810,38 @@ export function extractSymbols(filePath, content) {
|
|
|
1810
1810
|
}
|
|
1811
1811
|
};
|
|
1812
1812
|
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1813
|
+
if (filePath.endsWith('.sql')) {
|
|
1814
|
+
// Capture the object name, skipping an optional schema qualifier (qas.fn_rpt_billing → fn_rpt_billing).
|
|
1815
|
+
const sqlIdent = '(?:(?:["`]?[A-Za-z_][A-Za-z0-9_$]*["`]?)\\s*\\.\\s*)?["`]?([A-Za-z_][A-Za-z0-9_$]*)["`]?';
|
|
1816
|
+
addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'table');
|
|
1817
|
+
addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?VIEW\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'view');
|
|
1818
|
+
addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?FUNCTION\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'function');
|
|
1819
|
+
addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?PROCEDURE\\s+${sqlIdent}`, 'gi'), 'procedure');
|
|
1820
|
+
addMatches(new RegExp(`CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'index');
|
|
1821
|
+
addMatches(new RegExp(`CREATE\\s+(?:TYPE|DOMAIN|SEQUENCE|MATERIALIZED\\s+VIEW)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'type');
|
|
1822
|
+
addMatches(new RegExp(`ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'table');
|
|
1823
|
+
} else {
|
|
1824
|
+
addMatches(/export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'function');
|
|
1825
|
+
addMatches(/export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'class');
|
|
1826
|
+
addMatches(/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\(/g, 'callable');
|
|
1827
|
+
addMatches(/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>/g, 'callable');
|
|
1828
|
+
addMatches(/export\s+default\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'default-function');
|
|
1829
|
+
addMatches(/export\s+default\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'default-class');
|
|
1830
|
+
addMatches(/export\s+default\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:;|\n|$)/g, 'default-reference');
|
|
1831
|
+
addMatches(/defineOptions\(\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
|
|
1832
|
+
addMatches(/defineComponent\(\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
|
|
1833
|
+
addMatches(/export\s+default\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
|
|
1834
|
+
|
|
1835
|
+
if (/\bexport\s+default\b/.test(content)) {
|
|
1836
|
+
addSymbol('default', 'default-export');
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
for (const match of content.matchAll(/export\s*\{([^}]+)\}/g)) {
|
|
1840
|
+
for (const part of (match[1] ?? '').split(',').map((x) => x.trim()).filter(Boolean)) {
|
|
1841
|
+
const parsed = part.match(/^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/);
|
|
1842
|
+
if (!parsed) continue;
|
|
1843
|
+
addSymbol(parsed[2] ?? parsed[1], 'named-export', match.index);
|
|
1844
|
+
}
|
|
1833
1845
|
}
|
|
1834
1846
|
}
|
|
1835
1847
|
|
|
@@ -234,7 +234,7 @@ export const ROUTE_CATALOG = [
|
|
|
234
234
|
contextMode: 'standalone',
|
|
235
235
|
signals: [
|
|
236
236
|
{ type: 'prompt', regex: /\b(what(?:'s| is)? next|next steps?|project status|current status|where are we|continue(?: from)?(?: last session)?|roadmap|status\.md|task queue|tasks\.md|next queued task|pick next task|work from tasks)\b/i, score: 7 },
|
|
237
|
-
{ type: 'prompt', regex:
|
|
237
|
+
{ type: 'prompt', regex: /(?<![A-Za-z0-9_])(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project|task tiếp theo|việc tiếp theo trong tasks)(?![A-Za-z0-9_])/i, score: 7 },
|
|
238
238
|
],
|
|
239
239
|
},
|
|
240
240
|
{
|
|
@@ -1044,7 +1044,7 @@ function hasHandoffSignal(lower, raw) {
|
|
|
1044
1044
|
|| /\b(ai handoff|handoff phase|handoff mode|clear handoff|update handoff|start handoff|handoff clear)\b/.test(lower)
|
|
1045
1045
|
|| /\b(brainstorm|idea dump|ideas?).{0,80}\b(handoff|tasks?|taskify|split|breakdown)\b/.test(lower)
|
|
1046
1046
|
|| /\b(handoff|tasks?|taskify|split|breakdown).{0,80}\b(brainstorm|idea dump|ideas?)\b/.test(lower)
|
|
1047
|
-
||
|
|
1047
|
+
|| /(?<![A-Za-z0-9_])(gom|chia|tach|tách|lên|len|xóa|clear|dọn).{0,80}(?<![A-Za-z0-9_])(idea|ý tưởng|y tuong|task|công việc|cong viec|handoff)(?![A-Za-z0-9_])/.test(raw)
|
|
1048
1048
|
|| /\b(bàn giao|ban giao).{0,80}\b(ai|task|công việc|cong viec)\b/.test(raw);
|
|
1049
1049
|
}
|
|
1050
1050
|
|
|
@@ -1056,7 +1056,7 @@ function hasHandoffClearSignal(lower, raw) {
|
|
|
1056
1056
|
function hasOpenEndedStatusSignal(lower, raw) {
|
|
1057
1057
|
return /\b(what next|what is next|what's next|next step|next steps|project status|current status|where are we|continue|continue from last session|roadmap|status\.md|task queue|tasks\.md|next queued task|pick next task|work from tasks)\b/.test(lower)
|
|
1058
1058
|
|| /\b(lam gi tiep|buoc tiep theo|tiep theo lam gi|lam tiep|dang o dau|trang thai project|tinh trang project|task tiep theo|viec tiep theo trong tasks)\b/.test(lower)
|
|
1059
|
-
||
|
|
1059
|
+
|| /(?<![A-Za-z0-9_])(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project|task tiếp theo|việc tiếp theo trong tasks)(?![A-Za-z0-9_])/.test(raw);
|
|
1060
1060
|
}
|
|
1061
1061
|
|
|
1062
1062
|
function hasTaskQueueNextSignal(lower, raw) {
|
|
@@ -1070,7 +1070,7 @@ function hasConcreteTaskSignal(lower, raw, targetFile, { taskQueueNext = false }
|
|
|
1070
1070
|
}
|
|
1071
1071
|
|
|
1072
1072
|
return /\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|implement|build|create|add|ship|deliver|refactor|integrate|integration|scaffold|feature|update|change|modify|inject|apply|review|audit|diff|pr feedback|code review|auth|login|api|endpoint|test|spec)\b/.test(lower)
|
|
1073
|
-
||
|
|
1073
|
+
|| /(?<![A-Za-z0-9_])(sửa|fix|lỗi|bug|debug|implement|cài|thêm|review|kiểm tra|soát|đăng nhập)(?![A-Za-z0-9_])/.test(raw);
|
|
1074
1074
|
}
|
|
1075
1075
|
|
|
1076
1076
|
function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = false } = {}) {
|
|
@@ -1093,11 +1093,11 @@ function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = fal
|
|
|
1093
1093
|
|
|
1094
1094
|
return mentionsTargetDoc(lower, targetName)
|
|
1095
1095
|
|| /\b(edit|write|improve|document|docs?|readme|changelog|worklog|memory|code map|template|wording|copy|grammar|format|structure|heading|section|handoff notes?)\b/.test(lower)
|
|
1096
|
-
||
|
|
1096
|
+
|| /(?<![A-Za-z0-9_])(câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu|ghi chú bàn giao)(?![A-Za-z0-9_])/.test(raw);
|
|
1097
1097
|
}
|
|
1098
1098
|
|
|
1099
1099
|
return /\b(template|wording|edit|copy|grammar|format|structure|heading|section|docs?)\b/.test(lower)
|
|
1100
|
-
||
|
|
1100
|
+
|| /(?<![A-Za-z0-9_])(mẫu|câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu)(?![A-Za-z0-9_])/.test(raw);
|
|
1101
1101
|
}
|
|
1102
1102
|
|
|
1103
1103
|
function hasExplicitStatusUpdateSignal(lower, raw) {
|
|
@@ -1644,6 +1644,48 @@ function deriveExecutionScores({
|
|
|
1644
1644
|
};
|
|
1645
1645
|
}
|
|
1646
1646
|
|
|
1647
|
+
function isInformationalPrompt({
|
|
1648
|
+
promptText = '',
|
|
1649
|
+
commandText = '',
|
|
1650
|
+
scores = null,
|
|
1651
|
+
} = {}) {
|
|
1652
|
+
if (String(commandText || '').trim()) return false;
|
|
1653
|
+
const raw = String(promptText || '').toLowerCase().trim();
|
|
1654
|
+
if (!raw) return false;
|
|
1655
|
+
const questionSignal = raw.includes('?')
|
|
1656
|
+
|| /\b(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
|
|
1657
|
+
|| /(là\s+(?:[^\s]+\s+){0,2}gì|thế nào|như thế nào|bao nhiêu|khi nào|bao giờ|ở đâu|nghĩa là|được không|không\b)/.test(raw);
|
|
1658
|
+
// '?' or a leading/Vietnamese interrogative — required to overrule error wording.
|
|
1659
|
+
const strongQuestion = raw.includes('?')
|
|
1660
|
+
|| /^(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
|
|
1661
|
+
|| /(là\s+(?:[^\s]+\s+){0,2}gì|thế nào|như thế nào|bao nhiêu|khi nào|bao giờ|ở đâu)/.test(raw);
|
|
1662
|
+
if (scores) {
|
|
1663
|
+
if (
|
|
1664
|
+
scores.implementSignal
|
|
1665
|
+
|| scores.reviewSignal
|
|
1666
|
+
|| scores.debugSignal
|
|
1667
|
+
|| scores.impactSignal
|
|
1668
|
+
|| scores.smallFixSignal
|
|
1669
|
+
|| scores.directTransformSignal
|
|
1670
|
+
) {
|
|
1671
|
+
return false;
|
|
1672
|
+
}
|
|
1673
|
+
if (scores.failureSignal && !strongQuestion) {
|
|
1674
|
+
return false;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
const implementWords = /(?<![A-Za-z0-9_])(implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|sửa|thêm|tạo|xóa|đổi|thay thế|cập nhật|viết|build|chạy|cài)(?![A-Za-z0-9_])/.test(raw);
|
|
1678
|
+
if (implementWords) return false;
|
|
1679
|
+
const investigationWords = /\b(why|debug|triage|root cause|investigate|tại sao)\b/.test(raw);
|
|
1680
|
+
if (investigationWords) return false;
|
|
1681
|
+
// \b never matches around 'lỗi' (diacritics are not \w), so test it as a plain
|
|
1682
|
+
// substring: an error report is informational only when phrased as a question.
|
|
1683
|
+
if (raw.includes('lỗi') && !strongQuestion) return false;
|
|
1684
|
+
const reviewWords = /\b(review|audit|verify)\b/.test(raw);
|
|
1685
|
+
if (reviewWords) return false;
|
|
1686
|
+
return questionSignal;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1647
1689
|
function deriveExecutionMode({
|
|
1648
1690
|
promptText = '',
|
|
1649
1691
|
commandText = '',
|
|
@@ -1666,6 +1708,10 @@ function deriveExecutionMode({
|
|
|
1666
1708
|
&& (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
|
|
1667
1709
|
const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
|
|
1668
1710
|
|
|
1711
|
+
if (isInformationalPrompt({ promptText, commandText, scores })) {
|
|
1712
|
+
return 'informational';
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1669
1715
|
if ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal) {
|
|
1670
1716
|
return 'review-release';
|
|
1671
1717
|
}
|
|
@@ -1956,7 +2002,7 @@ function buildApproachSelectorResult({
|
|
|
1956
2002
|
}
|
|
1957
2003
|
|
|
1958
2004
|
function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
|
|
1959
|
-
if (!executionMode) {
|
|
2005
|
+
if (!executionMode || executionMode === 'informational') {
|
|
1960
2006
|
return null;
|
|
1961
2007
|
}
|
|
1962
2008
|
|
|
@@ -481,9 +481,80 @@ export function resetCompactPressureState(rawState = {}, config = {}) {
|
|
|
481
481
|
recentOutputs: [],
|
|
482
482
|
cooldownUntil: 0,
|
|
483
483
|
latestPlan: null,
|
|
484
|
+
// Stamp the moment a compaction was accounted for, so the transcript sync (which may
|
|
485
|
+
// run slightly later than the boundary was written) does not double-fire.
|
|
486
|
+
lastCompactBoundaryAt: Date.now(),
|
|
484
487
|
}, config);
|
|
485
488
|
}
|
|
486
489
|
|
|
490
|
+
const BOUNDARY_TAIL_BYTES = 4 * 1024 * 1024;
|
|
491
|
+
|
|
492
|
+
// Find the newest compaction recorded in the session transcript, as epoch ms (0 if none).
|
|
493
|
+
// Every real compaction — manual /compact AND harness auto-compact — appends a
|
|
494
|
+
// `compact_boundary` entry to the transcript, while PreCompact hooks do not fire on every
|
|
495
|
+
// compaction path (SDK/VSCode auto-compact being the known gap). That makes the transcript
|
|
496
|
+
// the one record that cannot miss a compaction. Only the tail is read: boundaries are
|
|
497
|
+
// appended chronologically and only the newest matters.
|
|
498
|
+
export async function detectLatestCompactBoundaryAt(transcriptPath, { tailBytes = BOUNDARY_TAIL_BYTES } = {}) {
|
|
499
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) {
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const stat = await fs.stat(transcriptPath);
|
|
504
|
+
if (!stat.isFile() || stat.size === 0) {
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
const start = Math.max(0, stat.size - tailBytes);
|
|
508
|
+
const length = stat.size - start;
|
|
509
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
510
|
+
const handle = await fs.open(transcriptPath, 'r');
|
|
511
|
+
let text;
|
|
512
|
+
try {
|
|
513
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
514
|
+
text = buffer.toString('utf8', 0, bytesRead);
|
|
515
|
+
} finally {
|
|
516
|
+
await handle.close();
|
|
517
|
+
}
|
|
518
|
+
const lines = text.split('\n');
|
|
519
|
+
if (start > 0) lines.shift(); // a tail read can split the first line
|
|
520
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
521
|
+
const line = lines[i];
|
|
522
|
+
if (!line.includes('compact_boundary')) continue;
|
|
523
|
+
try {
|
|
524
|
+
const entry = JSON.parse(line);
|
|
525
|
+
if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
|
|
526
|
+
const at = Date.parse(entry.timestamp ?? '');
|
|
527
|
+
if (Number.isFinite(at)) return at;
|
|
528
|
+
}
|
|
529
|
+
} catch { /* not a usable boundary line */ }
|
|
530
|
+
}
|
|
531
|
+
return 0;
|
|
532
|
+
} catch {
|
|
533
|
+
return 0;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Rebase the tracked counter whenever the transcript proves a compaction this state has
|
|
538
|
+
// not accounted for yet (lastCompactBoundaryAt stamps what has been seen, so each
|
|
539
|
+
// boundary fires the reset exactly once). Without this, sessionTokens keeps counting
|
|
540
|
+
// history that was already summarised away and the hard-cap gate bricks a healthy
|
|
541
|
+
// session. Callers must persist when reset=true, or the reset would re-fire (and
|
|
542
|
+
// zero the counter) on every call.
|
|
543
|
+
export async function syncCompactPressureStateWithTranscript(rawState = null, config = {}, transcriptPath = '') {
|
|
544
|
+
const boundaryAt = await detectLatestCompactBoundaryAt(transcriptPath);
|
|
545
|
+
const accounted = finiteNumber(rawState?.lastCompactBoundaryAt, 0);
|
|
546
|
+
if (!boundaryAt || boundaryAt <= accounted) {
|
|
547
|
+
return { state: rawState, reset: false };
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
state: {
|
|
551
|
+
...resetCompactPressureState(rawState ?? {}, config),
|
|
552
|
+
lastCompactBoundaryAt: boundaryAt,
|
|
553
|
+
},
|
|
554
|
+
reset: true,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
487
558
|
function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
|
|
488
559
|
const softReleaseThreshold = Math.max(
|
|
489
560
|
Math.min(thresholds.softThreshold, Math.round(thresholds.softThreshold * SOFT_RELEASE_RATIO)),
|
|
@@ -648,6 +719,7 @@ export function buildCompactPressureState(rawState = null, config = {}) {
|
|
|
648
719
|
phase: resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
|
|
649
720
|
taskMode,
|
|
650
721
|
cooldownUntil: finiteNumber(rawState?.cooldownUntil, 0),
|
|
722
|
+
lastCompactBoundaryAt: finiteNumber(rawState?.lastCompactBoundaryAt, 0),
|
|
651
723
|
routingContext: rawState?.routingContext && typeof rawState.routingContext === 'object'
|
|
652
724
|
? rawState.routingContext
|
|
653
725
|
: {},
|
|
@@ -1058,6 +1130,22 @@ async function runCli() {
|
|
|
1058
1130
|
return;
|
|
1059
1131
|
}
|
|
1060
1132
|
|
|
1133
|
+
// Heal a stale counter BEFORE the phase is computed. Auto-compact paths that never
|
|
1134
|
+
// fire the PreCompact hook leave sessionTokens counting history that was already
|
|
1135
|
+
// summarised away — that phantom total is how a healthy session got hard-cap blocked.
|
|
1136
|
+
// The transcript's compact_boundary entries record every real compaction, manual or
|
|
1137
|
+
// automatic, so they are the correction signal here.
|
|
1138
|
+
try {
|
|
1139
|
+
const synced = await syncCompactPressureStateWithTranscript(
|
|
1140
|
+
await readCompactPressureState(projectRoot, config),
|
|
1141
|
+
config,
|
|
1142
|
+
payload.transcript_path,
|
|
1143
|
+
);
|
|
1144
|
+
if (synced.reset) {
|
|
1145
|
+
await writeCompactPressureState(projectRoot, synced.state, config);
|
|
1146
|
+
}
|
|
1147
|
+
} catch { /* advisory healing only — never block the prompt over it */ }
|
|
1148
|
+
|
|
1061
1149
|
const nextState = await updateCompactPressureFromPrompt(projectRoot, {
|
|
1062
1150
|
promptText,
|
|
1063
1151
|
routingContext: routeState?.routingContext ?? {},
|
|
@@ -1065,9 +1153,16 @@ async function runCli() {
|
|
|
1065
1153
|
routeSummary: routeState?.routeSummary ?? null,
|
|
1066
1154
|
}, config);
|
|
1067
1155
|
|
|
1068
|
-
if (nextState.phase === '
|
|
1156
|
+
if (nextState.phase === 'hard') {
|
|
1157
|
+
process.stdout.write(
|
|
1158
|
+
`[ukit-skill-router] Context pressure: hard (est ${nextState.estimatedTotalTokens} / soft ${nextState.softThreshold} / hard ${nextState.hardThreshold}). `
|
|
1159
|
+
+ 'FIRST THING IN YOUR REPLY, tell the user plainly — do not stay silent: "Context sắp đầy — hãy gõ /compact ngay bây giờ (run /compact now)". '
|
|
1160
|
+
+ 'Then hold heavy work: no new investigations, subagents, or large edits until the user has compacted.\n',
|
|
1161
|
+
);
|
|
1162
|
+
} else if (nextState.phase === 'soft') {
|
|
1069
1163
|
process.stdout.write(
|
|
1070
|
-
`[ukit-skill-router] Context pressure:
|
|
1164
|
+
`[ukit-skill-router] Context pressure: soft (est ${nextState.estimatedTotalTokens} / soft ${nextState.softThreshold} / hard ${nextState.hardThreshold}). `
|
|
1165
|
+
+ 'Finish the current step, then tell the user to run /compact at the next natural pause instead of continuing indefinitely.\n',
|
|
1071
1166
|
);
|
|
1072
1167
|
}
|
|
1073
1168
|
}
|
|
@@ -195,7 +195,7 @@
|
|
|
195
195
|
"handoff": {
|
|
196
196
|
"enabled": true,
|
|
197
197
|
"crossTool": true,
|
|
198
|
-
"maxParallelAgents":
|
|
198
|
+
"maxParallelAgents": 2,
|
|
199
199
|
"autonomy": {
|
|
200
200
|
"askWindow": "plan-only",
|
|
201
201
|
"planReviewRounds": 2,
|
|
@@ -511,7 +511,7 @@
|
|
|
511
511
|
"handoff": {
|
|
512
512
|
"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).",
|
|
513
513
|
"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.",
|
|
514
|
-
"maxParallelAgents": "Số agent chạy song song TỐI ĐA trong một wave (mặc định
|
|
514
|
+
"maxParallelAgents": "Số agent chạy song song TỐI ĐA trong một wave (mặc định 2 để giữ session chính nhẹ và hạn chế rủi ro compact/worktree rác; nếu máy khỏe và task độc lập nhiều thì có thể nâng dần 3-5, tối đa ~10-15). 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 — áp dụng cho cả Phase 3 Implement và Phase 4 Review. Lý do giới hạn vẫn còn: 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 (vd 20+) vẫn có thể làm session chính vượt context window và bỏ lại worktree rác. Hạ xuống 1-2 nếu task nặng (verification output dài) hoặc thấy compact bị trigger liên tục.",
|
|
515
515
|
"plan": {
|
|
516
516
|
"requireTestPlan": "Bắt buộc PLAN.md §4 phải có Test Plan trước khi task chuyển ready.",
|
|
517
517
|
"minTestsHappyPath": "Tối thiểu test cho happy path.",
|