@ngockhoale/ukit 3.4.0 → 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 +23 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/code.js +29 -5
  4. package/src/cli/commands/decision.js +18 -4
  5. package/src/cli/commands/doctor.js +7 -3
  6. package/src/cli/commands/install.js +29 -4
  7. package/src/cli/commands/memory.js +25 -5
  8. package/src/cli/commands/telemetry.js +18 -1
  9. package/src/cli/commands/vm.js +7 -1
  10. package/src/context/detectProjectContext.js +7 -2
  11. package/src/core/agentRuntime/contract.js +5 -1
  12. package/src/core/agentRuntime/eventStore.js +54 -7
  13. package/src/core/agentRuntime/recovery.js +22 -15
  14. package/src/core/agentRuntime/supervisor.js +71 -13
  15. package/src/core/applyPlan.js +11 -1
  16. package/src/core/codeintel/compiler.js +51 -8
  17. package/src/core/codeintel/diagnostics.js +124 -33
  18. package/src/core/codeintel/freshness.js +25 -12
  19. package/src/core/codeintel/invalidation.js +11 -3
  20. package/src/core/codeintel/retriever.js +53 -20
  21. package/src/core/codeintel/router.js +19 -9
  22. package/src/core/codeintel/summaries.js +4 -3
  23. package/src/core/codeintel/vectorProvider.js +30 -4
  24. package/src/core/compact/index.js +24 -7
  25. package/src/core/compact/threshold.js +49 -14
  26. package/src/core/diffPlan.js +51 -23
  27. package/src/core/ensureGitignore.js +19 -2
  28. package/src/core/fileOps.js +61 -0
  29. package/src/core/memory/hygiene.js +51 -1
  30. package/src/core/memory/migrate.js +41 -21
  31. package/src/core/memory/store.js +96 -61
  32. package/src/core/metadata.js +37 -2
  33. package/src/core/observability/adapters/ingest.js +30 -2
  34. package/src/core/observability/emit/config.js +19 -4
  35. package/src/core/observability/emit/crash.js +3 -1
  36. package/src/core/observability/emit/recorder.js +15 -7
  37. package/src/core/observability/privacy/sanitizeObserved.js +3 -1
  38. package/src/core/observability/segments/internal.js +36 -8
  39. package/src/core/observability/segments/retention.js +11 -0
  40. package/src/core/observability/support/import.js +27 -1
  41. package/src/core/output/index.js +16 -1
  42. package/src/core/permissionDoctor.js +72 -9
  43. package/src/core/repairBrokenHooks.js +15 -2
  44. package/src/core/reviewPanelAggregate.js +26 -10
  45. package/src/core/runInstallPipeline.js +71 -22
  46. package/src/core/runtimeConfig.js +2 -0
  47. package/src/core/status.js +2 -0
  48. package/src/core/taskBudgetValidator.js +7 -1
  49. package/src/core/taskProgressGuard.js +11 -1
  50. package/src/core/unattendedDoctor.js +36 -5
  51. package/src/core/uninstall.js +52 -12
  52. package/src/core/update.js +5 -1
  53. package/src/decision/client.js +158 -27
  54. package/src/decision/reviewVerdict.js +23 -7
  55. package/src/diagnostics/failurePatterns.js +1 -1
  56. package/src/diagnostics/feedbackEvents.js +1 -1
  57. package/src/diagnostics/routeOutcomes.js +42 -4
  58. package/src/diagnostics/skillAccuracy.js +35 -4
  59. package/src/index/buildIndex.js +123 -26
  60. package/src/index/fixLoopEscalation.js +3 -0
  61. package/src/index/gitHooks.js +99 -29
  62. package/src/index/importResolution.js +7 -1
  63. package/src/index/playbookRegistry.js +15 -11
  64. package/src/index/queryIndex.js +28 -10
  65. package/src/index/routeResolver.js +8 -3
  66. package/src/index/taskRouting.js +37 -2
  67. package/src/learning/codeProposals.js +24 -5
  68. package/src/learning/selfImprove.js +29 -5
  69. package/src/learning/tunedOverlay.js +18 -7
  70. package/src/learning/tuning.js +10 -4
  71. package/src/skill/auditSkill.js +46 -7
  72. package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
  73. package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
  74. package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
  75. package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
  76. package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
  77. package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
  78. package/template_project/.claude/hooks/session-episode.sh +60 -28
  79. package/template_project/.claude/hooks/verification-guard.sh +26 -15
  80. package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
  81. package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
  82. package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
  83. package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
  84. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
  85. package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
  86. package/template_project/.claude/ukit/index/query-index.mjs +13 -6
  87. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
  88. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
  89. package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
  90. package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
  91. package/template_project/.claude/ukit/index/route-task.mjs +15 -0
  92. package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
  93. package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
  94. package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
  95. package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
  96. package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
  97. package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
  98. package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
  99. package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
  100. package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
  101. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
  102. package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
  103. package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
  104. package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
  105. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
  106. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
  107. package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
  108. package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
  109. package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
  110. package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
5
 
5
6
  function parseArgs(argv) {
6
7
  return {
@@ -9,17 +10,112 @@ function parseArgs(argv) {
9
10
  };
10
11
  }
11
12
 
13
+ // W3-03 fail-closed read: a state file that exists but will not parse must
14
+ // abort the reset — the previous `{}` fallback let a corrupt/JSONC
15
+ // settings.local.json be rewritten as an empty object, wiping the whole allow
16
+ // list. Missing files still map to `fallback` (a fresh project is fine).
12
17
  async function readJson(filePath, fallback) {
18
+ try {
19
+ await fs.access(filePath);
20
+ } catch {
21
+ return { value: fallback, corrupt: false };
22
+ }
13
23
  try {
14
24
  const raw = await fs.readFile(filePath, 'utf8');
15
- return JSON.parse(raw);
25
+ return { value: JSON.parse(raw), corrupt: false };
16
26
  } catch {
17
- return fallback;
27
+ return { value: null, corrupt: true };
28
+ }
29
+ }
30
+
31
+ // Same atomic shape as the auto-allow hook's writeJsonAtomic: tmp + rename so
32
+ // readers (Claude Code re-parses settings.local.json to grant permissions)
33
+ // see old-or-new, never a torn file.
34
+ async function writeJsonAtomic(filePath, value) {
35
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
36
+ const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
37
+ try {
38
+ await fs.writeFile(tempPath, JSON.stringify(value, null, 2) + '\n');
39
+ await fs.rename(tempPath, filePath);
40
+ } catch (error) {
41
+ try {
42
+ await fs.rm(tempPath, { force: true });
43
+ } catch {}
44
+ throw error;
18
45
  }
19
46
  }
20
47
 
21
- async function writeJson(filePath, value) {
22
- await fs.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
48
+ function runtimeDirForScript() {
49
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'runtime');
50
+ }
51
+
52
+ // The whole mutation runs inside the shared async lock — the same
53
+ // `permission-usage.json` lock the auto-allow/auto-prune hooks take, so this
54
+ // CLI serializes against them instead of racing their writes. Returns null on
55
+ // a clean no-op, throws on fail-closed conditions.
56
+ async function applyReset({ settingsLocalPath, usagePath, auditPath }) {
57
+ const lockModulePath = path.join(runtimeDirForScript(), 'async-lock.mjs');
58
+ let lockModule = null;
59
+ try {
60
+ lockModule = await import(pathToFileURL(lockModulePath).href);
61
+ } catch {
62
+ lockModule = null;
63
+ }
64
+ if (!lockModule || typeof lockModule.withAsyncLock !== 'function') {
65
+ throw new Error(
66
+ 'async-lock.mjs is unavailable — refusing to write permission state unlocked (degraded install)',
67
+ );
68
+ }
69
+
70
+ // The lock dir lives beside the usage file; its parent must exist before
71
+ // mkdir can acquire (same preflight the hooks do).
72
+ await fs.mkdir(path.dirname(usagePath), { recursive: true });
73
+
74
+ const outcome = await lockModule.withAsyncLock(usagePath, {}, async () => {
75
+ // Re-read BOTH state files inside the critical section — a racing
76
+ // auto-allow write may have landed between the preflight read and lock
77
+ // acquisition, and deciding on the stale snapshot would drop its rule.
78
+ const settingsState = await readJson(settingsLocalPath, {});
79
+ if (settingsState.corrupt) {
80
+ throw new Error(`settings.local.json exists but is not valid JSON (${settingsLocalPath}) — refusing to overwrite it`);
81
+ }
82
+ const usageState = await readJson(usagePath, {});
83
+ if (usageState.corrupt) {
84
+ throw new Error(`permission-usage.json exists but is not valid JSON (${usagePath}) — refusing to guess managed rules`);
85
+ }
86
+ const settings = settingsState.value && typeof settingsState.value === 'object' ? settingsState.value : {};
87
+ const usage = usageState.value && typeof usageState.value === 'object' ? usageState.value : {};
88
+
89
+ const managedRules = Object.keys(usage.managedRules || {});
90
+ if (managedRules.length === 0) return null;
91
+
92
+ const allow = Array.isArray(settings?.permissions?.allow) ? settings.permissions.allow : [];
93
+ const removedRules = allow.filter((rule) => managedRules.includes(rule));
94
+
95
+ settings.permissions = settings.permissions && typeof settings.permissions === 'object' ? settings.permissions : {};
96
+ settings.permissions.allow = allow.filter((rule) => !managedRules.includes(rule));
97
+ await fs.mkdir(path.dirname(settingsLocalPath), { recursive: true });
98
+ await writeJsonAtomic(settingsLocalPath, settings);
99
+
100
+ usage.managedRules = {};
101
+ usage.lastResetAt = new Date().toISOString();
102
+ await writeJsonAtomic(usagePath, usage);
103
+
104
+ const lines = removedRules
105
+ .map((rule) => JSON.stringify({ ts: new Date().toISOString(), event: 'manual_reset', rule }))
106
+ .join('\n');
107
+ await fs.mkdir(path.dirname(auditPath), { recursive: true });
108
+ await fs.appendFile(auditPath, lines + '\n', 'utf8');
109
+
110
+ return { managedRules, removedRules };
111
+ });
112
+
113
+ if (!outcome.ok) {
114
+ // Fail closed (TASK-028 policy): a contended lock means a hook write is in
115
+ // flight — never write permission state unlocked. Nothing was mutated.
116
+ throw new Error(`could not acquire the permission-state lock (${outcome.reason}) — reset aborted, nothing was changed`);
117
+ }
118
+ return outcome.value;
23
119
  }
24
120
 
25
121
  async function main() {
@@ -29,12 +125,22 @@ async function main() {
29
125
  const usagePath = path.join(rootDir, '.claude', 'ukit', 'permission-usage.json');
30
126
  const auditPath = path.join(rootDir, '.claude', 'ukit', 'permission-audit.log');
31
127
 
32
- const settings = await readJson(settingsLocalPath, {});
33
- const usage = await readJson(usagePath, {});
128
+ // Preflight reads (reporting only): a corrupt state file aborts before the
129
+ // reset is even attempted — in --dry-run too, so the report never describes
130
+ // state that is not really there.
131
+ const settingsState = await readJson(settingsLocalPath, {});
132
+ if (settingsState.corrupt) {
133
+ throw new Error(`settings.local.json exists but is not valid JSON (${settingsLocalPath}) — refusing to overwrite it`);
134
+ }
135
+ const usageState = await readJson(usagePath, {});
136
+ if (usageState.corrupt) {
137
+ throw new Error(`permission-usage.json exists but is not valid JSON (${usagePath}) — refusing to guess managed rules`);
138
+ }
139
+ const settings = settingsState.value && typeof settingsState.value === 'object' ? settingsState.value : {};
140
+ const usage = usageState.value && typeof usageState.value === 'object' ? usageState.value : {};
34
141
 
35
- const managedRules = Object.keys(usage?.managedRules || {});
142
+ const managedRules = Object.keys(usage.managedRules || {});
36
143
  const allow = Array.isArray(settings?.permissions?.allow) ? settings.permissions.allow : [];
37
-
38
144
  const removedRules = allow.filter((rule) => managedRules.includes(rule));
39
145
  if (managedRules.length === 0) {
40
146
  if (!quiet) {
@@ -43,28 +149,24 @@ async function main() {
43
149
  return;
44
150
  }
45
151
 
152
+ let resetResult = { managedRules, removedRules };
46
153
  if (!dryRun) {
47
- settings.permissions = settings.permissions && typeof settings.permissions === 'object' ? settings.permissions : {};
48
- settings.permissions.allow = allow.filter((rule) => !managedRules.includes(rule));
49
- await fs.mkdir(path.dirname(settingsLocalPath), { recursive: true });
50
- await writeJson(settingsLocalPath, settings);
51
-
52
- usage.managedRules = {};
53
- usage.lastResetAt = new Date().toISOString();
54
- await fs.mkdir(path.dirname(usagePath), { recursive: true });
55
- await writeJson(usagePath, usage);
56
-
57
- const lines = removedRules
58
- .map((rule) => JSON.stringify({ ts: new Date().toISOString(), event: 'manual_reset', rule }))
59
- .join('\n');
60
- await fs.mkdir(path.dirname(auditPath), { recursive: true });
61
- await fs.appendFile(auditPath, lines + '\n', 'utf8');
154
+ const applied = await applyReset({ settingsLocalPath, usagePath, auditPath });
155
+ if (applied === null) {
156
+ // A serialized writer cleared managedRules between preflight and lock
157
+ // acquisition — nothing left to do, and that is a clean no-op.
158
+ if (!quiet) {
159
+ console.log('[UKit] No managed auto-permission rules to reset.');
160
+ }
161
+ return;
162
+ }
163
+ resetResult = applied;
62
164
  }
63
165
 
64
166
  if (!quiet) {
65
167
  const mode = dryRun ? 'Would remove' : 'Removed';
66
- console.log(`[UKit] ${mode} ${managedRules.length} managed auto-permission rule(s).`);
67
- for (const rule of removedRules) {
168
+ console.log(`[UKit] ${mode} ${resetResult.managedRules.length} managed auto-permission rule(s).`);
169
+ for (const rule of resetResult.removedRules) {
68
170
  console.log(`- ${rule}`);
69
171
  }
70
172
  }
@@ -11,7 +11,8 @@
11
11
  // Usage:
12
12
  // node .claude/ukit/index/review-panel-aggregate.mjs <TASK-xxx.md> [TASK-xxx.md...]
13
13
  //
14
- // Reads every `## Reviewer Verdict` block from the given task files, then prints:
14
+ // Reads the latest review round's `## Reviewer Verdict` blocks from the given
15
+ // task files (re-review appends ` (round N)`; superseded rounds don't vote), then prints:
15
16
  // AGREEMENT_MAP: finding → member, member (one line per finding)
16
17
  // HIGH_SIGNAL: finding → member, member (≥2 members or any critical)
17
18
  // UNPARSED: <file>#verdict[<i>] (blocks with no VERDICT line)
@@ -41,17 +42,31 @@ export function parseVerdictBlock(text) {
41
42
  const rest = src.slice(findingsMatch.index + findingsMatch[0].length);
42
43
  let severity = null;
43
44
  for (const line of rest.split('\n')) {
44
- if (/^\S/.test(line)) break; // next top-level field ends FINDINGS
45
- const sev = line.match(/^\s{2}(critical|important|minor)\s*:/);
45
+ if (line.trim() === '') continue;
46
+ // Non-canonical layouts are tolerated: severity headers may carry any
47
+ // indentation or capitalization, and inline text after the colon
48
+ // (e.g. `Critical: auth bypass`) still counts as a finding.
49
+ const sev = line.match(/^\s*(critical|important|minor)\s*:\s*(.*)$/i);
46
50
  if (sev) {
47
- severity = sev[1];
51
+ severity = sev[1].toLowerCase();
52
+ const inline = sev[2].trim();
53
+ // Placeholders — `none`, `(none)`, `-`, `(none — reason)` — are not
54
+ // findings; `critical: (none)` must not fabricate a critical.
55
+ const lc = inline.toLowerCase();
56
+ if (inline !== '' && lc !== '-' && lc !== 'none' && !lc.startsWith('(none')) {
57
+ findings[severity].push(inline);
58
+ }
48
59
  continue;
49
60
  }
50
- const item = line.match(/^\s{4}-\s+(.+?)\s*$/);
61
+ const item = line.match(/^\s*[-*]\s+(.+?)\s*$/);
51
62
  if (item && severity) {
52
- const text2 = item[1];
53
- if (text2 !== 'none' && text2 !== '-') findings[severity].push(text2);
63
+ // Same placeholder set for bullets — `- (none)` under `critical:` must
64
+ // not fabricate a critical.
65
+ const text2 = item[1].toLowerCase();
66
+ if (text2 !== 'none' && text2 !== '-' && !text2.startsWith('(none')) findings[severity].push(item[1]);
67
+ continue;
54
68
  }
69
+ if (/^\S/.test(line)) break; // next top-level field ends FINDINGS
55
70
  }
56
71
  }
57
72
 
@@ -106,7 +121,7 @@ export function aggregatePanelVerdicts(verdicts) {
106
121
  parsed.some((p) => p.verdict === 'CRITICAL') || parsed.some((p) => p.findings.critical.length > 0);
107
122
 
108
123
  let verdict;
109
- if (hasCritical || changeRequests >= 2) verdict = 'changes_requested';
124
+ if (hasCritical || changeRequests >= 1 || parsed.length === 0) verdict = 'changes_requested';
110
125
  else if (approvals >= 2) verdict = 'approved';
111
126
  else verdict = 'approved_minor';
112
127
 
@@ -115,19 +130,26 @@ export function aggregatePanelVerdicts(verdicts) {
115
130
 
116
131
  // CLI-only helpers below (not part of the canonical module).
117
132
 
118
- // Extract every `## Reviewer Verdict` block from a task file. A block runs from
119
- // its heading to the next `## ` heading or EOF.
133
+ // Extract the latest review round's `## Reviewer Verdict` blocks from a task
134
+ // file. A block runs from its heading to the next `## ` heading or EOF.
135
+ // Re-reviews after a fix round append `## Reviewer Verdict (round N)`; a bare
136
+ // heading is round 1, or inherits the highest explicit round seen so far — a
137
+ // trailing bare heading is never silently dropped (fail closed). Verdicts from
138
+ // superseded rounds do not vote.
120
139
  function extractVerdictBlocks(markdown) {
121
- const blocks = [];
122
- const re = /^##\s+Reviewer Verdict\s*$/gm;
140
+ const entries = [];
141
+ const re = /^##\s+Reviewer Verdict(?:\s*\(\s*[rR]ound\s+(\d+)\s*\))?\s*$/gm;
123
142
  let m;
143
+ let latest = 1;
124
144
  while ((m = re.exec(markdown)) !== null) {
125
145
  const start = m.index;
126
146
  const rest = markdown.slice(start + m[0].length);
127
147
  const next = rest.search(/^##\s+/m);
128
- blocks.push(next === -1 ? rest : rest.slice(0, next));
148
+ const round = m[1] ? Number(m[1]) : latest;
149
+ if (round > latest) latest = round;
150
+ entries.push({ round, block: next === -1 ? rest : rest.slice(0, next) });
129
151
  }
130
- return blocks;
152
+ return entries.filter((e) => e.round === latest).map((e) => e.block);
131
153
  }
132
154
 
133
155
  function main() {
@@ -148,16 +148,20 @@ function stableBatchId(findings) {
148
148
 
149
149
  export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
150
150
  const usedIds = new Set();
151
+ // Sanitize the FULL finding set — MAX_FINDINGS is a display/transport bound
152
+ // for the batch, not a verdict bound. The deterministic verdict and the
153
+ // truncation clamp both run on `fullFindings` (W3-RV1).
151
154
  const sanitized = (Array.isArray(findings) ? findings : [])
152
- .slice(0, MAX_FINDINGS)
153
155
  .map((raw, index) => sanitizeFinding(raw, index, usedIds));
156
+ const batchFindings = sanitized.slice(0, MAX_FINDINGS);
157
+ const truncated = sanitized.length > batchFindings.length;
154
158
 
155
159
  const verdictDecisionKey = mode === 'panel'
156
160
  ? REVIEW_VERDICT_KEYS.panel
157
161
  : REVIEW_VERDICT_KEYS.solo;
158
162
 
159
163
  const counts = { critical: 0, important: 0, minor: 0, unknown: 0 };
160
- for (const f of sanitized) counts[f.severity] += 1;
164
+ for (const f of batchFindings) counts[f.severity] += 1;
161
165
 
162
166
  const verdictQuestion = {
163
167
  decisionKey: verdictDecisionKey,
@@ -173,7 +177,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
173
177
  hardConstraintRefs: ['verdict-vocabulary'],
174
178
  };
175
179
 
176
- const bucketQuestions = sanitized.map((f) => ({
180
+ const bucketQuestions = batchFindings.map((f) => ({
177
181
  decisionKey: `${REVIEW_VERDICT_KEYS.bucket}#${f.id}`,
178
182
  schemaVersion: 1,
179
183
  family: 'review',
@@ -189,20 +193,28 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
189
193
  const statePacket = {
190
194
  kind: 'review-verdict',
191
195
  mode: mode === 'panel' ? 'panel' : 'solo',
192
- findingCount: sanitized.length,
196
+ findingCount: batchFindings.length,
193
197
  severityCounts: counts,
194
- findings: sanitized,
198
+ findings: batchFindings,
195
199
  };
196
200
 
197
201
  const batch = {
198
202
  batchVersion: 1,
199
- batchId: stableBatchId(sanitized),
203
+ batchId: stableBatchId(batchFindings),
200
204
  boundary: 'review-verdict',
201
205
  questions,
202
206
  statePacket,
203
207
  };
204
208
 
205
- return { questions, statePacket, batch, findings: sanitized, verdictDecisionKey };
209
+ return {
210
+ questions,
211
+ statePacket,
212
+ batch,
213
+ findings: batchFindings,
214
+ fullFindings: sanitized,
215
+ truncated,
216
+ verdictDecisionKey,
217
+ };
206
218
  }
207
219
 
208
220
  // Deterministic fallback — the documented off-stage rule and the
@@ -264,6 +276,10 @@ export function parseVerdictAnswer(result, { findings = [] } = {}) {
264
276
  verdict = answer.value;
265
277
  verdictStatus = 'valid';
266
278
  } else {
279
+ // The pair stays consistent: an invalid answer for the verdict key
280
+ // clears any earlier valid verdict — never leave a stale APPROVED
281
+ // standing behind verdictStatus 'invalid' (W3-RV2).
282
+ verdict = null;
267
283
  verdictStatus = 'invalid';
268
284
  }
269
285
  continue;
@@ -361,12 +377,15 @@ function emitVerdict({ mode, stage, outcomeClass, verdict, verdictStatus,
361
377
  };
362
378
  }
363
379
 
364
- function deterministicResult({ mode, stage, outcomeClass, findings, batchId, fallbackCode }) {
380
+ function deterministicResult({ mode, stage, outcomeClass, findings, batchId, fallbackCode, verdictOverride }) {
365
381
  return emitVerdict({
366
382
  mode,
367
383
  stage,
368
384
  outcomeClass,
369
- verdict: deriveDeterministicVerdict(findings),
385
+ // `verdictOverride` carries the verdict computed on the FULL finding set
386
+ // when `findings` is the display-truncated list (W3-RV1); absent it, the
387
+ // deterministic rule is derived from `findings` directly.
388
+ verdict: verdictOverride ?? deriveDeterministicVerdict(findings),
370
389
  verdictStatus: 'deterministic',
371
390
  verdictSource: 'deterministic',
372
391
  buckets: findings.map((f) => ({
@@ -379,9 +398,27 @@ function deterministicResult({ mode, stage, outcomeClass, findings, batchId, fal
379
398
  });
380
399
  }
381
400
 
401
+ const VERDICT_SEVERITY_RANK = {
402
+ APPROVED: 0,
403
+ 'APPROVED-WITH-MINOR': 1,
404
+ 'CHANGES-REQUESTED': 2,
405
+ CRITICAL: 3,
406
+ };
407
+
382
408
  function foldBatchResult(result, built, mode, stage) {
383
409
  const folded = parseVerdictAnswer(result, { findings: built.findings });
384
410
  const modelVerdict = folded.verdict;
411
+ // W3-RV1: the batch may be display-truncated at MAX_FINDINGS — the model
412
+ // verdict was computed on `built.findings` only, so when truncation
413
+ // occurred it must never be weaker than the deterministic verdict over the
414
+ // full finding set (a critical the model never saw still blocks approval).
415
+ const fullVerdict = deriveDeterministicVerdict(
416
+ built.fullFindings ?? built.findings,
417
+ );
418
+ const clamped = built.truncated
419
+ && modelVerdict !== null
420
+ && (VERDICT_SEVERITY_RANK[fullVerdict] ?? 0)
421
+ > (VERDICT_SEVERITY_RANK[modelVerdict] ?? 0);
385
422
  // Parse-level classes (accepted/partial/invalid/abstained) pass through;
386
423
  // every transport/gateway class surfaces as 'unavailable' — the fallback
387
424
  // code carries the finer-grained reason for receipts.
@@ -391,16 +428,18 @@ function foldBatchResult(result, built, mode, stage) {
391
428
  mode,
392
429
  stage,
393
430
  outcomeClass,
394
- verdict: modelVerdict ?? deriveDeterministicVerdict(built.findings),
431
+ verdict: clamped ? fullVerdict : (modelVerdict ?? fullVerdict),
395
432
  verdictStatus: modelVerdict === null
396
433
  ? `fallback:${folded.verdictStatus}`
397
- : folded.verdictStatus,
398
- verdictSource: modelVerdict === null ? 'deterministic' : 'model',
434
+ : (clamped ? 'clamp:truncated-findings' : folded.verdictStatus),
435
+ verdictSource: modelVerdict === null || clamped ? 'deterministic' : 'model',
399
436
  buckets: folded.buckets,
400
437
  batchId: built.batch.batchId,
401
- fallbackCode: modelVerdict === null
402
- ? (result?.fallbackCode ?? folded.status)
403
- : null,
438
+ fallbackCode: clamped
439
+ ? 'truncated-findings'
440
+ : (modelVerdict === null
441
+ ? (result?.fallbackCode ?? folded.status)
442
+ : null),
404
443
  });
405
444
  }
406
445
 
@@ -420,9 +459,12 @@ export async function runReviewVerdict({ findings, mode = 'solo', config,
420
459
  mode: normalizedMode,
421
460
  stage,
422
461
  outcomeClass: 'skipped',
462
+ // The authoritative verdict runs on the FULL sanitized finding set —
463
+ // MAX_FINDINGS truncates the emitted/batch surface only (W3-RV1).
423
464
  findings: built.findings,
424
465
  batchId: built.batch.batchId,
425
466
  fallbackCode: 'stage-off',
467
+ verdictOverride: deriveDeterministicVerdict(built.fullFindings),
426
468
  });
427
469
  }
428
470
 
@@ -464,13 +506,37 @@ function readFlagValue(argv, flag) {
464
506
  return value && !value.startsWith('--') ? value : null;
465
507
  }
466
508
 
509
+ // stdin is bounded (W3-UD6): findings input is a review artifact, never
510
+ // megabytes — unbounded reads let a poisoned/miswired pipe pin the process.
511
+ const MAX_STDIN_BYTES = 8 * 1024 * 1024;
512
+
467
513
  function readStdin() {
468
514
  return new Promise((resolve, reject) => {
469
515
  let data = '';
516
+ let bytes = 0;
517
+ let done = false;
518
+ const fail = (error) => {
519
+ if (done) return;
520
+ done = true;
521
+ process.stdin.destroy();
522
+ reject(error);
523
+ };
470
524
  process.stdin.setEncoding('utf8');
471
- process.stdin.on('data', (chunk) => { data += chunk; });
472
- process.stdin.on('end', () => resolve(data));
473
- process.stdin.on('error', reject);
525
+ process.stdin.on('data', (chunk) => {
526
+ if (done) return;
527
+ data += chunk;
528
+ bytes += Buffer.byteLength(chunk, 'utf8');
529
+ if (bytes > MAX_STDIN_BYTES) {
530
+ fail(new Error(`stdin exceeds ${MAX_STDIN_BYTES} bytes`));
531
+ }
532
+ });
533
+ process.stdin.on('end', () => {
534
+ if (!done) {
535
+ done = true;
536
+ resolve(data);
537
+ }
538
+ });
539
+ process.stdin.on('error', fail);
474
540
  });
475
541
  }
476
542
 
@@ -529,7 +595,15 @@ async function main() {
529
595
  return 1;
530
596
  }
531
597
  } else {
532
- raw = await readStdin();
598
+ try {
599
+ raw = await readStdin();
600
+ } catch (error) {
601
+ // Bound/read failures degrade to the same conservative fallback as
602
+ // malformed JSON — never a crash, never APPROVED.
603
+ process.stderr.write(`review-verdict: ${error?.message ?? error}; emitting fallback verdict\n`);
604
+ printResult(invalidInputResult(mode, 'stdin-unusable'));
605
+ return 0;
606
+ }
533
607
  }
534
608
 
535
609
  let parsed;
@@ -456,7 +456,7 @@ export function deriveRiskFloor({
456
456
  codes.push('shared-impact');
457
457
  }
458
458
  if (
459
- /(^|\/)(package\.json|manifests\/|.*\.d\.ts$|(^|\/)api\/|openapi|swagger)/i.test(target)
459
+ /(^|\/)(package\.json|manifests\/|.*\.d\.ts$|api\/|openapi|swagger)/i.test(target)
460
460
  || /\b(public api|breaking change|api contract|semver)\b/i.test(signalText)
461
461
  ) {
462
462
  codes.push('public-contract');
@@ -561,14 +561,19 @@ export function deriveFastPath({
561
561
  return null;
562
562
  }
563
563
  const reasons = (riskFloor.codes ?? []).filter((code) => FAST_PATH_REASON_CODES.has(code));
564
- const hasLocalTarget = Boolean(targetFile) || contextPreview?.primaryTargets?.length === 1;
564
+ // C91-022 (W3-TR2): the shared-impact veto must consult the same target that
565
+ // granted locality — the explicit targetFile, else the single primaryTarget.
566
+ const localTarget = targetFile || (contextPreview?.primaryTargets?.length === 1
567
+ ? contextPreview.primaryTargets[0]
568
+ : null);
569
+ const hasLocalTarget = Boolean(localTarget);
565
570
  const hasBoundedVerification = (verificationRecommendation?.commands?.length ?? 0) > 0
566
571
  || buildExecutionContract(executionMode)?.verificationPolicy === 'minimal-or-targeted';
567
572
  const eligible = FAST_PATH_MODES.has(executionMode)
568
573
  && hasLocalTarget
569
574
  && riskFloor.floor === 'none'
570
575
  && hasBoundedVerification
571
- && !isSharedImpactFile(targetFile);
576
+ && !isSharedImpactFile(localTarget);
572
577
  return {
573
578
  eligible,
574
579
  suppressed: eligible ? [...FAST_PATH_SUPPRESSED] : [],
@@ -5426,6 +5426,21 @@ export function compactRouteSummary(routeSummary = null) {
5426
5426
  nextActionType: routeSummary.nextActionType ?? null,
5427
5427
  nextActionCommand: routeSummary.nextActionCommand ?? null,
5428
5428
  helperHint: routeSummary.helperHint ?? null,
5429
+ // TASK-004 (BL-006) shared-resolver fields — identical to what
5430
+ // skill-router.sh persists (identical block documented there) and to what
5431
+ // route-task.mjs / taskRouting.js routeSummary emits on the helper path.
5432
+ // C92-A-01: riskFloor is the safety-critical one — without it a rescued
5433
+ // high-risk route re-runs fix-loop escalation at 'runnable' depth with
5434
+ // riskSignals: [] (fail-open on the verification gate). Unconditional
5435
+ // null/[] defaults keep the persisted shape identical to the hook writer.
5436
+ rigor: routeSummary.rigor ?? null,
5437
+ fastPath: routeSummary.fastPath ?? null,
5438
+ riskFloor: routeSummary.riskFloor ?? null,
5439
+ resumable: routeSummary.resumable ?? null,
5440
+ decisionShadow: routeSummary.decisionShadow ?? null,
5441
+ escalationTriggers: Array.isArray(routeSummary.escalationTriggers)
5442
+ ? routeSummary.escalationTriggers
5443
+ : [],
5429
5444
  // TASK-007 (BL-009): playbook fields survive compaction so
5430
5445
  // skill-router-state.json and cached route states carry the identical
5431
5446
  // playbookId/workGroup the helper emitted. BL-010 adds the direct/handoff
@@ -54,7 +54,10 @@ export async function applySafePatch({ projectRoot = process.cwd(), filePath, an
54
54
  throw new Error('Patch old text is outside the bounded anchor window.');
55
55
  }
56
56
 
57
- const nextText = beforeProfile.text.replace(oldForProfile, newForProfile);
57
+ // BUG-C90-01 (FR-003): the string form of .replace treats $-sequences in the
58
+ // replacement as patterns ($&, $', $`, $1, $<name>) — a function replacer
59
+ // returns the new text verbatim, so `${x}`/`$&` land literally in the file.
60
+ const nextText = beforeProfile.text.replace(oldForProfile, () => newForProfile);
58
61
  await writeTextWithProfile(resolved.absolute, nextText, beforeProfile);
59
62
  const afterProfile = await analyzeTextFile(resolved.absolute);
60
63
 
@@ -359,7 +359,11 @@ function validModelAnswer(result, decisionKey) {
359
359
  * deterministic rule again on any adapter failure (outcomeClass 'unavailable').
360
360
  */
361
361
  export async function runSidecarDecision({ decision, context = {}, rootDir, homeDir, config } = {}) {
362
- const spec = SIDECAR_DECISIONS[decision];
362
+ // Own-property lookup (W3-SD1): `SIDECAR_DECISIONS[name]` would also resolve
363
+ // inherited Object.prototype members ('toString', 'constructor', ...), which
364
+ // are truthy and would bypass the allowlist into `spec.decide`/`spec.question`
365
+ // dereferences on a non-spec value — crashing inside the never-throws guard.
366
+ const spec = Object.hasOwn(SIDECAR_DECISIONS, decision) ? SIDECAR_DECISIONS[decision] : null;
363
367
  if (!spec) {
364
368
  return {
365
369
  decision: decision ?? null,
@@ -379,7 +383,7 @@ export async function runSidecarDecision({ decision, context = {}, rootDir, home
379
383
  try {
380
384
  deterministic = spec.decide(ctx);
381
385
  } catch {
382
- deterministic = spec.question.candidates[0];
386
+ deterministic = spec.question?.candidates?.[0] ?? null;
383
387
  }
384
388
  const base = {
385
389
  decision,
@@ -461,12 +465,27 @@ function readFlagValue(argv, flag) {
461
465
  return value && !value.startsWith('--') ? value : null;
462
466
  }
463
467
 
468
+ // W3-UD6: stdin must not accumulate unboundedly — a held pipe writing forever
469
+ // would grow memory without limit. Bytes beyond the cap are still drained
470
+ // (so the writer finishes and we exit promptly) but never appended; the CLI
471
+ // then rejects with the byte-limit message. Mirrors unic-decision.mjs.
472
+ const MAX_STDIN_BYTES = 1024 * 1024;
473
+
464
474
  function readStdin() {
465
475
  return new Promise((resolve, reject) => {
466
476
  let data = '';
477
+ let overLimit = false;
467
478
  process.stdin.setEncoding('utf8');
468
- process.stdin.on('data', (chunk) => { data += chunk; });
469
- process.stdin.on('end', () => resolve(data));
479
+ process.stdin.on('data', (chunk) => {
480
+ if (overLimit) return;
481
+ if (Buffer.byteLength(data, 'utf8') + Buffer.byteLength(chunk, 'utf8') > MAX_STDIN_BYTES) {
482
+ overLimit = true;
483
+ data = '';
484
+ return;
485
+ }
486
+ data += chunk;
487
+ });
488
+ process.stdin.on('end', () => resolve(overLimit ? null : data));
470
489
  process.stdin.on('error', reject);
471
490
  });
472
491
  }
@@ -518,6 +537,12 @@ async function main() {
518
537
  }
519
538
 
520
539
  const raw = await readStdin();
540
+ if (raw === null) {
541
+ process.stderr.write(
542
+ `sidecar-decision: stdin context exceeds the ${MAX_STDIN_BYTES}-byte limit\n`,
543
+ );
544
+ return 1;
545
+ }
521
546
  let input = {};
522
547
  if (raw.trim().length > 0) {
523
548
  try {
@@ -535,8 +560,12 @@ async function main() {
535
560
  }
536
561
  }
537
562
 
563
+ // Only absent keys are usage errors. Proto-polluted names ('toString',
564
+ // 'constructor', ...) resolve non-null here, but runSidecarDecision's
565
+ // own-property allowlist still rejects them into the typed invalid-decision
566
+ // fallback — exit 0, never throws.
538
567
  const decision = readFlagValue(args, '--decision') ?? input.decision;
539
- if (typeof decision !== 'string' || !SIDECAR_DECISIONS[decision]) {
568
+ if (typeof decision !== 'string' || SIDECAR_DECISIONS[decision] == null) {
540
569
  process.stderr.write(
541
570
  `sidecar-decision: unknown or missing decision; expected one of: `
542
571
  + `${Object.keys(SIDECAR_DECISIONS).join(', ')}\n`,
@@ -548,15 +577,19 @@ async function main() {
548
577
  try {
549
578
  printResult(await runSidecarDecision({ decision, context, rootDir, homeDir }));
550
579
  } catch (error) {
551
- // Last-resort never-throws guard: emit the deterministic rule answer.
552
- const spec = SIDECAR_DECISIONS[decision];
553
- let deterministic = spec.question.candidates[0];
580
+ // Last-resort never-throws guard: emit the deterministic rule answer. The
581
+ // optional chains keep this fallback non-throwing even if spec resolution
582
+ // yielded a non-spec value (proto-polluted names are already rejected
583
+ // upstream by the own-property check, so a null spec can't reach here —
584
+ // this is belt-and-braces for the never-throws contract).
585
+ const spec = Object.hasOwn(SIDECAR_DECISIONS, decision) ? SIDECAR_DECISIONS[decision] : null;
586
+ let deterministic = spec?.question?.candidates?.[0] ?? null;
554
587
  try {
555
- deterministic = spec.decide(isPlainObject(context) ? context : {});
588
+ if (spec) deterministic = spec.decide(isPlainObject(context) ? context : {});
556
589
  } catch { /* keep first candidate */ }
557
590
  printResult({
558
591
  decision,
559
- decisionKey: spec.decisionKey,
592
+ decisionKey: spec?.decisionKey ?? null,
560
593
  stage: 'unknown',
561
594
  answer: deterministic,
562
595
  deterministic,