@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/code.js +29 -5
  4. package/src/cli/commands/decision.js +18 -4
  5. package/src/cli/commands/doctor.js +7 -3
  6. package/src/cli/commands/install.js +29 -4
  7. package/src/cli/commands/memory.js +25 -5
  8. package/src/cli/commands/telemetry.js +18 -1
  9. package/src/cli/commands/vm.js +7 -1
  10. package/src/context/detectProjectContext.js +7 -2
  11. package/src/core/agentRuntime/contract.js +5 -1
  12. package/src/core/agentRuntime/eventStore.js +54 -7
  13. package/src/core/agentRuntime/recovery.js +22 -15
  14. package/src/core/agentRuntime/supervisor.js +71 -13
  15. package/src/core/applyPlan.js +11 -1
  16. package/src/core/codeintel/compiler.js +51 -8
  17. package/src/core/codeintel/diagnostics.js +124 -33
  18. package/src/core/codeintel/freshness.js +25 -12
  19. package/src/core/codeintel/invalidation.js +11 -3
  20. package/src/core/codeintel/retriever.js +53 -20
  21. package/src/core/codeintel/router.js +19 -9
  22. package/src/core/codeintel/summaries.js +4 -3
  23. package/src/core/codeintel/vectorProvider.js +30 -4
  24. package/src/core/compact/index.js +24 -7
  25. package/src/core/compact/threshold.js +49 -14
  26. package/src/core/diffPlan.js +51 -23
  27. package/src/core/ensureGitignore.js +19 -2
  28. package/src/core/fileOps.js +61 -0
  29. package/src/core/memory/hygiene.js +51 -1
  30. package/src/core/memory/migrate.js +41 -21
  31. package/src/core/memory/store.js +96 -61
  32. package/src/core/metadata.js +37 -2
  33. package/src/core/observability/adapters/ingest.js +30 -2
  34. package/src/core/observability/emit/config.js +19 -4
  35. package/src/core/observability/emit/crash.js +3 -1
  36. package/src/core/observability/emit/recorder.js +15 -7
  37. package/src/core/observability/privacy/sanitizeObserved.js +3 -1
  38. package/src/core/observability/segments/internal.js +36 -8
  39. package/src/core/observability/segments/retention.js +11 -0
  40. package/src/core/observability/support/import.js +27 -1
  41. package/src/core/output/index.js +16 -1
  42. package/src/core/permissionDoctor.js +72 -9
  43. package/src/core/repairBrokenHooks.js +15 -2
  44. package/src/core/reviewPanelAggregate.js +26 -10
  45. package/src/core/runInstallPipeline.js +71 -22
  46. package/src/core/runtimeConfig.js +2 -0
  47. package/src/core/status.js +2 -0
  48. package/src/core/taskBudgetValidator.js +7 -1
  49. package/src/core/taskProgressGuard.js +11 -1
  50. package/src/core/unattendedDoctor.js +36 -5
  51. package/src/core/uninstall.js +52 -12
  52. package/src/core/update.js +5 -1
  53. package/src/decision/client.js +158 -27
  54. package/src/decision/reviewVerdict.js +23 -7
  55. package/src/diagnostics/failurePatterns.js +1 -1
  56. package/src/diagnostics/feedbackEvents.js +1 -1
  57. package/src/diagnostics/routeOutcomes.js +42 -4
  58. package/src/diagnostics/skillAccuracy.js +35 -4
  59. package/src/index/buildIndex.js +123 -26
  60. package/src/index/fixLoopEscalation.js +3 -0
  61. package/src/index/gitHooks.js +99 -29
  62. package/src/index/importResolution.js +7 -1
  63. package/src/index/playbookRegistry.js +15 -11
  64. package/src/index/queryIndex.js +28 -10
  65. package/src/index/routeResolver.js +8 -3
  66. package/src/index/taskRouting.js +37 -2
  67. package/src/learning/codeProposals.js +24 -5
  68. package/src/learning/selfImprove.js +29 -5
  69. package/src/learning/tunedOverlay.js +18 -7
  70. package/src/learning/tuning.js +10 -4
  71. package/src/skill/auditSkill.js +46 -7
  72. package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
  73. package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
  74. package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
  75. package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
  76. package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
  77. package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
  78. package/template_project/.claude/hooks/session-episode.sh +60 -28
  79. package/template_project/.claude/hooks/verification-guard.sh +26 -15
  80. package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
  81. package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
  82. package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
  83. package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
  84. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
  85. package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
  86. package/template_project/.claude/ukit/index/query-index.mjs +13 -6
  87. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
  88. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
  89. package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
  90. package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
  91. package/template_project/.claude/ukit/index/route-task.mjs +15 -0
  92. package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
  93. package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
  94. package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
  95. package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
  96. package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
  97. package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
  98. package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
  99. package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
  100. package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
  101. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
  102. package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
  103. package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
  104. package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
  105. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
  106. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
  107. package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
  108. package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
  109. package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
  110. package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
@@ -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
 
@@ -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 limitMs = 2 * milestoneIntervalMin * 60 * 1000;
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, readJsonIfExists } from './fileOps.js';
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 runtimeConfig = await readJsonIfExists(
74
- path.join(projectRoot, '.ukit', 'storage', 'config.json'),
75
- );
76
- const permissionModeRaw = runtimeConfig?.orchestration?.permissionMode;
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',
@@ -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
- regularPaths: [
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
- let installData;
298
- try {
299
- installData = await readJsonIfExists(installMetaPath);
300
- } catch {
301
- return { removed: 0, attempted: 0, wasInstalled: false };
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
- // Legacy installs have no per-file ownership metadata; the hardcoded list
381
- // keeps its historical recursive semantics (.ukit/, state dir are dirs).
382
- regularPaths = fallback.regularPaths.map((abs) => ({ abs, recursive: true }));
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
 
@@ -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
- const INSTALLED_MARKERS = ['.ukit', '.claude', 'CLAUDE.md', 'AGENTS.md', '.codex'];
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.
@@ -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
- // Pure SSE frames: join data payloads.
128
- const payload = trimmed
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
- .join('');
133
- return JSON.parse(payload || trimmed);
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
- async function postWithDeadline(url, headers, body, deadlineMs) {
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 timer = setTimeout(() => controller.abort(), deadlineMs);
237
- try {
238
- return await transport(url, {
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
- const fingerprint = requestFingerprint(body);
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
- let res;
453
+ if (deadlineAt - startedAt <= 0) {
454
+ lastError = 'timeout';
455
+ lastStatus = null;
456
+ break;
457
+ }
458
+ let call;
348
459
  try {
349
- res = await postWithDeadline(endpoint.url, endpoint.headers, body, deadlineMs);
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 deadline.
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
- const errBody = clone ? parseMaybeSse(await clone.text()) : null;
371
- if (errBody?.error?.code === 'model_not_found') notFound = true;
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 readResponseBody(res);
400
- } catch {
401
- lastError = 'invalid';
402
- lastStatus = 'malformed-response';
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 sanitized) counts[f.severity] += 1;
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 = sanitized.map((f) => ({
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: sanitized.length,
190
+ findingCount: batchFindings.length,
187
191
  severityCounts: counts,
188
- findings: sanitized,
192
+ findings: batchFindings,
189
193
  };
190
194
 
191
195
  const batch = {
192
196
  batchVersion: 1,
193
- batchId: stableBatchId(sanitized),
197
+ batchId: stableBatchId(batchFindings),
194
198
  boundary: 'review-verdict',
195
199
  questions,
196
200
  statePacket,
197
201
  };
198
202
 
199
- return { questions, statePacket, batch, findings: sanitized, verdictDecisionKey };
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));