@ngockhoale/ukit 3.0.3 → 3.0.5
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 +26 -0
- package/bin/ukit +12 -4
- package/manifests/engineConformance.yaml +29 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +2 -1
- package/scripts/audit/decision-coverage.mjs +295 -0
- package/scripts/bench/outline-savings.mjs +19 -4
- package/scripts/bench/parallel-agents.mjs +15 -4
- package/scripts/bench/runGold.mjs +22 -4
- package/scripts/bench/v3-ceremony.mjs +7 -1
- package/scripts/bug/triage.mjs +56 -17
- package/scripts/index/build-index.mjs +94 -28
- package/scripts/index/query-index.mjs +48 -14
- package/scripts/index/refresh-index.mjs +142 -62
- package/scripts/perf/audit-perf.mjs +8 -2
- package/scripts/skill/audit-skill.mjs +54 -25
- package/src/bug/triageBug.js +9 -6
- package/src/cli/adapters.js +6 -0
- package/src/cli/commands/code.js +7 -1
- package/src/cli/commands/indexArgs.js +4 -2
- package/src/cli/commands/indexTools.js +8 -1
- package/src/cli/commands/install.js +13 -0
- package/src/cli/commands/memory.js +13 -9
- package/src/cli/commands/status.js +17 -1
- package/src/cli/commands/update.js +7 -0
- package/src/context/detectProjectContext.js +3 -1
- package/src/core/codeintel/analogy.js +1 -1
- package/src/core/codeintel/diagnostics.js +9 -6
- package/src/core/codeintel/graph.js +14 -8
- package/src/core/codeintel/impact.js +0 -1
- package/src/core/codeintel/invalidation.js +5 -3
- package/src/core/codeintel/packet.js +11 -0
- package/src/core/codeintel/router.js +17 -3
- package/src/core/codeintel/semanticProvider.js +1 -1
- package/src/core/codeintel/summaries.js +11 -7
- package/src/core/compact/contextBudget.js +26 -12
- package/src/core/compact/index.js +15 -8
- package/src/core/docContracts.js +10 -2
- package/src/core/experiments/deliberation.js +321 -0
- package/src/core/experiments/dynamicWorkflow.js +492 -0
- package/src/core/fileOps.js +8 -1
- package/src/core/gatewayProbe.js +29 -1
- package/src/core/gatewayResilienceEnv.js +44 -3
- package/src/core/handoffDocValidator.js +3 -1
- package/src/core/hookChainDoctor.js +16 -1
- package/src/core/memory/deltaOverlays.js +448 -0
- package/src/core/memory/learningCandidates.js +302 -0
- package/src/core/memory/migrate.js +59 -32
- package/src/core/memory/recordStore.js +24 -1
- package/src/core/memory/store.js +44 -8
- package/src/core/memory/storeV2.js +48 -33
- package/src/core/memory/userMemory.js +26 -10
- package/src/core/output/index.js +15 -10
- package/src/core/permissionPolicy.js +8 -0
- package/src/core/runtimeConfig.js +224 -4
- package/src/core/sensitiveValueScanner.js +10 -2
- package/src/core/taskBudgetValidator.js +12 -17
- package/src/core/taskProgressGuard.js +59 -9
- package/src/core/unattendedDoctor.js +5 -2
- package/src/core/uninstall.js +37 -8
- package/src/decision/client.js +371 -0
- package/src/decision/lease.js +198 -0
- package/src/decision/preflight.js +492 -0
- package/src/decision/protocol.js +308 -0
- package/src/decision/registry.js +384 -0
- package/src/decision/shadow.js +281 -0
- package/src/decision/statePacket.js +165 -0
- package/src/diagnostics/failurePatterns.js +2 -1
- package/src/diagnostics/ledgerFiles.js +3 -1
- package/src/diagnostics/routeOutcomes.js +1 -29
- package/src/index/buildIndex.js +23 -11
- package/src/index/impactContext.js +21 -2
- package/src/index/importResolution.js +13 -7
- package/src/index/queryIndex.js +11 -5
- package/src/index/resolveContext.js +22 -9
- package/src/index/taskRouting.js +63 -2
- package/src/index/verificationPlan.js +12 -1
- package/src/learning/patternProposals.js +6 -0
- package/src/render/renderTemplate.js +1 -1
- package/src/skill/auditSkill.js +3 -1
- package/src/stack/detectStack.js +3 -1
- package/template_project/.claude/agents/handoff-planner.md +2 -5
- package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
- package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
- package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
- package/template_project/.claude/hooks/handoff-resume.sh +10 -1
- package/template_project/.claude/hooks/protect-files.sh +0 -1
- package/template_project/.claude/hooks/record-execution.mjs +13 -1
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +80 -5
- package/template_project/.claude/hooks/session-episode.sh +9 -2
- package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
- package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +129 -42
- package/template_project/.claude/ukit/index/route-task.mjs +444 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
- package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
- package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
- package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
- package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
- package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
- package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
- package/template_project/ukit/storage/config.json +34 -1
- package/template_project/.claude/ukit/skill-router-state.json +0 -1
- package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protocol.js (TASK-002 / M07)
|
|
3
|
+
*
|
|
4
|
+
* Pure, offline-testable encoders/decoders for the UNIC Decision Agent
|
|
5
|
+
* (docs/pstack/UNIC_DECISION_SPEC.md §8/§9, CONTRACTS.md C13/C14).
|
|
6
|
+
*
|
|
7
|
+
* A question is encoded as an OpenAI `tools[]` item — a function *schema*, never
|
|
8
|
+
* a callable. `parseBatchResponse` reads `choices[0].message.tool_calls` as data
|
|
9
|
+
* exactly once; it NEVER dispatches a call, never feeds a tool result back, and
|
|
10
|
+
* never continues an agent tool loop. Invalid/missing/duplicate/out-of-enum/
|
|
11
|
+
* non-finite answers invalidate only their own question.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const VALID_KINDS = new Set(['choice', 'noul', 'score']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Encode one decision question as an OpenAI function-tool schema item.
|
|
18
|
+
*
|
|
19
|
+
* kind mapping (UNIC_DECISION_SPEC §8):
|
|
20
|
+
* choice → string enum over `candidates`
|
|
21
|
+
* noul → boolean (interpreted as P(true))
|
|
22
|
+
* score → integer with an ordered legend derived from `candidates`
|
|
23
|
+
*
|
|
24
|
+
* Exactly one decision-bearing property (`value`) per question.
|
|
25
|
+
*
|
|
26
|
+
* @param {{decisionKey: string, kind: 'choice'|'noul'|'score',
|
|
27
|
+
* instruction: string, candidates?: string[]}} question
|
|
28
|
+
* @returns {{type:'function', function: object}}
|
|
29
|
+
*/
|
|
30
|
+
export function encodeQuestion(question) {
|
|
31
|
+
if (!question || typeof question !== 'object') {
|
|
32
|
+
throw new TypeError('question must be an object.');
|
|
33
|
+
}
|
|
34
|
+
const { decisionKey, kind, instruction, candidates } = question;
|
|
35
|
+
if (typeof decisionKey !== 'string' || decisionKey.length === 0) {
|
|
36
|
+
throw new TypeError('question.decisionKey must be a non-empty string.');
|
|
37
|
+
}
|
|
38
|
+
if (!VALID_KINDS.has(kind)) {
|
|
39
|
+
throw new TypeError(`question.kind must be one of ${[...VALID_KINDS].join('|')}.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let value;
|
|
43
|
+
if (kind === 'choice') {
|
|
44
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
45
|
+
throw new TypeError('choice question requires a non-empty candidates array.');
|
|
46
|
+
}
|
|
47
|
+
value = { type: 'string', enum: [...candidates] };
|
|
48
|
+
} else if (kind === 'noul') {
|
|
49
|
+
value = {
|
|
50
|
+
type: 'boolean',
|
|
51
|
+
description: 'true iff the proposition holds; interpreted as P(true).',
|
|
52
|
+
};
|
|
53
|
+
} else {
|
|
54
|
+
// score — ordinal integer; the legend MUST be defined in the description.
|
|
55
|
+
const legend = Array.isArray(candidates) ? candidates : [];
|
|
56
|
+
value = { type: 'integer' };
|
|
57
|
+
if (legend.length > 0) {
|
|
58
|
+
value.minimum = 1;
|
|
59
|
+
value.maximum = legend.length;
|
|
60
|
+
value.description = `Ordered scale: ${legend
|
|
61
|
+
.map((label, i) => `${i + 1}=${label}`)
|
|
62
|
+
.join(', ')}.`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
type: 'function',
|
|
68
|
+
function: {
|
|
69
|
+
name: decisionKey,
|
|
70
|
+
description: String(instruction ?? ''),
|
|
71
|
+
parameters: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: { value },
|
|
74
|
+
required: ['value'],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Deterministic serialization shared with statePacket.js: sorted keys so the
|
|
81
|
+
// same logical packet always produces the same bytes (fingerprint-stable).
|
|
82
|
+
function stableStringify(value) {
|
|
83
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
84
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
85
|
+
const keys = Object.keys(value).sort();
|
|
86
|
+
return `{${keys
|
|
87
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
|
88
|
+
.join(',')}}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the OpenAI-compatible `/v1/chat/completions` request body for a batch.
|
|
93
|
+
* `temperature`, `stream`, and `tool_choice` are intentionally omitted — the
|
|
94
|
+
* endpoint ignores them (UNIC_DECISION_SPEC §8).
|
|
95
|
+
*
|
|
96
|
+
* @param {{model: string, statePacket: object|string, questions: object[]}} args
|
|
97
|
+
* @returns {{model: string, messages: object[], tools: object[]}}
|
|
98
|
+
*/
|
|
99
|
+
export function encodeBatch({ model, statePacket, questions }) {
|
|
100
|
+
if (!Array.isArray(questions) || questions.length === 0) {
|
|
101
|
+
throw new TypeError('encodeBatch requires a non-empty questions array.');
|
|
102
|
+
}
|
|
103
|
+
const content =
|
|
104
|
+
typeof statePacket === 'string' ? statePacket : stableStringify(statePacket);
|
|
105
|
+
return {
|
|
106
|
+
model,
|
|
107
|
+
messages: [{ role: 'user', content }],
|
|
108
|
+
tools: questions.map(encodeQuestion),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Deterministic language classifier (UNIC_DECISION_SPEC §3). The English
|
|
114
|
+
* checkpoint may only receive state this function proves all-English:
|
|
115
|
+
* any non-ASCII letter (Vietnamese diacritics, CJK, Cyrillic, …) or any
|
|
116
|
+
* non-Latin letter forces 'multilingual'. Text with no letters at all is
|
|
117
|
+
* 'unknown' — never silently promoted to English.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} text
|
|
120
|
+
* @returns {'english'|'multilingual'|'unknown'}
|
|
121
|
+
*/
|
|
122
|
+
export function classifyLanguage(text) {
|
|
123
|
+
if (typeof text !== 'string' || text.trim().length === 0) return 'unknown';
|
|
124
|
+
let sawLetter = false;
|
|
125
|
+
for (const ch of text) {
|
|
126
|
+
if (/\p{L}/u.test(ch)) {
|
|
127
|
+
sawLetter = true;
|
|
128
|
+
if (ch.codePointAt(0) > 0x7f) return 'multilingual';
|
|
129
|
+
} else if (ch.codePointAt(0) > 0x7f) {
|
|
130
|
+
// Non-letter non-ASCII (emoji, CJK punctuation, …): cannot prove English.
|
|
131
|
+
return 'multilingual';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return sawLetter ? 'english' : 'unknown';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isFiniteNumber(v) {
|
|
138
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function invalidAnswer(question, fallbackCode) {
|
|
142
|
+
return {
|
|
143
|
+
decisionKey: question.decisionKey,
|
|
144
|
+
schemaVersion: question.schemaVersion ?? 1,
|
|
145
|
+
kind: question.kind,
|
|
146
|
+
value: null,
|
|
147
|
+
probabilities: null,
|
|
148
|
+
confidence: null,
|
|
149
|
+
actProbability: null,
|
|
150
|
+
calibration: 'unknown',
|
|
151
|
+
validationStatus: 'invalid',
|
|
152
|
+
fallbackCode,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeProbabilities(raw) {
|
|
157
|
+
if (raw == null) return { probabilities: null, calibration: 'unknown' };
|
|
158
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
159
|
+
return { error: 'non-finite-probabilities' };
|
|
160
|
+
}
|
|
161
|
+
const out = {};
|
|
162
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
163
|
+
// Probabilities must be finite AND in [0,1] — an out-of-range value marked
|
|
164
|
+
// calibration 'eligible' is data-quality noise for shadow-agreement analysis.
|
|
165
|
+
if (!isFiniteNumber(v) || v < 0 || v > 1) return { error: 'non-finite-probabilities' };
|
|
166
|
+
out[k] = v;
|
|
167
|
+
}
|
|
168
|
+
return { probabilities: out, calibration: 'eligible' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function validateValue(question, args) {
|
|
172
|
+
const value = args?.value;
|
|
173
|
+
if (question.kind === 'choice') {
|
|
174
|
+
if (typeof value !== 'string') return 'missing-value';
|
|
175
|
+
if (!question.candidates.includes(value)) return 'out-of-enum';
|
|
176
|
+
return { value };
|
|
177
|
+
}
|
|
178
|
+
if (question.kind === 'noul') {
|
|
179
|
+
// P(true): accept a boolean or a finite probability in [0,1].
|
|
180
|
+
if (typeof value === 'boolean') return { value: value ? 1 : 0 };
|
|
181
|
+
if (isFiniteNumber(value) && value >= 0 && value <= 1) return { value };
|
|
182
|
+
return 'missing-value';
|
|
183
|
+
}
|
|
184
|
+
// score — finite integer within the declared legend range.
|
|
185
|
+
if (!isFiniteNumber(value) || !Number.isInteger(value)) return 'missing-value';
|
|
186
|
+
const legend = Array.isArray(question.candidates) ? question.candidates : [];
|
|
187
|
+
if (legend.length > 0 && (value < 1 || value > legend.length)) return 'out-of-enum';
|
|
188
|
+
return { value };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Parse one chat/completions response into a normalized DecisionBatchResult
|
|
193
|
+
* (C14). `arguments` is parsed exactly once per call; calls are data only.
|
|
194
|
+
*
|
|
195
|
+
* Batch status: 'accepted' (all valid) | 'partial' (mixed) | 'abstained'
|
|
196
|
+
* (no tool calls) | 'invalid' (malformed envelope or every answer invalid).
|
|
197
|
+
*
|
|
198
|
+
* @param {object} response parsed response body
|
|
199
|
+
* @param {object[]} questions the questions that were sent
|
|
200
|
+
* @returns {object} DecisionBatchResult
|
|
201
|
+
*/
|
|
202
|
+
export function parseBatchResponse(response, questions) {
|
|
203
|
+
const message = response?.choices?.[0]?.message;
|
|
204
|
+
const finishReason = response?.choices?.[0]?.finish_reason ?? null;
|
|
205
|
+
const toolCalls = Array.isArray(message?.tool_calls) ? message.tool_calls : null;
|
|
206
|
+
|
|
207
|
+
const answers = [];
|
|
208
|
+
let status;
|
|
209
|
+
|
|
210
|
+
if (!toolCalls) {
|
|
211
|
+
status = 'invalid';
|
|
212
|
+
for (const q of questions) answers.push(invalidAnswer(q, 'malformed-response'));
|
|
213
|
+
} else if (toolCalls.length === 0) {
|
|
214
|
+
status = 'abstained';
|
|
215
|
+
for (const q of questions) answers.push(invalidAnswer(q, 'missing-answer'));
|
|
216
|
+
} else {
|
|
217
|
+
// Name-based reconciliation: order in the response is not trusted.
|
|
218
|
+
const byName = new Map();
|
|
219
|
+
const duplicates = new Set();
|
|
220
|
+
const unknown = [];
|
|
221
|
+
const questionNames = new Set(questions.map((q) => q.decisionKey));
|
|
222
|
+
for (const call of toolCalls) {
|
|
223
|
+
const name = call?.function?.name;
|
|
224
|
+
if (typeof name !== 'string' || !questionNames.has(name)) {
|
|
225
|
+
unknown.push(name ?? 'unknown');
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (byName.has(name)) {
|
|
229
|
+
duplicates.add(name);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
byName.set(name, call);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const q of questions) {
|
|
236
|
+
if (duplicates.has(q.decisionKey)) {
|
|
237
|
+
answers.push(invalidAnswer(q, 'duplicate-answer'));
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const call = byName.get(q.decisionKey);
|
|
241
|
+
if (!call) {
|
|
242
|
+
answers.push(invalidAnswer(q, 'missing-answer'));
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
let args;
|
|
246
|
+
try {
|
|
247
|
+
args = JSON.parse(call.function.arguments);
|
|
248
|
+
} catch {
|
|
249
|
+
answers.push(invalidAnswer(q, 'malformed-arguments'));
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
253
|
+
answers.push(invalidAnswer(q, 'malformed-arguments'));
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const checked = validateValue(q, args);
|
|
257
|
+
if (typeof checked === 'string') {
|
|
258
|
+
answers.push(invalidAnswer(q, checked));
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const probs = normalizeProbabilities(args.probabilities);
|
|
262
|
+
if (probs.error) {
|
|
263
|
+
answers.push(invalidAnswer(q, probs.error));
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
answers.push({
|
|
267
|
+
decisionKey: q.decisionKey,
|
|
268
|
+
schemaVersion: q.schemaVersion ?? 1,
|
|
269
|
+
kind: q.kind,
|
|
270
|
+
value: checked.value,
|
|
271
|
+
probabilities: probs.probabilities,
|
|
272
|
+
confidence: isFiniteNumber(args.confidence) && args.confidence >= 0 && args.confidence <= 1
|
|
273
|
+
? args.confidence
|
|
274
|
+
: null,
|
|
275
|
+
actProbability: isFiniteNumber(args.actProbability) && args.actProbability >= 0 && args.actProbability <= 1
|
|
276
|
+
? args.actProbability
|
|
277
|
+
: isFiniteNumber(args?.action?.act_probability) && args.action.act_probability >= 0 && args.action.act_probability <= 1
|
|
278
|
+
? args.action.act_probability
|
|
279
|
+
: null,
|
|
280
|
+
calibration: probs.calibration,
|
|
281
|
+
validationStatus: 'valid',
|
|
282
|
+
fallbackCode: null,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Unknown call names are data too — surfaced as invalid answers, never run.
|
|
287
|
+
for (const name of unknown) {
|
|
288
|
+
answers.push({
|
|
289
|
+
decisionKey: name,
|
|
290
|
+
schemaVersion: 1,
|
|
291
|
+
kind: null,
|
|
292
|
+
value: null,
|
|
293
|
+
probabilities: null,
|
|
294
|
+
confidence: null,
|
|
295
|
+
actProbability: null,
|
|
296
|
+
calibration: 'unknown',
|
|
297
|
+
validationStatus: 'invalid',
|
|
298
|
+
fallbackCode: 'unknown-answer',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const validCount = answers.filter((a) => a.validationStatus === 'valid').length;
|
|
303
|
+
status =
|
|
304
|
+
validCount === answers.length ? 'accepted' : validCount > 0 ? 'partial' : 'invalid';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { status, finishReason, answers };
|
|
308
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry.js (TASK-003 / M07)
|
|
3
|
+
*
|
|
4
|
+
* Decision catalog (CONTRACTS.md C17, UNIC_DECISION_SPEC §5). Every
|
|
5
|
+
* consequential nondeterministic AI choice is registered here — or explicitly
|
|
6
|
+
* declared deterministic elsewhere. The registry is metadata consumed by the
|
|
7
|
+
* existing owners; it is NOT a second router and resolves nothing by itself.
|
|
8
|
+
*
|
|
9
|
+
* Module-level functions operate on the default registry preloaded with
|
|
10
|
+
* DECISION_REGISTRY (the shipped initial catalog). `createDecisionRegistry`
|
|
11
|
+
* builds an isolated instance for tests and embedders.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { VALID_ROUTE_STAGES } from '../core/runtimeConfig.js';
|
|
15
|
+
|
|
16
|
+
export const DECISION_FAMILIES = new Set([
|
|
17
|
+
'preflight',
|
|
18
|
+
'route',
|
|
19
|
+
'rigor',
|
|
20
|
+
'capability',
|
|
21
|
+
'workflow',
|
|
22
|
+
'verify',
|
|
23
|
+
'review',
|
|
24
|
+
'resume',
|
|
25
|
+
'learn',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const DECISION_KINDS = new Set(['choice', 'noul', 'score']);
|
|
29
|
+
|
|
30
|
+
// How a decision's answer interacts with the cache epoch (C16):
|
|
31
|
+
// frozen-in-epoch — lane/model: frozen once the epoch opens
|
|
32
|
+
// verified-native-update — effort: may change mid-epoch only via a verified
|
|
33
|
+
// native update that preserves prefix identity
|
|
34
|
+
// none — not part of the cache-sensitive preflight bundle
|
|
35
|
+
export const CACHE_SENSITIVITY = new Set([
|
|
36
|
+
'frozen-in-epoch',
|
|
37
|
+
'verified-native-update',
|
|
38
|
+
'none',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// Stable dotted key ending in an explicit schema version: `family.name.vN`.
|
|
42
|
+
const DECISION_KEY_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)*\.v[0-9]+$/;
|
|
43
|
+
|
|
44
|
+
const REQUIRED_STRING_FIELDS = [
|
|
45
|
+
'decisionKey',
|
|
46
|
+
'family',
|
|
47
|
+
'owner',
|
|
48
|
+
'kind',
|
|
49
|
+
'candidatePolicy',
|
|
50
|
+
'probabilityPolicy',
|
|
51
|
+
'fallbackPolicy',
|
|
52
|
+
'telemetryClass',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The shipped initial catalog (SPEC §5 FR-005). Preflight questions are owned
|
|
57
|
+
* by taskRouting; the rest are registered to their existing owners. All ship
|
|
58
|
+
* at rolloutStage 'off' — deterministic policy stays authoritative until a
|
|
59
|
+
* family stage is promoted in runtime config.
|
|
60
|
+
*/
|
|
61
|
+
export const DECISION_REGISTRY = Object.freeze([
|
|
62
|
+
{
|
|
63
|
+
decisionKey: 'preflight.execution-lane.v1',
|
|
64
|
+
schemaVersion: 1,
|
|
65
|
+
family: 'preflight',
|
|
66
|
+
owner: 'taskRouting',
|
|
67
|
+
kind: 'choice',
|
|
68
|
+
description: 'Execution lane for the next task boundary, from host-eligible lanes.',
|
|
69
|
+
candidatePolicy: 'host-eligible-lanes',
|
|
70
|
+
hardConstraints: ['lane-eligibility'],
|
|
71
|
+
probabilityPolicy: 'raw-label',
|
|
72
|
+
fallbackPolicy: 'deterministic-route',
|
|
73
|
+
telemetryClass: 'decision',
|
|
74
|
+
rolloutStage: 'off',
|
|
75
|
+
cacheSensitivity: 'frozen-in-epoch',
|
|
76
|
+
leasePolicy: 'preflight-bundle',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
decisionKey: 'preflight.model-role.v1',
|
|
80
|
+
schemaVersion: 1,
|
|
81
|
+
family: 'preflight',
|
|
82
|
+
owner: 'taskRouting',
|
|
83
|
+
kind: 'choice',
|
|
84
|
+
description: 'Execution model role from the host-declared capability map.',
|
|
85
|
+
candidatePolicy: 'host-model-roles',
|
|
86
|
+
hardConstraints: ['host-capability-map'],
|
|
87
|
+
probabilityPolicy: 'raw-label',
|
|
88
|
+
fallbackPolicy: 'decision-table-v2',
|
|
89
|
+
telemetryClass: 'decision',
|
|
90
|
+
rolloutStage: 'off',
|
|
91
|
+
cacheSensitivity: 'frozen-in-epoch',
|
|
92
|
+
leasePolicy: 'preflight-bundle',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
decisionKey: 'preflight.reasoning-effort.v1',
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
family: 'preflight',
|
|
98
|
+
owner: 'taskRouting',
|
|
99
|
+
kind: 'choice',
|
|
100
|
+
description: 'Lowest portable reasoning effort sufficient for the next unresolved work.',
|
|
101
|
+
candidatePolicy: 'host-efforts-intersect-portable',
|
|
102
|
+
hardConstraints: ['model-effort-compatibility'],
|
|
103
|
+
probabilityPolicy: 'raw-label',
|
|
104
|
+
fallbackPolicy: 'decision-table-v2',
|
|
105
|
+
telemetryClass: 'decision',
|
|
106
|
+
rolloutStage: 'off',
|
|
107
|
+
cacheSensitivity: 'verified-native-update',
|
|
108
|
+
leasePolicy: 'preflight-bundle',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
decisionKey: 'preflight.rigor.v1',
|
|
112
|
+
schemaVersion: 1,
|
|
113
|
+
family: 'preflight',
|
|
114
|
+
owner: 'taskRouting',
|
|
115
|
+
kind: 'score',
|
|
116
|
+
description: 'Rigor level r0-r4 inside deterministic floors.',
|
|
117
|
+
candidatePolicy: 'floor-filtered-rigor',
|
|
118
|
+
hardConstraints: ['risk-floor'],
|
|
119
|
+
probabilityPolicy: 'raw-label',
|
|
120
|
+
fallbackPolicy: 'deterministic-floor',
|
|
121
|
+
telemetryClass: 'decision',
|
|
122
|
+
rolloutStage: 'off',
|
|
123
|
+
cacheSensitivity: 'none',
|
|
124
|
+
leasePolicy: 'preflight-bundle',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
decisionKey: 'preflight.capabilities.v1',
|
|
128
|
+
schemaVersion: 1,
|
|
129
|
+
family: 'preflight',
|
|
130
|
+
owner: 'taskRouting',
|
|
131
|
+
kind: 'choice',
|
|
132
|
+
description: 'Active capability set label from owner-derived bounded sets.',
|
|
133
|
+
candidatePolicy: 'owner-derived-sets',
|
|
134
|
+
hardConstraints: ['capability-availability'],
|
|
135
|
+
probabilityPolicy: 'raw-label',
|
|
136
|
+
fallbackPolicy: 'deterministic-route',
|
|
137
|
+
telemetryClass: 'decision',
|
|
138
|
+
rolloutStage: 'off',
|
|
139
|
+
cacheSensitivity: 'none',
|
|
140
|
+
leasePolicy: 'preflight-bundle',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
decisionKey: 'preflight.verification-depth.v1',
|
|
144
|
+
schemaVersion: 1,
|
|
145
|
+
family: 'preflight',
|
|
146
|
+
owner: 'taskRouting',
|
|
147
|
+
kind: 'choice',
|
|
148
|
+
description: 'Initial verification posture: sanity | targeted | impact | full.',
|
|
149
|
+
candidatePolicy: 'floor-filtered-depths',
|
|
150
|
+
hardConstraints: ['risk-floor'],
|
|
151
|
+
probabilityPolicy: 'raw-label',
|
|
152
|
+
fallbackPolicy: 'deterministic-route',
|
|
153
|
+
telemetryClass: 'decision',
|
|
154
|
+
rolloutStage: 'off',
|
|
155
|
+
cacheSensitivity: 'none',
|
|
156
|
+
leasePolicy: 'preflight-bundle',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
decisionKey: 'preflight.lease.v1',
|
|
160
|
+
schemaVersion: 1,
|
|
161
|
+
family: 'preflight',
|
|
162
|
+
owner: 'taskRouting',
|
|
163
|
+
kind: 'choice',
|
|
164
|
+
description: 'Decision lease length from the owner-bounded lease choices.',
|
|
165
|
+
candidatePolicy: 'config-lease-choices',
|
|
166
|
+
hardConstraints: ['lease-choices'],
|
|
167
|
+
probabilityPolicy: 'raw-label',
|
|
168
|
+
fallbackPolicy: 'minimal-lease',
|
|
169
|
+
telemetryClass: 'decision',
|
|
170
|
+
rolloutStage: 'off',
|
|
171
|
+
cacheSensitivity: 'none',
|
|
172
|
+
leasePolicy: 'preflight-bundle',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
decisionKey: 'route.intent-kind.v1',
|
|
176
|
+
schemaVersion: 1,
|
|
177
|
+
family: 'route',
|
|
178
|
+
owner: 'taskRouting',
|
|
179
|
+
kind: 'choice',
|
|
180
|
+
description: 'Intent kind for the route: informational | mutation | investigation | review | delivery.',
|
|
181
|
+
candidatePolicy: 'owner-shortlist',
|
|
182
|
+
hardConstraints: ['lane-eligibility'],
|
|
183
|
+
probabilityPolicy: 'raw-label',
|
|
184
|
+
fallbackPolicy: 'deterministic-route',
|
|
185
|
+
telemetryClass: 'decision',
|
|
186
|
+
rolloutStage: 'off',
|
|
187
|
+
cacheSensitivity: 'none',
|
|
188
|
+
leasePolicy: 'none',
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
decisionKey: 'route.rigor.v1',
|
|
192
|
+
schemaVersion: 1,
|
|
193
|
+
family: 'rigor',
|
|
194
|
+
owner: 'taskRouting',
|
|
195
|
+
kind: 'score',
|
|
196
|
+
description: 'R0-R4 rigor recommendation inside deterministic floors.',
|
|
197
|
+
candidatePolicy: 'floor-filtered-rigor',
|
|
198
|
+
hardConstraints: ['risk-floor', 'monotonic-escalation'],
|
|
199
|
+
probabilityPolicy: 'raw-label',
|
|
200
|
+
fallbackPolicy: 'deterministic-floor',
|
|
201
|
+
telemetryClass: 'decision',
|
|
202
|
+
rolloutStage: 'off',
|
|
203
|
+
cacheSensitivity: 'none',
|
|
204
|
+
leasePolicy: 'none',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
decisionKey: 'capability.impact.v1',
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
family: 'capability',
|
|
210
|
+
owner: 'taskRouting',
|
|
211
|
+
kind: 'noul',
|
|
212
|
+
description: 'P(the impact/blast-radius capability should activate for this task).',
|
|
213
|
+
candidatePolicy: 'owner-shortlist',
|
|
214
|
+
hardConstraints: ['capability-availability', 'active-set-cap'],
|
|
215
|
+
probabilityPolicy: 'noul-threshold',
|
|
216
|
+
fallbackPolicy: 'deterministic-route',
|
|
217
|
+
telemetryClass: 'decision',
|
|
218
|
+
rolloutStage: 'off',
|
|
219
|
+
cacheSensitivity: 'none',
|
|
220
|
+
leasePolicy: 'none',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
decisionKey: 'verify.depth.v1',
|
|
224
|
+
schemaVersion: 1,
|
|
225
|
+
family: 'verify',
|
|
226
|
+
owner: 'verificationPlan',
|
|
227
|
+
kind: 'choice',
|
|
228
|
+
description: 'Verification depth among allowed levels.',
|
|
229
|
+
candidatePolicy: 'floor-filtered-depths',
|
|
230
|
+
hardConstraints: ['required-evidence'],
|
|
231
|
+
probabilityPolicy: 'raw-label',
|
|
232
|
+
fallbackPolicy: 'deterministic-verification-plan',
|
|
233
|
+
telemetryClass: 'decision',
|
|
234
|
+
rolloutStage: 'off',
|
|
235
|
+
cacheSensitivity: 'none',
|
|
236
|
+
leasePolicy: 'none',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
decisionKey: 'resume.next-action.v1',
|
|
240
|
+
schemaVersion: 1,
|
|
241
|
+
family: 'resume',
|
|
242
|
+
owner: 'resumableRun',
|
|
243
|
+
kind: 'choice',
|
|
244
|
+
description: 'Next valid resumable action at a continuation boundary.',
|
|
245
|
+
candidatePolicy: 'owner-shortlist',
|
|
246
|
+
hardConstraints: ['task-boundary', 'source-freshness'],
|
|
247
|
+
probabilityPolicy: 'raw-label',
|
|
248
|
+
fallbackPolicy: 'deterministic-continuation',
|
|
249
|
+
telemetryClass: 'decision',
|
|
250
|
+
rolloutStage: 'off',
|
|
251
|
+
cacheSensitivity: 'none',
|
|
252
|
+
leasePolicy: 'none',
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
decisionKey: 'learn.candidate-class.v1',
|
|
256
|
+
schemaVersion: 1,
|
|
257
|
+
family: 'learn',
|
|
258
|
+
owner: 'learningCandidates',
|
|
259
|
+
kind: 'choice',
|
|
260
|
+
description: 'Learning candidate classification for a captured signal.',
|
|
261
|
+
candidatePolicy: 'owner-shortlist',
|
|
262
|
+
hardConstraints: ['promotion-approval'],
|
|
263
|
+
probabilityPolicy: 'raw-label',
|
|
264
|
+
fallbackPolicy: 'deterministic-classifier',
|
|
265
|
+
telemetryClass: 'decision',
|
|
266
|
+
rolloutStage: 'off',
|
|
267
|
+
cacheSensitivity: 'none',
|
|
268
|
+
leasePolicy: 'none',
|
|
269
|
+
},
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
// Deep-freeze the shipped catalog — entries are shared metadata consumed by
|
|
273
|
+
// every registry instance, not scratch space a caller may mutate.
|
|
274
|
+
for (const entry of DECISION_REGISTRY) {
|
|
275
|
+
if (Array.isArray(entry.hardConstraints)) Object.freeze(entry.hardConstraints);
|
|
276
|
+
Object.freeze(entry);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isPlainObject(value) {
|
|
280
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function validateEntry(entry, index, seen) {
|
|
284
|
+
const label = isPlainObject(entry) && typeof entry.decisionKey === 'string'
|
|
285
|
+
? entry.decisionKey
|
|
286
|
+
: `entry[${index}]`;
|
|
287
|
+
const errors = [];
|
|
288
|
+
|
|
289
|
+
if (!isPlainObject(entry)) {
|
|
290
|
+
return [`${label}: entry must be a plain object.`];
|
|
291
|
+
}
|
|
292
|
+
for (const field of REQUIRED_STRING_FIELDS) {
|
|
293
|
+
if (typeof entry[field] !== 'string' || entry[field].length === 0) {
|
|
294
|
+
errors.push(`${label}: ${field} must be a non-empty string.`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (typeof entry.decisionKey === 'string' && entry.decisionKey.length > 0) {
|
|
298
|
+
if (!DECISION_KEY_PATTERN.test(entry.decisionKey)) {
|
|
299
|
+
errors.push(`${label}: decisionKey must be a stable dotted key ending in .vN.`);
|
|
300
|
+
}
|
|
301
|
+
if (seen.has(entry.decisionKey)) {
|
|
302
|
+
errors.push(`${label}: duplicate decisionKey.`);
|
|
303
|
+
}
|
|
304
|
+
seen.add(entry.decisionKey);
|
|
305
|
+
}
|
|
306
|
+
if (!Number.isInteger(entry.schemaVersion) || entry.schemaVersion < 1) {
|
|
307
|
+
errors.push(`${label}: schemaVersion must be a positive integer.`);
|
|
308
|
+
}
|
|
309
|
+
if (typeof entry.family === 'string' && !DECISION_FAMILIES.has(entry.family)) {
|
|
310
|
+
errors.push(`${label}: family must be one of: ${[...DECISION_FAMILIES].join(', ')}.`);
|
|
311
|
+
}
|
|
312
|
+
if (typeof entry.kind === 'string' && !DECISION_KINDS.has(entry.kind)) {
|
|
313
|
+
errors.push(`${label}: kind must be one of: ${[...DECISION_KINDS].join(', ')}.`);
|
|
314
|
+
}
|
|
315
|
+
if (entry.rolloutStage !== undefined && !VALID_ROUTE_STAGES.has(entry.rolloutStage)) {
|
|
316
|
+
errors.push(`${label}: rolloutStage must be one of: ${[...VALID_ROUTE_STAGES].join(', ')}.`);
|
|
317
|
+
}
|
|
318
|
+
if (entry.hardConstraints !== undefined && !Array.isArray(entry.hardConstraints)) {
|
|
319
|
+
errors.push(`${label}: hardConstraints must be an array.`);
|
|
320
|
+
}
|
|
321
|
+
if (entry.cacheSensitivity !== undefined && !CACHE_SENSITIVITY.has(entry.cacheSensitivity)) {
|
|
322
|
+
errors.push(`${label}: cacheSensitivity must be one of: ${[...CACHE_SENSITIVITY].join(', ')}.`);
|
|
323
|
+
}
|
|
324
|
+
return errors;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Create an isolated decision registry.
|
|
329
|
+
* @param {object[]} [entries] initial entries (defaults to DECISION_REGISTRY)
|
|
330
|
+
*/
|
|
331
|
+
export function createDecisionRegistry(entries = DECISION_REGISTRY) {
|
|
332
|
+
const list = [];
|
|
333
|
+
const byKey = new Map(); // first registration wins for lookups
|
|
334
|
+
|
|
335
|
+
function registerDecision(entry) {
|
|
336
|
+
if (!isPlainObject(entry)) {
|
|
337
|
+
throw new TypeError('registerDecision requires a plain object entry.');
|
|
338
|
+
}
|
|
339
|
+
list.push(entry);
|
|
340
|
+
if (typeof entry.decisionKey === 'string' && !byKey.has(entry.decisionKey)) {
|
|
341
|
+
byKey.set(entry.decisionKey, entry);
|
|
342
|
+
}
|
|
343
|
+
return entry;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function getDecision(decisionKey) {
|
|
347
|
+
return byKey.get(decisionKey) ?? null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function listDecisions({ family = null, stage = null } = {}) {
|
|
351
|
+
return list.filter(
|
|
352
|
+
(entry) =>
|
|
353
|
+
(family === null || entry.family === family) &&
|
|
354
|
+
(stage === null || entry.rolloutStage === stage),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function validateRegistry() {
|
|
359
|
+
const seen = new Set();
|
|
360
|
+
const errors = list.flatMap((entry, index) => validateEntry(entry, index, seen));
|
|
361
|
+
return { valid: errors.length === 0, errors };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for (const entry of entries) registerDecision(entry);
|
|
365
|
+
return { entries: list, registerDecision, getDecision, listDecisions, validateRegistry };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const defaultRegistry = createDecisionRegistry();
|
|
369
|
+
|
|
370
|
+
export function registerDecision(entry) {
|
|
371
|
+
return defaultRegistry.registerDecision(entry);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function getDecision(decisionKey) {
|
|
375
|
+
return defaultRegistry.getDecision(decisionKey);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function listDecisions(filter = {}) {
|
|
379
|
+
return defaultRegistry.listDecisions(filter);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function validateRegistry() {
|
|
383
|
+
return defaultRegistry.validateRegistry();
|
|
384
|
+
}
|