@ngockhoale/ukit 3.4.1 → 3.4.2
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 +19 -0
- package/package.json +1 -1
- package/src/cli/commands/code.js +29 -5
- package/src/cli/commands/decision.js +18 -4
- package/src/cli/commands/doctor.js +7 -3
- package/src/cli/commands/install.js +29 -4
- package/src/cli/commands/memory.js +25 -5
- package/src/cli/commands/telemetry.js +18 -1
- package/src/cli/commands/vm.js +7 -1
- package/src/context/detectProjectContext.js +7 -2
- package/src/core/agentRuntime/contract.js +5 -1
- package/src/core/agentRuntime/eventStore.js +54 -7
- package/src/core/agentRuntime/recovery.js +22 -15
- package/src/core/agentRuntime/supervisor.js +71 -13
- package/src/core/applyPlan.js +11 -1
- package/src/core/codeintel/compiler.js +51 -8
- package/src/core/codeintel/diagnostics.js +124 -33
- package/src/core/codeintel/freshness.js +25 -12
- package/src/core/codeintel/invalidation.js +11 -3
- package/src/core/codeintel/retriever.js +53 -20
- package/src/core/codeintel/router.js +19 -9
- package/src/core/codeintel/summaries.js +4 -3
- package/src/core/codeintel/vectorProvider.js +30 -4
- package/src/core/compact/index.js +24 -7
- package/src/core/compact/threshold.js +49 -14
- package/src/core/diffPlan.js +51 -23
- package/src/core/ensureGitignore.js +19 -2
- package/src/core/fileOps.js +61 -0
- package/src/core/memory/hygiene.js +51 -1
- package/src/core/memory/migrate.js +41 -21
- package/src/core/memory/store.js +96 -61
- package/src/core/metadata.js +37 -2
- package/src/core/observability/adapters/ingest.js +30 -2
- package/src/core/observability/emit/config.js +19 -4
- package/src/core/observability/emit/crash.js +3 -1
- package/src/core/observability/emit/recorder.js +15 -7
- package/src/core/observability/privacy/sanitizeObserved.js +3 -1
- package/src/core/observability/segments/internal.js +36 -8
- package/src/core/observability/segments/retention.js +11 -0
- package/src/core/observability/support/import.js +27 -1
- package/src/core/output/index.js +16 -1
- package/src/core/permissionDoctor.js +72 -9
- package/src/core/repairBrokenHooks.js +15 -2
- package/src/core/reviewPanelAggregate.js +26 -10
- package/src/core/runInstallPipeline.js +71 -22
- package/src/core/runtimeConfig.js +2 -0
- package/src/core/status.js +2 -0
- package/src/core/taskBudgetValidator.js +7 -1
- package/src/core/taskProgressGuard.js +11 -1
- package/src/core/unattendedDoctor.js +36 -5
- package/src/core/uninstall.js +52 -12
- package/src/core/update.js +5 -1
- package/src/decision/client.js +158 -27
- package/src/decision/reviewVerdict.js +23 -7
- package/src/diagnostics/failurePatterns.js +1 -1
- package/src/diagnostics/feedbackEvents.js +1 -1
- package/src/diagnostics/routeOutcomes.js +42 -4
- package/src/diagnostics/skillAccuracy.js +35 -4
- package/src/index/buildIndex.js +123 -26
- package/src/index/fixLoopEscalation.js +3 -0
- package/src/index/gitHooks.js +99 -29
- package/src/index/importResolution.js +7 -1
- package/src/index/playbookRegistry.js +15 -11
- package/src/index/queryIndex.js +28 -10
- package/src/index/routeResolver.js +8 -3
- package/src/index/taskRouting.js +37 -2
- package/src/learning/codeProposals.js +24 -5
- package/src/learning/selfImprove.js +29 -5
- package/src/learning/tunedOverlay.js +18 -7
- package/src/learning/tuning.js +10 -4
- package/src/skill/auditSkill.js +46 -7
- package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
- package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
- package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
- package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
- package/template_project/.claude/hooks/session-episode.sh +60 -28
- package/template_project/.claude/hooks/verification-guard.sh +26 -15
- package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
- package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
- package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
- package/template_project/.claude/ukit/index/query-index.mjs +13 -6
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
- package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
- package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
- package/template_project/.claude/ukit/index/route-task.mjs +15 -0
- package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
- package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
- package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
- package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
- package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
- package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
- package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
- package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
- package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
- package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.4.2 - 2026-09-29
|
|
6
|
+
|
|
7
|
+
**Bug-sweep cycles C91–C93 — 42 fixes.** Handoff cycles C91 (26), C92 (13) and
|
|
8
|
+
C93 (3) closed the confirmed-bug backlog across deny-gates, install/uninstall
|
|
9
|
+
data-loss paths, agent-runtime supervision, index integrity, codeintel
|
|
10
|
+
freshness, telemetry durability, routing floors, and runtime CLI locking.
|
|
11
|
+
|
|
12
|
+
- Deny gates: quote/splice/IFS bypasses and compact `-c`/`eval` operand
|
|
13
|
+
recursion in the PreToolUse guards (templateHooks + audit tests).
|
|
14
|
+
- Install/uninstall: typed `InstallMetadataError` on corrupt `install.json`
|
|
15
|
+
(doctor), uninstall refuses corrupt/unreadable ledger instead of silently
|
|
16
|
+
proceeding, plus other destructive-path hardening.
|
|
17
|
+
- agentRuntime: supervisor pgid kill-target fix, `cancel_pending` edge,
|
|
18
|
+
dead `recovery.js` cancelled-branch removal.
|
|
19
|
+
- Index/codeintel: integrity checks, freshness/retrieval guards.
|
|
20
|
+
- Handoff machinery: model-tier guard override anchor (task-file declaration +
|
|
21
|
+
`.ukit/storage/tier-override` marker), Release Policy mirror restored into
|
|
22
|
+
root `CLAUDE.md`/`AGENTS.md`.
|
|
23
|
+
|
|
5
24
|
## 3.4.1 - 2026-09-28
|
|
6
25
|
|
|
7
26
|
Republish of 3.4.0 — npm staged-version E409 limbo (same content, version bump only).
|
package/package.json
CHANGED
package/src/cli/commands/code.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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'
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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();
|
package/src/cli/commands/vm.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 === '
|
|
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,
|