@ngockhoale/ukit 2.7.13 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/manifests/documentation.yaml +11 -0
  3. package/manifests/platform.full.yaml +182 -0
  4. package/manifests/platform.user.yaml +53 -0
  5. package/package.json +3 -1
  6. package/src/cli/commands/diff.js +4 -2
  7. package/src/cli/commands/doctor.js +22 -1
  8. package/src/cli/commands/install.js +10 -0
  9. package/src/cli/commands/memory.js +142 -3
  10. package/src/cli/commands/playbook.js +53 -0
  11. package/src/cli/index.js +7 -0
  12. package/src/core/memory/recordStore.js +81 -0
  13. package/src/core/memory/storeV2.js +16 -52
  14. package/src/core/memory/userMemory.js +111 -0
  15. package/src/core/paths.js +1 -0
  16. package/src/core/runInstallPipeline.js +96 -3
  17. package/src/core/runtimeConfig.js +170 -5
  18. package/src/core/userPaths.js +21 -0
  19. package/src/core/userPlaybooks.js +185 -0
  20. package/src/index/taskRouting.js +422 -21
  21. package/src/index/verificationPlan.js +17 -0
  22. package/src/manifest/validateManifest.js +19 -0
  23. package/templates/.claude/config/providers.md +1 -3
  24. package/templates/.claude/skills/principle-attack-the-premise/SKILL.md +16 -0
  25. package/templates/.claude/skills/principle-boundary-discipline/SKILL.md +16 -0
  26. package/templates/.claude/skills/principle-encode-lessons-in-structure/SKILL.md +16 -0
  27. package/templates/.claude/skills/principle-fix-root-causes/SKILL.md +18 -0
  28. package/templates/.claude/skills/principle-foundational-thinking/SKILL.md +17 -0
  29. package/templates/.claude/skills/principle-guard-the-context-window/SKILL.md +16 -0
  30. package/templates/.claude/skills/principle-laziness-protocol/SKILL.md +17 -0
  31. package/templates/.claude/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +16 -0
  32. package/templates/.claude/skills/principle-minimize-reader-load/SKILL.md +17 -0
  33. package/templates/.claude/skills/principle-model-the-domain/SKILL.md +16 -0
  34. package/templates/.claude/skills/principle-never-block-on-the-human/SKILL.md +16 -0
  35. package/templates/.claude/skills/principle-prove-it-works/SKILL.md +18 -0
  36. package/templates/.claude/skills/principle-sequence-verifiable-units/SKILL.md +16 -0
  37. package/templates/.claude/skills/principle-subtract-before-you-add/SKILL.md +16 -0
  38. package/templates/.claude/skills/principle-test-behavior-not-implementation/SKILL.md +18 -0
  39. package/templates/.claude/ukit/index/route-task.mjs +652 -28
  40. package/templates/.claude/ukit/runtime/execution-ledger.mjs +238 -9
  41. package/templates/ukit/README.md +31 -0
  42. package/templates/ukit/storage/config.json +10 -0
  43. package/templates/user/README.md +21 -0
  44. package/templates/user/playbooks/bug-fix.md +18 -0
  45. package/templates/user/playbooks/issue-implementation.md +14 -0
  46. 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 readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
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
- const configPath = path.join(absoluteRoot, '.ukit', 'storage', 'config.json');
428
- const configStat = await countRouteIo(budget, statOrNothing(configPath));
429
- payload.config = configStat
430
- ? { ...statFingerprintTuple(configStat), content: await countRouteIo(budget, readJson(configPath, null)) }
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 readJson(path.join(absoluteRoot, '.ukit', 'storage', 'config.json'), null);
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 readJson(path.join(rootDir, '.ukit', 'storage', 'config.json'), null);
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
- // A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
2438
- // repository mutation the ledger could ever receipt. With no edit/review/debug/
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()