@ngockhoale/ukit 3.4.18 → 3.4.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/scripts/release/verify-release.mjs +44 -2
- package/src/cli/commands/update.js +14 -4
- package/src/core/update.js +16 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +4 -3
- package/template_project/AGENTS.md +1 -0
- package/template_project/CLAUDE.md +1 -0
- package/template_project/instructions/core.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.4.20 - 2026-10-06
|
|
6
|
+
|
|
7
|
+
**`ukit update` now displays the NEW version.** The running process is the old code, so the
|
|
8
|
+
banner used to print only the stale "Current version" and read like the update had not
|
|
9
|
+
worked. It now prints `Updated: <old> -> <new>` (version read from the freshly installed global
|
|
10
|
+
package.json) right after `npm install -g`, repeats the new version in the final line, and says
|
|
11
|
+
"already the latest" when nothing changed.
|
|
12
|
+
|
|
13
|
+
**Release/verify tooling no longer wastes minutes on trivial work.**
|
|
14
|
+
- `release:verify` now runs the cheap deterministic gates first (`docs:render:check`, `tests/consistency`) and
|
|
15
|
+
stops the ~4 min core suite after 5 failures (`--bail 5`); a stale instruction render or template/active
|
|
16
|
+
drift is reported in seconds, not after the full suite.
|
|
17
|
+
- Single-instance lock: a second concurrent `release:verify` for the same repo exits 3 naming the live pid
|
|
18
|
+
instead of racing the first (two concurrent suites made both slow and flaky).
|
|
19
|
+
- `rollout.test.js` no longer re-runs the entire `release:verify` from inside `release:verify` (that one test
|
|
20
|
+
took ~307 s, doubling the release gate).
|
|
21
|
+
- Instruction core: "Long commands run once" rule (never duplicate a backgrounded/timed-out long run).
|
|
22
|
+
- Fixed a comment in `handoff-model-guard.sh` that the watchdog-coverage scan mistook for an unarmed inline
|
|
23
|
+
`node -e` block (shipped red in 3.4.18/3.4.19 because only the hooks suite was run before release).
|
|
24
|
+
|
|
25
|
+
## 3.4.19 - 2026-10-06
|
|
26
|
+
|
|
27
|
+
**Create-scope guard: more read-only shell false positives removed (found by replaying 4vn's real blocked commands).**
|
|
28
|
+
`2>/dev/null;` / `2>/dev/null|` (operator glued to the redirect target) was read as a write to a file
|
|
29
|
+
named `/dev/null;`; commands after shell keywords (`for ...; do jq ...`, `then`, `else`, `while`) skipped the
|
|
30
|
+
read-only interpreter mask. Real writes (`>src/a.js;`, write APIs) still block. Test 14 in createScopeGuard.
|
|
31
|
+
|
|
5
32
|
## 3.4.18 - 2026-10-06
|
|
6
33
|
|
|
7
34
|
**Stops the token-burning respawn loop at its source.**
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
|
|
@@ -38,7 +39,48 @@ if (postPublish) {
|
|
|
38
39
|
process.exit(0);
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
|
|
43
|
+
// Single-instance guard (full verification takes ~4 min). Starting a second run while one is
|
|
44
|
+
// alive (e.g. after the first was moved to the background by a tool timeout) only makes both
|
|
45
|
+
// slower and flakier — wait for / read the first run's result instead.
|
|
46
|
+
let lockPath = null;
|
|
47
|
+
function acquireVerifyLock() {
|
|
48
|
+
// Nested runs (the release suite's own tests spawn this script) and test workers must not contend.
|
|
49
|
+
if (process.env.UKIT_RELEASE_VERIFY_ACTIVE === '1' || process.env.VITEST || process.env.UKIT_VERIFY_NO_LOCK === '1') return;
|
|
50
|
+
lockPath = path.join(os.tmpdir(), `ukit-release-verify-${crypto.createHash('sha1').update(rootDir).digest('hex').slice(0, 10)}.lock`);
|
|
51
|
+
if (existsSync(lockPath)) {
|
|
52
|
+
const pid = Number(readFileSync(lockPath, 'utf8').trim());
|
|
53
|
+
let alive = false;
|
|
54
|
+
try { process.kill(pid, 0); alive = Number.isInteger(pid) && pid > 0 && pid !== process.pid; } catch { /* stale */ }
|
|
55
|
+
if (alive) {
|
|
56
|
+
console.error(`[release:verify] another release:verify is already running for this repo (pid ${pid}). Wait for it (or read its output) — do NOT start a second full run; two concurrent suites slow each other and cause flaky failures.`);
|
|
57
|
+
process.exit(3);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
writeFileSync(lockPath, String(process.pid));
|
|
61
|
+
process.env.UKIT_RELEASE_VERIFY_ACTIVE = '1';
|
|
62
|
+
const release = () => { try { if (readFileSync(lockPath, 'utf8').trim() === String(process.pid)) unlinkSync(lockPath); } catch { /* gone */ } };
|
|
63
|
+
process.on('exit', release);
|
|
64
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { release(); process.exit(130); });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
acquireVerifyLock();
|
|
68
|
+
|
|
69
|
+
// Cheap, deterministic gates FIRST (seconds): a stale instruction render or a consistency drift
|
|
70
|
+
// used to surface only after the ~4 min full suite.
|
|
41
71
|
const steps = [
|
|
72
|
+
{
|
|
73
|
+
label: 'Rendered instructions up to date (fix: yarn docs:render)',
|
|
74
|
+
command: 'yarn',
|
|
75
|
+
args: ['docs:render:check'],
|
|
76
|
+
env: process.env,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
label: 'Consistency suite (template<->active sync, watchdog coverage, render parity)',
|
|
80
|
+
command: 'yarn',
|
|
81
|
+
args: ['vitest', 'run', 'tests/consistency'],
|
|
82
|
+
env: process.env,
|
|
83
|
+
},
|
|
42
84
|
{
|
|
43
85
|
label: 'Artifact smoke',
|
|
44
86
|
command: 'yarn',
|
|
@@ -54,7 +96,7 @@ const steps = [
|
|
|
54
96
|
{
|
|
55
97
|
label: 'Core test suite',
|
|
56
98
|
command: 'yarn',
|
|
57
|
-
args: ['test:release-core'],
|
|
99
|
+
args: ['test:release-core', '--bail', '5'], // stop after 5 failures instead of finishing a doomed 4 min run
|
|
58
100
|
env: process.env,
|
|
59
101
|
},
|
|
60
102
|
{
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { updateUkit, runInstallAfterUpdate, UKIT_PACKAGE_NAME } from '../../core/update.js';
|
|
1
|
+
import { updateUkit, runInstallAfterUpdate, readInstalledGlobalVersion, UKIT_PACKAGE_NAME } from '../../core/update.js';
|
|
2
2
|
|
|
3
3
|
const KNOWN_UPDATE_FLAGS = new Set(['--help', '-h', '--no-install']);
|
|
4
4
|
|
|
@@ -27,13 +27,23 @@ export async function runUpdate({ packageVersion, argv = [], cwd = process.cwd()
|
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
console.log(`[UKit]
|
|
30
|
+
console.log(`[UKit] Version before update: ${packageVersion}`);
|
|
31
31
|
console.log(`[UKit] Running: npm install -g ${UKIT_PACKAGE_NAME}`);
|
|
32
32
|
|
|
33
33
|
updateUkit();
|
|
34
34
|
|
|
35
|
+
// This process still runs the OLD code, so report the version the update actually
|
|
36
|
+
// installed (read from disk), never the stale `packageVersion`.
|
|
37
|
+
const installedVersion = readInstalledGlobalVersion();
|
|
38
|
+
const versionLine = installedVersion
|
|
39
|
+
? installedVersion === packageVersion
|
|
40
|
+
? `[UKit] Installed version: ${installedVersion} (already the latest)`
|
|
41
|
+
: `[UKit] Updated: ${packageVersion} -> ${installedVersion}`
|
|
42
|
+
: '[UKit] Updated (could not read the new version — run `ukit --version`)';
|
|
43
|
+
console.log(versionLine);
|
|
44
|
+
|
|
35
45
|
if (argv.includes('--no-install')) {
|
|
36
|
-
console.log(
|
|
46
|
+
console.log(`[UKit] Update complete${installedVersion ? ` — UKit ${installedVersion}` : ''}. Skipped project refresh (--no-install).`);
|
|
37
47
|
return;
|
|
38
48
|
}
|
|
39
49
|
|
|
@@ -41,7 +51,7 @@ export async function runUpdate({ packageVersion, argv = [], cwd = process.cwd()
|
|
|
41
51
|
const { ran, reason } = runInstallAfterUpdate({ projectRoot: cwd });
|
|
42
52
|
|
|
43
53
|
if (ran) {
|
|
44
|
-
console.log(
|
|
54
|
+
console.log(`[UKit] Update complete — UKit ${installedVersion ?? 'latest'} installed and project refreshed.`);
|
|
45
55
|
return;
|
|
46
56
|
}
|
|
47
57
|
|
package/src/core/update.js
CHANGED
|
@@ -62,6 +62,22 @@ function resolveGlobalUkitBinDetailed({ spawnSync = defaultSpawnSync } = {}) {
|
|
|
62
62
|
return { binPath: fs.existsSync(binPath) ? binPath : null, timedOut: false };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Version of the globally installed UKit package as it is on disk NOW, or null if unreadable.
|
|
67
|
+
* `packageVersion` in the running process is the OLD code's version (the process predates
|
|
68
|
+
* `npm install -g`), so it must never be printed as the post-update version.
|
|
69
|
+
*/
|
|
70
|
+
export function readInstalledGlobalVersion({ spawnSync = defaultSpawnSync } = {}) {
|
|
71
|
+
try {
|
|
72
|
+
const { binPath } = resolveGlobalUkitBinDetailed({ spawnSync });
|
|
73
|
+
if (!binPath) return null;
|
|
74
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(binPath), '..', 'package.json'), 'utf8'));
|
|
75
|
+
return typeof pkg.version === 'string' ? pkg.version : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
/**
|
|
66
82
|
* Run `ukit install` in `projectRoot` using the freshly installed global package.
|
|
67
83
|
* Returns { ran, reason } so the caller can report accurately rather than claim an
|
|
@@ -388,7 +388,7 @@ const SHELL_WRITE_PATTERNS = [
|
|
|
388
388
|
new RegExp(String.raw`${CMD_POS}curl\s(?:[^;&|\n]*\s)?(?:-[sSfLvkiI#]*[oO]\b|--(?:output|remote-name|output-dir)\b)`),
|
|
389
389
|
new RegExp(String.raw`${CMD_POS}[gm]?awk\s(?:[^;&|\n]*\s)?-i\s*inplace\b`),
|
|
390
390
|
];
|
|
391
|
-
// Read-only inline interpreters (
|
|
391
|
+
// Read-only inline interpreters (inline node scripts (-e flag), `python3 -c '…'`, `python3 - <<'PY'`, `jq '…'`):
|
|
392
392
|
// their quoted script/program body is code, not shell. Scanning it with the shell write
|
|
393
393
|
// patterns misfires on arrow functions (`l=>{…}`), comparisons (`n>1750`), and words such as
|
|
394
394
|
// `ln`/`cp`/`install` used as identifiers — which blocked read-only transcript analysis in a
|
|
@@ -407,7 +407,7 @@ const SCRIPT_WRITE_API = new RegExp([
|
|
|
407
407
|
function firstWord(text) {
|
|
408
408
|
const words = text.trim().split(/\s+/);
|
|
409
409
|
let i = 0;
|
|
410
|
-
while (i < words.length && (/^[A-Za-z_]\w*=/.test(words[i]) || /^(?:sudo|time|nohup|command|exec)$/.test(words[i]))) i += 1;
|
|
410
|
+
while (i < words.length && (/^[A-Za-z_]\w*=/.test(words[i]) || /^(?:sudo|time|nohup|command|exec|do|then|else|elif|if|while|until|!|\{|\()$/.test(words[i]))) i += 1;
|
|
411
411
|
return (words[i] || '').replace(/^.*\//, '');
|
|
412
412
|
}
|
|
413
413
|
function splitShellSegments(command) {
|
|
@@ -488,7 +488,8 @@ function shellWriteHit(rawCommand) {
|
|
|
488
488
|
// Redirect (optionally fd-numbered: `1>f`, `3>f`, `&>f`, `>&f`) to anything but /dev/null.
|
|
489
489
|
// fd duplication (`2>&1`, `>&2`, `>&-`) is excluded by the `(?!&)` target shape.
|
|
490
490
|
for (const m of command.matchAll(/(^|[^>])&?>{1,2}\s*(?:&(?![0-9-]))?((?!&)\S+)/g)) {
|
|
491
|
-
|
|
491
|
+
// `2>/dev/null;` / `2>/dev/null|` / `2>/dev/null)` glue the shell operator onto the target token.
|
|
492
|
+
if (!['/dev/null', '/dev/stdout', '/dev/stderr'].includes(m[2].replace(/[;&|)]+$/, ''))) return true;
|
|
492
493
|
}
|
|
493
494
|
return SHELL_WRITE_PATTERNS.some((re) => re.test(command));
|
|
494
495
|
}
|
|
@@ -86,6 +86,7 @@ Chỉ active khi task đi qua `docs/AI_HANDOFF/` ("execute task TASK-xxx" hoặc
|
|
|
86
86
|
- **Trivial**: no docs, no index query unless the target is unclear. **Simple**: `docs/MEMORY.md` + resolver-selected files/tests. **Non-trivial**: + `docs/PROJECT.md` + `docs/CODE_MAP.md`.
|
|
87
87
|
<!-- RULES: BUDGET-02 BUDGET-03 -->
|
|
88
88
|
- `docs/STATUS.md` for open-ended/continue; `docs/TASKS.md` only for queued-task prompts; `docs/WORKLOG.md` recent entries only. Verification: targeted first, widen on risk/shared scope, ask before broad runs.
|
|
89
|
+
- **Long commands run once:** start a full suite/release gate (>1 min) a single time; if it times out or goes to background, read its output — never launch a duplicate in parallel. Cheap deterministic gates first; trivial/simple tasks never need the full suite.
|
|
89
90
|
|
|
90
91
|
## Living Status Workflow
|
|
91
92
|
<!-- RULE: STATUS-01 -->
|
|
@@ -86,6 +86,7 @@ Chỉ active khi task đi qua `docs/AI_HANDOFF/` ("execute task TASK-xxx" hoặc
|
|
|
86
86
|
- **Trivial**: no docs, no index query unless the target is unclear. **Simple**: `docs/MEMORY.md` + resolver-selected files/tests. **Non-trivial**: + `docs/PROJECT.md` + `docs/CODE_MAP.md`.
|
|
87
87
|
<!-- RULES: BUDGET-02 BUDGET-03 -->
|
|
88
88
|
- `docs/STATUS.md` for open-ended/continue; `docs/TASKS.md` only for queued-task prompts; `docs/WORKLOG.md` recent entries only. Verification: targeted first, widen on risk/shared scope, ask before broad runs.
|
|
89
|
+
- **Long commands run once:** start a full suite/release gate (>1 min) a single time; if it times out or goes to background, read its output — never launch a duplicate in parallel. Cheap deterministic gates first; trivial/simple tasks never need the full suite.
|
|
89
90
|
|
|
90
91
|
## Living Status Workflow
|
|
91
92
|
<!-- RULE: STATUS-01 -->
|
|
@@ -83,6 +83,7 @@ Chỉ active khi task đi qua `docs/AI_HANDOFF/` ("execute task TASK-xxx" hoặc
|
|
|
83
83
|
- **Trivial**: no docs, no index query unless the target is unclear. **Simple**: `docs/MEMORY.md` + resolver-selected files/tests. **Non-trivial**: + `docs/PROJECT.md` + `docs/CODE_MAP.md`.
|
|
84
84
|
<!-- RULES: BUDGET-02 BUDGET-03 -->
|
|
85
85
|
- `docs/STATUS.md` for open-ended/continue; `docs/TASKS.md` only for queued-task prompts; `docs/WORKLOG.md` recent entries only. Verification: targeted first, widen on risk/shared scope, ask before broad runs.
|
|
86
|
+
- **Long commands run once:** start a full suite/release gate (>1 min) a single time; if it times out or goes to background, read its output — never launch a duplicate in parallel. Cheap deterministic gates first; trivial/simple tasks never need the full suite.
|
|
86
87
|
|
|
87
88
|
## Living Status Workflow
|
|
88
89
|
<!-- RULE: STATUS-01 -->
|