@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
|
@@ -529,6 +529,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
529
529
|
archiveAfterDays: 30,
|
|
530
530
|
maxSessions: 20,
|
|
531
531
|
maxArchivedSessions: 50,
|
|
532
|
+
maxPatternCandidates: 50,
|
|
532
533
|
redactSecrets: true,
|
|
533
534
|
},
|
|
534
535
|
validation: {
|
|
@@ -1185,6 +1186,7 @@ export function validateRuntimeConfig(config) {
|
|
|
1185
1186
|
pushPositiveNumberError(errors, config.memory.archiveAfterDays, 'memory.archiveAfterDays');
|
|
1186
1187
|
pushPositiveNumberError(errors, config.memory.maxSessions, 'memory.maxSessions');
|
|
1187
1188
|
pushPositiveNumberError(errors, config.memory.maxArchivedSessions, 'memory.maxArchivedSessions');
|
|
1189
|
+
pushPositiveNumberError(errors, config.memory.maxPatternCandidates, 'memory.maxPatternCandidates');
|
|
1188
1190
|
pushBooleanError(errors, config.memory.redactSecrets, 'memory.redactSecrets');
|
|
1189
1191
|
}
|
|
1190
1192
|
|
package/src/core/status.js
CHANGED
|
@@ -138,6 +138,8 @@ export async function buildStatusReport(projectRoot) {
|
|
|
138
138
|
throw new Error('Shared UKit runtime not found. Run `ukit install` first.');
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// C92-C-03: read-only — detectProjectContext defaults to mint:false, so this
|
|
142
|
+
// reports an existing identity but never creates/modifies the registry.
|
|
141
143
|
const projectContext = await detectProjectContext(projectRoot);
|
|
142
144
|
const config = await loadRuntimeConfig(projectRoot);
|
|
143
145
|
const compactHistory = normalizeCompactEntries(await readStatusJson(runtimePaths.compactHistoryPath));
|
|
@@ -41,6 +41,12 @@ export const VERIFICATION_MINUTE_TABLE = Object.freeze({
|
|
|
41
41
|
'node scripts/release/verify-release.mjs': 2,
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
// W3-05: `yarn test` / `yarn vitest run` with NO file args runs the entire
|
|
45
|
+
// suite — the single most expensive verification command a task can carry.
|
|
46
|
+
// Estimating it at 0 let verification-heavy tasks slip maxVerificationMinutes;
|
|
47
|
+
// floor it at the release-core cost.
|
|
48
|
+
const WHOLE_SUITE_MINUTES = 3;
|
|
49
|
+
|
|
44
50
|
// Per-command minute estimate. `yarn vitest [run]` and the `yarn test` alias (the repo's
|
|
45
51
|
// dominant verification form) → 0.5 per FILE argument (per non-flag token after the runner
|
|
46
52
|
// word); anything else falls back to 1 flat.
|
|
@@ -60,7 +66,7 @@ export function estimateVerificationMinutes(commands) {
|
|
|
60
66
|
const runIdx = tokens.indexOf('run');
|
|
61
67
|
const tail = runIdx >= 0 ? tokens.slice(runIdx + 1) : tokens.slice(2);
|
|
62
68
|
const fileArgs = tail.filter((t) => !t.startsWith('-') && t.length > 0);
|
|
63
|
-
total += fileArgs.length * 0.5;
|
|
69
|
+
total += fileArgs.length === 0 ? WHOLE_SUITE_MINUTES : fileArgs.length * 0.5;
|
|
64
70
|
continue;
|
|
65
71
|
}
|
|
66
72
|
total += 1;
|
|
@@ -188,7 +188,8 @@ export function checkMilestoneProgress({
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
if (Number.isFinite(newestTs)) {
|
|
191
|
-
const
|
|
191
|
+
const intervalMs = milestoneIntervalMin * 60 * 1000;
|
|
192
|
+
const limitMs = 2 * intervalMs;
|
|
192
193
|
const ageMs = now - newestTs;
|
|
193
194
|
// Boundary: ageMs === limitMs is still OK; ageMs > limitMs is stale.
|
|
194
195
|
if (ageMs > limitMs) {
|
|
@@ -197,6 +198,15 @@ export function checkMilestoneProgress({
|
|
|
197
198
|
detail: `newest entry is ${Math.round(ageMs / 60000)} min old; threshold is ${2 * milestoneIntervalMin} min`,
|
|
198
199
|
});
|
|
199
200
|
}
|
|
201
|
+
// W2-C7: a timestamp in the future would yield negative age and silently
|
|
202
|
+
// never fire staleness. Rule: >1 interval in the future counts as
|
|
203
|
+
// stale-milestone (same vocabulary); <=1 interval is tolerated clock skew.
|
|
204
|
+
if (newestTs - now > intervalMs) {
|
|
205
|
+
violations.push({
|
|
206
|
+
type: 'stale-milestone',
|
|
207
|
+
detail: `newest entry is ${Math.round((newestTs - now) / 60000)} min in the future; allowed skew is ${milestoneIntervalMin} min`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
200
210
|
}
|
|
201
211
|
|
|
202
212
|
const targetFiles = collectTargetFiles(markdown);
|
|
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { parse } from 'yaml';
|
|
6
|
-
import { pathExists
|
|
6
|
+
import { pathExists } from './fileOps.js';
|
|
7
7
|
import { VALID_PERMISSION_MODES } from './runtimeConfig.js';
|
|
8
8
|
|
|
9
9
|
// TASK-011 / SPEC §8 — unattended-mode doctor checks (FR-007).
|
|
@@ -69,11 +69,27 @@ async function readTemplateDenyMatches() {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// FR-025 / W2-C3 — a corrupt `.ukit/storage/config.json` is exactly what doctor
|
|
73
|
+
// exists to diagnose: readJsonIfExists would throw the SyntaxError and kill the
|
|
74
|
+
// run, so the surface read is tolerant and the parse error becomes a finding.
|
|
75
|
+
async function readRuntimeConfigSurface(projectRoot) {
|
|
76
|
+
const configPath = path.join(projectRoot, '.ukit', 'storage', 'config.json');
|
|
77
|
+
try {
|
|
78
|
+
return { exists: true, parsed: JSON.parse(await fs.readFile(configPath, 'utf8')), parseError: null };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error?.code === 'ENOENT') return { exists: false, parsed: null, parseError: null };
|
|
81
|
+
return { exists: true, parsed: null, parseError: error?.message ?? String(error) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
72
85
|
export async function inspectUnattendedMode({ projectRoot, ompPath = 'omp' }) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
|
|
86
|
+
const runtimeConfigSurface = await readRuntimeConfigSurface(projectRoot);
|
|
87
|
+
const runtimeConfig = runtimeConfigSurface.parseError ? null : runtimeConfigSurface.parsed;
|
|
88
|
+
// A corrupt file hides its permissionMode; only a parsed (or absent) config may
|
|
89
|
+
// report the 'unattended (default)' detail — corruption must never masquerade.
|
|
90
|
+
const permissionModeRaw = runtimeConfigSurface.parseError
|
|
91
|
+
? '<unreadable: corrupt config.json>'
|
|
92
|
+
: runtimeConfig?.orchestration?.permissionMode;
|
|
77
93
|
const permissionModeValid = permissionModeRaw === undefined
|
|
78
94
|
|| VALID_PERMISSION_MODES.has(permissionModeRaw);
|
|
79
95
|
const permissionMode = permissionModeRaw === undefined ? 'unattended' : permissionModeRaw;
|
|
@@ -146,6 +162,21 @@ export async function inspectUnattendedMode({ projectRoot, ompPath = 'omp' }) {
|
|
|
146
162
|
});
|
|
147
163
|
|
|
148
164
|
const checks = [
|
|
165
|
+
// 0 — runtime config.json parses (FR-025/W2-C3): a corrupt file is an
|
|
166
|
+
// owner-action finding, never a crash, and never silently treated as defaults.
|
|
167
|
+
check(
|
|
168
|
+
'runtime config.json parses',
|
|
169
|
+
!runtimeConfigSurface.parseError,
|
|
170
|
+
'owner-action',
|
|
171
|
+
'Fix or remove .ukit/storage/config.json — invalid JSON hides the declared permissionMode.',
|
|
172
|
+
{
|
|
173
|
+
detail: runtimeConfigSurface.parseError
|
|
174
|
+
? `corrupt: ${runtimeConfigSurface.parseError}`
|
|
175
|
+
: runtimeConfigSurface.exists
|
|
176
|
+
? 'parsed'
|
|
177
|
+
: 'absent (defaults apply)',
|
|
178
|
+
},
|
|
179
|
+
),
|
|
149
180
|
// 1 — permissionMode declared
|
|
150
181
|
check(
|
|
151
182
|
'`permissionMode` declared',
|
package/src/core/uninstall.js
CHANGED
|
@@ -3,12 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import {
|
|
4
4
|
cleanupEmptyParents,
|
|
5
5
|
copyFileRawExclusive,
|
|
6
|
-
readJsonIfExists,
|
|
7
6
|
removeFileOrLinkOnly,
|
|
8
7
|
removeLinkOrDir,
|
|
9
8
|
removeLinkOnly,
|
|
10
9
|
resolveProjectRelativePath,
|
|
11
10
|
} from './fileOps.js';
|
|
11
|
+
import { readInstallMetadata } from './metadata.js';
|
|
12
12
|
import { removeGitignoreBlock } from './ensureGitignore.js';
|
|
13
13
|
import { PROJECT_IMPORTANT_FILENAME } from './projectImportant.js';
|
|
14
14
|
|
|
@@ -245,14 +245,19 @@ async function hasSymlinkedParent(projectRoot, absolutePath) {
|
|
|
245
245
|
// Hardcoded fallback for installs that predate file tracking (no 'files' field
|
|
246
246
|
// in install.json). New installs always have a 'files' list, so this fallback
|
|
247
247
|
// only applies when upgrading from a very old UKit version.
|
|
248
|
+
//
|
|
249
|
+
// W2-C5: the list is SHAPE-annotated. `dirPaths` are entries that were always
|
|
250
|
+
// directories in legacy installs; `filePaths` are file/symlink entries. Only a
|
|
251
|
+
// dirPaths entry that lstat confirms is a real directory gets recursive
|
|
252
|
+
// removal — a file-shaped path must NEVER be rm -rf'd, because a user
|
|
253
|
+
// directory planted at e.g. CLAUDE.md is user content, not a managed dir.
|
|
248
254
|
function buildFallbackPaths(projectRoot) {
|
|
249
255
|
const stateDir = path.join(projectRoot, '.claude', 'ukit', '.ukit');
|
|
250
256
|
return {
|
|
251
|
-
|
|
257
|
+
filePaths: [
|
|
252
258
|
path.join(projectRoot, 'CLAUDE.md'),
|
|
253
259
|
path.join(projectRoot, 'AGENTS.md'),
|
|
254
260
|
path.join(projectRoot, '.claude', 'commands', 'ukit.md'),
|
|
255
|
-
path.join(projectRoot, '.claude', 'ukit', 'mcp'),
|
|
256
261
|
path.join(projectRoot, '.claude', 'ukit', 'scripts', 'setup-mcp.sh'),
|
|
257
262
|
path.join(projectRoot, '.claude', 'ukit', '.env.example'),
|
|
258
263
|
path.join(projectRoot, '.claude', 'ukit', 'permission-usage.json'),
|
|
@@ -274,6 +279,10 @@ function buildFallbackPaths(projectRoot) {
|
|
|
274
279
|
path.join(projectRoot, '.omp', 'agents', 'handoff-planner.md'),
|
|
275
280
|
path.join(projectRoot, '.omp', 'agents', 'ukit-small-task-maintainer.md'),
|
|
276
281
|
path.join(projectRoot, '.omp', 'agents', 'ukit-vision-analyst.md'),
|
|
282
|
+
],
|
|
283
|
+
dirPaths: [
|
|
284
|
+
// legacy MCP venv — a real directory in old installs (contained .venv/)
|
|
285
|
+
path.join(projectRoot, '.claude', 'ukit', 'mcp'),
|
|
277
286
|
path.join(projectRoot, '.ukit'),
|
|
278
287
|
stateDir,
|
|
279
288
|
],
|
|
@@ -287,6 +296,36 @@ function buildFallbackPaths(projectRoot) {
|
|
|
287
296
|
};
|
|
288
297
|
}
|
|
289
298
|
|
|
299
|
+
// W2-C5: `recursive: true` is earned, not assumed — only for a declared
|
|
300
|
+
// dir-shaped entry that lstat currently confirms is a real directory.
|
|
301
|
+
// Files, symlinks-to-anything, FIFOs, and vanished paths all fall back to
|
|
302
|
+
// removeFileOrLinkOnly semantics (non-recursive), so a user directory sitting
|
|
303
|
+
// at a file-shaped fallback path is refused instead of rm -rf'd.
|
|
304
|
+
async function classifyLegacyFallbackPaths(filePaths, dirPaths) {
|
|
305
|
+
const regularPaths = [];
|
|
306
|
+
const add = async (abs, declaredDir) => {
|
|
307
|
+
if (!declaredDir) {
|
|
308
|
+
regularPaths.push({ abs, recursive: false });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
let stat;
|
|
312
|
+
try {
|
|
313
|
+
stat = await fs.lstat(abs);
|
|
314
|
+
} catch {
|
|
315
|
+
stat = null; // missing or inaccessible — non-recursive unlink attempt is safe
|
|
316
|
+
}
|
|
317
|
+
const realDir = Boolean(stat?.isDirectory() && !stat.isSymbolicLink());
|
|
318
|
+
regularPaths.push({ abs, recursive: realDir });
|
|
319
|
+
};
|
|
320
|
+
for (const abs of filePaths) {
|
|
321
|
+
await add(abs, false);
|
|
322
|
+
}
|
|
323
|
+
for (const abs of dirPaths) {
|
|
324
|
+
await add(abs, true);
|
|
325
|
+
}
|
|
326
|
+
return regularPaths;
|
|
327
|
+
}
|
|
328
|
+
|
|
290
329
|
export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
291
330
|
const installMetaPath = path.join(projectRoot, '.claude', 'ukit', '.ukit', 'install.json');
|
|
292
331
|
|
|
@@ -294,12 +333,12 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
294
333
|
// An attacker (or confused user) could forge the file to trigger uninstall of
|
|
295
334
|
// the hardcoded managed paths. Requiring 'tool: ukit' ensures the file was
|
|
296
335
|
// written by the UKit installer, not created manually.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
336
|
+
// FR-002 (TASK-C93-002): corruption is NOT absence. A malformed ledger rejects
|
|
337
|
+
// with InstallMetadataError and non-parse fs errors (EISDIR, EACCES, …)
|
|
338
|
+
// propagate — neither may fall through to the no-install result, which would
|
|
339
|
+
// report a clean uninstall while orphaning every managed file. Absent file
|
|
340
|
+
// alone still maps to null → wasInstalled:false.
|
|
341
|
+
const installData = await readInstallMetadata(installMetaPath);
|
|
303
342
|
if (!installData || installData.tool !== 'ukit') {
|
|
304
343
|
return { removed: 0, attempted: 0, wasInstalled: false };
|
|
305
344
|
}
|
|
@@ -377,9 +416,10 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
377
416
|
// These are user-created content (mergeStrategy: skip) — deleting them
|
|
378
417
|
// would cause data loss. Users must remove them manually if desired.
|
|
379
418
|
const fallback = buildFallbackPaths(projectRoot);
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
419
|
+
// W2-C5: recursive removal is reserved for declared dir entries that lstat
|
|
420
|
+
// confirms are real directories. File-shaped entries always go through the
|
|
421
|
+
// non-recursive remover so a user dir at a file path is refused.
|
|
422
|
+
regularPaths = await classifyLegacyFallbackPaths(fallback.filePaths, fallback.dirPaths);
|
|
383
423
|
linkPaths = fallback.linkPaths;
|
|
384
424
|
}
|
|
385
425
|
|
package/src/core/update.js
CHANGED
|
@@ -8,7 +8,11 @@ export const UKIT_PACKAGE_NAME = '@ngockhoale/ukit';
|
|
|
8
8
|
// `ukit update` refreshes such a project automatically; it must never scaffold a
|
|
9
9
|
// directory that merely happens to be the shell's cwd (install writes .claude/,
|
|
10
10
|
// CLAUDE.md and edits .gitignore — an unwanted surprise in, say, a home directory).
|
|
11
|
-
|
|
11
|
+
// FR-015 (W2-C1): only UKit-specific state counts — the install metadata/state
|
|
12
|
+
// tree (.claude/ukit/) or the storage root (.ukit/). A plain Claude Code repo
|
|
13
|
+
// carries .claude/ + CLAUDE.md without ever having been installed, so those
|
|
14
|
+
// generic engine files are deliberately NOT markers.
|
|
15
|
+
const INSTALLED_MARKERS = ['.ukit', '.claude/ukit'];
|
|
12
16
|
|
|
13
17
|
// BUG-C21-12 (SPEC FR-005): spawnSync defaults to NO timeout, so a wedged npm
|
|
14
18
|
// (network partition, hung credential helper) would block the CLI forever.
|
package/src/decision/client.js
CHANGED
|
@@ -124,13 +124,27 @@ function isTransientStatus(status) {
|
|
|
124
124
|
function parseMaybeSse(text) {
|
|
125
125
|
const trimmed = String(text ?? '').trim();
|
|
126
126
|
if (!trimmed.startsWith('{')) {
|
|
127
|
-
//
|
|
128
|
-
|
|
127
|
+
// SSE frames: a single full-completion payload parses directly; a chunked
|
|
128
|
+
// stream is one chunk object per `data:` frame and needs delta merge.
|
|
129
|
+
const payloads = trimmed
|
|
129
130
|
.split('\n')
|
|
130
131
|
.filter((line) => line.startsWith('data:') && !line.includes('[DONE]'))
|
|
131
|
-
.map((line) => line.slice(5).trim())
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
.map((line) => line.slice(5).trim());
|
|
133
|
+
const chunks = [];
|
|
134
|
+
for (const payload of payloads) {
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(payload);
|
|
137
|
+
if (parsed && typeof parsed === 'object') chunks.push(parsed);
|
|
138
|
+
} catch { /* unparseable frame — drop it, merge what survives */ }
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const joined = JSON.parse(payloads.join('') || trimmed);
|
|
142
|
+
// A lone `data:` frame may itself be one chunk object — merge it so
|
|
143
|
+
// callers always see a completion, never a raw chunk.
|
|
144
|
+
if (joined?.choices?.[0]?.delta === undefined) return joined;
|
|
145
|
+
} catch { /* joined payload is not one object — merge chunks below */ }
|
|
146
|
+
if (chunks.length > 0) return mergeSseChunks(chunks);
|
|
147
|
+
return JSON.parse(trimmed); // let the caller map the parse error
|
|
134
148
|
}
|
|
135
149
|
// JSON object possibly followed by a glued SSE trailer (`}data: [DONE]`,
|
|
136
150
|
// observed live) — extract the balanced object, strings/comments aware.
|
|
@@ -153,6 +167,60 @@ function parseMaybeSse(text) {
|
|
|
153
167
|
return JSON.parse(trimmed);
|
|
154
168
|
}
|
|
155
169
|
|
|
170
|
+
// Fold chat.completion.chunk frames into a single completion-shaped object:
|
|
171
|
+
// deltas merge into `choices[0].message`, per-index tool_calls arguments
|
|
172
|
+
// concatenate. Throws (typed malformed-response upstream) when no chunk is
|
|
173
|
+
// usable.
|
|
174
|
+
function mergeSseChunks(chunks) {
|
|
175
|
+
const mergedCalls = [];
|
|
176
|
+
let finishReason = null;
|
|
177
|
+
let role = null;
|
|
178
|
+
for (const chunk of chunks) {
|
|
179
|
+
const choice = chunk?.choices?.[0];
|
|
180
|
+
if (!choice) continue;
|
|
181
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
182
|
+
const delta = choice.delta ?? {};
|
|
183
|
+
if (delta.role) role = delta.role;
|
|
184
|
+
for (const call of delta.tool_calls ?? []) {
|
|
185
|
+
const slot = mergedCalls[call.index ?? 0] ??= {
|
|
186
|
+
id: call.id ?? null,
|
|
187
|
+
type: call.type ?? 'function',
|
|
188
|
+
function: { name: call.function?.name ?? '', arguments: '' },
|
|
189
|
+
};
|
|
190
|
+
if (call.function?.arguments) slot.function.arguments += call.function.arguments;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (mergedCalls.length === 0) {
|
|
194
|
+
throw new SyntaxError('unusable SSE stream — no tool_call chunks');
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
choices: [{
|
|
198
|
+
index: 0,
|
|
199
|
+
finish_reason: finishReason,
|
|
200
|
+
message: { role: role ?? 'assistant', content: null, tool_calls: mergedCalls },
|
|
201
|
+
}],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Abort-aware race: fetch's signal does not cover res.text()/res.json() once
|
|
206
|
+
// headers arrived, and injected transports may ignore it entirely — the read
|
|
207
|
+
// is bounded by the caller's remaining deadline either way.
|
|
208
|
+
function abortError() {
|
|
209
|
+
return new DOMException('The operation was aborted.', 'AbortError');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function raceAbort(promise, signal) {
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
if (signal?.aborted) { reject(signal.reason ?? abortError()); return; }
|
|
215
|
+
const onAbort = () => reject(signal.reason ?? abortError());
|
|
216
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
217
|
+
Promise.resolve(promise).then(
|
|
218
|
+
(v) => { signal?.removeEventListener('abort', onAbort); resolve(v); },
|
|
219
|
+
(e) => { signal?.removeEventListener('abort', onAbort); reject(e); },
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
156
224
|
async function readResponseBody(res) {
|
|
157
225
|
if (res && typeof res.text === 'function') return parseMaybeSse(await res.text());
|
|
158
226
|
if (res && typeof res.json === 'function') return res.json();
|
|
@@ -231,19 +299,53 @@ export function createDecisionClient({
|
|
|
231
299
|
};
|
|
232
300
|
}
|
|
233
301
|
|
|
234
|
-
|
|
302
|
+
// `deadlineAt` covers the WHOLE request lifetime — headers plus body
|
|
303
|
+
// consumption — not just the fetch() call. The abort timer is cleared only
|
|
304
|
+
// by release(), invoked after readBody()/discard() settle or on transport
|
|
305
|
+
// failure; until then a stalled body still aborts (W3-UD1).
|
|
306
|
+
async function postWithDeadline(url, headers, body, deadlineAt) {
|
|
235
307
|
const controller = new AbortController();
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
method: 'POST',
|
|
240
|
-
headers: { 'content-type': 'application/json', ...headers },
|
|
241
|
-
body,
|
|
242
|
-
signal: controller.signal,
|
|
243
|
-
});
|
|
244
|
-
} finally {
|
|
308
|
+
const remaining = () => deadlineAt - now();
|
|
309
|
+
const timer = setTimeout(() => controller.abort(), Math.max(0, remaining()));
|
|
310
|
+
const release = () => {
|
|
245
311
|
clearTimeout(timer);
|
|
312
|
+
controller.abort(); // signal any stale readers; settled callers unaffected
|
|
313
|
+
};
|
|
314
|
+
let res;
|
|
315
|
+
try {
|
|
316
|
+
// Race the transport await too — a transport that ignores the abort
|
|
317
|
+
// signal (or a hung socket before headers) must not outlive deadlineAt.
|
|
318
|
+
res = await raceAbort(
|
|
319
|
+
transport(url, {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
322
|
+
body,
|
|
323
|
+
signal: controller.signal,
|
|
324
|
+
}),
|
|
325
|
+
controller.signal,
|
|
326
|
+
);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
release();
|
|
329
|
+
throw err;
|
|
246
330
|
}
|
|
331
|
+
const readBody = async (target = res) => {
|
|
332
|
+
try {
|
|
333
|
+
return await raceAbort(readResponseBody(target), controller.signal);
|
|
334
|
+
} finally {
|
|
335
|
+
release();
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
// Release the socket without parsing — the caller only needed headers.
|
|
339
|
+
const discard = async () => {
|
|
340
|
+
try {
|
|
341
|
+
if (typeof res?.body?.cancel === 'function') await res.body.cancel();
|
|
342
|
+
else if (typeof res?.text === 'function') {
|
|
343
|
+
await raceAbort(res.text(), controller.signal);
|
|
344
|
+
} else if (typeof res?.destroy === 'function') res.destroy();
|
|
345
|
+
} catch { /* best-effort release */ }
|
|
346
|
+
release();
|
|
347
|
+
};
|
|
348
|
+
return { res, readBody, discard };
|
|
247
349
|
}
|
|
248
350
|
|
|
249
351
|
/**
|
|
@@ -332,10 +434,14 @@ export function createDecisionClient({
|
|
|
332
434
|
detail: error?.message ?? String(error),
|
|
333
435
|
});
|
|
334
436
|
}
|
|
335
|
-
|
|
437
|
+
let fingerprint = requestFingerprint(body);
|
|
336
438
|
const deadlineMs = Number.isFinite(batch?.deadlineMs)
|
|
337
439
|
? batch.deadlineMs
|
|
338
440
|
: timeoutMs;
|
|
441
|
+
// Cumulative budget: every attempt (headers AND body read) must finish
|
|
442
|
+
// before deadlineAt — retries share the caller's budget, they do not
|
|
443
|
+
// each get a fresh deadlineMs (W3-UD2).
|
|
444
|
+
const deadlineAt = now() + deadlineMs;
|
|
339
445
|
|
|
340
446
|
// A half-open probe is a single trial — no retry against a possibly-dead
|
|
341
447
|
// gateway; a closed circuit gets the bounded transient retry.
|
|
@@ -344,19 +450,25 @@ export function createDecisionClient({
|
|
|
344
450
|
let lastStatus = null;
|
|
345
451
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
346
452
|
const startedAt = now();
|
|
347
|
-
|
|
453
|
+
if (deadlineAt - startedAt <= 0) {
|
|
454
|
+
lastError = 'timeout';
|
|
455
|
+
lastStatus = null;
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
let call;
|
|
348
459
|
try {
|
|
349
|
-
|
|
460
|
+
call = await postWithDeadline(endpoint.url, endpoint.headers, body, deadlineAt);
|
|
350
461
|
} catch (err) {
|
|
351
462
|
const isTimeout =
|
|
352
463
|
err?.name === 'AbortError' || err?.code === 'ABORT_ERR';
|
|
353
464
|
lastError = isTimeout ? 'timeout' : 'unavailable';
|
|
354
465
|
lastStatus = null;
|
|
355
466
|
// Timeouts never retry; transient transport errors retry once inside
|
|
356
|
-
// the
|
|
467
|
+
// the remaining budget.
|
|
357
468
|
if (isTimeout || attempt + 1 >= maxAttempts) break;
|
|
358
469
|
continue;
|
|
359
470
|
}
|
|
471
|
+
const { res, readBody, discard } = call;
|
|
360
472
|
|
|
361
473
|
const status = res?.status ?? (res?.ok === false ? 500 : 200);
|
|
362
474
|
if (res?.ok === false || status < 200 || status >= 300) {
|
|
@@ -365,21 +477,30 @@ export function createDecisionClient({
|
|
|
365
477
|
// model, the credential-safe alias the owner provisions first.
|
|
366
478
|
if (checkpoint !== FALLBACK_CHECKPOINT) {
|
|
367
479
|
let notFound = status === 404;
|
|
480
|
+
let consumed = false;
|
|
368
481
|
try {
|
|
369
482
|
const clone = typeof res?.clone === 'function' ? res.clone() : null;
|
|
370
|
-
|
|
371
|
-
|
|
483
|
+
if (clone) {
|
|
484
|
+
const errBody = await readBody(clone);
|
|
485
|
+
consumed = true;
|
|
486
|
+
if (errBody?.error?.code === 'model_not_found') notFound = true;
|
|
487
|
+
}
|
|
372
488
|
} catch { /* keep status-based guess */ }
|
|
373
489
|
if (notFound) {
|
|
490
|
+
if (!consumed) await discard();
|
|
374
491
|
checkpoint = FALLBACK_CHECKPOINT;
|
|
375
492
|
try {
|
|
376
493
|
body = JSON.stringify(
|
|
377
494
|
encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
|
|
378
495
|
);
|
|
379
496
|
} catch { /* keep prior body */ }
|
|
497
|
+
// The receipt hash must cover the bytes actually sent — after
|
|
498
|
+
// the fallback re-encode the original fingerprint is stale.
|
|
499
|
+
fingerprint = requestFingerprint(body);
|
|
380
500
|
continue;
|
|
381
501
|
}
|
|
382
502
|
}
|
|
503
|
+
await discard();
|
|
383
504
|
if (isTransientStatus(status) && attempt + 1 < maxAttempts) continue;
|
|
384
505
|
if (status === 401 || status === 403) {
|
|
385
506
|
lastError = 'unavailable';
|
|
@@ -396,10 +517,17 @@ export function createDecisionClient({
|
|
|
396
517
|
|
|
397
518
|
let parsed;
|
|
398
519
|
try {
|
|
399
|
-
parsed = await
|
|
400
|
-
} catch {
|
|
401
|
-
|
|
402
|
-
|
|
520
|
+
parsed = await readBody();
|
|
521
|
+
} catch (err) {
|
|
522
|
+
if (err?.name === 'AbortError' || err?.code === 'ABORT_ERR') {
|
|
523
|
+
// The body stream outlived the deadline — typed timeout, not a
|
|
524
|
+
// malformed response.
|
|
525
|
+
lastError = 'timeout';
|
|
526
|
+
lastStatus = null;
|
|
527
|
+
} else {
|
|
528
|
+
lastError = 'invalid';
|
|
529
|
+
lastStatus = 'malformed-response';
|
|
530
|
+
}
|
|
403
531
|
break;
|
|
404
532
|
}
|
|
405
533
|
|
|
@@ -456,13 +584,16 @@ export function createDecisionClient({
|
|
|
456
584
|
max_tokens: 1,
|
|
457
585
|
});
|
|
458
586
|
try {
|
|
459
|
-
const res = await postWithDeadline(
|
|
587
|
+
const { res, discard } = await postWithDeadline(
|
|
460
588
|
endpoint.url,
|
|
461
589
|
endpoint.headers,
|
|
462
590
|
body,
|
|
463
|
-
timeoutMs,
|
|
591
|
+
now() + timeoutMs,
|
|
464
592
|
);
|
|
465
593
|
const status = res?.status ?? (res?.ok === false ? 500 : 200);
|
|
594
|
+
// The probe only needed the headers — cancel the body so the keep-alive
|
|
595
|
+
// socket is released instead of idling to its own timeout.
|
|
596
|
+
await discard();
|
|
466
597
|
if (res?.ok === false || status < 200 || status >= 300) {
|
|
467
598
|
return { status: 'unavailable', httpStatus: status };
|
|
468
599
|
}
|
|
@@ -137,16 +137,20 @@ function stableBatchId(findings) {
|
|
|
137
137
|
*/
|
|
138
138
|
export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
139
139
|
const usedIds = new Set();
|
|
140
|
+
// Sanitize the FULL finding set — MAX_FINDINGS is a display/transport bound
|
|
141
|
+
// for the batch, not a verdict bound. The deterministic verdict and the
|
|
142
|
+
// truncation clamp both run on `fullFindings` (W3-RV1).
|
|
140
143
|
const sanitized = (Array.isArray(findings) ? findings : [])
|
|
141
|
-
.slice(0, MAX_FINDINGS)
|
|
142
144
|
.map((raw, index) => sanitizeFinding(raw, index, usedIds));
|
|
145
|
+
const batchFindings = sanitized.slice(0, MAX_FINDINGS);
|
|
146
|
+
const truncated = sanitized.length > batchFindings.length;
|
|
143
147
|
|
|
144
148
|
const verdictDecisionKey = mode === 'panel'
|
|
145
149
|
? REVIEW_VERDICT_KEYS.panel
|
|
146
150
|
: REVIEW_VERDICT_KEYS.solo;
|
|
147
151
|
|
|
148
152
|
const counts = { critical: 0, important: 0, minor: 0, unknown: 0 };
|
|
149
|
-
for (const f of
|
|
153
|
+
for (const f of batchFindings) counts[f.severity] += 1;
|
|
150
154
|
|
|
151
155
|
const verdictQuestion = {
|
|
152
156
|
decisionKey: verdictDecisionKey,
|
|
@@ -165,7 +169,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
|
165
169
|
// One bucket question per finding. The shared registered key cannot repeat
|
|
166
170
|
// in one batch (name-based reconciliation), so each wire name carries
|
|
167
171
|
// `#<findingId>`; parseVerdictAnswer splits it back off.
|
|
168
|
-
const bucketQuestions =
|
|
172
|
+
const bucketQuestions = batchFindings.map((f) => ({
|
|
169
173
|
decisionKey: `${REVIEW_VERDICT_KEYS.bucket}#${f.id}`,
|
|
170
174
|
schemaVersion: 1,
|
|
171
175
|
family: 'review',
|
|
@@ -183,20 +187,28 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
|
183
187
|
const statePacket = {
|
|
184
188
|
kind: 'review-verdict',
|
|
185
189
|
mode: mode === 'panel' ? 'panel' : 'solo',
|
|
186
|
-
findingCount:
|
|
190
|
+
findingCount: batchFindings.length,
|
|
187
191
|
severityCounts: counts,
|
|
188
|
-
findings:
|
|
192
|
+
findings: batchFindings,
|
|
189
193
|
};
|
|
190
194
|
|
|
191
195
|
const batch = {
|
|
192
196
|
batchVersion: 1,
|
|
193
|
-
batchId: stableBatchId(
|
|
197
|
+
batchId: stableBatchId(batchFindings),
|
|
194
198
|
boundary: 'review-verdict',
|
|
195
199
|
questions,
|
|
196
200
|
statePacket,
|
|
197
201
|
};
|
|
198
202
|
|
|
199
|
-
return {
|
|
203
|
+
return {
|
|
204
|
+
questions,
|
|
205
|
+
statePacket,
|
|
206
|
+
batch,
|
|
207
|
+
findings: batchFindings,
|
|
208
|
+
fullFindings: sanitized,
|
|
209
|
+
truncated,
|
|
210
|
+
verdictDecisionKey,
|
|
211
|
+
};
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
/**
|
|
@@ -285,6 +297,10 @@ export function parseVerdictAnswer(result, { findings = [] } = {}) {
|
|
|
285
297
|
verdict = answer.value;
|
|
286
298
|
verdictStatus = 'valid';
|
|
287
299
|
} else {
|
|
300
|
+
// The pair stays consistent: an invalid answer for the verdict key
|
|
301
|
+
// clears any earlier valid verdict — never leave a stale APPROVED
|
|
302
|
+
// standing behind verdictStatus 'invalid' (W3-RV2).
|
|
303
|
+
verdict = null;
|
|
288
304
|
verdictStatus = 'invalid';
|
|
289
305
|
}
|
|
290
306
|
continue;
|
|
@@ -68,7 +68,7 @@ function failureCommands(ledger) {
|
|
|
68
68
|
|
|
69
69
|
async function writeArtifact(filePath, result) {
|
|
70
70
|
const dir = path.dirname(filePath);
|
|
71
|
-
const tmp = path.join(dir, `.failure-patterns-${process.pid}.tmp`);
|
|
71
|
+
const tmp = path.join(dir, `.failure-patterns-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
72
72
|
try {
|
|
73
73
|
await fs.mkdir(dir, { recursive: true });
|
|
74
74
|
await fs.writeFile(tmp, JSON.stringify(result, null, 2));
|
|
@@ -92,7 +92,7 @@ async function readManualEvents(projectRoot, byKind) {
|
|
|
92
92
|
|
|
93
93
|
async function writeArtifact(filePath, result) {
|
|
94
94
|
const dir = path.dirname(filePath);
|
|
95
|
-
const tmp = path.join(dir, `.feedback-events-${process.pid}.tmp`);
|
|
95
|
+
const tmp = path.join(dir, `.feedback-events-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
96
96
|
try {
|
|
97
97
|
await fs.mkdir(dir, { recursive: true });
|
|
98
98
|
await fs.writeFile(tmp, JSON.stringify(result, null, 2));
|