@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
|
@@ -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
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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 (
|
|
45
|
-
|
|
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
|
|
61
|
+
const item = line.match(/^\s*[-*]\s+(.+?)\s*$/);
|
|
51
62
|
if (item && severity) {
|
|
52
|
-
|
|
53
|
-
|
|
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 >=
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
196
|
+
findingCount: batchFindings.length,
|
|
193
197
|
severityCounts: counts,
|
|
194
|
-
findings:
|
|
198
|
+
findings: batchFindings,
|
|
195
199
|
};
|
|
196
200
|
|
|
197
201
|
const batch = {
|
|
198
202
|
batchVersion: 1,
|
|
199
|
-
batchId: stableBatchId(
|
|
203
|
+
batchId: stableBatchId(batchFindings),
|
|
200
204
|
boundary: 'review-verdict',
|
|
201
205
|
questions,
|
|
202
206
|
statePacket,
|
|
203
207
|
};
|
|
204
208
|
|
|
205
|
-
return {
|
|
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
|
|
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 ??
|
|
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:
|
|
402
|
-
?
|
|
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) => {
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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$|
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) => {
|
|
469
|
-
|
|
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' ||
|
|
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
|
-
|
|
553
|
-
|
|
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
|
|
592
|
+
decisionKey: spec?.decisionKey ?? null,
|
|
560
593
|
stage: 'unknown',
|
|
561
594
|
answer: deterministic,
|
|
562
595
|
deterministic,
|