@ngockhoale/ukit 3.4.1 → 3.4.3

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 (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/code.js +29 -5
  4. package/src/cli/commands/decision.js +18 -4
  5. package/src/cli/commands/doctor.js +7 -3
  6. package/src/cli/commands/install.js +29 -4
  7. package/src/cli/commands/memory.js +25 -5
  8. package/src/cli/commands/telemetry.js +18 -1
  9. package/src/cli/commands/vm.js +7 -1
  10. package/src/context/detectProjectContext.js +7 -2
  11. package/src/core/agentRuntime/contract.js +5 -1
  12. package/src/core/agentRuntime/eventStore.js +54 -7
  13. package/src/core/agentRuntime/recovery.js +22 -15
  14. package/src/core/agentRuntime/supervisor.js +71 -13
  15. package/src/core/applyPlan.js +11 -1
  16. package/src/core/codeintel/compiler.js +51 -8
  17. package/src/core/codeintel/diagnostics.js +124 -33
  18. package/src/core/codeintel/freshness.js +25 -12
  19. package/src/core/codeintel/invalidation.js +11 -3
  20. package/src/core/codeintel/retriever.js +53 -20
  21. package/src/core/codeintel/router.js +19 -9
  22. package/src/core/codeintel/summaries.js +4 -3
  23. package/src/core/codeintel/vectorProvider.js +30 -4
  24. package/src/core/compact/index.js +24 -7
  25. package/src/core/compact/threshold.js +49 -14
  26. package/src/core/diffPlan.js +51 -23
  27. package/src/core/ensureGitignore.js +19 -2
  28. package/src/core/fileOps.js +61 -0
  29. package/src/core/memory/hygiene.js +51 -1
  30. package/src/core/memory/migrate.js +41 -21
  31. package/src/core/memory/store.js +96 -61
  32. package/src/core/metadata.js +37 -2
  33. package/src/core/observability/adapters/ingest.js +30 -2
  34. package/src/core/observability/emit/config.js +19 -4
  35. package/src/core/observability/emit/crash.js +3 -1
  36. package/src/core/observability/emit/recorder.js +15 -7
  37. package/src/core/observability/privacy/sanitizeObserved.js +3 -1
  38. package/src/core/observability/segments/internal.js +36 -8
  39. package/src/core/observability/segments/retention.js +11 -0
  40. package/src/core/observability/support/import.js +27 -1
  41. package/src/core/output/index.js +16 -1
  42. package/src/core/permissionDoctor.js +72 -9
  43. package/src/core/repairBrokenHooks.js +15 -2
  44. package/src/core/reviewPanelAggregate.js +26 -10
  45. package/src/core/runInstallPipeline.js +71 -22
  46. package/src/core/runtimeConfig.js +2 -0
  47. package/src/core/status.js +2 -0
  48. package/src/core/taskBudgetValidator.js +7 -1
  49. package/src/core/taskProgressGuard.js +11 -1
  50. package/src/core/unattendedDoctor.js +36 -5
  51. package/src/core/uninstall.js +52 -12
  52. package/src/core/update.js +5 -1
  53. package/src/decision/client.js +158 -27
  54. package/src/decision/reviewVerdict.js +23 -7
  55. package/src/diagnostics/failurePatterns.js +1 -1
  56. package/src/diagnostics/feedbackEvents.js +1 -1
  57. package/src/diagnostics/routeOutcomes.js +42 -4
  58. package/src/diagnostics/skillAccuracy.js +35 -4
  59. package/src/index/buildIndex.js +123 -26
  60. package/src/index/fixLoopEscalation.js +3 -0
  61. package/src/index/gitHooks.js +99 -29
  62. package/src/index/importResolution.js +7 -1
  63. package/src/index/playbookRegistry.js +15 -11
  64. package/src/index/queryIndex.js +28 -10
  65. package/src/index/routeResolver.js +8 -3
  66. package/src/index/taskRouting.js +37 -2
  67. package/src/learning/codeProposals.js +24 -5
  68. package/src/learning/selfImprove.js +29 -5
  69. package/src/learning/tunedOverlay.js +18 -7
  70. package/src/learning/tuning.js +10 -4
  71. package/src/skill/auditSkill.js +46 -7
  72. package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
  73. package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
  74. package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
  75. package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
  76. package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
  77. package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
  78. package/template_project/.claude/hooks/session-episode.sh +60 -28
  79. package/template_project/.claude/hooks/verification-guard.sh +26 -15
  80. package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
  81. package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
  82. package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
  83. package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
  84. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
  85. package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
  86. package/template_project/.claude/ukit/index/query-index.mjs +13 -6
  87. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
  88. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
  89. package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
  90. package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
  91. package/template_project/.claude/ukit/index/route-task.mjs +15 -0
  92. package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
  93. package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
  94. package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
  95. package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
  96. package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
  97. package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
  98. package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
  99. package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
  100. package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
  101. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
  102. package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
  103. package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
  104. package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
  105. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
  106. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
  107. package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
  108. package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
  109. package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
  110. package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.3 - 2026-09-29
6
+
7
+ Republish of 3.4.2 — npm staged-version E409 limbo (same content, version bump only).
8
+
9
+ ## 3.4.2 - 2026-09-29
10
+
11
+ **Bug-sweep cycles C91–C93 — 42 fixes.** Handoff cycles C91 (26), C92 (13) and
12
+ C93 (3) closed the confirmed-bug backlog across deny-gates, install/uninstall
13
+ data-loss paths, agent-runtime supervision, index integrity, codeintel
14
+ freshness, telemetry durability, routing floors, and runtime CLI locking.
15
+
16
+ - Deny gates: quote/splice/IFS bypasses and compact `-c`/`eval` operand
17
+ recursion in the PreToolUse guards (templateHooks + audit tests).
18
+ - Install/uninstall: typed `InstallMetadataError` on corrupt `install.json`
19
+ (doctor), uninstall refuses corrupt/unreadable ledger instead of silently
20
+ proceeding, plus other destructive-path hardening.
21
+ - agentRuntime: supervisor pgid kill-target fix, `cancel_pending` edge,
22
+ dead `recovery.js` cancelled-branch removal.
23
+ - Index/codeintel: integrity checks, freshness/retrieval guards.
24
+ - Handoff machinery: model-tier guard override anchor (task-file declaration +
25
+ `.ukit/storage/tier-override` marker), Release Policy mirror restored into
26
+ root `CLAUDE.md`/`AGENTS.md`.
27
+
5
28
  ## 3.4.1 - 2026-09-28
6
29
 
7
30
  Republish of 3.4.0 — npm staged-version E409 limbo (same content, version bump only).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.1",
3
+ "version": "3.4.3",
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",
@@ -9,21 +9,40 @@ import { IndexFileSyntaxProvider } from '../../core/codeintel/providers.js';
9
9
  const HELP_FLAGS = new Set(['--help', '-h', 'help']);
10
10
  const SUBCOMMANDS = new Set(['peek', 'search', 'context', 'impact']);
11
11
 
12
- function extractFlag(args, flag) {
13
- const index = args.indexOf(flag);
12
+ export function extractFlag(args, flag) {
13
+ // Both forms are accepted, mirroring indexArgs.js: `--flag value` and
14
+ // `--flag=value` (first `=` separates, so `a=b` values survive whole).
15
+ const spaceIndex = args.indexOf(flag);
16
+ const equalsIndex = args.findIndex((arg) => arg.startsWith(`${flag}=`));
17
+ const index = spaceIndex < 0 ? equalsIndex
18
+ : equalsIndex < 0 ? spaceIndex
19
+ : Math.min(spaceIndex, equalsIndex);
14
20
  if (index < 0) return { value: null, rest: args };
15
- const value = args[index + 1];
21
+ const isEqualsForm = args[index].startsWith(`${flag}=`);
22
+ const value = isEqualsForm ? args[index].slice(flag.length + 1) : args[index + 1];
16
23
  // A missing value — or the next flag swallowed as the value — used to pass
17
24
  // silently (e.g. `--mode --budget 5` set mode='--budget' and dropped --budget).
18
- if (typeof value !== 'string' || value.startsWith('--')) {
25
+ // The `=` form shares the contract: `--mode=` and `--mode=--budget` are errors.
26
+ if (typeof value !== 'string' || value === '' || value.startsWith('--')) {
19
27
  throw new Error(`Missing value for ${flag}.`);
20
28
  }
21
29
  return {
22
30
  value,
23
- rest: [...args.slice(0, index), ...args.slice(index + 2)],
31
+ rest: isEqualsForm
32
+ ? [...args.slice(0, index), ...args.slice(index + 1)]
33
+ : [...args.slice(0, index), ...args.slice(index + 2)],
24
34
  };
25
35
  }
26
36
 
37
+ // First occurrence wins; a later duplicate stays in `rest` and is rejected by
38
+ // assertNoUnknownOptions — deterministic, never silently half-applied.
39
+ function assertNoUnknownOptions(args) {
40
+ const unknown = args.find((arg) => arg.startsWith('--'));
41
+ if (unknown) {
42
+ throw new Error(`Unknown option: ${unknown.split('=', 1)[0]}`);
43
+ }
44
+ }
45
+
27
46
  function hasFlag(args, flag) {
28
47
  return args.includes(flag);
29
48
  }
@@ -89,6 +108,7 @@ export async function runCode({ projectRoot, argv = [] }) {
89
108
 
90
109
  if (subcommand === 'peek') {
91
110
  const { value: lines, rest: args } = extractFlag(withoutJson, '--lines');
111
+ assertNoUnknownOptions(args);
92
112
  const filePath = args[0];
93
113
  if (!filePath) {
94
114
  console.error('[UKit] Missing file path. Usage: ukit code peek <path> [--lines a-b] [--json]');
@@ -112,6 +132,7 @@ export async function runCode({ projectRoot, argv = [] }) {
112
132
  if (subcommand === 'search') {
113
133
  const { value: limitArg, rest: args } = extractFlag(withoutJson, '--limit');
114
134
  const limit = parsePositiveInt(limitArg, 20);
135
+ assertNoUnknownOptions(args);
115
136
  const query = args.join(' ').trim();
116
137
  if (!query) {
117
138
  console.error('[UKit] Missing query. Usage: ukit code search "<query>" [--limit n] [--json]');
@@ -138,6 +159,7 @@ export async function runCode({ projectRoot, argv = [] }) {
138
159
  const budget = budgetArg !== null ? parsePositiveInt(budgetArg, null) : undefined;
139
160
  const diagnostics = hasFlag(afterBudget, '--diagnostics');
140
161
  const args = afterBudget.filter((arg) => arg !== '--diagnostics');
162
+ assertNoUnknownOptions(args);
141
163
  const task = args.join(' ').trim();
142
164
  if (!task) {
143
165
  console.error('[UKit] Missing task. Usage: ukit code context "<task>" [--mode m] [--budget n] [--diagnostics] [--json]');
@@ -156,6 +178,7 @@ export async function runCode({ projectRoot, argv = [] }) {
156
178
  // subcommand === 'impact'
157
179
  const { value: depthArg, rest: impactArgs } = extractFlag(withoutJson, '--depth');
158
180
  const depth = depthArg !== null ? parsePositiveInt(depthArg, null) : undefined;
181
+ assertNoUnknownOptions(impactArgs);
159
182
  const target = impactArgs[0];
160
183
  if (!target) {
161
184
  console.error('[UKit] Missing target. Usage: ukit code impact <path|symbol> [--depth N] [--json]');
@@ -185,4 +208,5 @@ export function printCodeHelp() {
185
208
  console.log(' --budget <n> Override routed token budget');
186
209
  console.log(' --depth <N> Impact-mode hop depth (clamped 1..codeIntel.impact.maxDepth)');
187
210
  console.log(' --diagnostics Include post-edit diagnostics in next_actions');
211
+ console.log(' Every value flag also accepts the --flag=value form.');
188
212
  }
@@ -51,11 +51,25 @@ function printUsage(stream = console.log) {
51
51
 
52
52
  function parseArgs(argv) {
53
53
  const out = { setUnic: false, clear: false, status: false };
54
+ // Value-taking flags must not swallow the NEXT flag: `--key --status` used
55
+ // to write the literal string '--status' into gatewayDecision.json.
56
+ const takeValue = (i, flag) => {
57
+ const value = argv[i + 1];
58
+ if (value === undefined || value.startsWith('-')) {
59
+ return { error: `${flag} requires a value` };
60
+ }
61
+ return { value };
62
+ };
54
63
  for (let i = 0; i < argv.length; i++) {
55
64
  const a = argv[i];
56
- if (a === '--base-url') out.baseUrl = argv[++i];
57
- else if (a === '--key') out.key = argv[++i];
58
- else if (a === '--scheme') out.scheme = argv[++i];
65
+ if (a === '--base-url' || a === '--key' || a === '--scheme') {
66
+ const taken = takeValue(i, a);
67
+ if (taken.error) return { error: taken.error };
68
+ if (a === '--base-url') out.baseUrl = taken.value;
69
+ else if (a === '--key') out.key = taken.value;
70
+ else out.scheme = taken.value;
71
+ i += 1;
72
+ }
59
73
  else if (a === '--set-unic') out.setUnic = true;
60
74
  else if (a === '--clear') out.clear = true;
61
75
  else if (a === '--status') out.status = true;
@@ -74,7 +88,7 @@ async function readGatewayFile(filePath) {
74
88
 
75
89
  async function writeGatewayFile(filePath, json) {
76
90
  await fs.mkdir(path.dirname(filePath), { recursive: true });
77
- const tmp = `${filePath}.tmp-${process.pid}`;
91
+ const tmp = `${filePath}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
78
92
  await fs.writeFile(tmp, JSON.stringify(json, null, 2) + '\n', { mode: 0o600 });
79
93
  await fs.rename(tmp, filePath);
80
94
  }
@@ -2,7 +2,8 @@ import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import { parse } from 'yaml';
5
- import { pathExists, readJsonIfExists } from '../../core/fileOps.js';
5
+ import { pathExists } from '../../core/fileOps.js';
6
+ import { readInstallMetadata } from '../../core/metadata.js';
6
7
  import { buildPathConfig } from '../../core/paths.js';
7
8
  import { buildRuntimePaths } from '../../core/runtimePaths.js';
8
9
  import { inspectRuntimeConfig } from '../../core/runtimeConfig.js';
@@ -484,7 +485,7 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
484
485
  const manifest = await loadManifest(pathConfig.manifestPath);
485
486
  const stack = await detectStack(projectRoot);
486
487
  const providers = await detectProviders(projectRoot);
487
- const installMeta = await readJsonIfExists(pathConfig.installMetaPath);
488
+ const installMeta = await readInstallMetadata(pathConfig.installMetaPath);
488
489
  const runtimeConfigInspection = await inspectRuntimeConfig(projectRoot, { homeDir });
489
490
 
490
491
  const trackedPaths = Array.isArray(installMeta?.files)
@@ -498,7 +499,10 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
498
499
  let worklogLineCount = 0;
499
500
  try {
500
501
  const content = await fs.readFile(path.join(projectRoot, 'docs', 'WORKLOG.md'), 'utf8');
501
- worklogLineCount = content.split('\n').length;
502
+ // FR-025 / W2-A02 — POSIX line counting: a trailing '\n' terminates the last
503
+ // line, it does not start a new one. `split('\n').length` off-by-ones every
504
+ // well-formed file; strip one terminator first (600-line file → 600, not 601).
505
+ worklogLineCount = content === '' ? 0 : content.replace(/\n$/, '').split('\n').length;
502
506
  } catch {
503
507
  // file may not exist
504
508
  }
@@ -6,8 +6,8 @@ import { resolveDecisionApiKey, resolveDecisionBaseUrl } from '../../core/gatewa
6
6
  import { buildCodeIndex, buildDerivedIndexArtifacts } from '../../index/buildIndex.js';
7
7
  import { installIndexRefreshHooks } from '../../index/gitHooks.js';
8
8
  import fs from 'node:fs/promises';
9
- import { pathExists, readJsonIfExists, removeFileOrLinkOnly } from '../../core/fileOps.js';
10
- import { removeTrackedPathsFromMetadata } from '../../core/metadata.js';
9
+ import { pathExists, removeFileOrLinkOnly } from '../../core/fileOps.js';
10
+ import { readInstallMetadata, removeTrackedPathsFromMetadata } from '../../core/metadata.js';
11
11
  import { provisionSupportDir } from '../../core/observability/support/provision.js';
12
12
  import {
13
13
  ADAPTER_BY_KEY,
@@ -72,7 +72,7 @@ function isSameOrDescendantPath(candidatePath, parentPath) {
72
72
 
73
73
  async function loadTrackedManagedPathSet(projectRoot) {
74
74
  const installMetaPath = path.join(projectRoot, '.claude', 'ukit', '.ukit', 'install.json');
75
- const installMeta = await readJsonIfExists(installMetaPath);
75
+ const installMeta = await readInstallMetadata(installMetaPath);
76
76
  if (installMeta?.tool !== 'ukit' || !Array.isArray(installMeta.files)) {
77
77
  return null;
78
78
  }
@@ -211,8 +211,16 @@ export async function pruneDeselectedAdapters({
211
211
  continue;
212
212
  }
213
213
 
214
+ // C92-G-01: iterate the install.json-tracked subset only. A managed-path
215
+ // name is adapter-owned, not file-owned — an untracked file at a managed
216
+ // path (e.g. `.codex/settings.local.json`, a `mergeStrategy: skip` seed
217
+ // that was never written, so never tracked) is user content, and
218
+ // unlinking it is unrecoverable data loss.
219
+ const untrackedManagedPaths = adapter.managedPaths.filter(
220
+ (relativePath) => !existingManagedPaths.includes(relativePath),
221
+ );
214
222
  let removedCount = 0;
215
- for (const relativePath of adapter.managedPaths) {
223
+ for (const relativePath of existingManagedPaths) {
216
224
  const absolutePath = path.join(projectRoot, relativePath);
217
225
  const removed = await removeFileOrLinkOnly(absolutePath);
218
226
  if (removed) {
@@ -234,6 +242,14 @@ export async function pruneDeselectedAdapters({
234
242
  }
235
243
  }
236
244
 
245
+ for (const relativePath of untrackedManagedPaths) {
246
+ if (await pathExists(path.join(projectRoot, relativePath))) {
247
+ console.log(
248
+ `[UKit] Keeping ${relativePath} — not recorded in install.json, so it is treated as user content.`,
249
+ );
250
+ }
251
+ }
252
+
237
253
  console.log(`[UKit] Removed ${removedCount} ${adapter.label} path(s).`);
238
254
  }
239
255
  } finally {
@@ -318,6 +334,15 @@ export async function runInstall({ packageRoot, projectRoot, packageVersion, arg
318
334
  );
319
335
  }
320
336
 
337
+ // C92-C-02: pruned obsolete managed paths (e.g. a pack skill whose heuristic
338
+ // flipped off) are backed up under .claude/ukit/.ukit/backups before unlink —
339
+ // and the count is reported, never silent.
340
+ if ((result.prune?.removed ?? 0) > 0) {
341
+ console.log(
342
+ `[UKit] Pruned ${result.prune.removed} obsolete managed path(s) — backups under .claude/ukit/.ukit/backups/.`,
343
+ );
344
+ }
345
+
321
346
  if (result.userLayer) {
322
347
  if (result.userLayer.error) {
323
348
  console.warn(`[UKit] Warning: user-layer seeding skipped — ${result.userLayer.error}`);
@@ -247,19 +247,30 @@ async function runMemoryV2(projectRoot, homeDir, args) {
247
247
  counts[entry.action] = (counts[entry.action] ?? 0) + 1;
248
248
  }
249
249
  const countText = Object.entries(counts).map(([a, n]) => `${a}=${n}`).join(' ') || 'none';
250
- console.log(`[UKit] migrate dry-run: ${countText} (migrated=${result.migrated} skipped=${result.skipped})`);
250
+ console.log(`[UKit] migrate dry-run: ${countText} (migrated=${result.migrated} skipped=${result.skipped} corrupt=${result.corruptFiles ?? 0})`);
251
+ if (result.corruptFiles > 0) {
252
+ console.warn(`[UKit] ${result.corruptFiles} corrupt legacy file(s) skipped: ${result.corrupt.join(', ')}`);
253
+ }
251
254
  for (const entry of result.plan) {
252
255
  console.log(`[UKit] ${entry.action} ${entry.id} (${entry.source}) — ${entry.reason}`);
253
256
  }
254
257
  return;
255
258
  }
256
259
 
260
+ if (result.corruptFiles > 0) {
261
+ console.warn(
262
+ `[UKit] ${result.corruptFiles} corrupt legacy file(s) NOT migrated: ${result.corrupt.join(', ')}`
263
+ + ' — migration marker withheld; repair or remove them and re-run.',
264
+ );
265
+ }
257
266
  if (result.migrated === 0) {
258
- console.log('[UKit] Nothing to migrate (marker present or no legacy memory).');
267
+ console.log(result.corruptFiles > 0
268
+ ? '[UKit] Nothing migratable yet — corrupt legacy files remain (see above).'
269
+ : '[UKit] Nothing to migrate (marker present or no legacy memory).');
259
270
  return;
260
271
  }
261
272
  console.log(`[UKit] Migrated ${result.migrated} record(s) to v2.`);
262
- console.log(`[UKit] Backup: ${result.backupDir ?? 'none'} — marker: ${result.markerPath}`);
273
+ console.log(`[UKit] Backup: ${result.backupDir ?? 'none'} — marker: ${result.corruptFiles > 0 ? 'withheld (corrupt files remain)' : result.markerPath}`);
263
274
  const stats = await v2Stats(projectRoot, { homeDir });
264
275
  console.log(`[UKit] v2 store: ${stats.total} record(s) — ${JSON.stringify(stats.byType)}`);
265
276
  return;
@@ -487,8 +498,17 @@ async function runMemoryPromote(projectRoot, homeDir, args) {
487
498
  let existing = '';
488
499
  try {
489
500
  existing = await fs.readFile(memoryMdPath, 'utf8');
490
- } catch {
491
- existing = '';
501
+ } catch (error) {
502
+ // C90-11: ENOENT is the only safe fallback — any other read failure
503
+ // (EACCES, EIO, EISDIR…) means MEMORY.md exists but is unreadable, and
504
+ // proceeding would overwrite it with a fresh block, destroying the
505
+ // user's content. Abort with the original error.
506
+ if (error?.code !== 'ENOENT') {
507
+ throw new Error(
508
+ `memory promote: cannot read ${memoryMdPath} (${error?.code ?? error})`
509
+ + ' — fix permissions or remove the file before promoting.',
510
+ );
511
+ }
492
512
  }
493
513
 
494
514
  const outsideIds = externallyReferencedIds(existing);
@@ -39,6 +39,7 @@ import {
39
39
  import { maybeRefreshSupport } from '../../core/observability/support/schedule.js';
40
40
  import { resolveSupportDir, SUPPORT_DIR_NAME } from '../../core/observability/support/paths.js';
41
41
  import { validateSupportBundle } from '../../core/observability/support/import.js';
42
+ import { isValidOperationId } from '../../core/agentRuntime/eventStore.js';
42
43
  import { runEvaluation } from '../../core/observability/evaluation/runner.js';
43
44
 
44
45
  const HELP_FLAGS = new Set(['--help', '-h', 'help']);
@@ -380,6 +381,16 @@ async function exportSupportCommand({ projectRoot, config }) {
380
381
  * writes by contract (SPEC G7-FR04).
381
382
  */
382
383
  async function exportRunCommand({ projectRoot, config, operationId }) {
384
+ // W2-A03: the operand becomes a journal/state filename inside
385
+ // agent-runtime. Validate it against the ONE canonical eventStore rule
386
+ // before any path/bundle work — a traversal opId is a usage error, not a
387
+ // degraded bundle.
388
+ if (!isValidOperationId(operationId)) {
389
+ console.error('[UKit] export-run: invalid_operation_id');
390
+ printUsage();
391
+ process.exitCode = 1;
392
+ return;
393
+ }
383
394
  const { exportSupportBundle } = await import('../../core/agentRuntime/diagnostics.js');
384
395
  const runtimeDir = path.join(projectRoot, '.ukit', 'storage', 'agent-runtime');
385
396
  let res;
@@ -454,10 +465,16 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
454
465
  const args = Array.isArray(argv) ? argv : [];
455
466
  const sub = (args[0] ?? '').toLowerCase();
456
467
 
457
- if (args.length === 0 || HELP_FLAGS.has(sub)) {
468
+ if (HELP_FLAGS.has(sub)) {
458
469
  printUsage();
459
470
  return;
460
471
  }
472
+ if (args.length === 0) {
473
+ // W2-A04: no subcommand is a usage error, not a no-op success.
474
+ printUsage();
475
+ process.exitCode = 1;
476
+ return;
477
+ }
461
478
  if (!SUBCOMMANDS.has(sub)) {
462
479
  console.error(`[UKit] Unknown telemetry subcommand: ${sub}`);
463
480
  printUsage();
@@ -234,10 +234,16 @@ export async function runVm({ projectRoot, argv = [] }) {
234
234
  const args = Array.isArray(argv) ? argv : [];
235
235
  const sub = (args[0] ?? '').toLowerCase();
236
236
 
237
- if (args.length === 0 || HELP_FLAGS.has(sub)) {
237
+ if (HELP_FLAGS.has(sub)) {
238
238
  printUsage();
239
239
  return;
240
240
  }
241
+ if (args.length === 0) {
242
+ // W2-A04: no subcommand is a usage error, not a no-op success.
243
+ printUsage();
244
+ process.exitCode = 1;
245
+ return;
246
+ }
241
247
  if (!SUBCOMMANDS.has(sub)) {
242
248
  console.error(`[UKit] Unknown vm subcommand: ${sub}`);
243
249
  printUsage();
@@ -11,7 +11,7 @@ function inferProjectName(projectRoot, packageJson) {
11
11
  return path.basename(projectRoot);
12
12
  }
13
13
 
14
- export async function detectProjectContext(projectRoot, { homeDir } = {}) {
14
+ export async function detectProjectContext(projectRoot, { homeDir, mint = false } = {}) {
15
15
  const packageJsonPath = path.join(projectRoot, 'package.json');
16
16
  // A corrupt package.json must degrade to basename naming, not crash the
17
17
  // status/memory CLIs with a raw SyntaxError.
@@ -19,9 +19,14 @@ export async function detectProjectContext(projectRoot, { homeDir } = {}) {
19
19
 
20
20
  // Identity resolution must never break context detection: any resolver
21
21
  // failure degrades to `id: null` (SPEC §10 — deny-by-default downstream).
22
- const identity = await resolveProjectIdentity(projectRoot, { homeDir })
22
+ // C92-C-03: `mint` defaults to false (fail-closed). Read-only commands
23
+ // (`ukit diff`, `ukit status`, `ukit memory list/recall`, `ukit self-improve`)
24
+ // must never create/modify ~/.ukit/storage/projects/registry.json or bind a
25
+ // generic alias — only `ukit install` opts in via `mint: !dryRun`.
26
+ const identity = await resolveProjectIdentity(projectRoot, { homeDir, mint })
23
27
  .catch(() => null);
24
28
 
29
+
25
30
  return {
26
31
  project: {
27
32
  root: projectRoot,
@@ -30,10 +30,14 @@ const STATE_SET = new Set(OPERATION_STATES);
30
30
  /**
31
31
  * Legal edges. `recovery_required` is reachable from every non-terminal state
32
32
  * and resolves to exactly one terminal state — it never re-enters `running`.
33
+ * `starting → failed` is the spawn-throw recovery edge (C92-E-02): a
34
+ * synchronous spawnImpl throw must be able to land the op terminal, or it
35
+ * strands non-terminally. Cancel before spawn does NOT get a direct edge —
36
+ * it routes `starting → cancel_pending → cancelled`.
33
37
  */
34
38
  const TRANSITIONS = Object.freeze({
35
39
  queued: new Set(['starting', 'recovery_required']),
36
- starting: new Set(['running', 'cancel_pending', 'recovery_required']),
40
+ starting: new Set(['running', 'cancel_pending', 'failed', 'recovery_required']),
37
41
  running: new Set(['completed', 'failed', 'cancel_pending', 'retry_pending', 'recovery_required']),
38
42
  cancel_pending: new Set(['cancelled', 'recovery_required']),
39
43
  retry_pending: new Set(['running', 'cancel_pending', 'recovery_required']),
@@ -54,10 +54,32 @@ const STATE_DIR = 'state';
54
54
  const CONTINUATIONS_FILE = 'continuations.jsonl';
55
55
  const CONSUMPTIONS_FILE = 'consumptions.jsonl';
56
56
 
57
- const journalPath = (dir, operationId) =>
58
- path.join(dir, JOURNAL_DIR, `${operationId}.jsonl`);
59
- const statePath = (dir, operationId) =>
60
- path.join(dir, STATE_DIR, `${operationId}.json`);
57
+ // W2-B2: operationId becomes a filename via path.join — anything outside a
58
+ // strict filename charset, or containing a `..` segment (even 'a..b'), can
59
+ // write or read a journal/state file OUTSIDE the store. Reject typed before
60
+ // any path math. `:` is kept: engine opIds are `<planInstanceId>:<nodeId>`
61
+ // (vmEngine/shadowRun) and it is not a path separator on any shipped host.
62
+ // Exported (W2-A03): this is the ONE canonical opId rule — CLI surfaces
63
+ // (telemetry export-run) must validate with it, never re-derive a copy.
64
+ const OPERATION_ID_RE = /^[A-Za-z0-9._:-]+$/;
65
+
66
+ export const isValidOperationId = (id) =>
67
+ typeof id === 'string' && OPERATION_ID_RE.test(id) && !id.includes('..');
68
+
69
+ function assertOperationId(id) {
70
+ if (!isValidOperationId(id)) {
71
+ throw new EventStoreError('invalid_operation_id', `unsafe operationId ${JSON.stringify(id)}`);
72
+ }
73
+ }
74
+
75
+ const journalPath = (dir, operationId) => {
76
+ assertOperationId(operationId);
77
+ return path.join(dir, JOURNAL_DIR, `${operationId}.jsonl`);
78
+ };
79
+ const statePath = (dir, operationId) => {
80
+ assertOperationId(operationId);
81
+ return path.join(dir, STATE_DIR, `${operationId}.json`);
82
+ };
61
83
 
62
84
  async function pathExists(p) {
63
85
  try {
@@ -70,7 +92,10 @@ async function pathExists(p) {
70
92
 
71
93
  /** Atomic write: temp file + fsync + rename (SPEC §6 migration/state windows). */
72
94
  async function writeFileAtomic(target, data) {
73
- const tmp = `${target}.tmp-${process.pid}`;
95
+ // C90-15: pid-only tmp names collide between concurrent writers in one
96
+ // process (second rename ENOENTs the first writer's tmp). Per-call UUID
97
+ // makes each atomic write private.
98
+ const tmp = `${target}.tmp-${process.pid}-${crypto.randomUUID()}`;
74
99
  const handle = await fs.open(tmp, 'w');
75
100
  try {
76
101
  await handle.writeFile(data, 'utf8');
@@ -595,17 +620,23 @@ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
595
620
  const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
596
621
  const isPlainRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
597
622
 
623
+ // W2-B3: artifacts are bounded at write time (~4 MiB) but the READ side was
624
+ // unbounded — a stale/crafted ref named any in-root file and readFile
625
+ // allocated the whole thing. Stat before read and refuse over the cap.
626
+ const DEFAULT_MAX_RESOLVE_BYTES = 64 * 1024 * 1024;
627
+
598
628
  const unavailable = (reason) => ({ status: 'unavailable', reason });
599
629
 
600
630
  /**
601
631
  * Resolve a bounded run-artifact ref for an authorized caller.
602
632
  *
603
633
  * @param {object} ref `{path, sha256, bytes?, runId?, truncated?, sensitivity?}`
604
- * @param {object} scope `{runRoot, caller:{runId, role?}}`
634
+ * @param {object} scope `{runRoot, caller:{runId, role?}, maxBytes?}` —
635
+ * `maxBytes` bounds the read (default 64 MiB); over-cap → `too_large`
605
636
  * @returns {Promise<{bytes:Uint8Array, sha256:string, truncated?:boolean}
606
637
  * |{status:'unavailable', reason:string}>}
607
638
  */
608
- export async function resolveRunArtifact(ref, { runRoot, caller } = {}) {
639
+ export async function resolveRunArtifact(ref, { runRoot, caller, maxBytes } = {}) {
609
640
  if (!isPlainRecord(caller) || !isNonEmptyString(caller.runId)) {
610
641
  return unavailable('unauthorized');
611
642
  }
@@ -649,6 +680,22 @@ export async function resolveRunArtifact(ref, { runRoot, caller } = {}) {
649
680
  if (err && err.code === 'ENOENT') return unavailable('missing');
650
681
  return unavailable('unreadable');
651
682
  }
683
+ // W2-B3: size-check BEFORE the read — stat is cheap, readFile of a
684
+ // multi-GB stale/crafted target is not. `isFile` also short-circuits
685
+ // directories that would otherwise hit readFile's EISDIR as 'unreadable'.
686
+ const cap = Number.isInteger(maxBytes) && maxBytes >= 0 ? maxBytes : DEFAULT_MAX_RESOLVE_BYTES;
687
+ let stat;
688
+ try {
689
+ stat = await fs.stat(real);
690
+ } catch (err) {
691
+ if (err && err.code === 'ENOENT') return unavailable('missing');
692
+ return unavailable('unreadable');
693
+ }
694
+ if (!stat.isFile()) return unavailable('unreadable');
695
+ if (stat.size > cap) return unavailable('too_large');
696
+ if (Number.isInteger(ref.bytes) && ref.bytes !== stat.size) {
697
+ return unavailable('bytes_mismatch');
698
+ }
652
699
  let bytes;
653
700
  try {
654
701
  bytes = await fs.readFile(real);
@@ -127,16 +127,27 @@ export async function reconcileOwnedOperations(ctx) {
127
127
 
128
128
  const results = [];
129
129
  for (const operationId of await listOperationIds(runtimeDir)) {
130
- results.push(await reconcileOne({
131
- runtimeDir,
132
- operationId,
133
- processTable,
134
- fencingEpoch,
135
- ownerEpoch: undefined,
136
- launchDisabled,
137
- ctxOwnerEpoch: ctx?.ownerEpoch,
138
- now,
139
- }));
130
+ try {
131
+ results.push(await reconcileOne({
132
+ runtimeDir,
133
+ operationId,
134
+ processTable,
135
+ fencingEpoch,
136
+ ownerEpoch: undefined,
137
+ launchDisabled,
138
+ ctxOwnerEpoch: ctx?.ownerEpoch,
139
+ now,
140
+ }));
141
+ } catch (err) {
142
+ // W2-B2 follow-on: ids are enumerated from on-disk filenames — a stale
143
+ // entry the store now rejects (e.g. a legacy `..` name) must not abort
144
+ // the whole reconcile; record it skipped and move on.
145
+ results.push({
146
+ operationId,
147
+ resolution: 'skipped',
148
+ code: typeof err?.code === 'string' ? err.code : 'reconcile_error',
149
+ });
150
+ }
140
151
  }
141
152
  return { results };
142
153
  }
@@ -249,11 +260,7 @@ async function reconcileOne({
249
260
 
250
261
  // --- no live owned process ------------------------------------------------
251
262
  if (Number.isInteger(stateRec?.exitStatus)) {
252
- const to = stateRec.exitStatus === 'cancelled'
253
- ? 'cancelled'
254
- : stateRec.exitStatus === 0
255
- ? 'completed'
256
- : 'failed';
263
+ const to = stateRec.exitStatus === 0 ? 'completed' : 'failed';
257
264
  return persistTerminal(effectiveState, to, 'exited_unconfirmed');
258
265
  }
259
266
  // Gone without outcome (or still starting): ambiguous — recovery_required,