@ngockhoale/ukit 3.0.2 → 3.0.4

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 (109) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/bin/ukit +12 -4
  3. package/manifests/engineConformance.yaml +29 -0
  4. package/manifests/platform.full.yaml +13 -0
  5. package/package.json +2 -1
  6. package/scripts/audit/decision-coverage.mjs +295 -0
  7. package/scripts/bench/outline-savings.mjs +19 -4
  8. package/scripts/bench/parallel-agents.mjs +15 -4
  9. package/scripts/bench/runGold.mjs +22 -4
  10. package/scripts/bench/v3-ceremony.mjs +7 -1
  11. package/scripts/bug/triage.mjs +56 -17
  12. package/scripts/index/build-index.mjs +94 -28
  13. package/scripts/index/query-index.mjs +48 -14
  14. package/scripts/index/refresh-index.mjs +142 -62
  15. package/scripts/perf/audit-perf.mjs +8 -2
  16. package/scripts/skill/audit-skill.mjs +54 -25
  17. package/src/bug/triageBug.js +9 -6
  18. package/src/cli/adapters.js +6 -0
  19. package/src/cli/commands/code.js +7 -1
  20. package/src/cli/commands/indexArgs.js +4 -2
  21. package/src/cli/commands/indexTools.js +8 -1
  22. package/src/cli/commands/install.js +13 -0
  23. package/src/cli/commands/memory.js +10 -3
  24. package/src/cli/commands/status.js +17 -1
  25. package/src/cli/commands/update.js +7 -0
  26. package/src/context/detectProjectContext.js +3 -1
  27. package/src/core/codeintel/analogy.js +1 -1
  28. package/src/core/codeintel/diagnostics.js +9 -6
  29. package/src/core/codeintel/graph.js +14 -8
  30. package/src/core/codeintel/impact.js +0 -1
  31. package/src/core/codeintel/invalidation.js +5 -3
  32. package/src/core/codeintel/packet.js +11 -0
  33. package/src/core/codeintel/router.js +17 -3
  34. package/src/core/codeintel/semanticProvider.js +1 -1
  35. package/src/core/codeintel/summaries.js +11 -7
  36. package/src/core/compact/contextBudget.js +26 -12
  37. package/src/core/compact/index.js +15 -8
  38. package/src/core/docContracts.js +10 -2
  39. package/src/core/experiments/deliberation.js +321 -0
  40. package/src/core/experiments/dynamicWorkflow.js +492 -0
  41. package/src/core/fileOps.js +8 -1
  42. package/src/core/gatewayProbe.js +29 -1
  43. package/src/core/gatewayResilienceEnv.js +44 -3
  44. package/src/core/handoffDocValidator.js +3 -1
  45. package/src/core/hookChainDoctor.js +16 -1
  46. package/src/core/memory/deltaOverlays.js +448 -0
  47. package/src/core/memory/learningCandidates.js +302 -0
  48. package/src/core/memory/migrate.js +59 -32
  49. package/src/core/memory/recordStore.js +24 -1
  50. package/src/core/memory/store.js +44 -8
  51. package/src/core/memory/storeV2.js +48 -33
  52. package/src/core/memory/userMemory.js +11 -10
  53. package/src/core/output/index.js +15 -10
  54. package/src/core/permissionPolicy.js +8 -0
  55. package/src/core/runtimeConfig.js +224 -4
  56. package/src/core/sensitiveValueScanner.js +10 -2
  57. package/src/core/taskBudgetValidator.js +12 -17
  58. package/src/core/taskProgressGuard.js +59 -9
  59. package/src/core/unattendedDoctor.js +5 -2
  60. package/src/core/uninstall.js +37 -8
  61. package/src/decision/client.js +371 -0
  62. package/src/decision/lease.js +198 -0
  63. package/src/decision/preflight.js +492 -0
  64. package/src/decision/protocol.js +308 -0
  65. package/src/decision/registry.js +384 -0
  66. package/src/decision/shadow.js +281 -0
  67. package/src/decision/statePacket.js +165 -0
  68. package/src/diagnostics/failurePatterns.js +2 -1
  69. package/src/diagnostics/ledgerFiles.js +3 -1
  70. package/src/diagnostics/routeOutcomes.js +1 -29
  71. package/src/index/buildIndex.js +23 -11
  72. package/src/index/impactContext.js +21 -2
  73. package/src/index/importResolution.js +13 -7
  74. package/src/index/queryIndex.js +11 -5
  75. package/src/index/resolveContext.js +16 -6
  76. package/src/index/taskRouting.js +63 -2
  77. package/src/index/verificationPlan.js +12 -1
  78. package/src/learning/patternProposals.js +6 -0
  79. package/src/render/renderTemplate.js +1 -1
  80. package/src/skill/auditSkill.js +3 -1
  81. package/src/stack/detectStack.js +3 -1
  82. package/template_project/.claude/agents/handoff-planner.md +2 -5
  83. package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
  84. package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
  85. package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
  86. package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
  87. package/template_project/.claude/hooks/handoff-resume.sh +10 -1
  88. package/template_project/.claude/hooks/protect-files.sh +0 -1
  89. package/template_project/.claude/hooks/record-execution.mjs +13 -1
  90. package/template_project/.claude/hooks/sensitive-data-guard.mjs +71 -5
  91. package/template_project/.claude/hooks/session-episode.sh +9 -2
  92. package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
  93. package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
  94. package/template_project/.claude/ukit/index/lib/index-core.mjs +123 -39
  95. package/template_project/.claude/ukit/index/route-task.mjs +444 -0
  96. package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
  97. package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
  98. package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
  99. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
  100. package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
  101. package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
  102. package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
  103. package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
  104. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
  105. package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
  106. package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
  107. package/template_project/ukit/storage/config.json +34 -1
  108. package/template_project/.claude/ukit/skill-router-state.json +0 -1
  109. package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
@@ -0,0 +1,281 @@
1
+ /**
2
+ * shadow.js (TASK-004 / M07)
3
+ *
4
+ * Shadow evaluator for the UNIC Decision Agent (SPEC §5 FR-008/FR-009,
5
+ * UNIC_DECISION_SPEC §15). Runs the atomic preflight batch through the
6
+ * decision client, compares answers against the deterministic baseline
7
+ * (decision-table-v2 via buildFallbackPreflight), and emits a REDACTED
8
+ * receipt — decision keys, probability bands, outcome/latency classes,
9
+ * fallback codes, agreement counts. The receipt never carries state text,
10
+ * prompts, source, or secrets.
11
+ *
12
+ * Stage contract (decisionPlane.stage, absence = 'off'):
13
+ * off → returns null before any client/transport work (zero cost)
14
+ * shadow → receipt only; deterministic policy stays authoritative
15
+ * canary/default → receipt + validated preflight bundle; blocked/invalid
16
+ * answers degrade to the deterministic fallback bundle
17
+ * `decisionPlane.enabled === false` is the global emergency disable → null.
18
+ *
19
+ * The evaluator never throws: transport outages, malformed responses, and
20
+ * sensitive-state blocks all surface as typed receipt outcomes so the caller
21
+ * can fall back to the deterministic route with one bounded diagnostic.
22
+ */
23
+
24
+ import { resolveConfigStage } from '../core/runtimeConfig.js';
25
+ import { createDecisionClient, resolveCheckpoint } from './client.js';
26
+ import { classifyLanguage } from './protocol.js';
27
+ import { serializeStatePacket } from './statePacket.js';
28
+ import {
29
+ PREFLIGHT_DECISION_KEYS,
30
+ buildPreflightBatch,
31
+ validatePreflightAnswers,
32
+ applyPreflight,
33
+ buildFallbackPreflight,
34
+ } from './preflight.js';
35
+
36
+ function decisionPlane(config) {
37
+ const dp = config?.decisionPlane ?? config ?? {};
38
+ return dp && typeof dp === 'object' ? dp : {};
39
+ }
40
+
41
+ // Confidence → band. Receipts carry bands, never raw volatile probabilities.
42
+ function probabilityBand(confidence) {
43
+ if (!Number.isFinite(confidence)) return 'unknown';
44
+ if (confidence < 0.25) return 'low';
45
+ if (confidence < 0.5) return 'moderate';
46
+ if (confidence < 0.75) return 'high';
47
+ return 'very-high';
48
+ }
49
+
50
+ // Normalize an agent answer into the same units as the deterministic baseline
51
+ // bundle field it is compared against (score → legend label, capability set
52
+ // label → member list, lease label → integer).
53
+ function answerBaselineValue(answer, candidateMap) {
54
+ switch (answer.decisionKey) {
55
+ case 'preflight.rigor.v1': {
56
+ const legend = candidateMap?.rigor ?? [];
57
+ return legend[answer.value - 1] ?? null;
58
+ }
59
+ case 'preflight.capabilities.v1':
60
+ return candidateMap?.capabilities?.[answer.value] ?? null;
61
+ case 'preflight.lease.v1':
62
+ return Number(answer.value);
63
+ default:
64
+ return answer.value;
65
+ }
66
+ }
67
+
68
+ function baselineValue(decisionKey, baseline) {
69
+ switch (decisionKey) {
70
+ case 'preflight.execution-lane.v1': return baseline.executionLane;
71
+ case 'preflight.model-role.v1': return baseline.executionModelRole;
72
+ case 'preflight.reasoning-effort.v1': return baseline.reasoningEffort;
73
+ case 'preflight.rigor.v1':
74
+ return typeof baseline.rigor === 'string' ? baseline.rigor.toLowerCase() : baseline.rigor;
75
+ case 'preflight.capabilities.v1': return baseline.activeCapabilities ?? [];
76
+ case 'preflight.verification-depth.v1': return baseline.verificationDepth;
77
+ case 'preflight.lease.v1': return baseline.lease?.granted ?? null;
78
+ default: return undefined;
79
+ }
80
+ }
81
+
82
+ function valuesEqual(a, b) {
83
+ if (Array.isArray(a) || Array.isArray(b)) {
84
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
85
+ const sa = [...a].map(String).sort();
86
+ const sb = [...b].map(String).sort();
87
+ return sa.every((v, i) => v === sb[i]);
88
+ }
89
+ return a === b;
90
+ }
91
+
92
+ // Per-key agreement of the agent answers with the deterministic baseline.
93
+ // Invalid/missing answers count as 'no-answer', never as disagreement.
94
+ // Only keys actually asked in the batch are scored — buildPreflightBatch drops
95
+ // undecidable questions, and counting them as no-answer would skew the
96
+ // agreement signal with questions the agent never saw.
97
+ function computeAgreement(answers, baseline, candidateMap, askedKeys = PREFLIGHT_DECISION_KEYS) {
98
+ const agreement = { agree: 0, differ: 0, noAnswer: 0, differingKeys: [] };
99
+ const byKey = new Map(
100
+ (Array.isArray(answers) ? answers : [])
101
+ .filter((a) => a && typeof a.decisionKey === 'string')
102
+ .map((a) => [a.decisionKey, a]),
103
+ );
104
+ for (const key of askedKeys) {
105
+ const answer = byKey.get(key);
106
+ if (!answer || answer.validationStatus !== 'valid') {
107
+ agreement.noAnswer += 1;
108
+ continue;
109
+ }
110
+ const expected = baselineValue(key, baseline);
111
+ const actual = answerBaselineValue(answer, candidateMap);
112
+ if (valuesEqual(actual, expected)) {
113
+ agreement.agree += 1;
114
+ } else {
115
+ agreement.differ += 1;
116
+ agreement.differingKeys.push(key);
117
+ }
118
+ }
119
+ return agreement;
120
+ }
121
+
122
+ // The evaluator consumes the resolved-route (C01) shape. A legacy summary
123
+ // (routeSchema stage off) lacks the execution/intent groups — normalize the
124
+ // minimal fields so the batch still builds deterministic candidates.
125
+ function normalizeRoute(route) {
126
+ if (route?.execution && typeof route.execution === 'object') return route;
127
+ return {
128
+ intent: { kind: route?.intent?.kind ?? null },
129
+ execution: {
130
+ mode: route?.executionMode ?? null,
131
+ rigor: route?.execution?.rigor ?? null,
132
+ riskFloor: route?.riskFloor?.floor ?? null,
133
+ modelTier: route?.execution?.modelTier ?? null,
134
+ },
135
+ capabilityPolicy: route?.capabilityPolicy ?? {},
136
+ evidence: route?.evidence ?? {},
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Run the decision-plane shadow evaluation for one route.
142
+ *
143
+ * @param {{route?: object, config?: object, transport?: Function,
144
+ * hostCapabilities?: object, env?: object, projectRoot?: string,
145
+ * homeDir?: string, now?: Function, deadlineMs?: number}} args
146
+ * transport: fetch-compatible injection point (tests); defaults to fetch.
147
+ * hostCapabilities: host capability map; defaults to
148
+ * config.decisionPlane.hostCapabilities ({} when undeclared).
149
+ * @returns {Promise<null|{receipt: object, preflight: object|null}>}
150
+ * null at stage 'off'/disabled; otherwise the redacted receipt plus the
151
+ * applied/fallback preflight bundle for canary+ stages (null at shadow).
152
+ */
153
+ export async function runShadowDecisions({
154
+ route = {},
155
+ config = {},
156
+ transport,
157
+ hostCapabilities = null,
158
+ env,
159
+ projectRoot,
160
+ homeDir,
161
+ now,
162
+ deadlineMs,
163
+ } = {}) {
164
+ const dp = decisionPlane(config);
165
+ const stage = resolveConfigStage({ decisionPlane: dp }, 'decisionPlane.stage');
166
+ // Zero-cost exits first: no client construction, no transport, no batch.
167
+ if (stage === 'off' || dp.enabled === false) return null;
168
+
169
+ const hosts = hostCapabilities ?? dp.hostCapabilities ?? {};
170
+ const normalizedRoute = normalizeRoute(route);
171
+
172
+ const { batch, candidateMap } = buildPreflightBatch({
173
+ route: normalizedRoute,
174
+ hostCapabilities: hosts,
175
+ config,
176
+ });
177
+ // Reclassify over the same input the client uses (state + question text) so
178
+ // the batch record's checkpoint matches the one the client resolves —
179
+ // classifying state-only can mislabel a multilingual batch as english.
180
+ const questionText = (batch.questions ?? [])
181
+ .map((q) => `${q.decisionKey} ${q.instruction} ${(q.candidates ?? []).join(' ')}`)
182
+ .join(' ');
183
+ const languageClass = classifyLanguage(`${serializeStatePacket(batch.statePacket)} ${questionText}`);
184
+ batch.languageClass = languageClass;
185
+ batch.checkpoint = resolveCheckpoint(languageClass, config);
186
+ if (Number.isFinite(deadlineMs)) batch.deadlineMs = deadlineMs;
187
+
188
+ const baseline = buildFallbackPreflight({
189
+ route: normalizedRoute,
190
+ hostCapabilities: hosts,
191
+ config,
192
+ });
193
+
194
+ const client = createDecisionClient({
195
+ config,
196
+ transport,
197
+ now,
198
+ projectRoot,
199
+ homeDir,
200
+ env,
201
+ });
202
+
203
+ let result;
204
+ try {
205
+ result = await client.requestBatch(batch);
206
+ } catch {
207
+ // The client never throws for transport-level failures; anything that
208
+ // still escapes is an internal fault — same bounded fallback shape.
209
+ result = { status: 'unavailable', fallbackCode: 'shadow-error', answers: [] };
210
+ }
211
+ const answers = Array.isArray(result?.answers) ? result.answers : [];
212
+
213
+ const probabilityBands = {};
214
+ for (const answer of answers) {
215
+ // Only canonical keys land in the receipt — model-authored unknown
216
+ // tool-call names must never become receipt keys (they ride
217
+ // routeSummary.decisionPlane into printed diagnostics).
218
+ if (typeof answer?.decisionKey === 'string' && PREFLIGHT_DECISION_KEYS.includes(answer.decisionKey)) {
219
+ probabilityBands[answer.decisionKey] = probabilityBand(answer.confidence);
220
+ }
221
+ }
222
+ for (const key of PREFLIGHT_DECISION_KEYS) {
223
+ if (!(key in probabilityBands)) probabilityBands[key] = 'unknown';
224
+ }
225
+
226
+ const okOutcomes = new Set(['accepted', 'partial', 'abstained']);
227
+ const outcomeClass = result?.status ?? 'unavailable';
228
+ const receipt = {
229
+ receiptVersion: 1,
230
+ stage,
231
+ batchId: result?.batchId ?? batch.batchId,
232
+ decisionKeys: [...PREFLIGHT_DECISION_KEYS],
233
+ probabilityBands,
234
+ outcomeClass,
235
+ checkpoint: result?.checkpoint ?? batch.checkpoint ?? null,
236
+ latencyClass: result?.latencyClass ?? 'unknown',
237
+ fallbackCode: result?.fallbackCode ?? (okOutcomes.has(outcomeClass) ? null : outcomeClass),
238
+ agreement: computeAgreement(
239
+ answers,
240
+ baseline,
241
+ candidateMap,
242
+ (batch.questions ?? [])
243
+ .map((q) => q?.decisionKey)
244
+ .filter((k) => typeof k === 'string'),
245
+ ),
246
+ };
247
+
248
+ // canary/default: validate the answer set cross-field and apply the bundle
249
+ // atomically. A blocked bundle is never half-applied — the deterministic
250
+ // fallback bundle (decision-table-v2) takes its place.
251
+ let preflight = null;
252
+ if (stage === 'canary' || stage === 'default') {
253
+ if (outcomeClass === 'accepted' || outcomeClass === 'partial') {
254
+ const validated = validatePreflightAnswers(answers, {
255
+ hostCapabilities: hosts,
256
+ riskFloor: normalizedRoute?.execution?.riskFloor ?? null,
257
+ candidateMap,
258
+ config,
259
+ batch,
260
+ });
261
+ if (validated.status === 'validated') {
262
+ // No host binding exists at routing time — applyPreflight records the
263
+ // attempt and keeps the bundle 'validated' without acknowledgement.
264
+ const { preflight: applied } = applyPreflight(validated.bundle, {
265
+ host: hosts?.host ?? null,
266
+ });
267
+ preflight = applied;
268
+ } else {
269
+ preflight = buildFallbackPreflight({
270
+ route: normalizedRoute,
271
+ hostCapabilities: hosts,
272
+ config,
273
+ });
274
+ }
275
+ } else {
276
+ preflight = baseline;
277
+ }
278
+ }
279
+
280
+ return { receipt, preflight };
281
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * statePacket.js (TASK-002 / M07)
3
+ *
4
+ * C13 decision state packet (docs/pstack/UNIC_DECISION_SPEC.md §6): a compact,
5
+ * deterministic serialization of approved features — never raw conversation
6
+ * text. The sensitive-value gate runs BEFORE serialization/transport; a
7
+ * positive detection yields `blocked-sensitive`, never a redacted-after-send.
8
+ */
9
+
10
+ import {
11
+ scanText,
12
+ isSensitiveDataGateEnabled,
13
+ loadSensitiveAllowlist,
14
+ } from '../core/sensitiveValueScanner.js';
15
+
16
+ // Whitelisted C13 fields (UNIC_DECISION_SPEC §6). Anything else — prompts,
17
+ // source excerpts, diffs, command payloads, paths, secrets — is dropped.
18
+ const ALLOWED_FIELDS = new Set([
19
+ 'stateVersion',
20
+ 'taskClass',
21
+ 'intentSignals',
22
+ 'targetClass',
23
+ 'execution',
24
+ 'riskSignals',
25
+ 'evidenceCodes',
26
+ 'capabilityAvailability',
27
+ 'candidateLabels',
28
+ 'constraints',
29
+ 'priorDecisionRefs',
30
+ 'summary',
31
+ ]);
32
+
33
+ const MAX_CODE_LENGTH = 64;
34
+ const MAX_SUMMARY_LENGTH = 280;
35
+ const MAX_ARRAY_ITEMS = 24;
36
+
37
+ function sanitizeValue(value, key) {
38
+ if (typeof value === 'string') {
39
+ return value.slice(0, key === 'summary' ? MAX_SUMMARY_LENGTH : MAX_CODE_LENGTH);
40
+ }
41
+ if (typeof value === 'number') {
42
+ return Number.isFinite(value) ? value : undefined;
43
+ }
44
+ if (typeof value === 'boolean') return value;
45
+ if (Array.isArray(value)) {
46
+ const items = value
47
+ .slice(0, MAX_ARRAY_ITEMS)
48
+ .map((v) => sanitizeValue(v, key))
49
+ .filter((v) => v !== undefined);
50
+ return items;
51
+ }
52
+ if (value && typeof value === 'object') {
53
+ const out = {};
54
+ for (const [k, v] of Object.entries(value)) {
55
+ // '__proto__' assignment would mutate out's prototype instead of storing
56
+ // the key — silently dropping the value and polluting the packet shape.
57
+ if (k === '__proto__') continue;
58
+ const clean = sanitizeValue(v, k);
59
+ if (clean !== undefined) out[k] = clean;
60
+ }
61
+ return out;
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ /**
67
+ * Build a C13 state packet from approved fields. Unknown fields are dropped;
68
+ * values are bounded to short stable codes.
69
+ *
70
+ * @param {object} fields
71
+ * @returns {object} packet with stateVersion:1
72
+ */
73
+ export function buildStatePacket(fields = {}) {
74
+ const packet = { stateVersion: 1 };
75
+ if (!fields || typeof fields !== 'object') return packet;
76
+ for (const [key, value] of Object.entries(fields)) {
77
+ if (!ALLOWED_FIELDS.has(key) || key === 'stateVersion') continue;
78
+ const clean = sanitizeValue(value, key);
79
+ if (clean !== undefined) packet[key] = clean;
80
+ }
81
+ return packet;
82
+ }
83
+
84
+ // Sorted-key serialization — identical logical packets produce identical bytes
85
+ // (state fingerprints depend on it).
86
+ function stableStringify(value) {
87
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
88
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
89
+ const keys = Object.keys(value).sort();
90
+ return `{${keys
91
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
92
+ .join(',')}}`;
93
+ }
94
+
95
+ /**
96
+ * Serialize a packet deterministically.
97
+ * @param {object|string} packet
98
+ * @returns {string}
99
+ */
100
+ export function serializeStatePacket(packet) {
101
+ return typeof packet === 'string' ? packet : stableStringify(packet);
102
+ }
103
+
104
+ /**
105
+ * Deterministic token estimator: a ~4-chars/token heuristic with a
106
+ * conservative byte backstop (ceil(bytes/3)) that dominates for dense ASCII —
107
+ * the safe direction is overestimation (UNIC_DECISION_SPEC §6).
108
+ *
109
+ * @param {object|string} packet packet or serialized text
110
+ * @returns {number} estimated token count
111
+ */
112
+ export function estimateTokens(packet) {
113
+ const text = serializeStatePacket(packet);
114
+ const chars = text.length;
115
+ const bytes = Buffer.byteLength(text, 'utf8');
116
+ return Math.max(Math.ceil(chars / 4), Math.ceil(bytes / 3));
117
+ }
118
+
119
+ const DEFAULT_LIMITS = { multilingual: 1024, english: 512 };
120
+
121
+ /**
122
+ * Assert the packet fits the token budget for its language class.
123
+ * 'english' → 512; 'multilingual'/'unknown' → 1024 (config-overridable).
124
+ * Throws an Error with code 'state-too-large' — never silently truncates.
125
+ *
126
+ * @param {object|string} packet
127
+ * @param {'english'|'multilingual'|'unknown'} languageClass
128
+ * @param {{multilingual?: number, english?: number}} [limits]
129
+ * @returns {number} the estimated token count
130
+ */
131
+ export function assertStateBudget(packet, languageClass, limits = DEFAULT_LIMITS) {
132
+ const budget =
133
+ languageClass === 'english'
134
+ ? (limits?.english ?? DEFAULT_LIMITS.english)
135
+ : (limits?.multilingual ?? DEFAULT_LIMITS.multilingual);
136
+ const tokens = estimateTokens(packet);
137
+ if (tokens > budget) {
138
+ const err = new Error(`state-too-large: ${tokens} tokens exceeds ${budget}`);
139
+ err.code = 'state-too-large';
140
+ throw err;
141
+ }
142
+ return tokens;
143
+ }
144
+
145
+ /**
146
+ * Run the sensitive-value gate over the serialized packet. Fail-closed: any
147
+ * positive detection returns `blocked-sensitive` with labels only — the secret
148
+ * value is never carried in the result.
149
+ *
150
+ * @param {object|string} packet
151
+ * @param {{config?: object}} [options] runtime config (gate toggle + allowlist)
152
+ * @returns {{status:'ok', serialized:string, tokens:number}
153
+ * | {status:'blocked-sensitive', labels:string[]}}
154
+ */
155
+ export function redactStatePacket(packet, { config } = {}) {
156
+ const serialized = serializeStatePacket(packet);
157
+ const scan = scanText(serialized, {
158
+ gateEnabled: isSensitiveDataGateEnabled(config),
159
+ allowlistHashes: loadSensitiveAllowlist(config),
160
+ });
161
+ if (scan.hasSecret) {
162
+ return { status: 'blocked-sensitive', labels: scan.labels };
163
+ }
164
+ return { status: 'ok', serialized, tokens: estimateTokens(serialized) };
165
+ }
@@ -50,7 +50,8 @@ function normalizeSignature(command) {
50
50
 
51
51
  // Extract failure events from one parsed ledger. A failure event is a receipt with
52
52
  // `success === false` on a verification-ish kind carrying a `command`, OR (when the
53
- // ledger declares `verificationFailed === true`) any receipt carrying a `command`.
53
+ // ledger declares `verificationFailed === true`) any FAILED receipt carrying a
54
+ // `command` — a succeeded receipt is never a failure event.
54
55
  function failureCommands(ledger) {
55
56
  if (!isObject(ledger) || !Array.isArray(ledger.receipts)) return [];
56
57
  const out = [];
@@ -43,5 +43,7 @@ export async function listLedgerFiles(dir, limit) {
43
43
  }
44
44
  }));
45
45
  withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
46
- return withMtime.slice(0, Math.max(0, limit)).map((f) => f.name);
46
+ // `Math.max(0, undefined)` is NaN and slice(0, NaN) is [] — an omitted limit
47
+ // silently returned nothing. Omitted limit means "all files".
48
+ return withMtime.slice(0, Math.max(0, limit ?? Infinity)).map((f) => f.name);
47
49
  }
@@ -4,9 +4,9 @@
4
4
 
5
5
  import fs from 'node:fs/promises';
6
6
  import path from 'node:path';
7
+ import { listLedgerFiles } from './ledgerFiles.js';
7
8
 
8
9
  const UNCLASSIFIED = '(unclassified)';
9
- const LEDGER_EXCLUDE = new Set(['gate-crash-counter.json']);
10
10
 
11
11
  function zeroedResult(extra = {}) {
12
12
  return {
@@ -37,34 +37,6 @@ function emptyBucket() {
37
37
  };
38
38
  }
39
39
 
40
- async function listLedgerFiles(ledgerDir, limitLedgers) {
41
- let dirents;
42
- try {
43
- dirents = await fs.readdir(ledgerDir, { withFileTypes: true });
44
- } catch {
45
- return [];
46
- }
47
- const candidates = [];
48
- for (const dirent of dirents) {
49
- if (!dirent.isFile()) continue;
50
- const name = dirent.name;
51
- if (!name.endsWith('.json')) continue;
52
- if (LEDGER_EXCLUDE.has(name)) continue;
53
- if (name.includes('.journal') || name.includes('.quarantine')) continue;
54
- candidates.push(name);
55
- }
56
- const withMtime = await Promise.all(candidates.map(async (name) => {
57
- try {
58
- const stat = await fs.stat(path.join(ledgerDir, name));
59
- return { name, mtimeMs: stat.mtimeMs };
60
- } catch {
61
- return { name, mtimeMs: 0 };
62
- }
63
- }));
64
- withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
65
- return withMtime.slice(0, Math.max(0, limitLedgers)).map((entry) => entry.name);
66
- }
67
-
68
40
  async function readJson(filePath) {
69
41
  try {
70
42
  return JSON.parse(await fs.readFile(filePath, 'utf8'));
@@ -249,7 +249,10 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
249
249
  ]);
250
250
  canReuseParsedArtifacts = previousSymbolsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
251
251
  && previousImportsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
252
- && previousCallsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
252
+ && previousCallsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
253
+ && Array.isArray(previousSymbolsArtifact.items)
254
+ && Array.isArray(previousImportsArtifact.items)
255
+ && Array.isArray(previousCallsArtifact.items);
253
256
 
254
257
  if (canReuseParsedArtifacts) {
255
258
  const previousSymbolsByPath = groupBy(previousSymbolsArtifact.items ?? [], (item) => item.filePath);
@@ -306,7 +309,8 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
306
309
  ? await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.testsMap)
307
310
  : null;
308
311
  const canReuseTestsMap = canReuseTestsMapCandidate
309
- && previousTestsMapArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
312
+ && previousTestsMapArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
313
+ && Array.isArray(previousTestsMapArtifact.items);
310
314
  const testsMap = canReuseTestsMap
311
315
  ? previousTestsMapArtifact.items
312
316
  : buildTestsMap(fileRecords);
@@ -315,6 +319,7 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
315
319
  ? await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.hotspots)
316
320
  : null;
317
321
  const canReuseHotspots = previousHotspotsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
322
+ && Array.isArray(previousHotspotsArtifact.items)
318
323
  && areBugIndexSnapshotsEqual(previousHotspotsArtifact?.sourceSnapshot, bugIndexSnapshot);
319
324
  const hotspots = canReuseHotspots
320
325
  ? previousHotspotsArtifact.items
@@ -329,8 +334,9 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
329
334
  : null;
330
335
  const canReuseArchetypes = canReuseArchetypesCandidate
331
336
  && previousArchetypesArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
332
- && previousArchetypesArtifact?.items?.length === codeFiles.length
333
- && (previousArchetypesArtifact.items ?? []).every((item) => currentCodePaths.has(item.filePath));
337
+ && Array.isArray(previousArchetypesArtifact.items)
338
+ && previousArchetypesArtifact.items.length === codeFiles.length
339
+ && previousArchetypesArtifact.items.every((item) => currentCodePaths.has(item.filePath));
334
340
 
335
341
  // Reuse cached archetypes only when every indexed code file is unchanged
336
342
  const archetypes = canReuseArchetypes
@@ -356,6 +362,7 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
356
362
  };
357
363
  const canReuseRelations = canReuseGraphArtifactsCandidate
358
364
  && previousRelationsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
365
+ && Array.isArray(previousRelationsArtifact.items)
359
366
  && areStringArraysEqual(previousRelationsArtifact?.sourceSnapshot?.styleFiles, styleFilePaths)
360
367
  && arePathSnapshotsEqual(
361
368
  previousRelationsArtifact?.sourceSnapshot?.importAliasSnapshot,
@@ -363,6 +370,7 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
363
370
  );
364
371
  const canReuseAnalogs = canReuseGraphArtifactsCandidate
365
372
  && previousAnalogsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
373
+ && Array.isArray(previousAnalogsArtifact.items)
366
374
  && areStringArraysEqual(previousAnalogsArtifact?.sourceSnapshot?.styleFiles, styleFilePaths)
367
375
  && arePathSnapshotsEqual(
368
376
  previousAnalogsArtifact?.sourceSnapshot?.importAliasSnapshot,
@@ -948,11 +956,13 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
948
956
  // expensive work being avoided is discovery and re-parsing, not these pure
949
957
  // transformations, so their output stays identical to a clean rebuild.
950
958
  const canReuseTestsMap = haveStableTrackedFileIdentities(filesArtifact.items, mergedFileRecords)
951
- && testsMapArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
959
+ && testsMapArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
960
+ && Array.isArray(testsMapArtifact.items);
952
961
  const testsMap = canReuseTestsMap ? testsMapArtifact.items : buildTestsMap(mergedFileRecords);
953
962
 
954
963
  const bugIndexSnapshot = await readBugIndexSnapshot(absoluteRoot);
955
964
  const canReuseHotspots = hotspotsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
965
+ && Array.isArray(hotspotsArtifact.items)
956
966
  && areBugIndexSnapshotsEqual(hotspotsArtifact?.sourceSnapshot, bugIndexSnapshot);
957
967
  const hotspots = canReuseHotspots ? hotspotsArtifact.items : await buildHotspots(absoluteRoot, bugIndexSnapshot);
958
968
 
@@ -964,9 +974,10 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
964
974
  const canReuseArchetypes = parsedFiles.length === 0
965
975
  && !recordsChanged
966
976
  && archetypesArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
977
+ && Array.isArray(archetypesArtifact.items)
967
978
  && codeFiles.every((file) => previousCodePaths.has(file.filePath))
968
- && archetypesArtifact?.items?.length === archetypes.length
969
- && (archetypesArtifact.items ?? []).every((item) => currentCodePaths.has(item?.filePath));
979
+ && archetypesArtifact.items.length === archetypes.length
980
+ && archetypesArtifact.items.every((item) => currentCodePaths.has(item?.filePath));
970
981
  const archetypeItems = canReuseArchetypes ? archetypesArtifact.items : archetypes;
971
982
 
972
983
  const needsAliasContext = importsNeedAliasContext(imports);
@@ -984,7 +995,8 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
984
995
  && areStringArraysEqual(relationsArtifact?.sourceSnapshot?.styleFiles, styleFilePaths)
985
996
  && arePathSnapshotsEqual(relationsArtifact?.sourceSnapshot?.importAliasSnapshot, styleSnapshot.importAliasSnapshot);
986
997
  const canReuseAnalogs = canReuseRelations
987
- && analogsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
998
+ && analogsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
999
+ && Array.isArray(analogsArtifact.items);
988
1000
 
989
1001
  let relations = relationsArtifact.items;
990
1002
  let analogs = analogsArtifact?.items ?? null;
@@ -1639,7 +1651,7 @@ function extractImports(filePath, content) {
1639
1651
  const stripped = stripCommentsStringAware(content);
1640
1652
  const imports = [];
1641
1653
 
1642
- for (const match of stripped.matchAll(/(?:^|[;\n])\s*import\s+(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]/g)) {
1654
+ for (const match of stripped.matchAll(/(?:^|[;\n])\s*import\s*(?:[^'";]+?\s*from\s*)?['"]([^'"]+)['"]/g)) {
1643
1655
  imports.push({
1644
1656
  from: filePath,
1645
1657
  to: match[1],
@@ -1663,7 +1675,7 @@ function extractImports(filePath, content) {
1663
1675
  });
1664
1676
  }
1665
1677
 
1666
- for (const match of stripped.matchAll(/(?:^|[;\n])\s*export\s+(?:type\s+)?\{[^}]+\}\s+from\s+['"]([^'"]+)['"]/g)) {
1678
+ for (const match of stripped.matchAll(/(?:^|[;\n])\s*export\s+(?:type\s+)?\{[^}]+\}\s*from\s*['"]([^'"]+)['"]/g)) {
1667
1679
  imports.push({
1668
1680
  from: filePath,
1669
1681
  to: match[1],
@@ -1671,7 +1683,7 @@ function extractImports(filePath, content) {
1671
1683
  });
1672
1684
  }
1673
1685
 
1674
- for (const match of stripped.matchAll(/(?:^|[;\n])\s*export\s+\*(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s+from\s+['"]([^'"]+)['"]/g)) {
1686
+ for (const match of stripped.matchAll(/(?:^|[;\n])\s*export\s+\*(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*from\s*['"]([^'"]+)['"]/g)) {
1675
1687
  imports.push({
1676
1688
  from: filePath,
1677
1689
  to: match[1],
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { getArtifactPath, INDEX_ARTIFACTS } from './paths.js';
5
5
  import { classifyImpactRisk, findMirrorCounterparts } from './impactCatalog.js';
6
6
  import { inferRelatedTestsFromArtifacts, loadRelatedTestArtifacts } from './relatedTests.js';
7
+ import { importsNeedAliasContext, loadImportAliasContext, resolveImportSpecifier } from './importResolution.js';
7
8
 
8
9
  const DEFAULT_IMPACT_LIMITS = {
9
10
  maxCallers: 8,
@@ -86,9 +87,10 @@ export async function resolveImpactContext({
86
87
  const normalizedChangedFiles = unique(changedFiles.map(normalizePath));
87
88
  const normalizedChangedSymbols = unique(changedSymbols.map((symbol) => String(symbol ?? '').trim()));
88
89
 
89
- const [callsArtifact, importsArtifact, relatedArtifacts] = await Promise.all([
90
+ const [callsArtifact, importsArtifact, filesArtifact, relatedArtifacts] = await Promise.all([
90
91
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.calls),
91
92
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.imports),
93
+ readArtifact(absoluteRoot, INDEX_ARTIFACTS.files),
92
94
  loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
93
95
  ]);
94
96
 
@@ -140,9 +142,26 @@ export async function resolveImpactContext({
140
142
 
141
143
  const importers = [];
142
144
  const dependencies = [];
145
+ // Non-relative specifiers (aliases like `@/x`, `~/x`, tsconfig paths) resolve
146
+ // through the same machinery the index builder uses; relative specifiers keep
147
+ // the candidate-list match so a changed file absent from files.json (e.g. a
148
+ // brand-new file) still counts.
149
+ const indexedFileSet = new Set((filesArtifact.items ?? []).map((item) => item.filePath));
150
+ const importAliasContext = importsNeedAliasContext(importItems)
151
+ ? await loadImportAliasContext({ rootDir: absoluteRoot }).catch(() => null)
152
+ : null;
143
153
  for (const imp of importItems) {
144
154
  const from = normalizePath(imp.from);
145
- const targets = resolveImportTarget(from, imp.to) ?? [];
155
+ const specifier = String(imp.to ?? '').trim();
156
+ const targets = specifier.startsWith('.')
157
+ ? resolveImportTarget(from, specifier)
158
+ : [resolveImportSpecifier({
159
+ rootDir: absoluteRoot,
160
+ fromFilePath: from,
161
+ specifier,
162
+ indexedFileSet,
163
+ aliasContext: importAliasContext,
164
+ })].filter(Boolean);
146
165
  const matched = targets.find((candidate) => changedFileSet.has(normalizePath(candidate)));
147
166
 
148
167
  if (matched) {