@ngockhoale/ukit 3.4.26 → 3.4.28
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 +30 -0
- package/manifests/documentation.yaml +61 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +7 -1
- package/scripts/audit/subagent-cost/metrics.mjs +14 -2
- package/scripts/audit/subagent-cost/reader.mjs +15 -3
- package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
- package/scripts/dev/worktree-preflight.mjs +147 -0
- package/src/cli/commands/update.js +59 -2
- package/src/core/agentRuntime/context.js +78 -0
- package/src/core/diffPlan.js +100 -102
- package/src/core/docContracts.js +3 -0
- package/src/core/observability/emit/sessionBoot.js +14 -0
- package/src/core/runtimeConfigMerge.js +125 -0
- package/src/core/updateSafety.js +174 -0
- package/src/index/contextPack.js +285 -0
- package/src/index/sensitivePath.js +21 -0
- package/src/index/taskRouting.js +3 -0
- package/template_project/.claude/commands/ukit/ci-check.md +53 -0
- package/template_project/.claude/hooks/auto-allow-bash.sh +20 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +25 -1
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +3 -0
- package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +4 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
- package/template_project/.claude/ukit/index/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +154 -12
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +61 -0
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
- package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
- package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
- package/template_project/.claude/ukit/runtime/safe-patch-core.mjs +71 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +53 -6
- package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
5
|
+
import { readInstallMetadata } from './metadata.js';
|
|
6
|
+
import { writeFileAtomic } from './fileOps.js';
|
|
7
|
+
|
|
8
|
+
// F5: safety net around `ukit update`.
|
|
9
|
+
//
|
|
10
|
+
// preflight -> before ANY change: npm answers, project metadata readable and project
|
|
11
|
+
// writable. A failed preflight aborts with nothing modified.
|
|
12
|
+
// snapshot -> copies every UKit-managed regular file (install.json ledger + the known
|
|
13
|
+
// user-edited configs) plus a sha256 manifest, BEFORE `npm install -g`.
|
|
14
|
+
// restore -> if the project refresh fails, puts the project back and VERIFIES hashes.
|
|
15
|
+
//
|
|
16
|
+
// Honest scope: the snapshot covers PROJECT files only. The global npm package is never
|
|
17
|
+
// rolled back automatically — after a failed refresh the global CLI stays on the new
|
|
18
|
+
// version while the project is back on the old files. The report says so and prints the
|
|
19
|
+
// exact npm command to pin the previous version; it never claims a full rollback.
|
|
20
|
+
|
|
21
|
+
const SNAPSHOT_REL_DIR = path.join('.claude', 'ukit', '.ukit', 'update-snapshots');
|
|
22
|
+
const INSTALL_META_REL = path.join('.claude', 'ukit', '.ukit', 'install.json');
|
|
23
|
+
const EXTRA_SNAPSHOT_PATHS = [
|
|
24
|
+
'.claude/settings.json',
|
|
25
|
+
'.ukit/storage/config.json',
|
|
26
|
+
'.omp/config.yml',
|
|
27
|
+
'.gitignore',
|
|
28
|
+
'CLAUDE.md',
|
|
29
|
+
'AGENTS.md',
|
|
30
|
+
];
|
|
31
|
+
export const SNAPSHOT_KEEP = 3;
|
|
32
|
+
|
|
33
|
+
const toPosix = (p) => p.replace(/\\/g, '/');
|
|
34
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
35
|
+
|
|
36
|
+
function insideProject(projectRoot, rel) {
|
|
37
|
+
const abs = path.resolve(projectRoot, rel);
|
|
38
|
+
const relBack = path.relative(path.resolve(projectRoot), abs);
|
|
39
|
+
return relBack !== '' && !relBack.startsWith('..') && !path.isAbsolute(relBack) ? abs : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function preflightUpdate({ projectRoot, spawnSync = defaultSpawnSync, checkNpm = true } = {}) {
|
|
43
|
+
const problems = [];
|
|
44
|
+
if (checkNpm) {
|
|
45
|
+
const r = spawnSync('npm', ['--version'], { encoding: 'utf8', timeout: 15_000, killSignal: 'SIGKILL' });
|
|
46
|
+
if (r.error || r.status !== 0) problems.push(`npm is not runnable (${r.error?.message ?? `exit ${r.status}`})`);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await readInstallMetadata(path.join(projectRoot, INSTALL_META_REL));
|
|
50
|
+
} catch (error) {
|
|
51
|
+
problems.push(`install metadata unreadable: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(projectRoot, fs.constants.W_OK);
|
|
55
|
+
} catch {
|
|
56
|
+
problems.push(`project directory is not writable: ${projectRoot}`);
|
|
57
|
+
}
|
|
58
|
+
return { ok: problems.length === 0, problems };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function listManagedFiles(projectRoot) {
|
|
62
|
+
const rels = new Set(EXTRA_SNAPSHOT_PATHS.map(toPosix));
|
|
63
|
+
rels.add(toPosix(INSTALL_META_REL));
|
|
64
|
+
try {
|
|
65
|
+
const meta = await readInstallMetadata(path.join(projectRoot, INSTALL_META_REL));
|
|
66
|
+
for (const entry of Array.isArray(meta?.files) ? meta.files : []) {
|
|
67
|
+
const p = typeof entry === 'string' ? entry : entry?.p;
|
|
68
|
+
if (typeof p === 'string' && p.trim()) rels.add(toPosix(p.trim()));
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// preflight already reported unreadable metadata
|
|
72
|
+
}
|
|
73
|
+
return [...rels].filter((rel) => !rel.startsWith(`${toPosix(SNAPSHOT_REL_DIR)}/`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function createUpdateSnapshot({ projectRoot, previousGlobalVersion = null, now = Date.now() }) {
|
|
77
|
+
const dir = path.join(projectRoot, SNAPSHOT_REL_DIR, String(now));
|
|
78
|
+
const filesDir = path.join(dir, 'files');
|
|
79
|
+
await fs.mkdir(filesDir, { recursive: true });
|
|
80
|
+
const files = [];
|
|
81
|
+
for (const rel of await listManagedFiles(projectRoot)) {
|
|
82
|
+
const abs = insideProject(projectRoot, rel);
|
|
83
|
+
if (!abs) continue;
|
|
84
|
+
let stat;
|
|
85
|
+
try {
|
|
86
|
+
stat = await fs.lstat(abs);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error?.code === 'ENOENT') {
|
|
89
|
+
files.push({ rel, existed: false });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`snapshot failed: cannot stat ${rel}: ${error.message}`);
|
|
93
|
+
}
|
|
94
|
+
if (!stat.isFile()) continue; // links and directories are re-created by install
|
|
95
|
+
const content = await fs.readFile(abs);
|
|
96
|
+
const dest = path.join(filesDir, rel);
|
|
97
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
98
|
+
await fs.writeFile(dest, content);
|
|
99
|
+
files.push({ rel, existed: true, sha256: sha256(content), mode: stat.mode & 0o777 });
|
|
100
|
+
}
|
|
101
|
+
const manifest = { version: 1, createdAt: new Date(now).toISOString(), previousGlobalVersion, files };
|
|
102
|
+
await fs.writeFile(path.join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
103
|
+
await pruneSnapshots(path.join(projectRoot, SNAPSHOT_REL_DIR));
|
|
104
|
+
return { dir, manifest };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function pruneSnapshots(root) {
|
|
108
|
+
try {
|
|
109
|
+
const names = (await fs.readdir(root)).filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(b) - Number(a));
|
|
110
|
+
for (const name of names.slice(SNAPSHOT_KEEP)) await fs.rm(path.join(root, name), { recursive: true, force: true });
|
|
111
|
+
} catch {
|
|
112
|
+
// advisory
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Restore the project to the snapshot and verify every file by hash. Files the failed
|
|
117
|
+
// install created (tracked in the NEW ledger but absent from the snapshot ledger) are removed.
|
|
118
|
+
export async function restoreUpdateSnapshot({ projectRoot, snapshotDir }) {
|
|
119
|
+
const manifest = JSON.parse(await fs.readFile(path.join(snapshotDir, 'manifest.json'), 'utf8'));
|
|
120
|
+
const failures = [];
|
|
121
|
+
let restored = 0;
|
|
122
|
+
|
|
123
|
+
for (const file of manifest.files) {
|
|
124
|
+
const abs = insideProject(projectRoot, file.rel);
|
|
125
|
+
if (!abs) continue;
|
|
126
|
+
try {
|
|
127
|
+
if (file.existed) {
|
|
128
|
+
const content = await fs.readFile(path.join(snapshotDir, 'files', file.rel));
|
|
129
|
+
await writeFileAtomic(abs, content);
|
|
130
|
+
if (typeof file.mode === 'number') await fs.chmod(abs, file.mode).catch(() => {});
|
|
131
|
+
restored += 1;
|
|
132
|
+
} else {
|
|
133
|
+
await fs.rm(abs, { force: true }); // created by the failed refresh
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
failures.push(`${file.rel}: ${error.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Files tracked by the failed install but unknown to the snapshot: created by it.
|
|
141
|
+
const known = new Set(manifest.files.map((f) => f.rel));
|
|
142
|
+
try {
|
|
143
|
+
const meta = await readInstallMetadata(path.join(projectRoot, INSTALL_META_REL));
|
|
144
|
+
for (const entry of Array.isArray(meta?.files) ? meta.files : []) {
|
|
145
|
+
const rel = toPosix(typeof entry === 'string' ? entry : entry?.p ?? '');
|
|
146
|
+
if (!rel || known.has(rel)) continue;
|
|
147
|
+
const abs = insideProject(projectRoot, rel);
|
|
148
|
+
if (!abs) continue;
|
|
149
|
+
const stat = await fs.lstat(abs).catch(() => null);
|
|
150
|
+
if (stat?.isFile()) await fs.rm(abs, { force: true });
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// ledger itself was restored above; nothing more to reconcile
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const mismatches = [];
|
|
157
|
+
for (const file of manifest.files) {
|
|
158
|
+
const abs = insideProject(projectRoot, file.rel);
|
|
159
|
+
if (!abs) continue;
|
|
160
|
+
if (file.existed) {
|
|
161
|
+
const now = await fs.readFile(abs).catch(() => null);
|
|
162
|
+
if (!now || sha256(now) !== file.sha256) mismatches.push(file.rel);
|
|
163
|
+
} else if (await fs.lstat(abs).then(() => true, () => false)) {
|
|
164
|
+
mismatches.push(file.rel);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
ok: failures.length === 0 && mismatches.length === 0,
|
|
169
|
+
restored,
|
|
170
|
+
failures,
|
|
171
|
+
mismatches,
|
|
172
|
+
previousGlobalVersion: manifest.previousGlobalVersion ?? null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, normalizeRelative } from './paths.js';
|
|
5
|
+
import { getFileOutline, queryCodeIndex } from './queryIndex.js';
|
|
6
|
+
import { inferRelatedTests } from './relatedTests.js';
|
|
7
|
+
import { discoverProjectFiles } from './buildIndex.js';
|
|
8
|
+
import { isSensitivePath } from './sensitivePath.js';
|
|
9
|
+
|
|
10
|
+
// TASK-B2 (PLAN-ENDUR-B §B2): deterministic, bounded context pack built from the
|
|
11
|
+
// existing index artifacts. No LLM, no network, no clock/random — the same
|
|
12
|
+
// inputs + the same index always serialize to byte-identical JSON.
|
|
13
|
+
//
|
|
14
|
+
// Contract notes (recorded in TASK-B2 Discussion):
|
|
15
|
+
// - `overBudget` (global constraint "flags overBudget instead of dropping a
|
|
16
|
+
// required item"): required entries (targets) are always admitted; if the
|
|
17
|
+
// admitted set alone violates maxBytes/maxFiles the pack says so honestly
|
|
18
|
+
// instead of silently dropping a target. `truncated` marks extras
|
|
19
|
+
// (related tests) dropped for budget — the pack then never exceeds the bounds.
|
|
20
|
+
// - `fallback: 'path-only'` is set when the index artifacts are missing/corrupt
|
|
21
|
+
// (whole pack is raw target paths) OR when any individual target could not be
|
|
22
|
+
// resolved through the index (that target is listed as a raw path).
|
|
23
|
+
// - `index` accepts preloaded `{ meta, files }` artifacts to override the disk
|
|
24
|
+
// reads used for targeting/fingerprint; symbol outlines still come from the
|
|
25
|
+
// on-disk symbols artifact via getFileOutline.
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_MAX_BYTES = 12000;
|
|
28
|
+
export const DEFAULT_MAX_FILES = 8;
|
|
29
|
+
|
|
30
|
+
const TARGET_SCORE = 1000;
|
|
31
|
+
const RELATED_TEST_SCORE = 12;
|
|
32
|
+
const SYMBOL_QUERY_LIMIT = 3;
|
|
33
|
+
const RELATED_TEST_LIMIT = 6;
|
|
34
|
+
|
|
35
|
+
const REQUIRED_REASONS = new Set(['target', 'target-symbol', 'unresolved-target', 'path-only']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a deterministic, bounded context pack from the existing index.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} [options]
|
|
41
|
+
* @param {string} [options.rootDir] Project root (default: cwd).
|
|
42
|
+
* @param {string[]} [options.targets] Target paths or symbol names.
|
|
43
|
+
* @param {number} [options.maxBytes] Byte budget for the files payload (default 12000).
|
|
44
|
+
* @param {number} [options.maxFiles] File-count budget (default 8).
|
|
45
|
+
* @param {{meta?: object, files?: object}} [options.index] Preloaded meta/files artifacts.
|
|
46
|
+
* @returns {Promise<{ok: boolean, files: Array<{path: string, reason: string, outline: Array, bytes: number}>,
|
|
47
|
+
* truncated: boolean, overBudget: boolean, stale: boolean, fallback: 'path-only'|null,
|
|
48
|
+
* bytes: number, fingerprint: {fileCount: number, newestMtimeMs: number, digest: string|null}|null}>}
|
|
49
|
+
* Never throws: every failure mode degrades to `ok: true` with a degraded pack.
|
|
50
|
+
*/
|
|
51
|
+
export async function buildContextPack({
|
|
52
|
+
rootDir = process.cwd(),
|
|
53
|
+
targets = [],
|
|
54
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
55
|
+
maxFiles = DEFAULT_MAX_FILES,
|
|
56
|
+
index = null,
|
|
57
|
+
} = {}) {
|
|
58
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
59
|
+
const byteBudget = normalizeBudget(maxBytes, DEFAULT_MAX_BYTES);
|
|
60
|
+
const fileBudget = normalizeBudget(maxFiles, DEFAULT_MAX_FILES);
|
|
61
|
+
const requestedTargets = normalizeTargets(targets);
|
|
62
|
+
|
|
63
|
+
const [filesArtifact, metaArtifact] = await Promise.all([
|
|
64
|
+
index?.files ?? readJsonArtifact(absoluteRoot, INDEX_ARTIFACTS.files),
|
|
65
|
+
index?.meta ?? readJsonArtifact(absoluteRoot, INDEX_ARTIFACTS.meta),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const indexUsable = isValidItemArtifact(filesArtifact) && isValidSchemaArtifact(metaArtifact);
|
|
69
|
+
const recordedFingerprint = normalizeRecordedFingerprint(metaArtifact?.sourceFingerprint);
|
|
70
|
+
|
|
71
|
+
// Staleness: compare meta.sourceFingerprint's file count / newest mtime to a
|
|
72
|
+
// fresh discovery enumeration. No auto refresh, no clock — pure comparison.
|
|
73
|
+
let stale = false;
|
|
74
|
+
if (indexUsable) {
|
|
75
|
+
if (!recordedFingerprint) {
|
|
76
|
+
stale = true;
|
|
77
|
+
} else {
|
|
78
|
+
const discovered = await discoverProjectFiles(absoluteRoot).catch(() => null);
|
|
79
|
+
stale = discovered
|
|
80
|
+
? isFingerprintStale(recordedFingerprint, fingerprintMetaFromEntries(absoluteRoot, discovered))
|
|
81
|
+
: true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const candidates = [];
|
|
86
|
+
let anyUnresolved = false;
|
|
87
|
+
|
|
88
|
+
if (indexUsable) {
|
|
89
|
+
const indexedFileSet = new Set(
|
|
90
|
+
filesArtifact.items
|
|
91
|
+
.filter((item) => item && typeof item === 'object' && typeof item.filePath === 'string')
|
|
92
|
+
.map((item) => item.filePath),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
let rank = 0;
|
|
96
|
+
const resolvedTargetPaths = [];
|
|
97
|
+
for (const target of requestedTargets) {
|
|
98
|
+
if (isSensitivePath(target)) {
|
|
99
|
+
rank += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const score = Math.max(1, TARGET_SCORE - rank);
|
|
103
|
+
rank += 1;
|
|
104
|
+
|
|
105
|
+
const directPath = normalizeTargetPath(target);
|
|
106
|
+
if (indexedFileSet.has(directPath)) {
|
|
107
|
+
candidates.push({ path: directPath, reason: 'target', score });
|
|
108
|
+
resolvedTargetPaths.push(directPath);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const queryHits = await queryCodeIndex({ rootDir: absoluteRoot, query: target, limit: SYMBOL_QUERY_LIMIT })
|
|
113
|
+
.catch(() => []);
|
|
114
|
+
const bestHit = queryHits.find((hit) => hit?.filePath && !isSensitivePath(hit.filePath));
|
|
115
|
+
if (bestHit) {
|
|
116
|
+
candidates.push({ path: bestHit.filePath, reason: 'target-symbol', score });
|
|
117
|
+
resolvedTargetPaths.push(bestHit.filePath);
|
|
118
|
+
} else {
|
|
119
|
+
anyUnresolved = true;
|
|
120
|
+
candidates.push({ path: directPath, reason: 'unresolved-target', score });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const relatedTests = resolvedTargetPaths.length > 0
|
|
125
|
+
? await inferRelatedTests({ rootDir: absoluteRoot, candidateFiles: resolvedTargetPaths, limit: RELATED_TEST_LIMIT })
|
|
126
|
+
.catch(() => [])
|
|
127
|
+
: [];
|
|
128
|
+
for (const related of relatedTests) {
|
|
129
|
+
if (!related?.filePath || isSensitivePath(related.filePath)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
candidates.push({ path: related.filePath, reason: 'related-test', score: RELATED_TEST_SCORE });
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
// Missing/corrupt index: path-only fallback listing the raw target paths.
|
|
136
|
+
anyUnresolved = requestedTargets.length > 0;
|
|
137
|
+
let rank = 0;
|
|
138
|
+
for (const target of requestedTargets) {
|
|
139
|
+
if (isSensitivePath(target)) {
|
|
140
|
+
rank += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
candidates.push({
|
|
144
|
+
path: normalizeTargetPath(target),
|
|
145
|
+
reason: 'path-only',
|
|
146
|
+
score: Math.max(1, TARGET_SCORE - rank),
|
|
147
|
+
});
|
|
148
|
+
rank += 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Stable order: score desc, then path asc (plan §B2 acceptance).
|
|
153
|
+
candidates.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
154
|
+
|
|
155
|
+
const seenPaths = new Set();
|
|
156
|
+
const deduped = candidates.filter((candidate) => {
|
|
157
|
+
if (seenPaths.has(candidate.path)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
seenPaths.add(candidate.path);
|
|
161
|
+
return true;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const withOutline = indexUsable;
|
|
165
|
+
const selected = [];
|
|
166
|
+
let usedBytes = 0;
|
|
167
|
+
for (const candidate of deduped.filter((entry) => REQUIRED_REASONS.has(entry.reason))) {
|
|
168
|
+
const entry = await materializeEntry(absoluteRoot, candidate, withOutline);
|
|
169
|
+
selected.push(entry);
|
|
170
|
+
usedBytes += entry.bytes;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let truncated = false;
|
|
174
|
+
for (const candidate of deduped.filter((entry) => !REQUIRED_REASONS.has(entry.reason))) {
|
|
175
|
+
const entry = await materializeEntry(absoluteRoot, candidate, withOutline);
|
|
176
|
+
if (selected.length >= fileBudget || usedBytes + entry.bytes > byteBudget) {
|
|
177
|
+
truncated = true;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
selected.push(entry);
|
|
181
|
+
usedBytes += entry.bytes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const overBudget = selected.length > fileBudget || usedBytes > byteBudget;
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
files: selected,
|
|
189
|
+
truncated,
|
|
190
|
+
overBudget,
|
|
191
|
+
stale,
|
|
192
|
+
fallback: !indexUsable || anyUnresolved ? 'path-only' : null,
|
|
193
|
+
bytes: usedBytes,
|
|
194
|
+
fingerprint: recordedFingerprint,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeBudget(value, defaultValue) {
|
|
199
|
+
const parsed = Number(value);
|
|
200
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeTargets(targets) {
|
|
204
|
+
const list = Array.isArray(targets) ? targets : [targets];
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const normalized = [];
|
|
207
|
+
for (const raw of list) {
|
|
208
|
+
const value = String(raw ?? '').trim();
|
|
209
|
+
if (!value || seen.has(value)) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
seen.add(value);
|
|
213
|
+
normalized.push(value);
|
|
214
|
+
}
|
|
215
|
+
return normalized;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeTargetPath(target) {
|
|
219
|
+
return String(target ?? '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function materializeEntry(absoluteRoot, candidate, withOutline) {
|
|
223
|
+
const outline = withOutline
|
|
224
|
+
? await getFileOutline({ rootDir: absoluteRoot, filePath: candidate.path }).catch(() => [])
|
|
225
|
+
: [];
|
|
226
|
+
const row = { path: candidate.path, reason: candidate.reason, outline, bytes: 0 };
|
|
227
|
+
row.bytes = Buffer.byteLength(JSON.stringify({ path: row.path, reason: row.reason, outline }));
|
|
228
|
+
return row;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function readJsonArtifact(rootDir, artifactName) {
|
|
232
|
+
try {
|
|
233
|
+
const content = await fs.readFile(getArtifactPath(rootDir, artifactName), 'utf8');
|
|
234
|
+
return JSON.parse(content);
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Same gate queryIndex applies (C92-B-03): artifacts written under a different
|
|
241
|
+
// INDEX_SCHEMA_VERSION are treated as absent. Item artifacts (files.json) must
|
|
242
|
+
// carry an `items` array; meta.json has none, so schema currency is its only gate.
|
|
243
|
+
function isValidItemArtifact(artifact) {
|
|
244
|
+
return Boolean(
|
|
245
|
+
artifact
|
|
246
|
+
&& typeof artifact === 'object'
|
|
247
|
+
&& Array.isArray(artifact.items)
|
|
248
|
+
&& artifact.schemaVersion === INDEX_SCHEMA_VERSION,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isValidSchemaArtifact(artifact) {
|
|
253
|
+
return Boolean(artifact && typeof artifact === 'object' && artifact.schemaVersion === INDEX_SCHEMA_VERSION);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizeRecordedFingerprint(raw) {
|
|
257
|
+
if (!raw || typeof raw !== 'object') {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
fileCount: Number(raw.fileCount),
|
|
262
|
+
newestMtimeMs: Number(raw.newestMtimeMs),
|
|
263
|
+
digest: typeof raw.digest === 'string' ? raw.digest : null,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isFingerprintStale(recorded, current) {
|
|
268
|
+
return recorded.fileCount !== current.fileCount || recorded.newestMtimeMs !== current.newestMtimeMs;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Mirrors createSourceFingerprint's normalization (buildIndex.js) for the two
|
|
272
|
+
// fields the plan's staleness contract compares: fileCount and newestMtimeMs.
|
|
273
|
+
function fingerprintMetaFromEntries(rootDir, entries) {
|
|
274
|
+
let fileCount = 0;
|
|
275
|
+
let newestMtimeMs = 0;
|
|
276
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
277
|
+
const filePath = normalizeRelative(rootDir, entry?.absolutePath ?? '');
|
|
278
|
+
if (!filePath || filePath === '.' || filePath.startsWith('../') || path.isAbsolute(filePath)) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
fileCount += 1;
|
|
282
|
+
newestMtimeMs = Math.max(newestMtimeMs, Number(entry.mtimeMs ?? -1));
|
|
283
|
+
}
|
|
284
|
+
return { fileCount, newestMtimeMs };
|
|
285
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Pure (I/O-free) sensitive-path predicate shared by the context pack builder
|
|
2
|
+
// (src/index/contextPack.js) and the delegation candidate mapper
|
|
3
|
+
// (src/core/agentRuntime/context.js). One definition so the two cannot drift.
|
|
4
|
+
export function isSensitivePath(value) {
|
|
5
|
+
const normalized = String(value ?? '').replace(/\\/g, '/');
|
|
6
|
+
const segments = normalized.split('/');
|
|
7
|
+
const base = segments[segments.length - 1] ?? '';
|
|
8
|
+
if (base.toLowerCase().startsWith('.env')) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (/\.(?:pem|key|p12|pfx|jks|keystore)$/i.test(base)) {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
if (/^(?:credentials?|\.npmrc|\.netrc|\.pypirc)(?:\.[^/]*)?$/i.test(base)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.[^/]*)?$/i.test(base)) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return segments.includes('.ssh') || segments.includes('.aws') || segments.includes('.gnupg');
|
|
21
|
+
}
|
package/src/index/taskRouting.js
CHANGED
|
@@ -1193,6 +1193,9 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
|
|
|
1193
1193
|
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
1194
1194
|
const residue = trimmed
|
|
1195
1195
|
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
1196
|
+
// Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
|
|
1197
|
+
// something already done. Only the verb is stripped, so a later imperative still survives.
|
|
1198
|
+
.replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
|
|
1196
1199
|
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
1197
1200
|
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
1198
1201
|
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# /ukit:ci-check — Manual cross-platform CI (Windows · macOS · Linux)
|
|
2
|
+
|
|
3
|
+
**Role: ORCHESTRATOR**
|
|
4
|
+
**Tool: Bash (`git`, `gh`, `node`)**
|
|
5
|
+
**Model: code model** (sonnet · unic-code)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
GitHub Actions CI never runs by itself in this project: `cross-platform-ci.yml` is `workflow_dispatch` only. **Only the user typing `/ukit:ci-check` (run mode) approves a dispatch.** Implement, commit, push, merge, review, release and handoff NEVER invoke this command implicitly.
|
|
10
|
+
|
|
11
|
+
All work is done by one helper; run it yourself, do not ask the user to:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
node .claude/ukit/index/ci-check.mjs [status [<runId>] | run] [--ref <branch>] [--dry-run] [--explicit] [--wait] [--json]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Modes
|
|
18
|
+
|
|
19
|
+
| User typed | You run | Dispatches? |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `/ukit:ci-check` | `run --explicit --json` then `status --wait --max-wait 540` (repeat until complete) | **max 1** |
|
|
22
|
+
| `/ukit:ci-check status` | `status --wait --max-wait 540` (read-only) | never |
|
|
23
|
+
| `/ukit:ci-check --ref <branch>` | `run --explicit --ref <branch> --json` then `status --wait --max-wait 540` (repeat until complete) | **max 1** |
|
|
24
|
+
| `/ukit:ci-check --dry-run` | `run --dry-run --json` (no `--explicit`) | never |
|
|
25
|
+
|
|
26
|
+
`--explicit` is passed ONLY because the user typed the command in run mode. Never add it for status/dry-run, never from another workflow.
|
|
27
|
+
|
|
28
|
+
## Steps
|
|
29
|
+
|
|
30
|
+
1. **Run the helper once.** It performs: A. precheck (git repo/remote/branch, `gh auth`, every workflow manual-only, commit exists on origin, no duplicate/in-flight run, default-branch registration, budget guard) → B. local preflight (referenced files tracked, docs registry; cheap only, no full suite) → C. exactly one `gh workflow run`, run id written to the project runtime storage (ci-check state file) *before and right after* the dispatch.
|
|
31
|
+
2. **If it reports `BLOCKED` / `PREFLIGHT_FAILED` / `DISPATCH_FAILED`: stop and report the blockers verbatim.** No dispatch happened (or one failed). Do NOT retry, do NOT push, do NOT edit the workflow to work around it. A branch missing on origin, or a local commit not on origin, needs the user's separate approval to push. A missing default-branch registration needs the bootstrap PR (`docs/design/CI-CROSS-PLATFORM-MATRIX.md`, "Default-branch bootstrap"); propose it, never do it silently.
|
|
32
|
+
3. **Monitor** with `status --wait --max-wait 540` (one Bash call is capped at 10 min; if the report is `INCOMPLETE` with `timedOutWaiting`, call it again, never dispatch) until the run completes (long wait: say `Waiting for GitHub Actions — run <id>`). If you are interrupted, the run id is on disk: resume with `/ukit:ci-check status` — it can never dispatch.
|
|
33
|
+
4. **Report** exactly this shape (helper output already contains the data):
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
CROSS_PLATFORM_CI: PASS | FAIL | INCOMPLETE
|
|
37
|
+
WINDOWS: <verdict> · stages · tests · time · job URL
|
|
38
|
+
MACOS: ...
|
|
39
|
+
LINUX: ...
|
|
40
|
+
Tests total / skipped (names) · failing stages · root cause WITH log evidence
|
|
41
|
+
Runner usage (billed-minute equivalent) · issues to fix · recommended next action
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A missing platform or an unfinished job is `INCOMPLETE`, never `PASS`. One failed platform makes the whole result `FAIL`. Root causes must quote log lines; if the logs do not show it, say so.
|
|
45
|
+
|
|
46
|
+
## Hard limits
|
|
47
|
+
|
|
48
|
+
- At most ONE dispatch per invocation; no auto-retry, no re-run of failed jobs, no second dispatch for "just one more try".
|
|
49
|
+
- Standard GitHub-hosted runners only; the budget guard fails closed (unknown runner, missing timeout, > `ciCheck.maxJobs` / `maxBilledMinutes`). Never edit `ciCheck.*` in `.ukit/storage/config.json` to get past a failed guard.
|
|
50
|
+
- Never push, merge, publish, bump versions, change billing, or enable `push` / `pull_request` / `schedule` triggers.
|
|
51
|
+
- The helper only reads/writes its own state under the project runtime storage; it never touches the real User Layer.
|
|
52
|
+
|
|
53
|
+
Works the same on Claude Code, Codex and omp (plain `node` + `gh`, no engine-specific tools).
|
|
@@ -289,16 +289,34 @@ async function applyMutation() {
|
|
|
289
289
|
// costs the normal permission prompt for this one command.
|
|
290
290
|
return;
|
|
291
291
|
}
|
|
292
|
+
// The default 1.2s slice is shorter than the queue a burst of parallel hooks builds on a
|
|
293
|
+
// loaded host, so waiters gave up "busy" and lost their rule (measured: 194 busy drops in
|
|
294
|
+
// 165 bursts of 30). A longer slice is still BOUNDED — lockBudgetMs takes the min with the
|
|
295
|
+
// remaining hook deadline minus the cleanup reserve — and exhaustion stays fail-closed.
|
|
292
296
|
const deadlineMs = lockModule.lockBudgetMs({
|
|
293
297
|
hookDeadlineMs: HOOK_DEADLINE_MS,
|
|
294
298
|
startedAt: LOCK_STARTED_AT,
|
|
299
|
+
maxWaitMs: 3000,
|
|
295
300
|
});
|
|
296
301
|
const outcome = await lockModule.withAsyncLock(usagePath, { deadlineMs }, applyMutation);
|
|
297
302
|
if (!outcome.ok) {
|
|
298
|
-
// busy or aborted: fail closed — write nothing unlocked, exit 0.
|
|
303
|
+
// busy or aborted: fail closed — write nothing unlocked, exit 0. The drop is journaled
|
|
304
|
+
// (reason + waited ms only — never the rule or command) so a lost allow rule is
|
|
305
|
+
// diagnosable instead of silent.
|
|
306
|
+
if (typeof lockModule.journalDroppedLockMutation === "function") {
|
|
307
|
+
await lockModule.journalDroppedLockMutation(usagePath, { reason: outcome.reason, waitedMs: outcome.waitedMs });
|
|
308
|
+
}
|
|
299
309
|
return;
|
|
300
310
|
}
|
|
301
|
-
})().catch(() => {
|
|
311
|
+
})().catch(async (error) => {
|
|
312
|
+
// A throw inside the locked mutation (e.g. atomic rename failure) is also a dropped rule.
|
|
313
|
+
// Record only the errno-style code, never the message (it can embed paths/rule text).
|
|
314
|
+
try {
|
|
315
|
+
const code = /^[A-Z0-9_]{2,24}$/.test(String(error && error.code)) ? error.code : "UNKNOWN";
|
|
316
|
+
const lockModule = await import(pathToFileURL(path.join(process.env.UKIT_RUNTIME_DIR || "", "async-lock.mjs")).href);
|
|
317
|
+
await lockModule.journalDroppedLockMutation(process.argv[2], { reason: "error:" + code, waitedMs: Date.now() - LOCK_STARTED_AT });
|
|
318
|
+
} catch {}
|
|
319
|
+
});
|
|
302
320
|
' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$RULE" "$NOW_UTC" "$MAX_RULES" >/dev/null 2>&1 || true
|
|
303
321
|
|
|
304
322
|
exit 0
|
|
@@ -489,7 +489,8 @@ function shellWriteHit(rawCommand) {
|
|
|
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
|
-
|
|
492
|
+
// The target token ends at the first shell operator (`2>/dev/null||true`, `>/dev/null&&x`).
|
|
493
|
+
if (!['/dev/null', '/dev/stdout', '/dev/stderr'].includes(m[2].split(/[;&|)]/)[0])) return true;
|
|
493
494
|
}
|
|
494
495
|
return SHELL_WRITE_PATTERNS.some((re) => re.test(command));
|
|
495
496
|
}
|
|
@@ -505,10 +506,16 @@ function patchTargets(input) {
|
|
|
505
506
|
if (typeof body !== 'string') continue;
|
|
506
507
|
const lines = body.split(/\r?\n/);
|
|
507
508
|
const found = [];
|
|
508
|
-
for (
|
|
509
|
+
for (let li = 0; li < lines.length; li++) {
|
|
510
|
+
const line = lines[li];
|
|
509
511
|
let m = line.match(/^\*\*\*\s+(?:(?:Add|Update|Delete)\s+File|Move\s+to):\s*(.+?)\s*$/);
|
|
510
512
|
if (!m) m = line.match(/^diff --git\s+(?:"?a\/)?(\S+)\s+"?b\/(\S+?)"?\s*$/);
|
|
511
513
|
if (m) { found.push(...m.slice(1).filter(Boolean)); continue; }
|
|
514
|
+
// A `---`/`+++` line is a header only as an adjacent `--- a` / `+++ b` pair; a lone one is
|
|
515
|
+
// hunk content (`--- x` = deleted line `-- x`). Pairing keeps declared hunk counts from hiding a header.
|
|
516
|
+
const isOld = /^--- \S/.test(line) && /^\+\+\+ \S/.test(lines[li + 1] ?? '');
|
|
517
|
+
const isNew = /^\+\+\+ \S/.test(line) && /^--- \S/.test(lines[li - 1] ?? '');
|
|
518
|
+
if (!isOld && !isNew) continue;
|
|
512
519
|
m = line.match(/^(?:---|\+\+\+)\s+(?:[ab]\/)?([^\t]+?)\s*(?:\t.*)?$/);
|
|
513
520
|
if (m && m[1] !== '/dev/null') found.push(m[1]);
|
|
514
521
|
}
|
|
@@ -103,7 +103,7 @@ setTimeout(() => {
|
|
|
103
103
|
}, HOOK_DEADLINE_MS).unref();
|
|
104
104
|
|
|
105
105
|
const projectRoot = process.env.PROJECT_ROOT;
|
|
106
|
-
|
|
106
|
+
let runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
|
|
107
107
|
const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'execution-ledger.mjs');
|
|
108
108
|
const intentPath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'handoff-intent.mjs');
|
|
109
109
|
|
|
@@ -169,6 +169,17 @@ async function emitOrdinaryResume(payload, source) {
|
|
|
169
169
|
})();
|
|
170
170
|
const source = typeof payload.source === 'string' ? payload.source : 'startup';
|
|
171
171
|
|
|
172
|
+
// Worktree scoping: the host pins CLAUDE_PROJECT_DIR to the primary checkout, so a
|
|
173
|
+
// session in an independent linked worktree must not be handed the primary's
|
|
174
|
+
// cursor. Only a git-verified worktree of this repo re-scopes the cursor; any
|
|
175
|
+
// failure keeps the primary path.
|
|
176
|
+
try {
|
|
177
|
+
const mod = await import(pathToFileURL(intentPath).href);
|
|
178
|
+
if (typeof mod.resolveHandoffRunRoot === 'function') {
|
|
179
|
+
runPath = path.join(mod.resolveHandoffRunRoot(projectRoot, payload.cwd), 'docs', 'AI_HANDOFF', 'RUN.md');
|
|
180
|
+
}
|
|
181
|
+
} catch {}
|
|
182
|
+
|
|
172
183
|
let text;
|
|
173
184
|
try {
|
|
174
185
|
text = await fs.promises.readFile(runPath, 'utf8');
|
|
@@ -188,6 +199,19 @@ async function emitOrdinaryResume(payload, source) {
|
|
|
188
199
|
process.exit(0);
|
|
189
200
|
}
|
|
190
201
|
|
|
202
|
+
// Structured PAUSED (C100 P1): never inject a resume for a paused run, on any source.
|
|
203
|
+
try {
|
|
204
|
+
const mod = await import(pathToFileURL(intentPath).href);
|
|
205
|
+
if (typeof mod.runCursorPaused === 'function' && mod.runCursorPaused(text) === true) {
|
|
206
|
+
process.stdout.write([
|
|
207
|
+
'UKit note: docs/AI_HANDOFF/RUN.md has a handoff-fullstack run that is PAUSED '
|
|
208
|
+
+ `(Phase: ${phase}; Cursor: ${field('Cursor') || 'not recorded'}).`,
|
|
209
|
+
'Not resumed or spawned; only an explicit user instruction lifts the pause. RUN.md untouched.',
|
|
210
|
+
].join('\n') + '\n');
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
} catch {}
|
|
214
|
+
|
|
191
215
|
const goal = field('Goal');
|
|
192
216
|
const base = field('Base');
|
|
193
217
|
const cursor = field('Cursor');
|