@ngockhoale/ukit 3.0.4 → 3.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,15 @@
3
3
  All notable changes to UKit are documented here.
4
4
 
5
5
 
6
+ ## 3.0.5 - 2026-09-24
7
+
8
+ - **Review-fix hardening (cycle C53 fix round 1)** — defects found by the release reviewer after 3.0.4 shipped:
9
+ - **Memory**: `appendCompactHistory` read-modify-write now runs under the store lock — concurrent appends can no longer lose entries (unlocked RMW race).
10
+ - **Installed mirror**: restored the missing `index-core` declaration in the installed `.claude/ukit/` mirror so dev-mirror parity checks pass on hydrated workspaces.
11
+ - **Shipped hooks**: `sensitive-data-guard` no longer bypassed by wrapper commands (`sudo`/`env`/`bash -c`/`xargs`) or basename-obfuscated invocations — the guard now unwraps wrappers and matches on the real binary name.
12
+ - **Test scoping**: `payloadDocs` stale-prose sweep restricted to shipped surfaces (root docs, `template_project`, `src`, `bin`, `scripts`) — it no longer walks `docs/AI_HANDOFF/**` reports or gitignored runtime dirs; `mirrorPayload` parity now tolerates stale/runtime files under the gitignored installed mirror.
13
+ - **Installed-mirror resync**: stale `index/detectProjectContext.js`, query-index cache, lock dirs, and resolve-context cache regenerated so the installed mirror matches the template payload.
14
+
6
15
  ## 3.0.4 - 2026-09-24
7
16
 
8
17
  - **Full source audit (cycle C53) — ~86 defects fixed across all 14 audit areas**; every fix carries a RED→GREEN regression test under `tests/audit/`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.0.4",
3
+ "version": "3.0.5",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,9 +18,8 @@ import {
18
18
  import {
19
19
  addUserRecord,
20
20
  getUserRecord,
21
- loadUserRecords,
22
21
  queryUserRecords,
23
- saveUserRecords,
22
+ removeUserRecord,
24
23
  updateUserRecord,
25
24
  userMemoryStats,
26
25
  } from '../../core/memory/userMemory.js';
@@ -297,12 +296,10 @@ async function runUserMemory(homeDir, args) {
297
296
  if (!id) {
298
297
  throw new Error('Missing record id. Usage: ukit memory --user forget <id>');
299
298
  }
300
- const records = await loadUserRecords({ homeDir });
301
- const remaining = records.filter((r) => r.id !== id);
302
- if (remaining.length === records.length) {
299
+ const removed = await removeUserRecord(id, { homeDir });
300
+ if (!removed) {
303
301
  throw new Error(`User memory record not found: ${id}`);
304
302
  }
305
- await saveUserRecords(remaining, { homeDir });
306
303
  console.log(`[UKit] Forgot user record ${id}.`);
307
304
  return;
308
305
  }
@@ -63,6 +63,21 @@ export async function updateUserRecord(id, patch = {}, { homeDir } = {}) {
63
63
  });
64
64
  }
65
65
 
66
+ /**
67
+ * removeUserRecord(id, {homeDir}={}) → record | null
68
+ * Deletes the record with `id` under the shared store lock — a concurrent
69
+ * add/update cannot be clobbered by the removal. Returns the removed record,
70
+ * or null when the id is absent.
71
+ */
72
+ export async function removeUserRecord(id, { homeDir } = {}) {
73
+ return mutateRecordStore(userRecordsPath(homeDir), (records) => {
74
+ const index = records.findIndex((r) => r.id === id);
75
+ if (index === -1) return null;
76
+ const removed = records[index];
77
+ return { result: removed, records: records.filter((r) => r.id !== id) };
78
+ });
79
+ }
80
+
66
81
  /**
67
82
  * getUserRecord(id, {homeDir}={}) → record | null
68
83
  */
@@ -10,9 +10,9 @@ const TASK_TYPE_BUDGETS = {
10
10
 
11
11
  const TRIVIAL_SIGNALS = ['typo', 'label', 'text', 'rename', 'color', 'spacing', 'toggle', 'config', 'comment'];
12
12
  const RISKY_SIGNALS = [
13
- 'auth', 'security', 'migration', 'uninstall', 'password', 'token', 'permission',
14
- 'delete all', 'drop table', 'race', 'flaky', 'intermittent', 'timeout', 'deadlock',
15
- 'core', 'shared', 'runtime',
13
+ 'auth', 'authentication', 'authorization', 'security', 'migration', 'uninstall',
14
+ 'password', 'token', 'permission', 'delete all', 'drop table', 'race', 'flaky',
15
+ 'flakiness', 'intermittent', 'timeout', 'deadlock', 'core', 'shared', 'runtime',
16
16
  ];
17
17
  const EXPANSION_SIGNALS = [
18
18
  'similar',
@@ -39,12 +39,15 @@ const EXPANSION_SIGNALS = [
39
39
 
40
40
  // Signals must match whole words: bare `includes` let 'score' fire 'core',
41
41
  // 'author' fire 'auth', 'context' fire 'text', and 'install ' fire 'all '.
42
+ // Common inflections (plural/past/-ing) still count — 'timeouts', 'migrations',
43
+ // 'deadlocked' — but 'authentication'/'authorization'/'flakiness' need explicit
44
+ // entries since their stems diverge from the base signal.
42
45
  function escapeSignalRegExp(signal) {
43
46
  return signal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44
47
  }
45
48
 
46
49
  function containsSignalWord(lower, signals) {
47
- return signals.some((signal) => new RegExp(`\\b${escapeSignalRegExp(signal)}\\b`).test(lower));
50
+ return signals.some((signal) => new RegExp(`\\b${escapeSignalRegExp(signal)}(?:s|es|ed|d|ing)?\\b`).test(lower));
48
51
  }
49
52
 
50
53
  export async function resolveContext({
@@ -271,9 +271,25 @@ async function evaluate({ rawInput, projectRoot, env }) {
271
271
  if (t === undefined) break;
272
272
  if (t === '--') { headIdx += 1; break; }
273
273
  if (isAssignToken(t)) { headIdx += 1; continue; }
274
+ if (wrapperPositionalPending > 0 && !isVerbToken(t) && !isFlagToken(t)) {
275
+ // The wrapper's own operand (user/host/duration/image/service), then
276
+ // the next token is the real command. Consumed BEFORE the wrapper
277
+ // branch so a 0-positional wrapper token sitting in an operand slot
278
+ // (`exec` after `docker`/`kubectl`) cannot clobber the pending count.
279
+ // Flags are never operands — they fall through to the flag branch.
280
+ wrapperPositionalPending -= 1;
281
+ headIdx += 1;
282
+ continue;
283
+ }
274
284
  const positionalCount = wrapperPositionals(t);
275
285
  if (positionalCount !== undefined) {
276
286
  wrapperPositionalPending = positionalCount;
287
+ // `docker compose <sub> <svc> <cmd>` carries one extra operand (the
288
+ // service) beyond the flat two — `compose` is a sub-CLI, not a
289
+ // subcommand like `exec`.
290
+ if (positionalCount === 2 && unquote(tokens[headIdx + 1]) === 'compose') {
291
+ wrapperPositionalPending += 1;
292
+ }
277
293
  headIdx += 1;
278
294
  continue;
279
295
  }
@@ -291,18 +307,12 @@ async function evaluate({ rawInput, projectRoot, env }) {
291
307
  }
292
308
  continue;
293
309
  }
294
- if (wrapperPositionalPending > 0 && !isVerbToken(t)) {
295
- // The wrapper's own operand (user/host/duration/image), then the
296
- // next token is the real command.
297
- wrapperPositionalPending -= 1;
298
- headIdx += 1;
299
- continue;
300
- }
301
310
  break;
302
311
  }
303
312
  const effectiveHead = unquote(tokens[headIdx] ?? '');
313
+ const headBase = effectiveHead.replace(/^.*\//, '');
304
314
 
305
- if (DUMP_VERBS.has(effectiveHead)) {
315
+ if (DUMP_VERBS.has(headBase)) {
306
316
  for (const token of tokens.slice(headIdx + 1)) {
307
317
  const cleaned = token.replace(/^["']+|["',:]+$/g, '');
308
318
  const kind = classifySecretFile(cleaned);
@@ -312,7 +322,6 @@ async function evaluate({ rawInput, projectRoot, env }) {
312
322
  }
313
323
  }
314
324
 
315
- const headBase = effectiveHead.replace(/^.*\//, '');
316
325
  const CRED_USER_TOOLS = new Set(['curl', 'wget', 'ftp', 'lftp', 'aria2c', 'http', 'https']);
317
326
  // `-uuser:pass`, `-u user:pass`, `--user=user:pass`, `--user user:pass` —
318
327
  // the old `\s+` separator missed the joined and `=` forms entirely.
@@ -967,6 +967,7 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
967
967
  const previousCodePaths = new Set(
968
968
  filesArtifact.items.filter((item) => CODE_EXTENSIONS.has(item?.ext)).map((item) => item.filePath),
969
969
  );
970
+ const currentCodePaths = new Set(codeFiles.map((file) => file.filePath));
970
971
  const canReuseArchetypes = parsedFiles.length === 0
971
972
  && !recordsChanged
972
973
  && archetypesArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
@@ -1358,11 +1359,10 @@ const TASK_TYPE_BUDGETS = {
1358
1359
  'non-trivial': { minFiles: 4, maxFiles: 8 },
1359
1360
  };
1360
1361
 
1361
- const TRIVIAL_SIGNALS = ['typo', 'label', 'text', 'rename', 'color', 'spacing', 'toggle', 'config', 'comment'];
1362
1362
  const RISKY_SIGNALS = [
1363
- 'auth', 'security', 'migration', 'uninstall', 'password', 'token', 'permission',
1364
- 'delete all', 'drop table', 'race', 'flaky', 'intermittent', 'timeout', 'deadlock',
1365
- 'core', 'shared', 'runtime',
1363
+ 'auth', 'authentication', 'authorization', 'security', 'migration', 'uninstall',
1364
+ 'password', 'token', 'permission', 'delete all', 'drop table', 'race', 'flaky',
1365
+ 'flakiness', 'intermittent', 'timeout', 'deadlock', 'core', 'shared', 'runtime',
1366
1366
  ];
1367
1367
  const EXPANSION_SIGNALS = [
1368
1368
  'similar',
@@ -1389,12 +1389,15 @@ const EXPANSION_SIGNALS = [
1389
1389
 
1390
1390
  // Signals must match whole words: bare `includes` let 'score' fire 'core',
1391
1391
  // 'author' fire 'auth', 'context' fire 'text', and 'install ' fire 'all '.
1392
+ // Common inflections (plural/past/-ing) still count — 'timeouts', 'migrations',
1393
+ // 'deadlocked' — but 'authentication'/'authorization'/'flakiness' need explicit
1394
+ // entries since their stems diverge from the base signal.
1392
1395
  function escapeSignalRegExp(signal) {
1393
1396
  return signal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1394
1397
  }
1395
1398
 
1396
1399
  function containsSignalWord(lower, signals) {
1397
- return signals.some((signal) => new RegExp(`\\b${escapeSignalRegExp(signal)}\\b`).test(lower));
1400
+ return signals.some((signal) => new RegExp(`\\b${escapeSignalRegExp(signal)}(?:s|es|ed|d|ing)?\\b`).test(lower));
1398
1401
  }
1399
1402
 
1400
1403
  export async function resolveContext({