@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.
- package/CHANGELOG.md +23 -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
|
@@ -53,7 +53,13 @@ import { sanitizeObserved } from '../privacy/sanitizeObserved.js';
|
|
|
53
53
|
import { SEMANTIC_REGISTRY } from '../schema/registry.js';
|
|
54
54
|
import { SCHEMA_VERSION } from '../schema/constants.js';
|
|
55
55
|
import { appendRecord } from '../segments/retention.js';
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
resolveStage,
|
|
58
|
+
resolveSampling,
|
|
59
|
+
samplingConfigInvalid,
|
|
60
|
+
samplingConfigInvalidReported,
|
|
61
|
+
markSamplingConfigInvalidReported,
|
|
62
|
+
} from './config.js';
|
|
57
63
|
|
|
58
64
|
const DEFAULT_QUEUE_SIZE = 1024;
|
|
59
65
|
const SAMPLING_BUCKETS = 10000;
|
|
@@ -138,7 +144,6 @@ export function createRecorder({ root, config, queueSize, writer, clock, samplin
|
|
|
138
144
|
invalid_spans: 0,
|
|
139
145
|
dropped_sampled: 0,
|
|
140
146
|
pending_sampled: 0,
|
|
141
|
-
config_invalid_reported: false,
|
|
142
147
|
flushes: 0,
|
|
143
148
|
last_flush: null,
|
|
144
149
|
last_error: null,
|
|
@@ -376,13 +381,16 @@ export function createRecorder({ root, config, queueSize, writer, clock, samplin
|
|
|
376
381
|
state.pending_sampled = 0;
|
|
377
382
|
}
|
|
378
383
|
}
|
|
379
|
-
|
|
380
|
-
|
|
384
|
+
// TASK-C91-015 (W3-OB2): the report latch lives in config.js at process
|
|
385
|
+
// scope — sessionBoot.js creates one recorder per emit, so a per-recorder
|
|
386
|
+
// flag re-reported CONFIG_INVALID on every emit. Mirror parity: claim
|
|
387
|
+
// before queueing (the mirror sets its flag before the bounded write).
|
|
388
|
+
if (samplingConfigInvalid() && !samplingConfigInvalidReported()) {
|
|
389
|
+
markSamplingConfigInvalidReported();
|
|
390
|
+
queueSelfRecord({
|
|
381
391
|
semantic_name: 'telemetry.dropped',
|
|
382
392
|
payload: { reason_code: 'CONFIG_INVALID', dropped_count: 1 },
|
|
383
|
-
}, policyAtFlush)
|
|
384
|
-
state.config_invalid_reported = true;
|
|
385
|
-
}
|
|
393
|
+
}, policyAtFlush);
|
|
386
394
|
}
|
|
387
395
|
const run = pump(deadlineMs);
|
|
388
396
|
state.pumping = run;
|
|
@@ -67,7 +67,9 @@ function sanitizeValue(value, ctx, depth) {
|
|
|
67
67
|
let kept = 0;
|
|
68
68
|
for (const [k, v] of Object.entries(value)) {
|
|
69
69
|
if (kept >= MAX_OBJECT_KEYS) break;
|
|
70
|
-
|
|
70
|
+
// TASK-C91-015 (W3-OB1): denylist matching is case-insensitive —
|
|
71
|
+
// 'Prompt'/'AUTHORIZATION' carry the same data as lowercase keys.
|
|
72
|
+
if (DENIED_PAYLOAD_FIELDS.has(k.toLowerCase())) continue;
|
|
71
73
|
const key = sanitizeKey(k, ctx);
|
|
72
74
|
if (key === null) continue;
|
|
73
75
|
const sv = sanitizeValue(v, ctx, depth + 1);
|
|
@@ -182,29 +182,57 @@ const STATE_DEFAULTS = Object.freeze({
|
|
|
182
182
|
active_since_ms: null,
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
-
/**
|
|
185
|
+
/**
|
|
186
|
+
* Marks a readState result synthesized from a FAILED read (corrupt file,
|
|
187
|
+
* transient EACCES/IO_TIMEOUT, non-object payload). Symbols never serialize,
|
|
188
|
+
* so the marker is in-memory only. Fail-closed gating still reads the flag
|
|
189
|
+
* via `state.disabled`; writeState refuses to persist marked state — a
|
|
190
|
+
* transient read error must never be committed as durable `disabled:true`.
|
|
191
|
+
*/
|
|
192
|
+
export const STATE_READ_FAILED = Symbol('ukit.observability.stateReadFailed');
|
|
193
|
+
|
|
194
|
+
/** Best-effort state read; a missing file is "no state yet", corrupt is closed. */
|
|
186
195
|
export async function readState(root, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
187
196
|
try {
|
|
188
197
|
const raw = await withTimeout(fs.promises.readFile(path.join(root, STATE_FILE), 'utf8'), timeoutMs);
|
|
189
198
|
const parsed = JSON.parse(raw);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
// TASK-C91-015 (W3-OB3): fail closed on unreadable/invalid state — a
|
|
200
|
+
// torn _state.json that defaults to disabled:false would keep the
|
|
201
|
+
// total-size gate open through corruption. ENOENT alone is the open
|
|
202
|
+
// default; anything else (or a non-object payload) is disabled:true
|
|
203
|
+
// until a clean write re-enables the store. The state is MARKED
|
|
204
|
+
// (STATE_READ_FAILED) so it is never persisted back — it synthesizes
|
|
205
|
+
// values, not facts.
|
|
206
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
207
|
+
return { ...STATE_DEFAULTS, disabled: true, [STATE_READ_FAILED]: true };
|
|
208
|
+
}
|
|
209
|
+
return { ...STATE_DEFAULTS, ...parsed };
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (err && err.code === 'ENOENT') return { ...STATE_DEFAULTS };
|
|
212
|
+
return { ...STATE_DEFAULTS, disabled: true, [STATE_READ_FAILED]: true };
|
|
193
213
|
}
|
|
194
214
|
}
|
|
195
215
|
|
|
196
216
|
export async function writeState(root, state, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
217
|
+
// TASK-C91-015 (W3-OB3): atomic publish — tmp sibling + rename so a
|
|
218
|
+
// crash mid-write leaves the previous _state.json (or none), never a
|
|
219
|
+
// torn file that readState must fail closed on. A state synthesized from
|
|
220
|
+
// a failed read is never published: persisting it would turn a transient
|
|
221
|
+
// I/O error into permanent disabled:true that never heals.
|
|
222
|
+
if (state && state[STATE_READ_FAILED]) return false;
|
|
223
|
+
const statePath = path.join(root, STATE_FILE);
|
|
224
|
+
const tmpPath = `${statePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
197
225
|
try {
|
|
198
|
-
await withTimeout(
|
|
199
|
-
|
|
200
|
-
timeoutMs,
|
|
201
|
-
);
|
|
226
|
+
await withTimeout(fs.promises.writeFile(tmpPath, JSON.stringify(state, null, 2)), timeoutMs);
|
|
227
|
+
await withTimeout(fs.promises.rename(tmpPath, statePath), timeoutMs);
|
|
202
228
|
return true;
|
|
203
229
|
} catch {
|
|
230
|
+
try { await fs.promises.unlink(tmpPath); } catch { /* best-effort */ }
|
|
204
231
|
return false;
|
|
205
232
|
}
|
|
206
233
|
}
|
|
207
234
|
|
|
235
|
+
|
|
208
236
|
export function sanitizeNameComponent(value) {
|
|
209
237
|
const s = typeof value === 'string' && value.length > 0 ? value : 'unknown';
|
|
210
238
|
return s.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64) || 'unknown';
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
nowOf,
|
|
58
58
|
policyOf,
|
|
59
59
|
readState,
|
|
60
|
+
STATE_READ_FAILED,
|
|
60
61
|
resolveRoot,
|
|
61
62
|
sanitizeNameComponent,
|
|
62
63
|
sha256Buffer,
|
|
@@ -477,6 +478,16 @@ export async function enforceRetention(root, policy) {
|
|
|
477
478
|
}
|
|
478
479
|
|
|
479
480
|
const disabled = usage > p.maxTotalBytes;
|
|
481
|
+
if (state[STATE_READ_FAILED]) {
|
|
482
|
+
// The read failed: `state` is synthesized (fail-closed disabled:true +
|
|
483
|
+
// zeroed counters), never facts — persisting it would turn a transient
|
|
484
|
+
// read error into a permanent shutdown and wipe expired_segments. A
|
|
485
|
+
// real cap breach is enforced independently of the failed read, so a
|
|
486
|
+
// bare {disabled:true} is still committed; everything else heals on the
|
|
487
|
+
// next successful read.
|
|
488
|
+
if (disabled) await writeState(resolved.root, { disabled: true }, timeoutMs);
|
|
489
|
+
return { ok: true, expired, disabled };
|
|
490
|
+
}
|
|
480
491
|
state.expired_segments = (state.expired_segments || 0) + expired;
|
|
481
492
|
if (disabled) state.disabled = true;
|
|
482
493
|
await writeState(resolved.root, state, timeoutMs);
|
|
@@ -262,9 +262,22 @@ async function readBundleDir(dir) {
|
|
|
262
262
|
// ignored — a support dir may hold user files; only manifest-listed
|
|
263
263
|
// members are verified below.
|
|
264
264
|
if (!dent.isFile() || !isSafeEntryName(dent.name)) continue;
|
|
265
|
+
const memberPath = path.join(dir, dent.name);
|
|
266
|
+
// W2-B4 (TASK-C91-015): stat before read — a bundle member past the
|
|
267
|
+
// entry cap is rejected on its declared size, not after its bytes are
|
|
268
|
+
// already in memory.
|
|
269
|
+
let st;
|
|
270
|
+
try {
|
|
271
|
+
st = await fs.promises.stat(memberPath);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
return fail(ioReason(err));
|
|
274
|
+
}
|
|
275
|
+
if (st.size > IMPORT_LIMITS.MAX_ENTRY_BYTES) {
|
|
276
|
+
return fail('entry_too_large', { name: dent.name, bytes: st.size });
|
|
277
|
+
}
|
|
265
278
|
let buf;
|
|
266
279
|
try {
|
|
267
|
-
buf = await fs.promises.readFile(
|
|
280
|
+
buf = await fs.promises.readFile(memberPath);
|
|
268
281
|
} catch (err) {
|
|
269
282
|
return fail(ioReason(err));
|
|
270
283
|
}
|
|
@@ -342,6 +355,11 @@ export async function validateSupportBundle(input = {}) {
|
|
|
342
355
|
let provenance;
|
|
343
356
|
let isDir = false;
|
|
344
357
|
if (hasBuffer) {
|
|
358
|
+
// W2-B4 (TASK-C91-015): stdin/pipe-fed bundles arrive as buffers — cap
|
|
359
|
+
// them at the same total bound before unzip work begins.
|
|
360
|
+
if (input.buffer.length > IMPORT_LIMITS.MAX_TOTAL_BYTES) {
|
|
361
|
+
return fail('bundle_too_large', { bytes: input.buffer.length });
|
|
362
|
+
}
|
|
345
363
|
const parsed = unzip(input.buffer);
|
|
346
364
|
if (!parsed.ok) return parsed;
|
|
347
365
|
entries = parsed.entries;
|
|
@@ -360,12 +378,20 @@ export async function validateSupportBundle(input = {}) {
|
|
|
360
378
|
if (!read.ok) return read;
|
|
361
379
|
entries = read.entries;
|
|
362
380
|
} else if (st.isFile()) {
|
|
381
|
+
// W2-B4 (TASK-C91-015): bound the archive read by its declared size —
|
|
382
|
+
// an over-cap file is rejected on lstat, not after an unbounded read.
|
|
383
|
+
if (st.size > IMPORT_LIMITS.MAX_TOTAL_BYTES) {
|
|
384
|
+
return fail('bundle_too_large', { bytes: st.size });
|
|
385
|
+
}
|
|
363
386
|
let buf;
|
|
364
387
|
try {
|
|
365
388
|
buf = await fs.promises.readFile(input.path);
|
|
366
389
|
} catch (err) {
|
|
367
390
|
return fail(ioReason(err));
|
|
368
391
|
}
|
|
392
|
+
if (buf.length > IMPORT_LIMITS.MAX_TOTAL_BYTES) {
|
|
393
|
+
return fail('bundle_too_large', { bytes: buf.length });
|
|
394
|
+
}
|
|
369
395
|
const parsed = unzip(buf);
|
|
370
396
|
if (!parsed.ok) return parsed;
|
|
371
397
|
entries = parsed.entries;
|
package/src/core/output/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { readJsonIfExists, writeJson, withFileLock, escapeRegExp } from '../fileOps.js';
|
|
@@ -268,6 +269,7 @@ function buildRecoveryFileName({
|
|
|
268
269
|
summary = '',
|
|
269
270
|
profile = 'generic',
|
|
270
271
|
exitCode = null,
|
|
272
|
+
contentHash = '',
|
|
271
273
|
} = {}) {
|
|
272
274
|
const slug = sanitizeFileComponent(String(command).split(/\s+/).slice(0, 3).join('-'), {
|
|
273
275
|
fallback: sanitizeFileComponent(profile, { fallback: 'output', maxLength: 16 }),
|
|
@@ -278,6 +280,9 @@ function buildRecoveryFileName({
|
|
|
278
280
|
summary: String(summary ?? '').trim(),
|
|
279
281
|
profile: String(profile ?? 'generic').trim(),
|
|
280
282
|
exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null,
|
|
283
|
+
// C90-05: bind the name to the bytes it stores — the shipped twin's
|
|
284
|
+
// fingerprint is the recovery name's only unique component.
|
|
285
|
+
contentHash: String(contentHash ?? ''),
|
|
281
286
|
}).split(':').at(-1)?.replace(/[^a-z0-9_-]/gi, '-').slice(0, 16) || 'recovery';
|
|
282
287
|
const uniqueSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
283
288
|
return `${slug}-${uniqueSuffix}-${fingerprint}.log`;
|
|
@@ -451,6 +456,11 @@ async function persistRawOutput(projectRoot, {
|
|
|
451
456
|
summary,
|
|
452
457
|
profile,
|
|
453
458
|
exitCode,
|
|
459
|
+
contentHash: crypto.createHash('sha256')
|
|
460
|
+
.update(String(stdout ?? ''))
|
|
461
|
+
.update('\0')
|
|
462
|
+
.update(String(stderr ?? ''))
|
|
463
|
+
.digest('hex'),
|
|
454
464
|
});
|
|
455
465
|
const absolutePath = path.join(teeCacheDir, fileName);
|
|
456
466
|
const rawOutputText = buildRawOutputText({
|
|
@@ -1071,7 +1081,12 @@ export function compressToolOutput({
|
|
|
1071
1081
|
maxLines,
|
|
1072
1082
|
forceFirstCount: Math.min(1 + anchorLines.length, 5),
|
|
1073
1083
|
}).join('\n').trim();
|
|
1074
|
-
|
|
1084
|
+
// Same contract as compactContextBlock: 'forced-anchors' must mean every
|
|
1085
|
+
// anchor is present — re-validate and report the shortfall instead of
|
|
1086
|
+
// claiming an anchor-complete summary that dropped the tail anchors.
|
|
1087
|
+
validationMode = findMissingAnchors(summary, anchorLines).length === 0
|
|
1088
|
+
? 'forced-anchors'
|
|
1089
|
+
: 'anchor-shortfall';
|
|
1075
1090
|
}
|
|
1076
1091
|
}
|
|
1077
1092
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
|
-
import { pathExists
|
|
4
|
+
import { pathExists } from './fileOps.js';
|
|
5
5
|
import { VALID_PERMISSION_MODES } from './runtimeConfig.js';
|
|
6
6
|
import {
|
|
7
7
|
compilePermissionPolicy,
|
|
@@ -47,15 +47,45 @@ async function readOmpConfig(projectRoot) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// FR-025 / W2-C3 — corrupt `.ukit/storage/config.json` / `.claude/settings.json`
|
|
51
|
+
// are exactly what doctor exists to diagnose: readJsonIfExists would throw the
|
|
52
|
+
// SyntaxError and kill the run, so both surface reads stay tolerant and the
|
|
53
|
+
// parse error is reported back as a finding instead.
|
|
54
|
+
async function readRuntimeConfigSurface(projectRoot) {
|
|
55
|
+
const configPath = path.join(projectRoot, '.ukit', 'storage', 'config.json');
|
|
56
|
+
try {
|
|
57
|
+
return { exists: true, parsed: JSON.parse(await fs.readFile(configPath, 'utf8')), parseError: null };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error?.code === 'ENOENT') return { exists: false, parsed: null, parseError: null };
|
|
60
|
+
return { exists: true, parsed: null, parseError: error?.message ?? String(error) };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
async function readClaudeSurface(projectRoot) {
|
|
51
65
|
const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
let settings = null;
|
|
67
|
+
let exists = false;
|
|
68
|
+
let parseError = null;
|
|
69
|
+
try {
|
|
70
|
+
settings = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
|
|
71
|
+
exists = true;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error?.code !== 'ENOENT') {
|
|
74
|
+
exists = true;
|
|
75
|
+
parseError = error?.message ?? String(error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// A corrupt file yields no usable surface — present stays false so the corrupt
|
|
79
|
+
// file's finding (its own check) is not masked as a phantom "no permissions"
|
|
80
|
+
// surface or inflated into per-entry deny-loss noise.
|
|
81
|
+
const permissions = parseError ? null : settings?.permissions;
|
|
54
82
|
if (!permissions || typeof permissions !== 'object') {
|
|
55
|
-
return { present: false, deny: [], allow: [], ask: [] };
|
|
83
|
+
return { exists, parseError, present: false, deny: [], allow: [], ask: [] };
|
|
56
84
|
}
|
|
57
85
|
const list = (key) => (Array.isArray(permissions[key]) ? permissions[key].filter((e) => typeof e === 'string') : []);
|
|
58
86
|
return {
|
|
87
|
+
exists,
|
|
88
|
+
parseError,
|
|
59
89
|
present: true,
|
|
60
90
|
deny: list('deny'),
|
|
61
91
|
allow: list('allow'),
|
|
@@ -80,12 +110,13 @@ async function scanUnresolvedTokens(projectRoot) {
|
|
|
80
110
|
}
|
|
81
111
|
|
|
82
112
|
export async function inspectPermissions({ projectRoot, ompPath = 'omp' }) {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
);
|
|
113
|
+
const runtimeConfigSurface = await readRuntimeConfigSurface(projectRoot);
|
|
114
|
+
const runtimeConfig = runtimeConfigSurface.parseError ? null : runtimeConfigSurface.parsed;
|
|
86
115
|
const declaredRaw = runtimeConfig?.orchestration?.permissionMode;
|
|
87
116
|
const declared = declaredRaw === undefined ? 'unattended' : declaredRaw;
|
|
88
|
-
|
|
117
|
+
// Corrupt config → declared mode is unreadable, not 'unattended (default)':
|
|
118
|
+
// failing modeValid keeps corruption from masquerading as a healthy default.
|
|
119
|
+
const modeValid = !runtimeConfigSurface.parseError && VALID_PERMISSION_MODES.has(declared);
|
|
89
120
|
const allowUnsafeEval = runtimeConfig?.orchestration?.allowUnsafeEval === true;
|
|
90
121
|
|
|
91
122
|
// --- Surface reads ---------------------------------------------------------
|
|
@@ -241,6 +272,8 @@ export async function inspectPermissions({ projectRoot, ompPath = 'omp' }) {
|
|
|
241
272
|
if (evalMismatch) failures.push('unsafe-eval-mismatch');
|
|
242
273
|
if (unresolvedTokens.length > 0) failures.push('unrendered-token');
|
|
243
274
|
if (!modeValid) failures.push('invalid-permission-mode');
|
|
275
|
+
if (runtimeConfigSurface.parseError) failures.push('corrupt-runtime-config');
|
|
276
|
+
if (claude.parseError) failures.push('corrupt-claude-settings');
|
|
244
277
|
|
|
245
278
|
const check = (label, passed, remediationClass, remedy, extra = {}) => ({
|
|
246
279
|
label,
|
|
@@ -252,12 +285,29 @@ export async function inspectPermissions({ projectRoot, ompPath = 'omp' }) {
|
|
|
252
285
|
});
|
|
253
286
|
|
|
254
287
|
const checks = [
|
|
288
|
+
check(
|
|
289
|
+
'runtime config.json parses',
|
|
290
|
+
!runtimeConfigSurface.parseError,
|
|
291
|
+
'owner-action',
|
|
292
|
+
'Fix or remove .ukit/storage/config.json — invalid JSON hides the declared permissionMode.',
|
|
293
|
+
{
|
|
294
|
+
detail: runtimeConfigSurface.parseError
|
|
295
|
+
? `corrupt: ${runtimeConfigSurface.parseError}`
|
|
296
|
+
: runtimeConfigSurface.exists
|
|
297
|
+
? 'parsed'
|
|
298
|
+
: 'absent (defaults apply)',
|
|
299
|
+
},
|
|
300
|
+
),
|
|
255
301
|
check(
|
|
256
302
|
'permission mode declared and valid',
|
|
257
303
|
modeValid,
|
|
258
304
|
'owner-action',
|
|
259
305
|
`Set orchestration.permissionMode in .ukit/storage/config.json to one of: ${[...VALID_PERMISSION_MODES].join(', ')}.`,
|
|
260
|
-
{
|
|
306
|
+
{
|
|
307
|
+
detail: runtimeConfigSurface.parseError
|
|
308
|
+
? 'declared: unreadable (corrupt config.json) → effective: unknown'
|
|
309
|
+
: `declared: ${declared}${declaredRaw === undefined ? ' (default)' : ''} → effective: ${modeValid ? declared : 'unknown'}`,
|
|
310
|
+
},
|
|
261
311
|
),
|
|
262
312
|
check(
|
|
263
313
|
'no prompts reachable under unattended mode',
|
|
@@ -315,6 +365,19 @@ export async function inspectPermissions({ projectRoot, ompPath = 'omp' }) {
|
|
|
315
365
|
: `missing: ${denyMissingAll.join(', ')}`,
|
|
316
366
|
},
|
|
317
367
|
),
|
|
368
|
+
check(
|
|
369
|
+
'claude settings.json parses',
|
|
370
|
+
!claude.parseError,
|
|
371
|
+
'owner-action',
|
|
372
|
+
'Fix or remove .claude/settings.json — invalid JSON hides the claude permission surface.',
|
|
373
|
+
{
|
|
374
|
+
detail: claude.parseError
|
|
375
|
+
? `corrupt: ${claude.parseError}`
|
|
376
|
+
: claude.exists
|
|
377
|
+
? 'parsed'
|
|
378
|
+
: 'absent (no claude permission surface)',
|
|
379
|
+
},
|
|
380
|
+
),
|
|
318
381
|
check(
|
|
319
382
|
'eval posture matches allowUnsafeEval',
|
|
320
383
|
!evalMismatch,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { writeFileAtomic } from './fileOps.js';
|
|
4
5
|
|
|
5
6
|
// Claude Code rejects any hook whose stdout carries a `hookSpecificOutput` object
|
|
6
7
|
// without `hookEventName` inside it, and surfaces it as a per-tool-call
|
|
@@ -199,8 +200,20 @@ export async function repairBrokenHooks({ projectRoot, homeDir = os.homedir() }
|
|
|
199
200
|
|
|
200
201
|
// Keep a restore point before touching a file the user did not ask us to edit.
|
|
201
202
|
await fs.writeFile(backupPathFor(settingsPath), raw, 'utf8');
|
|
202
|
-
|
|
203
|
-
|
|
203
|
+
// C92-C-05: ~/.claude/settings.json is user-GLOBAL — every Claude Code
|
|
204
|
+
// session on the machine reads it. A truncate-then-write leaves a torn
|
|
205
|
+
// (zero-length or partial) file observable to a concurrent session; publish
|
|
206
|
+
// the repaired settings through the same atomic tmp+rename helper the rest
|
|
207
|
+
// of the pipeline uses.
|
|
208
|
+
// Fix round 1: when settings.json is a symlink (dotfiles setups link it
|
|
209
|
+
// into a repo checkout), writeFileAtomic's rename would REPLACE the link
|
|
210
|
+
// with a regular file — severing the link and leaving the target's broken
|
|
211
|
+
// hook unrepaired. Write through to the link target instead.
|
|
212
|
+
const linkStat = await fs.lstat(settingsPath);
|
|
213
|
+
const writePath = linkStat.isSymbolicLink()
|
|
214
|
+
? await fs.realpath(settingsPath)
|
|
215
|
+
: settingsPath;
|
|
216
|
+
await writeFileAtomic(writePath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
204
217
|
files.push(settingsPath);
|
|
205
218
|
removals.push(...found.map((entry) => ({ ...entry, settingsPath })));
|
|
206
219
|
}
|
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Consensus rules (SPEC §5 FR-008):
|
|
16
16
|
// changes_requested — ≥1 CRITICAL verdict, ≥1 critical-severity finding,
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// ≥1 CHANGES-REQUESTED verdict, or zero parseable
|
|
18
|
+
// blocks. Fail closed: when in doubt, the more
|
|
19
|
+
// conservative verdict wins.
|
|
20
|
+
// approved — ≥2 APPROVED verdicts and no critical/change request.
|
|
21
|
+
// approved_minor — everything else.
|
|
20
22
|
//
|
|
21
23
|
// High signal: a finding reported by ≥2 panel members, or any critical-severity
|
|
22
24
|
// finding (a lone critical must never be buried below the lead's judgment).
|
|
@@ -47,17 +49,31 @@ export function parseVerdictBlock(text) {
|
|
|
47
49
|
const rest = src.slice(findingsMatch.index + findingsMatch[0].length);
|
|
48
50
|
let severity = null;
|
|
49
51
|
for (const line of rest.split('\n')) {
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
+
if (line.trim() === '') continue;
|
|
53
|
+
// Non-canonical layouts are tolerated: severity headers may carry any
|
|
54
|
+
// indentation or capitalization, and inline text after the colon
|
|
55
|
+
// (e.g. `Critical: auth bypass`) still counts as a finding.
|
|
56
|
+
const sev = line.match(/^\s*(critical|important|minor)\s*:\s*(.*)$/i);
|
|
52
57
|
if (sev) {
|
|
53
|
-
severity = sev[1];
|
|
58
|
+
severity = sev[1].toLowerCase();
|
|
59
|
+
const inline = sev[2].trim();
|
|
60
|
+
// Placeholders — `none`, `(none)`, `-`, `(none — reason)` — are not
|
|
61
|
+
// findings; `critical: (none)` must not fabricate a critical.
|
|
62
|
+
const lc = inline.toLowerCase();
|
|
63
|
+
if (inline !== '' && lc !== '-' && lc !== 'none' && !lc.startsWith('(none')) {
|
|
64
|
+
findings[severity].push(inline);
|
|
65
|
+
}
|
|
54
66
|
continue;
|
|
55
67
|
}
|
|
56
|
-
const item = line.match(/^\s
|
|
68
|
+
const item = line.match(/^\s*[-*]\s+(.+?)\s*$/);
|
|
57
69
|
if (item && severity) {
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
// Same placeholder set for bullets — `- (none)` under `critical:` must
|
|
71
|
+
// not fabricate a critical.
|
|
72
|
+
const text2 = item[1].toLowerCase();
|
|
73
|
+
if (text2 !== 'none' && text2 !== '-' && !text2.startsWith('(none')) findings[severity].push(item[1]);
|
|
74
|
+
continue;
|
|
60
75
|
}
|
|
76
|
+
if (/^\S/.test(line)) break; // next top-level field ends FINDINGS
|
|
61
77
|
}
|
|
62
78
|
}
|
|
63
79
|
|
|
@@ -112,7 +128,7 @@ export function aggregatePanelVerdicts(verdicts) {
|
|
|
112
128
|
parsed.some((p) => p.verdict === 'CRITICAL') || parsed.some((p) => p.findings.critical.length > 0);
|
|
113
129
|
|
|
114
130
|
let verdict;
|
|
115
|
-
if (hasCritical || changeRequests >=
|
|
131
|
+
if (hasCritical || changeRequests >= 1 || parsed.length === 0) verdict = 'changes_requested';
|
|
116
132
|
else if (approvals >= 2) verdict = 'approved';
|
|
117
133
|
else verdict = 'approved_minor';
|
|
118
134
|
|
|
@@ -10,12 +10,12 @@ import { buildInstallPlan } from './buildPlan.js';
|
|
|
10
10
|
import { diffInstallPlan } from './diffPlan.js';
|
|
11
11
|
import { applyDiffResults, pruneOldBackups } from './applyPlan.js';
|
|
12
12
|
import { summarizeDiff, toDiffRows } from './report.js';
|
|
13
|
-
import { writeInstallMetadata, writeCliLocator } from './metadata.js';
|
|
13
|
+
import { writeInstallMetadata, writeCliLocator, readInstallMetadata } from './metadata.js';
|
|
14
14
|
import { cleanupLegacyPaths, migrateLegacyRuntimeRoot } from './migrateLegacy.js';
|
|
15
15
|
import { ensureGitignore } from './ensureGitignore.js';
|
|
16
16
|
import { repairBrokenHooks } from './repairBrokenHooks.js';
|
|
17
17
|
import { applyGatewayResilienceEnv } from './gatewayResilienceEnv.js';
|
|
18
|
-
import { cleanupEmptyParents,
|
|
18
|
+
import { cleanupEmptyParents, copyFileSafe, removeFileOrLinkOnly, resolveProjectRelativePath, writeJson } from './fileOps.js';
|
|
19
19
|
import { findOpencodeArtifacts, opencodeSteerMessage } from './opencodeSteer.js';
|
|
20
20
|
import { loadRuntimeConfig } from './runtimeConfig.js';
|
|
21
21
|
import { buildUserPaths } from './userPaths.js';
|
|
@@ -188,14 +188,16 @@ async function pruneObsoleteManagedPaths({
|
|
|
188
188
|
projectRoot,
|
|
189
189
|
managedRelativePaths,
|
|
190
190
|
managedLinkRelativePaths = [],
|
|
191
|
+
backupRoot,
|
|
191
192
|
}) {
|
|
193
|
+
const prune = { removed: 0, paths: [] };
|
|
192
194
|
if (installMetaData?.tool !== 'ukit') {
|
|
193
|
-
return
|
|
195
|
+
return prune;
|
|
194
196
|
}
|
|
195
197
|
|
|
196
198
|
const previousFiles = Array.isArray(installMetaData?.files) ? installMetaData.files : [];
|
|
197
199
|
if (previousFiles.length === 0) {
|
|
198
|
-
return
|
|
200
|
+
return prune;
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
const activePathSet = new Set(
|
|
@@ -210,7 +212,6 @@ async function pruneObsoleteManagedPaths({
|
|
|
210
212
|
);
|
|
211
213
|
const activeLinkPaths = [...activeLinkPathSet];
|
|
212
214
|
|
|
213
|
-
let removedCount = 0;
|
|
214
215
|
for (const entry of previousFiles) {
|
|
215
216
|
const relativePath = normalizeRelativePath(typeof entry === 'string' ? entry : entry?.p);
|
|
216
217
|
if (!relativePath || activePathSet.has(relativePath) || !shouldAutoPruneObsoletePath(relativePath)) {
|
|
@@ -225,29 +226,64 @@ async function pruneObsoleteManagedPaths({
|
|
|
225
226
|
continue;
|
|
226
227
|
}
|
|
227
228
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
let stat;
|
|
230
|
+
try {
|
|
231
|
+
stat = await fs.lstat(absolutePath);
|
|
232
|
+
} catch {
|
|
233
|
+
// Already gone — nothing to prune and nothing to warn about.
|
|
232
234
|
continue;
|
|
233
235
|
}
|
|
234
236
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
237
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
238
|
+
// removeFileOrLinkOnly refuses real directories. A path recorded as managed that is
|
|
239
|
+
// now a real directory means the user replaced it — say so instead of silently
|
|
240
|
+
// leaving it behind, matching the link-update warning in applyPlan.js.
|
|
241
|
+
console.warn(
|
|
242
|
+
`[UKit] Warning: skipping obsolete path ${absolutePath} — exists as a real directory (possible user content). Remove it manually if intended.`,
|
|
243
|
+
);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// C92-C-02: this is the only deletion lane for .claude/skills/** — every
|
|
248
|
+
// write lane for the same files goes through overwrite_with_backup, so a
|
|
249
|
+
// prune must produce the same restore point before unlinking. When no
|
|
250
|
+
// backup can be written (no backupRoot configured, or the copy failed),
|
|
251
|
+
// refuse the unlink and warn: a stale-but-alive file beats an
|
|
252
|
+
// unrecoverable deletion. Symlinks carry no content to preserve.
|
|
253
|
+
if (!stat.isSymbolicLink()) {
|
|
254
|
+
if (!backupRoot) {
|
|
241
255
|
console.warn(
|
|
242
|
-
`[UKit] Warning: skipping obsolete path ${
|
|
256
|
+
`[UKit] Warning: skipping obsolete path ${relativePath} — no backup root configured, refusing to delete a managed file without a restore point.`,
|
|
243
257
|
);
|
|
258
|
+
continue;
|
|
244
259
|
}
|
|
245
|
-
|
|
246
|
-
|
|
260
|
+
try {
|
|
261
|
+
await copyFileSafe(
|
|
262
|
+
absolutePath,
|
|
263
|
+
path.join(backupRoot, `${relativePath}.${Date.now()}.bak`),
|
|
264
|
+
);
|
|
265
|
+
} catch (backupError) {
|
|
266
|
+
console.warn(
|
|
267
|
+
`[UKit] Warning: skipping obsolete path ${relativePath} — backup failed (${backupError?.message ?? backupError}).`,
|
|
268
|
+
);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const removed = await removeFileOrLinkOnly(absolutePath);
|
|
274
|
+
if (removed) {
|
|
275
|
+
prune.removed += 1;
|
|
276
|
+
prune.paths.push(relativePath);
|
|
277
|
+
await cleanupEmptyParents(absolutePath, projectRoot);
|
|
278
|
+
continue;
|
|
247
279
|
}
|
|
280
|
+
|
|
281
|
+
console.warn(
|
|
282
|
+
`[UKit] Warning: could not prune obsolete path ${absolutePath}. Remove it manually if intended.`,
|
|
283
|
+
);
|
|
248
284
|
}
|
|
249
285
|
|
|
250
|
-
return
|
|
286
|
+
return prune;
|
|
251
287
|
}
|
|
252
288
|
// SPEC FR-004 — user-layer seeding pass. Runs after the project plan/diff (and
|
|
253
289
|
// after the project apply on real installs): seeds ~/.ukit/ from
|
|
@@ -333,7 +369,10 @@ export async function runInstallPipeline({
|
|
|
333
369
|
// cleanupLegacyPaths() is only safe to run on reinstalls: on a fresh install
|
|
334
370
|
// there are no legacy UKit files to migrate, so running it risks deleting user
|
|
335
371
|
// files that happen to share a legacy path name (e.g. .claude/skills/delivery.md).
|
|
336
|
-
|
|
372
|
+
// FR-015 (W2-C2): a corrupt install.json is fatal and typed — treating it as
|
|
373
|
+
// "not installed" would re-scaffold over a real UKit project and lose the
|
|
374
|
+
// tracked-file manifest the uninstall path depends on.
|
|
375
|
+
const installMetaData = await readInstallMetadata(pathConfig.installMetaPath);
|
|
337
376
|
const isReinstall = Boolean(installMetaData?.tool === 'ukit');
|
|
338
377
|
|
|
339
378
|
// SPEC FR-004/§security — a leftover opencode.json is a user file: detect and
|
|
@@ -351,7 +390,10 @@ export async function runInstallPipeline({
|
|
|
351
390
|
|
|
352
391
|
const manifest = await loadManifest(pathConfig.manifestPath);
|
|
353
392
|
const stackContext = await detectStack(pathConfig.projectRoot);
|
|
354
|
-
|
|
393
|
+
// C92-C-03/C-04: mint only on a real install (ukit diff runs this pipeline
|
|
394
|
+
// with dryRun: true) and thread homeDir so sandboxed callers never touch the
|
|
395
|
+
// caller's real home registry.
|
|
396
|
+
const projectContext = await detectProjectContext(pathConfig.projectRoot, { homeDir, mint: !dryRun });
|
|
355
397
|
const providerContext = await detectProviders(pathConfig.projectRoot);
|
|
356
398
|
// FR-004 — feeds the omp.evalApproval render variable. Missing/invalid config
|
|
357
399
|
// degrades to defaults (allowUnsafeEval: false) inside loadRuntimeConfig, so
|
|
@@ -451,11 +493,16 @@ export async function runInstallPipeline({
|
|
|
451
493
|
projectRoot: pathConfig.projectRoot,
|
|
452
494
|
});
|
|
453
495
|
|
|
454
|
-
|
|
496
|
+
// C92-C-02: the obsolete-path prune is the only deletion lane for
|
|
497
|
+
// .claude/skills/** — it must carry the same backup discipline as
|
|
498
|
+
// overwrite_with_backup, and its result is surfaced to the caller so the
|
|
499
|
+
// install summary reports it instead of silently swallowing unlinks.
|
|
500
|
+
const pruneResult = await pruneObsoleteManagedPaths({
|
|
455
501
|
installMetaData,
|
|
456
502
|
projectRoot: pathConfig.projectRoot,
|
|
457
503
|
managedRelativePaths,
|
|
458
504
|
managedLinkRelativePaths,
|
|
505
|
+
backupRoot: pathConfig.backupRoot,
|
|
459
506
|
});
|
|
460
507
|
|
|
461
508
|
// Adjust summary to reflect entries that were skipped for safety
|
|
@@ -503,6 +550,7 @@ export async function runInstallPipeline({
|
|
|
503
550
|
hookRepair,
|
|
504
551
|
gatewayResilience,
|
|
505
552
|
userLayer,
|
|
553
|
+
prune: pruneResult,
|
|
506
554
|
};
|
|
507
555
|
}
|
|
508
556
|
|
|
@@ -527,5 +575,6 @@ export async function runInstallPipeline({
|
|
|
527
575
|
hookRepair: { removals: [], files: [] },
|
|
528
576
|
gatewayResilience: { customGateway: false, reason: 'dry-run' },
|
|
529
577
|
userLayer,
|
|
578
|
+
prune: { removed: 0, paths: [] },
|
|
530
579
|
};
|
|
531
580
|
}
|