@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/manifests/documentation.yaml +61 -0
  3. package/manifests/platform.full.yaml +24 -0
  4. package/package.json +7 -1
  5. package/scripts/audit/subagent-cost/metrics.mjs +14 -2
  6. package/scripts/audit/subagent-cost/reader.mjs +15 -3
  7. package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
  8. package/scripts/dev/worktree-preflight.mjs +147 -0
  9. package/src/cli/commands/update.js +59 -2
  10. package/src/core/agentRuntime/context.js +78 -0
  11. package/src/core/diffPlan.js +100 -102
  12. package/src/core/docContracts.js +3 -0
  13. package/src/core/observability/emit/sessionBoot.js +14 -0
  14. package/src/core/runtimeConfigMerge.js +125 -0
  15. package/src/core/updateSafety.js +174 -0
  16. package/src/index/contextPack.js +285 -0
  17. package/src/index/sensitivePath.js +21 -0
  18. package/src/index/taskRouting.js +3 -0
  19. package/template_project/.claude/commands/ukit/ci-check.md +53 -0
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +20 -2
  21. package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
  22. package/template_project/.claude/hooks/handoff-resume.sh +25 -1
  23. package/template_project/.claude/hooks/record-execution.mjs +9 -0
  24. package/template_project/.claude/hooks/skill-router.sh +3 -0
  25. package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
  26. package/template_project/.claude/ukit/index/post-edit-verify.mjs +4 -4
  27. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
  28. package/template_project/.claude/ukit/index/route-task.mjs +3 -0
  29. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +154 -12
  30. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +61 -0
  31. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
  32. package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
  33. package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
  34. package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
  35. package/template_project/.claude/ukit/runtime/safe-patch-core.mjs +71 -0
  36. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +53 -6
  37. package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
  38. 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
+ }
@@ -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
- if (!['/dev/null', '/dev/stdout', '/dev/stderr'].includes(m[2].replace(/[;&|)]+$/, ''))) return true;
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 (const line of lines) {
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
- const runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
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');