@ngockhoale/ukit 2.7.12 → 2.8.0
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 +13 -0
- package/manifests/documentation.yaml +11 -0
- package/manifests/platform.full.yaml +182 -0
- package/manifests/platform.user.yaml +53 -0
- package/package.json +3 -1
- package/src/cli/commands/diff.js +4 -2
- package/src/cli/commands/doctor.js +22 -1
- package/src/cli/commands/install.js +10 -0
- package/src/cli/commands/memory.js +142 -3
- package/src/cli/commands/playbook.js +53 -0
- package/src/cli/index.js +7 -0
- package/src/core/memory/recordStore.js +81 -0
- package/src/core/memory/storeV2.js +16 -52
- package/src/core/memory/userMemory.js +111 -0
- package/src/core/paths.js +1 -0
- package/src/core/runInstallPipeline.js +96 -3
- package/src/core/runtimeConfig.js +170 -5
- package/src/core/userPaths.js +21 -0
- package/src/core/userPlaybooks.js +185 -0
- package/src/index/taskRouting.js +422 -21
- package/src/index/verificationPlan.js +17 -0
- package/src/manifest/validateManifest.js +19 -0
- package/templates/.claude/config/providers.md +1 -3
- package/templates/.claude/skills/principle-attack-the-premise/SKILL.md +16 -0
- package/templates/.claude/skills/principle-boundary-discipline/SKILL.md +16 -0
- package/templates/.claude/skills/principle-encode-lessons-in-structure/SKILL.md +16 -0
- package/templates/.claude/skills/principle-fix-root-causes/SKILL.md +18 -0
- package/templates/.claude/skills/principle-foundational-thinking/SKILL.md +17 -0
- package/templates/.claude/skills/principle-guard-the-context-window/SKILL.md +16 -0
- package/templates/.claude/skills/principle-laziness-protocol/SKILL.md +17 -0
- package/templates/.claude/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +16 -0
- package/templates/.claude/skills/principle-minimize-reader-load/SKILL.md +17 -0
- package/templates/.claude/skills/principle-model-the-domain/SKILL.md +16 -0
- package/templates/.claude/skills/principle-never-block-on-the-human/SKILL.md +16 -0
- package/templates/.claude/skills/principle-prove-it-works/SKILL.md +18 -0
- package/templates/.claude/skills/principle-sequence-verifiable-units/SKILL.md +16 -0
- package/templates/.claude/skills/principle-subtract-before-you-add/SKILL.md +16 -0
- package/templates/.claude/skills/principle-test-behavior-not-implementation/SKILL.md +18 -0
- package/templates/.claude/ukit/index/route-task.mjs +652 -28
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +238 -9
- package/templates/ukit/README.md +31 -0
- package/templates/ukit/storage/config.json +10 -0
- package/templates/user/README.md +21 -0
- package/templates/user/playbooks/bug-fix.md +18 -0
- package/templates/user/playbooks/issue-implementation.md +14 -0
- package/templates/user/storage/config.json +16 -0
|
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
|
|
|
5
5
|
// module-bottom direct-CLI check. Normal-path fs stays async.
|
|
6
6
|
import * as fsSync from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
8
9
|
import crypto from 'node:crypto';
|
|
9
10
|
import { spawnSync } from 'node:child_process';
|
|
10
11
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -54,6 +55,438 @@ function deriveContextDocs({ taskType = null, intentMode = null } = {}) {
|
|
|
54
55
|
];
|
|
55
56
|
return unique(docs).slice(0, CONTEXT_DOCS_MAX);
|
|
56
57
|
}
|
|
58
|
+
|
|
59
|
+
// --- M01.1: additive ResolvedTaskRoute v1 (docs/pstack/CONTRACTS.md C01) -------------------
|
|
60
|
+
// Literal mirror of the canonical block in src/index/taskRouting.js — this file cannot
|
|
61
|
+
// import from src/, so the copy is locked by tests/consistency/executionContractSync.test.js.
|
|
62
|
+
// Emitted only when `routing.routeSchema.stage` (runtime config, default "off") is not
|
|
63
|
+
// "off". All fields are additive on top of the existing routeSummary shape — legacy
|
|
64
|
+
// top-level fields are never removed or renamed, so older consumers keep working.
|
|
65
|
+
export const ROUTE_VERSION = 1;
|
|
66
|
+
export const ROUTE_CONTRACT_VERSION = 1;
|
|
67
|
+
export const ROUTE_SCHEMA_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
|
|
68
|
+
export const ROUTE_INTENT_KINDS = new Set(['informational', 'delivery', 'mutation', 'investigation', 'review']);
|
|
69
|
+
export const ROUTE_MUTABILITIES = new Set(['read-only', 'mutating', 'mixed']);
|
|
70
|
+
export const ROUTE_RIGOR_LEVELS = new Set(['R0', 'R1', 'R2', 'R3', 'R4']);
|
|
71
|
+
export const ROUTE_MODEL_TIERS = new Set(['lite', 'code', 'smart']);
|
|
72
|
+
// 'informational' is a real router emission (no completion contract) even though it is
|
|
73
|
+
// not part of the seven-lane execution-mode ladder.
|
|
74
|
+
export const ROUTE_EXECUTION_MODES = [
|
|
75
|
+
'tiny-fix',
|
|
76
|
+
'local-fix',
|
|
77
|
+
'local-build',
|
|
78
|
+
'find-cause',
|
|
79
|
+
'shared-edit',
|
|
80
|
+
'map-impact',
|
|
81
|
+
'review-release',
|
|
82
|
+
'informational',
|
|
83
|
+
];
|
|
84
|
+
const ROUTE_ESCALATION_CEILING = 'review-release';
|
|
85
|
+
const ROUTE_GOAL_MAX_LENGTH = 240;
|
|
86
|
+
|
|
87
|
+
// --- v3 route-side advisory blocks (docs/pstack/SPEC-playbook-todo.md, ------------------
|
|
88
|
+
// SPEC-principle-index.md, SPEC-model-roles.md). Literal mirror of the canonical block in
|
|
89
|
+
// src/index/taskRouting.js — this file cannot import from src/, so the copy is locked by
|
|
90
|
+
// tests/consistency/executionContractSync.test.js. All three are additive stdout text —
|
|
91
|
+
// they never change mode selection. Static text is byte-stable for prompt-cache reuse.
|
|
92
|
+
|
|
93
|
+
// SPEC-playbook-todo §2.1: numbered step lists the model copies verbatim into its todo
|
|
94
|
+
// list. Header carries the "new task" re-match rule (§2.5); tail carries the narrowed
|
|
95
|
+
// ask-surface (§2.4). Emitted on matching lanes only (§2.2).
|
|
96
|
+
export const WORKFLOW_POLICIES = Object.freeze({
|
|
97
|
+
'bug-fix': `You own this bug. Reproduce, root-cause, fix, verify on the same surface.
|
|
98
|
+
If the user says "new task", re-route — do not treat the message as the next step.
|
|
99
|
+
1. Reproduce it yourself on the matching surface — ask the user only with a stated
|
|
100
|
+
reason the surface cannot reach the target.
|
|
101
|
+
2. Binary-search the cause: form candidate hypotheses, rule them out until one
|
|
102
|
+
survives; confirm the surviving mechanism with runtime evidence.
|
|
103
|
+
3. Make the smallest fix that kills the mechanism — belt-and-suspenders that
|
|
104
|
+
"might help" is a hypothesis, not a fix; it does not ship.
|
|
105
|
+
4. Verify on the same surface: the original repro now passes. "Inconclusive" or
|
|
106
|
+
wrong-surface is not a pass. A unit test shows branch behavior, not bug absence.
|
|
107
|
+
5. Keep the rejected hypotheses — one line each, why ruled out.
|
|
108
|
+
Reply: what was broken, root cause, fix, how verified — paste failing-then-passing
|
|
109
|
+
repro output verbatim.
|
|
110
|
+
Ask the human only for: irreversible writes, a genuine preference call no experiment settles, or a real dead end. Everything else: do it, report it.`,
|
|
111
|
+
'issue-implementation': `You own this task. Normalize the goal, build, verify.
|
|
112
|
+
If the user says "new task", re-route — do not treat the message as the next step.
|
|
113
|
+
1. State the done condition as a checkable predicate before writing code.
|
|
114
|
+
2. Find the established analog — follow it unless you name why it does not fit.
|
|
115
|
+
3. Name the data shape and its organizing structure before writing logic.
|
|
116
|
+
4. Implement the smallest change satisfying the predicate.
|
|
117
|
+
5. Verify against the predicate on the real artifact — not "it compiles".
|
|
118
|
+
6. Widen once: check the impact surface the route named, no broader.
|
|
119
|
+
Reply: what changed, the predicate, the evidence it now holds.
|
|
120
|
+
Ask the human only for: irreversible writes, a genuine preference call no experiment settles, or a real dead end. Everything else: do it, report it.`,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Lane → policy (§2.2). tiny-fix/local-fix stay lean (Fast Path); review-release and
|
|
124
|
+
// informational carry no policy.
|
|
125
|
+
export const WORKFLOW_POLICY_BY_MODE = Object.freeze({
|
|
126
|
+
'find-cause': 'bug-fix',
|
|
127
|
+
'local-build': 'issue-implementation',
|
|
128
|
+
'shared-edit': 'issue-implementation',
|
|
129
|
+
'map-impact': 'issue-implementation',
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// SPEC-principle-index §2.1: ~16-line index, one line per principle; group headers are
|
|
133
|
+
// the index's own compression. §2.3 citation contract is the trailing line.
|
|
134
|
+
export const PRINCIPLE_INDEX = `Principles (cite only leaves read this session; name the decision it changed):
|
|
135
|
+
Core — laziness-protocol: smallest change that solves it; bias to deletion.
|
|
136
|
+
foundational-thinking: pick core types/data structures before logic.
|
|
137
|
+
subtract-before-you-add: remove dead weight before building.
|
|
138
|
+
minimize-reader-load: collapse one-caller wrappers; shrink mutable scope.
|
|
139
|
+
attack-the-premise: 2+ fixes sharing one premise failed → question the premise.
|
|
140
|
+
Arch — model-the-domain: encode domain in structure, not scattered conditionals.
|
|
141
|
+
boundary-discipline: guards at boundaries; pure functions inside.
|
|
142
|
+
migrate-callers-then-delete: migrate every caller, delete old API same wave.
|
|
143
|
+
Verify — prove-it-works: verify the real artifact, not proxy/self-report/compiles.
|
|
144
|
+
fix-root-causes: reproduce first; no nil-guards that silence crashes.
|
|
145
|
+
sequence-verifiable-units: small units each ending verifiable.
|
|
146
|
+
test-behavior-not-implementation: assert what consumers observe.
|
|
147
|
+
Exec — guard-the-context-window: bulk to subagents; summaries in main thread.
|
|
148
|
+
never-block-on-the-human: proceed, report, course-correct after.
|
|
149
|
+
encode-lessons-in-structure: rule → lint/flag/script, not more prose.
|
|
150
|
+
When a principle shapes a decision, cite it and the choice it changed — only if you read the leaf this session.`;
|
|
151
|
+
|
|
152
|
+
// SPEC-model-roles §2.2: lanes that may delegate get the role block; Fast Path lanes
|
|
153
|
+
// (tiny-fix/local-fix) never delegate so the block would be dead context there.
|
|
154
|
+
export const MODEL_ROLES_DELEGATING_MODES = new Set([
|
|
155
|
+
'local-build',
|
|
156
|
+
'find-cause',
|
|
157
|
+
'shared-edit',
|
|
158
|
+
'map-impact',
|
|
159
|
+
'review-release',
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
// Principle index emission (§2.1): every non-trivial route — taskType non-trivial/
|
|
163
|
+
// shared-simple, or any mutating lane — except the Fast Path lanes, where a one-line
|
|
164
|
+
// edit does not need a principle index.
|
|
165
|
+
export function shouldEmitPrincipleIndex({ taskType = null, executionMode = null } = {}) {
|
|
166
|
+
if (executionMode === 'tiny-fix' || executionMode === 'local-fix') {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
if (taskType === 'non-trivial' || taskType === 'shared-simple') {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return Boolean(executionMode) && executionMode !== 'informational';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// SPEC-model-roles §2.1 mirror of src/core/runtimeConfig.js — role labels name WHAT the
|
|
176
|
+
// model is for. Values are cost-tier names or 'inherit-parent' — never raw provider
|
|
177
|
+
// model IDs. Arrays = panels (one agent per entry; list length sets the panel count).
|
|
178
|
+
export const MODEL_ROLE_TIERS = new Set(['lite', 'code', 'smart', 'vision']);
|
|
179
|
+
export const MODEL_ROLE_INHERIT = 'inherit-parent';
|
|
180
|
+
export const DEFAULT_MODEL_ROLES = Object.freeze({
|
|
181
|
+
code: 'code',
|
|
182
|
+
judgment: 'smart',
|
|
183
|
+
'review-panel': ['smart', 'code', 'lite'],
|
|
184
|
+
'fast-worker': 'lite',
|
|
185
|
+
vision: 'vision',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
function isValidModelRoleValue(value) {
|
|
189
|
+
if (typeof value === 'string') {
|
|
190
|
+
return MODEL_ROLE_TIERS.has(value) || value === MODEL_ROLE_INHERIT;
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(value)) {
|
|
193
|
+
return value.length > 0
|
|
194
|
+
&& value.every((entry) => MODEL_ROLE_TIERS.has(entry) || entry === MODEL_ROLE_INHERIT);
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Resolves the effective role→tier map. Invalid entries warn + fall back to the
|
|
200
|
+
// default for that role — never block, never throw. Unknown role names are dropped
|
|
201
|
+
// with a warning so a stale config cannot invent roles the skills don't speak.
|
|
202
|
+
export function resolveModelRoles(config = null) {
|
|
203
|
+
const warnings = [];
|
|
204
|
+
const resolved = {
|
|
205
|
+
code: DEFAULT_MODEL_ROLES.code,
|
|
206
|
+
judgment: DEFAULT_MODEL_ROLES.judgment,
|
|
207
|
+
'review-panel': [...DEFAULT_MODEL_ROLES['review-panel']],
|
|
208
|
+
'fast-worker': DEFAULT_MODEL_ROLES['fast-worker'],
|
|
209
|
+
vision: DEFAULT_MODEL_ROLES.vision,
|
|
210
|
+
};
|
|
211
|
+
const configured = config?.modelRoles;
|
|
212
|
+
if (configured === undefined || configured === null) {
|
|
213
|
+
return { roles: resolved, warnings };
|
|
214
|
+
}
|
|
215
|
+
if (configured === null || typeof configured !== 'object' || Array.isArray(configured)) {
|
|
216
|
+
warnings.push('modelRoles must be an object; using defaults.');
|
|
217
|
+
return { roles: resolved, warnings };
|
|
218
|
+
}
|
|
219
|
+
for (const [role, value] of Object.entries(configured)) {
|
|
220
|
+
if (!(role in DEFAULT_MODEL_ROLES)) {
|
|
221
|
+
warnings.push(`modelRoles.${role} is not a known role; ignored.`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!isValidModelRoleValue(value)) {
|
|
225
|
+
warnings.push(`modelRoles.${role} must be a tier name, 'inherit-parent', or a non-empty array of those; using default.`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
resolved[role] = Array.isArray(value) ? [...value] : value;
|
|
229
|
+
}
|
|
230
|
+
return { roles: resolved, warnings };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Formats the model-roles injection block from the resolved role map. Byte-stable for
|
|
234
|
+
// unchanged config (fixed role order, no timestamps).
|
|
235
|
+
export function formatModelRolesBlock(roles = null) {
|
|
236
|
+
const resolved = roles ?? resolveModelRoles(null).roles;
|
|
237
|
+
const panel = Array.isArray(resolved['review-panel'])
|
|
238
|
+
? resolved['review-panel'].join('+')
|
|
239
|
+
: String(resolved['review-panel']);
|
|
240
|
+
return `Model roles: code→${resolved.code}, judgment→${resolved.judgment}, review-panel→${panel},
|
|
241
|
+
fast-worker→${resolved['fast-worker']}, vision→${resolved.vision}. Delegate via role name; inherit-parent = same model as parent.`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Stage keys treat absence as "off" (MIGRATION_ROLLBACK named-keys table). Unknown or
|
|
245
|
+
// malformed values degrade to "off" — the conservative reading that keeps the route
|
|
246
|
+
// byte-identical to the pre-M01.1 shape.
|
|
247
|
+
export function resolveRouteSchemaStage(config = null) {
|
|
248
|
+
const stage = config?.routing?.routeSchema?.stage;
|
|
249
|
+
return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
|
|
253
|
+
// repository mutation the ledger could ever receipt. With no edit/review/debug/build
|
|
254
|
+
// signal present, routing it to an investigation lane fabricates write debt and the
|
|
255
|
+
// completion gate then demands an edit that cannot exist. Extracted from
|
|
256
|
+
// deriveExecutionMode so the C01 intent.kind mapping can reuse the identical predicate
|
|
257
|
+
// (delivery-only → 'delivery') instead of duplicating the regexes.
|
|
258
|
+
function isDeliveryOnlyRequest({ signalText = '', scores = {}, targetFile = null } = {}) {
|
|
259
|
+
const signalRaw = String(signalText || '').toLowerCase();
|
|
260
|
+
const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
|
|
261
|
+
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
|
|
262
|
+
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
|
|
263
|
+
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
|
|
264
|
+
return deliveryWordSignal
|
|
265
|
+
&& scores.editCertainty === 0
|
|
266
|
+
&& !scores.implementSignal
|
|
267
|
+
&& !scores.reviewSignal
|
|
268
|
+
&& !scores.debugSignal
|
|
269
|
+
&& !scores.failureSignal
|
|
270
|
+
&& !scores.impactSignal
|
|
271
|
+
&& !scores.buildSignal
|
|
272
|
+
&& !scores.directTransformSignal
|
|
273
|
+
&& !scores.smallFixSignal
|
|
274
|
+
&& !scores.sharedRisk
|
|
275
|
+
&& !targetFile;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// CONTRACTS.md "Intent vocabulary mapping": taskType informs mode priors, not
|
|
279
|
+
// intent.kind; intentMode maps to kind as question/explanation → informational,
|
|
280
|
+
// ship/deliver → delivery, code change → mutation, root-cause/diagnosis →
|
|
281
|
+
// investigation, review/audit → review.
|
|
282
|
+
function deriveRouteIntentKind({ executionMode = null, intentMode = null, deliveryOnly = false } = {}) {
|
|
283
|
+
if (executionMode === 'find-cause') return 'investigation';
|
|
284
|
+
if (executionMode === 'review-release') return 'review';
|
|
285
|
+
if (executionMode === 'informational') return deliveryOnly ? 'delivery' : 'informational';
|
|
286
|
+
if (executionMode) return 'mutation';
|
|
287
|
+
if (intentMode === 'review-specific') return 'review';
|
|
288
|
+
if (intentMode === 'debug-specific') return 'investigation';
|
|
289
|
+
if (intentMode === 'implement-specific' || intentMode === 'docs-specific') return 'mutation';
|
|
290
|
+
return 'informational';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Existing read-only vs mutating classification: only the informational lane carries
|
|
294
|
+
// no write debt; every contract lane is mutating.
|
|
295
|
+
function deriveRouteMutability(executionMode = null) {
|
|
296
|
+
return executionMode && executionMode !== 'informational' ? 'mutating' : 'read-only';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function compactRouteGoal(text = '') {
|
|
300
|
+
const goal = String(text || '').trim();
|
|
301
|
+
if (!goal) return null;
|
|
302
|
+
return goal.length > ROUTE_GOAL_MAX_LENGTH ? `${goal.slice(0, ROUTE_GOAL_MAX_LENGTH)}…` : goal;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Builds the additive C01 groups for one resolved route. rigor stays null until
|
|
306
|
+
// M01.2 derives it; ceremonyBudget/capabilityPolicy are empty shaped objects M02/M01.2
|
|
307
|
+
// populate; escalation.current mirrors the selected mode.
|
|
308
|
+
function buildResolvedRouteFields({
|
|
309
|
+
routingContext = {},
|
|
310
|
+
activeSkillIds = [],
|
|
311
|
+
executionMode = null,
|
|
312
|
+
executionContract = null,
|
|
313
|
+
completionState = null,
|
|
314
|
+
} = {}) {
|
|
315
|
+
const signalText = buildNormalizedRouteSignalText(routingContext.promptText, routingContext.commandText);
|
|
316
|
+
const deliveryOnly = isDeliveryOnlyRequest({
|
|
317
|
+
signalText,
|
|
318
|
+
scores: routingContext.executionScores ?? {},
|
|
319
|
+
targetFile: routingContext.targetFile ?? null,
|
|
320
|
+
});
|
|
321
|
+
return {
|
|
322
|
+
routeVersion: ROUTE_VERSION,
|
|
323
|
+
intent: {
|
|
324
|
+
kind: deriveRouteIntentKind({
|
|
325
|
+
executionMode,
|
|
326
|
+
intentMode: routingContext.intentMode ?? null,
|
|
327
|
+
deliveryOnly,
|
|
328
|
+
}),
|
|
329
|
+
mutability: deriveRouteMutability(executionMode),
|
|
330
|
+
goal: compactRouteGoal(routingContext.lastExplicitUserPromptText ?? routingContext.promptText),
|
|
331
|
+
doneConditions: [],
|
|
332
|
+
},
|
|
333
|
+
execution: {
|
|
334
|
+
mode: executionMode,
|
|
335
|
+
rigor: null,
|
|
336
|
+
phase: null,
|
|
337
|
+
contractVersion: ROUTE_CONTRACT_VERSION,
|
|
338
|
+
modelTier: executionContract?.modelTier ?? null,
|
|
339
|
+
},
|
|
340
|
+
evidence: {
|
|
341
|
+
observations: [],
|
|
342
|
+
riskSignals: [],
|
|
343
|
+
activationReasons: [],
|
|
344
|
+
suppressionReasons: [],
|
|
345
|
+
completionRequirements: unique(completionState?.missingEvidence ?? []),
|
|
346
|
+
},
|
|
347
|
+
ceremonyBudget: {
|
|
348
|
+
policyVersion: ROUTE_CONTRACT_VERSION,
|
|
349
|
+
rigor: null,
|
|
350
|
+
limits: {},
|
|
351
|
+
consumed: {},
|
|
352
|
+
exceptions: [],
|
|
353
|
+
},
|
|
354
|
+
capabilityPolicy: {
|
|
355
|
+
policyVersion: ROUTE_CONTRACT_VERSION,
|
|
356
|
+
required: [],
|
|
357
|
+
recommended: [],
|
|
358
|
+
suppressed: [],
|
|
359
|
+
activeSkillIds: unique(activeSkillIds),
|
|
360
|
+
},
|
|
361
|
+
escalation: {
|
|
362
|
+
current: { mode: executionMode, rigor: null },
|
|
363
|
+
ceiling: ROUTE_ESCALATION_CEILING,
|
|
364
|
+
triggers: [],
|
|
365
|
+
history: [],
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Plain-JS validator for the additive C01 groups. Returns { valid, errors }; it never
|
|
371
|
+
// throws and never inspects legacy fields — old consumers may carry anything else.
|
|
372
|
+
export function validateResolvedRoute(route = null) {
|
|
373
|
+
const errors = [];
|
|
374
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
375
|
+
if (!isObject(route)) {
|
|
376
|
+
return { valid: false, errors: ['route must be an object.'] };
|
|
377
|
+
}
|
|
378
|
+
if (route.routeVersion !== ROUTE_VERSION) {
|
|
379
|
+
errors.push(`routeVersion must be ${ROUTE_VERSION}.`);
|
|
380
|
+
}
|
|
381
|
+
if (!isObject(route.intent)) {
|
|
382
|
+
errors.push('intent must be an object.');
|
|
383
|
+
} else {
|
|
384
|
+
if (!ROUTE_INTENT_KINDS.has(route.intent.kind)) {
|
|
385
|
+
errors.push(`intent.kind must be one of: ${[...ROUTE_INTENT_KINDS].join(', ')}.`);
|
|
386
|
+
}
|
|
387
|
+
if (!ROUTE_MUTABILITIES.has(route.intent.mutability)) {
|
|
388
|
+
errors.push(`intent.mutability must be one of: ${[...ROUTE_MUTABILITIES].join(', ')}.`);
|
|
389
|
+
}
|
|
390
|
+
if (route.intent.goal !== null && typeof route.intent.goal !== 'string') {
|
|
391
|
+
errors.push('intent.goal must be a string or null.');
|
|
392
|
+
}
|
|
393
|
+
if (!Array.isArray(route.intent.doneConditions)) {
|
|
394
|
+
errors.push('intent.doneConditions must be an array.');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (!isObject(route.execution)) {
|
|
398
|
+
errors.push('execution must be an object.');
|
|
399
|
+
} else {
|
|
400
|
+
if (route.execution.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.execution.mode)) {
|
|
401
|
+
errors.push(`execution.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
|
|
402
|
+
}
|
|
403
|
+
if (route.execution.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.execution.rigor)) {
|
|
404
|
+
errors.push(`execution.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
|
|
405
|
+
}
|
|
406
|
+
if (route.execution.contractVersion !== ROUTE_CONTRACT_VERSION) {
|
|
407
|
+
errors.push(`execution.contractVersion must be ${ROUTE_CONTRACT_VERSION}.`);
|
|
408
|
+
}
|
|
409
|
+
if (route.execution.modelTier !== null && !ROUTE_MODEL_TIERS.has(route.execution.modelTier)) {
|
|
410
|
+
errors.push(`execution.modelTier must be null or one of: ${[...ROUTE_MODEL_TIERS].join(', ')}.`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (!isObject(route.evidence)) {
|
|
414
|
+
errors.push('evidence must be an object.');
|
|
415
|
+
} else {
|
|
416
|
+
for (const key of ['observations', 'riskSignals', 'activationReasons', 'suppressionReasons', 'completionRequirements']) {
|
|
417
|
+
if (!Array.isArray(route.evidence[key])) {
|
|
418
|
+
errors.push(`evidence.${key} must be an array.`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (!isObject(route.ceremonyBudget)) {
|
|
423
|
+
errors.push('ceremonyBudget must be an object.');
|
|
424
|
+
} else {
|
|
425
|
+
if (route.ceremonyBudget.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.ceremonyBudget.rigor)) {
|
|
426
|
+
errors.push(`ceremonyBudget.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
|
|
427
|
+
}
|
|
428
|
+
if (!isObject(route.ceremonyBudget.limits)) {
|
|
429
|
+
errors.push('ceremonyBudget.limits must be an object.');
|
|
430
|
+
}
|
|
431
|
+
if (!isObject(route.ceremonyBudget.consumed)) {
|
|
432
|
+
errors.push('ceremonyBudget.consumed must be an object.');
|
|
433
|
+
}
|
|
434
|
+
if (!Array.isArray(route.ceremonyBudget.exceptions)) {
|
|
435
|
+
errors.push('ceremonyBudget.exceptions must be an array.');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!isObject(route.capabilityPolicy)) {
|
|
439
|
+
errors.push('capabilityPolicy must be an object.');
|
|
440
|
+
} else {
|
|
441
|
+
for (const key of ['required', 'recommended', 'suppressed', 'activeSkillIds']) {
|
|
442
|
+
if (!Array.isArray(route.capabilityPolicy[key])) {
|
|
443
|
+
errors.push(`capabilityPolicy.${key} must be an array.`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!isObject(route.escalation)) {
|
|
448
|
+
errors.push('escalation must be an object.');
|
|
449
|
+
} else {
|
|
450
|
+
if (!isObject(route.escalation.current)) {
|
|
451
|
+
errors.push('escalation.current must be an object.');
|
|
452
|
+
} else {
|
|
453
|
+
if (route.escalation.current.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.escalation.current.mode)) {
|
|
454
|
+
errors.push(`escalation.current.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
|
|
455
|
+
}
|
|
456
|
+
if (route.escalation.current.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.escalation.current.rigor)) {
|
|
457
|
+
errors.push(`escalation.current.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!Array.isArray(route.escalation.triggers)) {
|
|
461
|
+
errors.push('escalation.triggers must be an array.');
|
|
462
|
+
}
|
|
463
|
+
if (!Array.isArray(route.escalation.history)) {
|
|
464
|
+
errors.push('escalation.history must be an array.');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return { valid: errors.length === 0, errors };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Compact serialization whitelist (C01 compatibility rule): adapter/runtime consumers
|
|
471
|
+
// get a bounded view that may omit verbose evidence but never mode, rigor, required
|
|
472
|
+
// completion evidence, or escalation state. Returns null for legacy (stage-off)
|
|
473
|
+
// summaries so compact output stays byte-identical when the schema stage is off.
|
|
474
|
+
export function compactResolvedRoute(routeSummary = null) {
|
|
475
|
+
if (!routeSummary || typeof routeSummary !== 'object' || routeSummary.routeVersion == null) {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
routeVersion: routeSummary.routeVersion,
|
|
480
|
+
intent: routeSummary.intent ?? null,
|
|
481
|
+
execution: routeSummary.execution ?? null,
|
|
482
|
+
evidence: {
|
|
483
|
+
completionRequirements: unique(routeSummary.evidence?.completionRequirements ?? []),
|
|
484
|
+
},
|
|
485
|
+
ceremonyBudget: routeSummary.ceremonyBudget ?? null,
|
|
486
|
+
capabilityPolicy: routeSummary.capabilityPolicy ?? null,
|
|
487
|
+
escalation: routeSummary.escalation ?? null,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
57
490
|
const STOPWORDS = new Set([
|
|
58
491
|
'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
|
|
59
492
|
'this', 'that', 'it', 'as', 'by', 'be', 'use', 'using', 'implement', 'fix', 'task',
|
|
@@ -153,7 +586,7 @@ async function resolveVisionModel({ rootDir = process.cwd() } = {}) {
|
|
|
153
586
|
gateway = null;
|
|
154
587
|
}
|
|
155
588
|
|
|
156
|
-
const config = await
|
|
589
|
+
const config = await readMergedConfig(rootDir);
|
|
157
590
|
const fallbackModel = config?.orchestration?.modelTiers?.vision?.fallbackModel;
|
|
158
591
|
|
|
159
592
|
if (gateway?.unicMode && gateway.aliasAvailable === false) {
|
|
@@ -424,10 +857,22 @@ async function buildRouteHotFingerprint({ rootDir, input, budget }) {
|
|
|
424
857
|
}
|
|
425
858
|
|
|
426
859
|
if (!payload.ioBudgetExhausted && !budget.expired()) {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
860
|
+
// Fingerprint both layers: a user-config edit must invalidate the hot cache the
|
|
861
|
+
// same way a project-config edit does.
|
|
862
|
+
const projectConfigStat = await countRouteIo(
|
|
863
|
+
budget,
|
|
864
|
+
statOrNothing(path.join(absoluteRoot, '.ukit', 'storage', 'config.json')),
|
|
865
|
+
);
|
|
866
|
+
const userConfigStat = await countRouteIo(
|
|
867
|
+
budget,
|
|
868
|
+
statOrNothing(path.join(os.homedir(), '.ukit', 'storage', 'config.json')),
|
|
869
|
+
);
|
|
870
|
+
payload.config = (projectConfigStat || userConfigStat)
|
|
871
|
+
? {
|
|
872
|
+
project: statFingerprintTuple(projectConfigStat),
|
|
873
|
+
user: statFingerprintTuple(userConfigStat),
|
|
874
|
+
content: await countRouteIo(budget, readMergedConfig(absoluteRoot)),
|
|
875
|
+
}
|
|
431
876
|
: null;
|
|
432
877
|
}
|
|
433
878
|
|
|
@@ -678,7 +1123,7 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
|
|
|
678
1123
|
// Phase 2 — bounded full computation. Mirrors main()'s pipeline minus the shared-state,
|
|
679
1124
|
// audit and helper-cache-seeding writes (main() stays the single writer for those).
|
|
680
1125
|
const escalationConfig = scan.configJson
|
|
681
|
-
?? await
|
|
1126
|
+
?? await readMergedConfig(absoluteRoot);
|
|
682
1127
|
const preparedRoute = await prepareTaskRoute({
|
|
683
1128
|
rootDir: absoluteRoot,
|
|
684
1129
|
promptText: normalizedInput.promptText,
|
|
@@ -812,7 +1257,7 @@ async function main() {
|
|
|
812
1257
|
const previousStateOwnershipBlocked = hasRouteStateOwner(persistedPreviousState)
|
|
813
1258
|
&& !isRouteStateCompatible(persistedPreviousState, sessionId);
|
|
814
1259
|
const previousState = previousStateOwnershipBlocked ? {} : persistedPreviousState;
|
|
815
|
-
const escalationConfig = await
|
|
1260
|
+
const escalationConfig = await readMergedConfig(rootDir);
|
|
816
1261
|
const targetFile = readFlagValue(args, '--target');
|
|
817
1262
|
const taskType = readFlagValue(args, '--type');
|
|
818
1263
|
const commandText = readFlagValue(args, '--tool-command') ?? '';
|
|
@@ -1207,12 +1652,21 @@ async function finalizeTaskRoute({
|
|
|
1207
1652
|
contextRecommendation,
|
|
1208
1653
|
verificationRecommendation,
|
|
1209
1654
|
});
|
|
1655
|
+
// SPEC FR-009 mirror: project playbook > user playbook > builtin. resolveWorkflowPolicy
|
|
1656
|
+
// already falls back to the builtin table, so null only means the lane has no policy.
|
|
1657
|
+
const workflowPolicyName = WORKFLOW_POLICY_BY_MODE[routingContext.executionMode] ?? null;
|
|
1658
|
+
const resolvedWorkflowPolicy = workflowPolicyName
|
|
1659
|
+
? await resolveWorkflowPolicy(workflowPolicyName, { projectRoot: absoluteRoot })
|
|
1660
|
+
: null;
|
|
1210
1661
|
const routeSummary = buildRouteSummary({
|
|
1211
1662
|
activeSkills,
|
|
1212
1663
|
routingContext,
|
|
1213
1664
|
contextRecommendation,
|
|
1214
1665
|
verificationRecommendation,
|
|
1215
1666
|
nextAction,
|
|
1667
|
+
routeSchemaStage: resolveRouteSchemaStage(escalationConfig),
|
|
1668
|
+
runtimeConfig: escalationConfig,
|
|
1669
|
+
resolvedWorkflowPolicy,
|
|
1216
1670
|
});
|
|
1217
1671
|
routeSummary.continuationState = advanceContinuationState(
|
|
1218
1672
|
routeSummary.continuationState,
|
|
@@ -1289,6 +1743,20 @@ function printRouteState(state) {
|
|
|
1289
1743
|
if (helperDisplay) {
|
|
1290
1744
|
console.log(`helper: ${helperDisplay}`);
|
|
1291
1745
|
}
|
|
1746
|
+
// v3 advisory blocks (SPEC-playbook-todo / SPEC-principle-index / SPEC-model-roles):
|
|
1747
|
+
// additive stdout text, byte-stable for unchanged config.
|
|
1748
|
+
if (state.routeSummary?.workflowPolicyName) {
|
|
1749
|
+
console.log(`workflow: ${state.routeSummary.workflowPolicyName} — copy steps verbatim into your todo list; skipped steps keep skip:<reason>`);
|
|
1750
|
+
}
|
|
1751
|
+
if (state.routeSummary?.workflowPolicy) {
|
|
1752
|
+
console.log(state.routeSummary.workflowPolicy);
|
|
1753
|
+
}
|
|
1754
|
+
if (state.routeSummary?.principleIndex) {
|
|
1755
|
+
console.log(state.routeSummary.principleIndex);
|
|
1756
|
+
}
|
|
1757
|
+
if (state.routeSummary?.modelRolesBlock) {
|
|
1758
|
+
console.log(state.routeSummary.modelRolesBlock);
|
|
1759
|
+
}
|
|
1292
1760
|
}
|
|
1293
1761
|
|
|
1294
1762
|
function hasRouteSummaryField(routeSummary, field) {
|
|
@@ -2147,6 +2615,9 @@ function buildRouteSummary({
|
|
|
2147
2615
|
verificationRecommendation = null,
|
|
2148
2616
|
nextAction = null,
|
|
2149
2617
|
contextDocs = null,
|
|
2618
|
+
routeSchemaStage = 'off',
|
|
2619
|
+
runtimeConfig = null,
|
|
2620
|
+
resolvedWorkflowPolicy = null,
|
|
2150
2621
|
} = {}) {
|
|
2151
2622
|
const autonomyLevel = routingContext.autonomyLevel ?? 'balanced';
|
|
2152
2623
|
const delegationRecommendation = deriveDelegationRecommendation({
|
|
@@ -2201,6 +2672,15 @@ function buildRouteSummary({
|
|
|
2201
2672
|
nextActionType: nextAction?.type ?? null,
|
|
2202
2673
|
completionState,
|
|
2203
2674
|
});
|
|
2675
|
+
const resolvedRouteFields = routeSchemaStage !== 'off'
|
|
2676
|
+
? buildResolvedRouteFields({
|
|
2677
|
+
routingContext,
|
|
2678
|
+
activeSkillIds: activeSkills.map((entry) => entry.id),
|
|
2679
|
+
executionMode,
|
|
2680
|
+
executionContract,
|
|
2681
|
+
completionState,
|
|
2682
|
+
})
|
|
2683
|
+
: null;
|
|
2204
2684
|
const helperHint = compactHelperHint(
|
|
2205
2685
|
compactHelperLane
|
|
2206
2686
|
? contextRecommendation?.command
|
|
@@ -2221,6 +2701,19 @@ function buildRouteSummary({
|
|
|
2221
2701
|
const docsSegment = resolvedContextDocs.length > 0
|
|
2222
2702
|
? `docs=[${resolvedContextDocs.slice(0, CONTEXT_DOCS_MAX).map((p) => path.posix.basename(String(p).replaceAll('\\', '/'))).join(',')}]`
|
|
2223
2703
|
: null;
|
|
2704
|
+
// v3 advisory blocks (SPEC-playbook-todo / SPEC-principle-index / SPEC-model-roles):
|
|
2705
|
+
// additive stdout text only — never change mode selection.
|
|
2706
|
+
const workflowPolicyName = WORKFLOW_POLICY_BY_MODE[executionMode] ?? null;
|
|
2707
|
+
const workflowPolicy = workflowPolicyName
|
|
2708
|
+
? (resolvedWorkflowPolicy?.body ?? WORKFLOW_POLICIES[workflowPolicyName])
|
|
2709
|
+
: null;
|
|
2710
|
+
const principleIndex = shouldEmitPrincipleIndex({ taskType, executionMode })
|
|
2711
|
+
? PRINCIPLE_INDEX
|
|
2712
|
+
: null;
|
|
2713
|
+
const modelRolesResolution = resolveModelRoles(runtimeConfig);
|
|
2714
|
+
const modelRolesBlock = MODEL_ROLES_DELEGATING_MODES.has(executionMode)
|
|
2715
|
+
? formatModelRolesBlock(modelRolesResolution.roles)
|
|
2716
|
+
: null;
|
|
2224
2717
|
const line = [
|
|
2225
2718
|
routingContext.taskType ? `task=${routingContext.taskType}` : null,
|
|
2226
2719
|
handoffFile ? `handoff=${handoffFile}` : null,
|
|
@@ -2231,6 +2724,7 @@ function buildRouteSummary({
|
|
|
2231
2724
|
editGuardHint ? `editGuard=${editGuardHint}` : null,
|
|
2232
2725
|
delegationRecommendation?.hint ? `delegate=${delegationRecommendation.hint}` : null,
|
|
2233
2726
|
policyMode ? `policy=${policyMode}` : null,
|
|
2727
|
+
workflowPolicyName ? `workflow=${workflowPolicyName}` : null,
|
|
2234
2728
|
].filter(Boolean).join(' | ');
|
|
2235
2729
|
|
|
2236
2730
|
return {
|
|
@@ -2246,6 +2740,8 @@ function buildRouteSummary({
|
|
|
2246
2740
|
executionContract,
|
|
2247
2741
|
tierLane,
|
|
2248
2742
|
completionState,
|
|
2743
|
+
// M01.1 additive C01 groups — present only when routing.routeSchema.stage != "off".
|
|
2744
|
+
...(resolvedRouteFields ?? {}),
|
|
2249
2745
|
continuationState,
|
|
2250
2746
|
autonomyLevel,
|
|
2251
2747
|
continuousExecution,
|
|
@@ -2258,6 +2754,13 @@ function buildRouteSummary({
|
|
|
2258
2754
|
helperHint,
|
|
2259
2755
|
contextMode,
|
|
2260
2756
|
contextDocs: resolvedContextDocs,
|
|
2757
|
+
workflowPolicyName,
|
|
2758
|
+
workflowPolicy,
|
|
2759
|
+
// TASK-007: emitted only when a policy is emitted — the stage-off route shape
|
|
2760
|
+
// stays unchanged on lanes that carry no workflow policy.
|
|
2761
|
+
...(workflowPolicy ? { workflowPolicySource: resolvedWorkflowPolicy?.source ?? 'builtin' } : {}),
|
|
2762
|
+
principleIndex,
|
|
2763
|
+
modelRolesBlock,
|
|
2261
2764
|
...(visionLane ? {
|
|
2262
2765
|
visionLane: true,
|
|
2263
2766
|
visionReason: routingContext.visionReason ?? null,
|
|
@@ -2434,27 +2937,8 @@ function deriveExecutionMode({
|
|
|
2434
2937
|
const strongImpactLead = scores.sharedRisk
|
|
2435
2938
|
&& (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
|
|
2436
2939
|
const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
|
|
2437
|
-
//
|
|
2438
|
-
|
|
2439
|
-
// build signal present, routing it to an investigation lane fabricates write debt
|
|
2440
|
-
// and the completion gate then demands an edit that cannot exist. Delivery-only
|
|
2441
|
-
// requests carry no completion contract instead.
|
|
2442
|
-
const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
|
|
2443
|
-
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
|
|
2444
|
-
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
|
|
2445
|
-
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
|
|
2446
|
-
const deliveryOnlySignal = deliveryWordSignal
|
|
2447
|
-
&& scores.editCertainty === 0
|
|
2448
|
-
&& !scores.implementSignal
|
|
2449
|
-
&& !scores.reviewSignal
|
|
2450
|
-
&& !scores.debugSignal
|
|
2451
|
-
&& !scores.failureSignal
|
|
2452
|
-
&& !scores.impactSignal
|
|
2453
|
-
&& !scores.buildSignal
|
|
2454
|
-
&& !scores.directTransformSignal
|
|
2455
|
-
&& !scores.smallFixSignal
|
|
2456
|
-
&& !scores.sharedRisk
|
|
2457
|
-
&& !targetFile;
|
|
2940
|
+
// Delivery-only requests carry no completion contract (see isDeliveryOnlyRequest).
|
|
2941
|
+
const deliveryOnlySignal = isDeliveryOnlyRequest({ signalText: signalRaw, scores, targetFile });
|
|
2458
2942
|
|
|
2459
2943
|
if (isInformationalPrompt({ promptText, commandText, scores })) {
|
|
2460
2944
|
return 'informational';
|
|
@@ -3724,10 +4208,22 @@ function compactRouteSummary(routeSummary = null) {
|
|
|
3724
4208
|
nextActionType: routeSummary.nextActionType ?? null,
|
|
3725
4209
|
nextActionCommand: routeSummary.nextActionCommand ?? null,
|
|
3726
4210
|
helperHint: routeSummary.helperHint ?? null,
|
|
4211
|
+
// v3 advisory blocks: survive compaction so printRouteState and cached/shared route
|
|
4212
|
+
// states still emit them. Null when the lane suppresses them — cheap either way.
|
|
4213
|
+
workflowPolicyName: routeSummary.workflowPolicyName ?? null,
|
|
4214
|
+
workflowPolicy: routeSummary.workflowPolicy ?? null,
|
|
4215
|
+
...(routeSummary.workflowPolicy ? {
|
|
4216
|
+
workflowPolicySource: routeSummary.workflowPolicySource ?? 'builtin',
|
|
4217
|
+
} : {}),
|
|
4218
|
+
principleIndex: routeSummary.principleIndex ?? null,
|
|
4219
|
+
modelRolesBlock: routeSummary.modelRolesBlock ?? null,
|
|
3727
4220
|
...(routeSummary.escalatedTier ? {
|
|
3728
4221
|
escalatedTier: routeSummary.escalatedTier,
|
|
3729
4222
|
escalationReason: routeSummary.escalationReason ?? null,
|
|
3730
4223
|
} : {}),
|
|
4224
|
+
// M01.1 compact whitelist (C01): grouped fields survive compaction minus verbose
|
|
4225
|
+
// evidence; absent entirely when the route schema stage is off.
|
|
4226
|
+
...(compactResolvedRoute(routeSummary) ?? {}),
|
|
3731
4227
|
...(routeSummary.visionLane ? {
|
|
3732
4228
|
visionLane: true,
|
|
3733
4229
|
visionReason: routeSummary.visionReason ?? null,
|
|
@@ -4063,6 +4559,134 @@ async function readJson(filePath, fallback = null) {
|
|
|
4063
4559
|
}
|
|
4064
4560
|
}
|
|
4065
4561
|
|
|
4562
|
+
// --- User-level .ukit layer (SPEC §5 FR-006/FR-009) -------------------------------
|
|
4563
|
+
// Self-contained mirror of src/core/runtimeConfig.js merge semantics and
|
|
4564
|
+
// src/core/userPlaybooks.js resolution — this file cannot import from src/.
|
|
4565
|
+
const BLOCKED_MERGE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
4566
|
+
const PROJECT_MANAGED_USER_KEYS = new Set(['version', 'agent']);
|
|
4567
|
+
const warnedManagedUserKeys = new Set();
|
|
4568
|
+
|
|
4569
|
+
function isPlainObject(value) {
|
|
4570
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
4571
|
+
}
|
|
4572
|
+
|
|
4573
|
+
// Plain-object recursion; arrays and scalars are replaced wholesale. `override` wins.
|
|
4574
|
+
function mergeConfigObjects(base, override) {
|
|
4575
|
+
if (!isPlainObject(base) || !isPlainObject(override)) {
|
|
4576
|
+
return override === undefined ? base : override;
|
|
4577
|
+
}
|
|
4578
|
+
const merged = { ...base };
|
|
4579
|
+
for (const [key, value] of Object.entries(override)) {
|
|
4580
|
+
if (BLOCKED_MERGE_KEYS.has(key)) {
|
|
4581
|
+
continue;
|
|
4582
|
+
}
|
|
4583
|
+
const current = merged[key];
|
|
4584
|
+
if (isPlainObject(current) && isPlainObject(value)) {
|
|
4585
|
+
merged[key] = mergeConfigObjects(current, value);
|
|
4586
|
+
continue;
|
|
4587
|
+
}
|
|
4588
|
+
merged[key] = value;
|
|
4589
|
+
}
|
|
4590
|
+
return merged;
|
|
4591
|
+
}
|
|
4592
|
+
|
|
4593
|
+
// `version`/`agent` are project-managed: a stale user file must not pin a project to
|
|
4594
|
+
// an old version or a removed agent. Warn once per process per stripped key.
|
|
4595
|
+
function stripProjectManagedKeys(userRaw) {
|
|
4596
|
+
if (!isPlainObject(userRaw)) {
|
|
4597
|
+
return userRaw;
|
|
4598
|
+
}
|
|
4599
|
+
const stripped = { ...userRaw };
|
|
4600
|
+
for (const key of PROJECT_MANAGED_USER_KEYS) {
|
|
4601
|
+
if (!(key in stripped)) {
|
|
4602
|
+
continue;
|
|
4603
|
+
}
|
|
4604
|
+
delete stripped[key];
|
|
4605
|
+
if (!warnedManagedUserKeys.has(key)) {
|
|
4606
|
+
warnedManagedUserKeys.add(key);
|
|
4607
|
+
console.warn(`[UKit] Ignoring project-managed key "${key}" in user-level config (~/.ukit/storage/config.json).`);
|
|
4608
|
+
}
|
|
4609
|
+
}
|
|
4610
|
+
return stripped;
|
|
4611
|
+
}
|
|
4612
|
+
|
|
4613
|
+
// Raw merged view (pre-validation): user layer under the project layer, project wins
|
|
4614
|
+
// every key. Missing or malformed files are skipped — never throws.
|
|
4615
|
+
async function readMergedConfig(rootDir) {
|
|
4616
|
+
const projectRaw = await readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
|
|
4617
|
+
const userRaw = await readJson(path.join(os.homedir(), '.ukit', 'storage', 'config.json'), null);
|
|
4618
|
+
return mergeConfigObjects(stripProjectManagedKeys(userRaw) ?? {}, projectRaw ?? {});
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
// Playbook file format (SPEC FR-009): optional `---` frontmatter then the markdown
|
|
4622
|
+
// body. Returns the trimmed body, or null when the file is missing, malformed, or
|
|
4623
|
+
// carries no usable body (caller falls through to the next precedence level).
|
|
4624
|
+
async function readPlaybookBody(filePath) {
|
|
4625
|
+
let raw;
|
|
4626
|
+
try {
|
|
4627
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
4628
|
+
} catch {
|
|
4629
|
+
return null;
|
|
4630
|
+
}
|
|
4631
|
+
let body = raw;
|
|
4632
|
+
if (raw.startsWith('---\n')) {
|
|
4633
|
+
// In `---\n---` the closing fence's leading \n is the newline that terminates the
|
|
4634
|
+
// opening fence; `---x` is not a closing fence.
|
|
4635
|
+
const fenceIndex = raw.indexOf('\n---', 3);
|
|
4636
|
+
if (fenceIndex === -1) {
|
|
4637
|
+
console.warn(`[UKit] Warning: skipping malformed playbook ${filePath}`);
|
|
4638
|
+
return null;
|
|
4639
|
+
}
|
|
4640
|
+
const after = raw.slice(fenceIndex + 4);
|
|
4641
|
+
if (after.length > 0 && !after.startsWith('\n')) {
|
|
4642
|
+
console.warn(`[UKit] Warning: skipping malformed playbook ${filePath}`);
|
|
4643
|
+
return null;
|
|
4644
|
+
}
|
|
4645
|
+
body = after.replace(/^\n/, '');
|
|
4646
|
+
}
|
|
4647
|
+
const trimmed = body.trim();
|
|
4648
|
+
if (trimmed === '') {
|
|
4649
|
+
console.warn(`[UKit] Warning: skipping malformed playbook ${filePath}`);
|
|
4650
|
+
return null;
|
|
4651
|
+
}
|
|
4652
|
+
return trimmed;
|
|
4653
|
+
}
|
|
4654
|
+
|
|
4655
|
+
function isSafePlaybookName(policyName) {
|
|
4656
|
+
return (
|
|
4657
|
+
typeof policyName === 'string'
|
|
4658
|
+
&& policyName !== ''
|
|
4659
|
+
&& !policyName.includes('/')
|
|
4660
|
+
&& !policyName.includes('\\')
|
|
4661
|
+
&& !policyName.includes('..')
|
|
4662
|
+
);
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4665
|
+
// Resolves a workflow policy through the playbook layer: project playbook > user
|
|
4666
|
+
// playbook > builtin table. Returns { body, source } or null when nothing matches.
|
|
4667
|
+
async function resolveWorkflowPolicy(policyName, { projectRoot } = {}) {
|
|
4668
|
+
if (!isSafePlaybookName(policyName)) {
|
|
4669
|
+
return null;
|
|
4670
|
+
}
|
|
4671
|
+
for (const [dir, source] of [
|
|
4672
|
+
[projectRoot ? path.join(projectRoot, '.ukit', 'playbooks') : null, 'project'],
|
|
4673
|
+
[path.join(os.homedir(), '.ukit', 'playbooks'), 'user'],
|
|
4674
|
+
]) {
|
|
4675
|
+
if (dir === null) {
|
|
4676
|
+
continue;
|
|
4677
|
+
}
|
|
4678
|
+
const body = await readPlaybookBody(path.join(dir, `${policyName}.md`));
|
|
4679
|
+
if (body !== null) {
|
|
4680
|
+
return { body, source };
|
|
4681
|
+
}
|
|
4682
|
+
}
|
|
4683
|
+
const builtinBody = WORKFLOW_POLICIES[policyName];
|
|
4684
|
+
if (typeof builtinBody === 'string' && builtinBody !== '') {
|
|
4685
|
+
return { body: builtinBody, source: 'builtin' };
|
|
4686
|
+
}
|
|
4687
|
+
return null;
|
|
4688
|
+
}
|
|
4689
|
+
|
|
4066
4690
|
function normalize(text) {
|
|
4067
4691
|
return String(text ?? '')
|
|
4068
4692
|
.toLowerCase()
|