@ngockhoale/ukit 2.6.6 → 2.6.8

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 (59) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +40 -177
  3. package/manifests/documentation.yaml +143 -15
  4. package/manifests/hostCapabilities.yaml +49 -0
  5. package/manifests/instructionRules.yaml +383 -0
  6. package/manifests/platform.full.yaml +15 -0
  7. package/package.json +3 -1
  8. package/scripts/bench/goldTasks.json +38 -0
  9. package/scripts/bench/runGold.mjs +220 -0
  10. package/scripts/docs/render-instructions.mjs +42 -0
  11. package/scripts/release/verify-release.mjs +6 -0
  12. package/src/cli/commands/code.js +182 -0
  13. package/src/cli/commands/doctor.js +35 -3
  14. package/src/cli/commands/indexTools.js +102 -1
  15. package/src/cli/commands/memory.js +137 -0
  16. package/src/cli/index.js +7 -0
  17. package/src/core/codeintel/compiler.js +316 -0
  18. package/src/core/codeintel/diagnostics.js +114 -0
  19. package/src/core/codeintel/freshness.js +295 -0
  20. package/src/core/codeintel/impact.js +251 -0
  21. package/src/core/codeintel/invalidation.js +150 -0
  22. package/src/core/codeintel/manifest.js +176 -0
  23. package/src/core/codeintel/packet.js +146 -0
  24. package/src/core/codeintel/providers.js +201 -0
  25. package/src/core/codeintel/retriever.js +372 -0
  26. package/src/core/codeintel/router.js +149 -0
  27. package/src/core/codeintel/semanticProvider.js +235 -0
  28. package/src/core/docContracts.js +723 -0
  29. package/src/core/memory/migrate.js +324 -0
  30. package/src/core/memory/records.js +172 -0
  31. package/src/core/memory/retrieval.js +161 -11
  32. package/src/core/memory/store.js +398 -0
  33. package/src/core/memory/storeV2.js +171 -0
  34. package/src/core/memory/storeV2Loader.js +22 -0
  35. package/src/core/projectImportant.js +1 -1
  36. package/src/core/runtimeConfig.js +125 -0
  37. package/src/core/runtimePaths.js +3 -0
  38. package/src/core/uninstall.js +1 -1
  39. package/src/index/taskRouting.js +39 -0
  40. package/src/render/instructionRenderer.js +226 -0
  41. package/templates/.claude/ukit/index/route-task.mjs +40 -0
  42. package/templates/.gitignore +2 -2
  43. package/templates/.omp/RULES.md +1 -0
  44. package/templates/AGENTS.md +89 -218
  45. package/templates/CLAUDE.md +85 -212
  46. package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
  47. package/templates/docs/BUGFIX.md +2 -19
  48. package/templates/docs/BUG_INDEX.md +43 -0
  49. package/templates/docs/BUG_METRICS.md +1 -5
  50. package/templates/docs/BUG_TEMPLATE.md +1 -11
  51. package/templates/docs/UKIT_INTERNALS.md +223 -0
  52. package/templates/instructions/core.md +157 -0
  53. package/templates/instructions/layout.yaml +149 -0
  54. package/templates/instructions/overlays/agents.md +15 -0
  55. package/templates/instructions/overlays/claude.md +3 -0
  56. package/templates/instructions/overlays/omp-rules.md +74 -0
  57. package/templates/instructions/overlays/repo.md +9 -0
  58. package/templates/instructions/repo-vars.yaml +23 -0
  59. package/templates/ukit/storage/config.json +30 -0
@@ -0,0 +1,171 @@
1
+ // Memory v2 store — SPEC §3.
2
+ // Single atomic JSON document at <memoryRoot>/v2/records.json
3
+ // ({ schemaVersion: 2, records: [...] }); writes via fileOps.writeJson (tmp+rename).
4
+ // Read ops call ensureMigrated() first so every consumer triggers the lazy
5
+ // v1→v2 migration (SPEC §4); migrate.js is lazy-imported to break the
6
+ // storeV2↔migrate import cycle (runMigration writes via saveRecords).
7
+
8
+ import fs from 'node:fs/promises';
9
+ import { buildRuntimePaths } from '../runtimePaths.js';
10
+ import { readJsonIfExists, writeJson } from '../fileOps.js';
11
+ import { loadRuntimeConfig } from '../runtimeConfig.js';
12
+ import { createRecord, normalizeRecord } from './records.js';
13
+
14
+ const SCHEMA_VERSION = 2;
15
+
16
+ // Per-process memoization for ensureMigrated — one-shot check per projectRoot.
17
+ const migratedRoots = new Set();
18
+
19
+ async function ensureMigrated(projectRoot) {
20
+ const key = String(projectRoot);
21
+ if (migratedRoots.has(key)) return;
22
+ migratedRoots.add(key); // mark first: a failed/again migration must not loop
23
+ try {
24
+ const paths = buildRuntimePaths(projectRoot);
25
+ let recordsExists = false;
26
+ try {
27
+ await fs.access(paths.memoryV2RecordsPath);
28
+ recordsExists = true;
29
+ } catch {
30
+ recordsExists = false;
31
+ }
32
+ if (recordsExists) return;
33
+
34
+ const config = await loadRuntimeConfig(projectRoot);
35
+ if (config?.memoryV2?.autoMigrate === false) return;
36
+
37
+ const migrate = await import('./migrate.js');
38
+ if (await migrate.needsMigration(projectRoot)) {
39
+ await migrate.runMigration(projectRoot);
40
+ }
41
+ } catch {
42
+ // Lazy migration is best-effort: a failed auto-run must not break reads.
43
+ }
44
+ }
45
+
46
+ function isRawRecord(input) {
47
+ return input && typeof input === 'object' && typeof input.id === 'string'
48
+ && typeof input.status === 'string';
49
+ }
50
+
51
+ /**
52
+ * loadRecords(projectRoot) → record[] — tolerant: invalid entries skipped.
53
+ */
54
+ export async function loadRecords(projectRoot) {
55
+ await ensureMigrated(projectRoot);
56
+ const { records } = await readStore(projectRoot);
57
+ return records;
58
+ }
59
+
60
+ async function readStore(projectRoot) {
61
+ const paths = buildRuntimePaths(projectRoot);
62
+ let doc;
63
+ try {
64
+ doc = await readJsonIfExists(paths.memoryV2RecordsPath);
65
+ } catch {
66
+ return { records: [], invalidSkipped: 1 };
67
+ }
68
+ if (!doc) return { records: [], invalidSkipped: 0 };
69
+
70
+ const rawRecords = Array.isArray(doc.records) ? doc.records : [];
71
+ const records = [];
72
+ let invalidSkipped = Array.isArray(doc.records) ? 0 : 1;
73
+ for (const raw of rawRecords) {
74
+ const normalized = normalizeRecord(raw);
75
+ if (normalized) records.push(normalized);
76
+ else invalidSkipped += 1;
77
+ }
78
+ return { records, invalidSkipped };
79
+ }
80
+
81
+ /**
82
+ * saveRecords(projectRoot, records) → void — atomic write; drops unrecoverable entries.
83
+ */
84
+ export async function saveRecords(projectRoot, records) {
85
+ const paths = buildRuntimePaths(projectRoot);
86
+ const normalized = (Array.isArray(records) ? records : [])
87
+ .map((raw) => normalizeRecord(raw))
88
+ .filter(Boolean);
89
+ await writeJson(paths.memoryV2RecordsPath, {
90
+ schemaVersion: SCHEMA_VERSION,
91
+ records: normalized,
92
+ });
93
+ }
94
+
95
+ /**
96
+ * addRecord(projectRoot, recordInput) → record
97
+ * Accepts either createRecord-style input or a full raw record (normalized).
98
+ */
99
+ export async function addRecord(projectRoot, recordInput) {
100
+ const record = isRawRecord(recordInput)
101
+ ? normalizeRecord(recordInput)
102
+ : createRecord(recordInput);
103
+ if (!record) {
104
+ throw new Error('addRecord: input could not be normalized into a valid record');
105
+ }
106
+ const { records } = await readStore(projectRoot);
107
+ await saveRecords(projectRoot, [...records, record]);
108
+ return record;
109
+ }
110
+
111
+ const PATCHABLE_FIELDS = ['type', 'scope', 'text', 'provenance', 'confidence',
112
+ 'valid_until', 'status', 'project_id', 'meta', 'source_fingerprint'];
113
+
114
+ /**
115
+ * updateRecord(projectRoot, id, patch) → record | null
116
+ */
117
+ export async function updateRecord(projectRoot, id, patch = {}) {
118
+ await ensureMigrated(projectRoot);
119
+ const { records } = await readStore(projectRoot);
120
+ const index = records.findIndex((r) => r.id === id);
121
+ if (index === -1) return null;
122
+
123
+ const next = { ...records[index] };
124
+ for (const field of PATCHABLE_FIELDS) {
125
+ if (Object.prototype.hasOwnProperty.call(patch, field)) {
126
+ next[field] = patch[field];
127
+ }
128
+ }
129
+ const normalized = normalizeRecord(next);
130
+ if (!normalized) {
131
+ throw new Error(`updateRecord: patch produces an invalid record (id ${id})`);
132
+ }
133
+ records[index] = normalized;
134
+ await saveRecords(projectRoot, records);
135
+ return normalized;
136
+ }
137
+
138
+ /**
139
+ * getRecord(projectRoot, id) → record | null
140
+ */
141
+ export async function getRecord(projectRoot, id) {
142
+ await ensureMigrated(projectRoot);
143
+ const { records } = await readStore(projectRoot);
144
+ return records.find((r) => r.id === id) ?? null;
145
+ }
146
+
147
+ /**
148
+ * queryRecords(projectRoot, { type?, scope?, status?, projectId? }) → record[]
149
+ */
150
+ export async function queryRecords(projectRoot, { type, scope, status, projectId } = {}) {
151
+ const records = await loadRecords(projectRoot);
152
+ return records.filter((r) => (type == null || r.type === type)
153
+ && (scope == null || r.scope === scope)
154
+ && (status == null || r.status === status)
155
+ && (projectId == null || r.project_id === projectId));
156
+ }
157
+
158
+ /**
159
+ * stats(projectRoot) → { total, byType, byStatus, invalidSkipped }
160
+ */
161
+ export async function stats(projectRoot) {
162
+ await ensureMigrated(projectRoot);
163
+ const { records, invalidSkipped } = await readStore(projectRoot);
164
+ const byType = {};
165
+ const byStatus = {};
166
+ for (const r of records) {
167
+ byType[r.type] = (byType[r.type] ?? 0) + 1;
168
+ byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;
169
+ }
170
+ return { total: records.length, byType, byStatus, invalidSkipped };
171
+ }
@@ -0,0 +1,22 @@
1
+ // Lazy/defensive bridge to storeV2.js (owned by TASK-002).
2
+ // Real storeV2.queryRecords performs ensureMigrated auto-migration internally —
3
+ // consumers must never call migration themselves; just call this and treat an
4
+ // empty result as "fall back to the legacy memory path".
5
+
6
+ /**
7
+ * loadV2Records(projectRoot) → record[] | []
8
+ * Returns [] when storeV2 is unavailable, disabled upstream, or the v2 store
9
+ * holds no records yet. Callers decide fallback.
10
+ */
11
+ export async function loadV2Records(projectRoot, filter = {}) {
12
+ try {
13
+ const storeV2 = await import('./storeV2.js');
14
+ if (typeof storeV2?.queryRecords !== 'function') {
15
+ return [];
16
+ }
17
+ const records = await storeV2.queryRecords(projectRoot, filter);
18
+ return Array.isArray(records) ? records : [];
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
@@ -2,7 +2,7 @@
2
2
  * projectImportant.js (TASK-037)
3
3
  *
4
4
  * Source-side inspector + deterministic envelope renderer for
5
- * PROJECT_IMPORTANT.md per docs/PROJECT_IMPORTANT_SPEC.md §3, §4, §5.2, §6,
5
+ * PROJECT_IMPORTANT.md per docs/archive/specs/PROJECT_IMPORTANT_SPEC.md §3, §4, §5.2, §6,
6
6
  * §12.1.
7
7
  *
8
8
  * Guarantees:
@@ -157,6 +157,36 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
157
157
  cap: 'smart',
158
158
  },
159
159
  },
160
+ codeIntel: {
161
+ enabled: true,
162
+ router: { enabled: true, defaultMode: 'auto' },
163
+ budgets: {
164
+ peek: 500,
165
+ targeted: 2000,
166
+ impact: 3000,
167
+ explore: 4000,
168
+ deep_flow: 6000,
169
+ analogy: 4000,
170
+ },
171
+ freshness: { guardLevel: 'L0', autoRefresh: false, incremental: true },
172
+ retriever: {
173
+ limit: 20,
174
+ bm25: { k1: 1.2, b: 0.75 },
175
+ merge: 'rrf',
176
+ rrfK: 60,
177
+ weights: { exact: 1.0, symbol: 1.2, bm25: 0.8, semantic: 1.0 },
178
+ },
179
+ impact: { defaultDepth: 2, maxDepth: 4, maxNodes: 200 },
180
+ diagnostics: { enabled: true, timeoutMs: 8000 },
181
+ providers: { semantic: 'null' },
182
+ },
183
+ memoryV2: {
184
+ enabled: true,
185
+ autoMigrate: true,
186
+ episodeTtlDays: 90,
187
+ promotion: { episodeToRuleRequiresApproval: true },
188
+ recall: { maxRecords: 8 },
189
+ },
160
190
  memory: {
161
191
  enabled: true,
162
192
  autoCapture: true,
@@ -354,6 +384,101 @@ export function validateRuntimeConfig(config) {
354
384
  }
355
385
  }
356
386
 
387
+ if (!isPlainObject(config.codeIntel)) {
388
+ errors.push('codeIntel must be an object.');
389
+ } else {
390
+ const codeIntel = config.codeIntel;
391
+ pushBooleanError(errors, codeIntel.enabled, 'codeIntel.enabled');
392
+ if (!isPlainObject(codeIntel.router)) {
393
+ errors.push('codeIntel.router must be an object.');
394
+ } else {
395
+ pushBooleanError(errors, codeIntel.router.enabled, 'codeIntel.router.enabled');
396
+ const VALID_ROUTER_MODES = new Set(['auto', 'none', 'peek', 'targeted', 'explore', 'impact', 'deep_flow', 'analogy']);
397
+ if (!VALID_ROUTER_MODES.has(codeIntel.router.defaultMode)) {
398
+ errors.push(`codeIntel.router.defaultMode must be one of: ${[...VALID_ROUTER_MODES].join(', ')}.`);
399
+ }
400
+ }
401
+ if (!isPlainObject(codeIntel.budgets)) {
402
+ errors.push('codeIntel.budgets must be an object.');
403
+ } else {
404
+ for (const key of ['peek', 'targeted', 'impact', 'explore', 'deep_flow', 'analogy']) {
405
+ pushPositiveNumberError(errors, codeIntel.budgets[key], `codeIntel.budgets.${key}`);
406
+ }
407
+ }
408
+ if (!isPlainObject(codeIntel.freshness)) {
409
+ errors.push('codeIntel.freshness must be an object.');
410
+ } else {
411
+ const VALID_GUARD_LEVELS = new Set(['L0', 'L1', 'L2', 'L3', 'L4']);
412
+ if (!VALID_GUARD_LEVELS.has(codeIntel.freshness.guardLevel)) {
413
+ errors.push(`codeIntel.freshness.guardLevel must be one of: ${[...VALID_GUARD_LEVELS].join(', ')}.`);
414
+ }
415
+ pushBooleanError(errors, codeIntel.freshness.autoRefresh, 'codeIntel.freshness.autoRefresh');
416
+ pushBooleanError(errors, codeIntel.freshness.incremental, 'codeIntel.freshness.incremental');
417
+ }
418
+ if (!isPlainObject(codeIntel.retriever)) {
419
+ errors.push('codeIntel.retriever must be an object.');
420
+ } else {
421
+ pushPositiveNumberError(errors, codeIntel.retriever.limit, 'codeIntel.retriever.limit');
422
+ if (!isPlainObject(codeIntel.retriever.bm25)) {
423
+ errors.push('codeIntel.retriever.bm25 must be an object.');
424
+ } else {
425
+ pushPositiveNumberError(errors, codeIntel.retriever.bm25.k1, 'codeIntel.retriever.bm25.k1');
426
+ pushPositiveNumberError(errors, codeIntel.retriever.bm25.b, 'codeIntel.retriever.bm25.b');
427
+ }
428
+ const VALID_RETRIEVER_MERGES = new Set(['rrf', 'concat']);
429
+ if (!VALID_RETRIEVER_MERGES.has(codeIntel.retriever.merge)) {
430
+ errors.push(`codeIntel.retriever.merge must be one of: ${[...VALID_RETRIEVER_MERGES].join(', ')}.`);
431
+ }
432
+ pushPositiveNumberError(errors, codeIntel.retriever.rrfK, 'codeIntel.retriever.rrfK');
433
+ if (!isPlainObject(codeIntel.retriever.weights)) {
434
+ errors.push('codeIntel.retriever.weights must be an object.');
435
+ } else {
436
+ for (const lane of ['exact', 'symbol', 'bm25', 'semantic']) {
437
+ if (typeof codeIntel.retriever.weights[lane] !== 'number' || Number.isNaN(codeIntel.retriever.weights[lane])) {
438
+ errors.push(`codeIntel.retriever.weights.${lane} must be a number.`);
439
+ }
440
+ }
441
+ }
442
+ }
443
+ if (!isPlainObject(codeIntel.impact)) {
444
+ errors.push('codeIntel.impact must be an object.');
445
+ } else {
446
+ pushPositiveNumberError(errors, codeIntel.impact.defaultDepth, 'codeIntel.impact.defaultDepth');
447
+ pushPositiveNumberError(errors, codeIntel.impact.maxDepth, 'codeIntel.impact.maxDepth');
448
+ pushPositiveNumberError(errors, codeIntel.impact.maxNodes, 'codeIntel.impact.maxNodes');
449
+ }
450
+ if (!isPlainObject(codeIntel.diagnostics)) {
451
+ errors.push('codeIntel.diagnostics must be an object.');
452
+ } else {
453
+ pushBooleanError(errors, codeIntel.diagnostics.enabled, 'codeIntel.diagnostics.enabled');
454
+ pushPositiveNumberError(errors, codeIntel.diagnostics.timeoutMs, 'codeIntel.diagnostics.timeoutMs');
455
+ }
456
+ if (!isPlainObject(codeIntel.providers)) {
457
+ errors.push('codeIntel.providers must be an object.');
458
+ } else {
459
+ pushNonEmptyStringError(errors, codeIntel.providers.semantic, 'codeIntel.providers.semantic');
460
+ }
461
+ }
462
+
463
+ if (!isPlainObject(config.memoryV2)) {
464
+ errors.push('memoryV2 must be an object.');
465
+ } else {
466
+ const memoryV2 = config.memoryV2;
467
+ pushBooleanError(errors, memoryV2.enabled, 'memoryV2.enabled');
468
+ pushBooleanError(errors, memoryV2.autoMigrate, 'memoryV2.autoMigrate');
469
+ pushPositiveNumberError(errors, memoryV2.episodeTtlDays, 'memoryV2.episodeTtlDays');
470
+ if (!isPlainObject(memoryV2.promotion)) {
471
+ errors.push('memoryV2.promotion must be an object.');
472
+ } else {
473
+ pushBooleanError(errors, memoryV2.promotion.episodeToRuleRequiresApproval, 'memoryV2.promotion.episodeToRuleRequiresApproval');
474
+ }
475
+ if (!isPlainObject(memoryV2.recall)) {
476
+ errors.push('memoryV2.recall must be an object.');
477
+ } else {
478
+ pushPositiveNumberError(errors, memoryV2.recall.maxRecords, 'memoryV2.recall.maxRecords');
479
+ }
480
+ }
481
+
357
482
  if (!isPlainObject(config.memory)) {
358
483
  errors.push('memory must be an object.');
359
484
  } else {
@@ -4,6 +4,7 @@ export function buildRuntimePaths(projectRoot) {
4
4
  const runtimeRoot = path.join(projectRoot, '.ukit');
5
5
  const storageRoot = path.join(runtimeRoot, 'storage');
6
6
  const memoryRoot = path.join(storageRoot, 'memory');
7
+ const memoryV2Dir = path.join(memoryRoot, 'v2');
7
8
  const cacheRoot = path.join(storageRoot, 'cache');
8
9
 
9
10
  return {
@@ -11,6 +12,8 @@ export function buildRuntimePaths(projectRoot) {
11
12
  storageRoot,
12
13
  cacheRoot,
13
14
  memoryRoot,
15
+ memoryV2Dir,
16
+ memoryV2RecordsPath: path.join(memoryV2Dir, 'records.json'),
14
17
  teeCacheDir: path.join(cacheRoot, 'tee'),
15
18
  configPath: path.join(storageRoot, 'config.json'),
16
19
  promptCachePath: path.join(cacheRoot, 'prompt-cache.json'),
@@ -16,7 +16,7 @@ import { PROJECT_IMPORTANT_FILENAME } from './projectImportant.js';
16
16
  // tracked/followed. Before any destructive uninstall step a regular source gets a
17
17
  // raw-byte backup beside it at the project root (never under .claude/.ukit/.codex/
18
18
  // .omp — those may be deleted): `<name>.ukit-backup`, then `.ukit-backup.1`, `.2`, …
19
- // via bounded exclusive-create collision walk. See docs/PROJECT_IMPORTANT_SPEC.md §15.
19
+ // via bounded exclusive-create collision walk. See docs/archive/specs/PROJECT_IMPORTANT_SPEC.md §15.
20
20
  const IMPORTANT_BACKUP_SUFFIX = '.ukit-backup';
21
21
  const IMPORTANT_BACKUP_COLLISION_LIMIT = 100;
22
22
 
@@ -14,6 +14,34 @@ import {
14
14
 
15
15
  const MAX_ACTIVE_ROUTE_SKILLS = 2;
16
16
 
17
+ // Declared complexity→docs mapping (DOC-201 FR-001). Canonical declaration lives in
18
+ // manifests/documentation.yaml `context_layers`; this constant is the routing-side copy
19
+ // (the router must not read the registry at runtime — zero new deps). Keep in sync.
20
+ // `queued-task` is omitted v1: docs/TASKS.md does not exist in every repo (SPEC §14).
21
+ // `shared-simple` mirrors the non-trivial layer to match deriveContextMode's FULL lane.
22
+ const CONTEXT_LAYER_DOCS = {
23
+ taskTypes: {
24
+ trivial: [],
25
+ simple: ['docs/MEMORY.md'],
26
+ 'non-trivial': ['docs/MEMORY.md', 'docs/PROJECT.md', 'docs/CODE_MAP.md'],
27
+ 'shared-simple': ['docs/MEMORY.md', 'docs/PROJECT.md', 'docs/CODE_MAP.md'],
28
+ },
29
+ intents: {
30
+ 'open-ended': ['docs/STATUS.md'],
31
+ 'open-ended-status': ['docs/STATUS.md'],
32
+ handoff: ['docs/AI_HANDOFF/INDEX.md'],
33
+ },
34
+ };
35
+ const CONTEXT_DOCS_MAX = 4;
36
+
37
+ function deriveContextDocs({ taskType = null, intentMode = null } = {}) {
38
+ const docs = [
39
+ ...(CONTEXT_LAYER_DOCS.taskTypes[taskType] ?? []),
40
+ ...(CONTEXT_LAYER_DOCS.intents[intentMode] ?? []),
41
+ ];
42
+ return unique(docs).slice(0, CONTEXT_DOCS_MAX);
43
+ }
44
+
17
45
  export async function deriveTaskRoute({
18
46
  rootDir = process.cwd(),
19
47
  promptText = '',
@@ -206,6 +234,7 @@ export function buildRouteSummary({
206
234
  nextAction = null,
207
235
  handoffBudget = null,
208
236
  worklogBudget = null,
237
+ contextDocs = null,
209
238
  } = {}) {
210
239
  const autonomyLevel = routingContext.autonomyLevel ?? 'balanced';
211
240
  const delegationRecommendation = deriveDelegationRecommendation({
@@ -264,10 +293,19 @@ export function buildRouteSummary({
264
293
  );
265
294
  const nextActionCommand = compactHelperLane ? null : nextAction?.command ?? null;
266
295
  const handoffFile = routingContext.intentMode === 'handoff' ? 'docs/AI_HANDOFF/ACTIVE.md' : null;
296
+ // DOC-201 FR-002: resolved context-layer docs (declared in manifests/documentation.yaml).
297
+ // Explicit override wins; otherwise derive from taskType + intentMode.
298
+ const resolvedContextDocs = ((Array.isArray(contextDocs) ? contextDocs : null)
299
+ ?? deriveContextDocs({ taskType, intentMode: routingContext.intentMode ?? null }))
300
+ .slice(0, CONTEXT_DOCS_MAX);
301
+ const docsSegment = resolvedContextDocs.length > 0
302
+ ? `docs=[${resolvedContextDocs.slice(0, CONTEXT_DOCS_MAX).map((p) => path.posix.basename(String(p).replaceAll('\\', '/'))).join(',')}]`
303
+ : null;
267
304
  const summaryLine = [
268
305
  routingContext.taskType ? `task=${routingContext.taskType}` : null,
269
306
  handoffFile ? `handoff=${handoffFile}` : null,
270
307
  formatCompactSegment('targets', primaryTargets),
308
+ docsSegment,
271
309
  formatCompactSegment('tests', relatedTests),
272
310
  formatCompactSegment('styles', styleFiles),
273
311
  editGuardHint ? `editGuard=${editGuardHint}` : null,
@@ -303,6 +341,7 @@ export function buildRouteSummary({
303
341
  nextActionCommand,
304
342
  helperHint,
305
343
  contextMode,
344
+ contextDocs: resolvedContextDocs,
306
345
  line: summaryLine || 'task=unknown',
307
346
  };
308
347
  }
@@ -0,0 +1,226 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import YAML from 'yaml';
4
+
5
+ import { renderTemplateString } from './renderTemplate.js';
6
+
7
+ /**
8
+ * Deterministic core/overlay renderer for the five instruction-contract
9
+ * outputs (SPEC §8.1, FR-004..FR-007). Pure functions; disk access only in
10
+ * renderInstructionsFromDisk / checkRenderedInstructions.
11
+ *
12
+ * Emission rules (byte-exact):
13
+ * h1 + '\n' + banner + '\n\n' + blocks.join('') [+ trailer] [+ '\n']
14
+ * where each `## ` block is `## <heading>` + the raw body slice — the body
15
+ * keeps its leading AND trailing whitespace, so concatenation reproduces
16
+ * the source bytes exactly; the reserved
17
+ * `__preamble__` block is the source's leading non-## text verbatim.
18
+ */
19
+
20
+ export const PREAMBLE_KEY = '__preamble__';
21
+
22
+ const SECTION_HEADING_RE = /^## (.+)$/gm;
23
+ const LEFTOVER_TOKEN_RE = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/;
24
+
25
+ /**
26
+ * Split markdown into a Map<heading, body> at `## ` boundaries.
27
+ * `### ` and deeper stay inside the enclosing body. Leading non-## text lands
28
+ * under the reserved `__preamble__` key (absent when the file starts with `## `).
29
+ * Throws on a duplicate `## ` heading, naming it.
30
+ */
31
+ export function parseSections(markdown) {
32
+ const sections = new Map();
33
+ const matches = [...markdown.matchAll(SECTION_HEADING_RE)];
34
+ const first = matches[0];
35
+
36
+ const preamble = first ? markdown.slice(0, first.index) : markdown;
37
+ if (preamble.trim().length > 0) {
38
+ // Raw slice kept verbatim: its trailing newline(s) are the separator
39
+ // before the first ## section (e.g. the RULES.md preamble block).
40
+ sections.set(PREAMBLE_KEY, preamble);
41
+ }
42
+
43
+ for (let i = 0; i < matches.length; i++) {
44
+ const m = matches[i];
45
+ const heading = m[1].trimEnd();
46
+ if (sections.has(heading)) {
47
+ throw new Error(`duplicate ## heading in source: ${heading}`);
48
+ }
49
+ const bodyStart = m.index + m[0].length;
50
+ const bodyEnd = i + 1 < matches.length ? matches[i + 1].index : markdown.length;
51
+ // Raw slice up to (not including) the next `## ` heading — leading AND
52
+ // trailing whitespace are byte-meaningful (marker line vs blank line
53
+ // under the heading; multi-blank separators between sections).
54
+ sections.set(heading, markdown.slice(bodyStart, bodyEnd));
55
+ }
56
+
57
+ return sections;
58
+ }
59
+
60
+ /**
61
+ * Render one layout output. `outputSpec` = layout.outputs[i];
62
+ * `sources` = { <sourceName>: Map<heading, body> }; `vars` = flat/nested
63
+ * variables applied when `outputSpec.resolve_vars` is true.
64
+ * Reserved specifiers: heading '__preamble__' emits the source's leading
65
+ * non-## block verbatim (no `## ` prefix); heading '*' expands to every
66
+ * `## ` section of that source in file order.
67
+ */
68
+ export function renderOutput(outputSpec, sources, vars = {}) {
69
+ if (!Array.isArray(outputSpec.sections) || outputSpec.sections.length === 0) {
70
+ throw new Error(`output ${outputSpec.target}: sections must be a non-empty list`);
71
+ }
72
+
73
+ const blocks = [];
74
+ for (const ref of outputSpec.sections) {
75
+ const source = sources.get(ref.from);
76
+ if (!source) {
77
+ throw new Error(`output ${outputSpec.target}: unknown source '${ref.from}'`);
78
+ }
79
+ if (ref.heading === '*') {
80
+ for (const [heading, body] of source) {
81
+ if (heading === PREAMBLE_KEY) continue;
82
+ blocks.push(`## ${heading}${body}`);
83
+ }
84
+ continue;
85
+ }
86
+ if (ref.heading === PREAMBLE_KEY) {
87
+ if (!source.has(PREAMBLE_KEY)) {
88
+ throw new Error(
89
+ `output ${outputSpec.target}: source '${ref.from}' has no __preamble__ block`,
90
+ );
91
+ }
92
+ blocks.push(source.get(PREAMBLE_KEY));
93
+ continue;
94
+ }
95
+ if (!source.has(ref.heading)) {
96
+ throw new Error(
97
+ `output ${outputSpec.target}: heading '${ref.heading}' missing from source '${ref.from}'`,
98
+ );
99
+ }
100
+ blocks.push(`## ${ref.heading}${source.get(ref.heading)}`);
101
+ }
102
+
103
+ let out = `${outputSpec.h1}\n${outputSpec.banner}\n\n${blocks.join('')}`;
104
+ if (outputSpec.trailer !== undefined && outputSpec.trailer !== null && outputSpec.trailer !== '') {
105
+ if (!out.endsWith('\n')) out += '\n';
106
+ out += `${outputSpec.trailer}`;
107
+ }
108
+ if (!out.endsWith('\n')) out += '\n';
109
+
110
+ if (outputSpec.resolve_vars) {
111
+ out = renderTemplateString(out, vars);
112
+ const leftover = out.match(LEFTOVER_TOKEN_RE);
113
+ if (leftover) {
114
+ throw new Error(
115
+ `output ${outputSpec.target}: unresolved template variable '{{${leftover[1]}}}'`,
116
+ );
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * Render every output in `layout` from `sourceTexts` ({name: markdown}).
124
+ * Source names are looked up as `sources.get(ref.from)`; overlay sources are
125
+ * named `overlay:<file-stem>` by convention in renderInstructionsFromDisk.
126
+ * `varsByResolve` supplies variables to outputs with resolve_vars: true.
127
+ * FR-006 strictness: a `## ` section in any source referenced by zero outputs
128
+ * throws, naming the heading.
129
+ */
130
+ export function renderAll(layout, sourceTexts, varsByResolve = {}) {
131
+ if (!layout || !Array.isArray(layout.outputs) || layout.outputs.length === 0) {
132
+ throw new Error('layout.outputs must be a non-empty list');
133
+ }
134
+
135
+ const sources = new Map(
136
+ Object.entries(sourceTexts).map(([name, text]) => [name, parseSections(text)]),
137
+ );
138
+
139
+ // Referenced-heading accounting for the unreferenced-section check.
140
+ const referenced = new Map(); // sourceName -> Set<heading>
141
+ const mark = (from, heading) => {
142
+ if (!referenced.has(from)) referenced.set(from, new Set());
143
+ referenced.get(from).add(heading);
144
+ };
145
+ for (const spec of layout.outputs) {
146
+ for (const ref of spec.sections || []) {
147
+ if (ref.heading === '*') {
148
+ const source = sources.get(ref.from);
149
+ if (!source) throw new Error(`unknown source '${ref.from}' referenced by '*'`);
150
+ for (const heading of source.keys()) {
151
+ if (heading !== PREAMBLE_KEY) mark(ref.from, heading);
152
+ }
153
+ } else if (ref.heading !== PREAMBLE_KEY) {
154
+ mark(ref.from, ref.heading);
155
+ }
156
+ }
157
+ }
158
+ for (const [name, source] of sources) {
159
+ for (const heading of source.keys()) {
160
+ if (heading === PREAMBLE_KEY) continue;
161
+ if (!referenced.get(name) || !referenced.get(name).has(heading)) {
162
+ throw new Error(`source '${name}': ## section '${heading}' referenced by zero outputs`);
163
+ }
164
+ }
165
+ }
166
+
167
+ const rendered = new Map();
168
+ for (const spec of layout.outputs) {
169
+ const vars = spec.resolve_vars ? varsByResolve : {};
170
+ rendered.set(spec.target, renderOutput(spec, sources, vars));
171
+ }
172
+ return rendered;
173
+ }
174
+
175
+ const SOURCE_FILES = {
176
+ core: 'templates/instructions/core.md',
177
+ 'overlay:claude': 'templates/instructions/overlays/claude.md',
178
+ 'overlay:agents': 'templates/instructions/overlays/agents.md',
179
+ 'overlay:omp-rules': 'templates/instructions/overlays/omp-rules.md',
180
+ 'overlay:repo': 'templates/instructions/overlays/repo.md',
181
+ };
182
+
183
+ /**
184
+ * Read layout + sources + repo-vars + package.json version from disk, render
185
+ * all outputs, and (write=true) write each target. Returns Map<target, content>.
186
+ */
187
+ export async function renderInstructionsFromDisk({ repoRoot, write = true } = {}) {
188
+ const abs = (p) => path.join(repoRoot, p);
189
+ const layout = YAML.parse(
190
+ fs.readFileSync(abs('templates/instructions/layout.yaml'), 'utf8'),
191
+ );
192
+ const repoVars =
193
+ YAML.parse(fs.readFileSync(abs('templates/instructions/repo-vars.yaml'), 'utf8')) || {};
194
+ const pkg = JSON.parse(fs.readFileSync(abs('package.json'), 'utf8'));
195
+
196
+ const sourceTexts = {};
197
+ for (const [name, rel] of Object.entries(SOURCE_FILES)) {
198
+ sourceTexts[name] = fs.readFileSync(abs(rel), 'utf8');
199
+ }
200
+
201
+ const vars = { ...repoVars, ukit: { ...(repoVars.ukit || {}), version: pkg.version } };
202
+ const rendered = renderAll(layout, sourceTexts, vars);
203
+
204
+ if (write) {
205
+ for (const [target, content] of rendered) {
206
+ fs.writeFileSync(abs(target), content);
207
+ }
208
+ }
209
+ return rendered;
210
+ }
211
+
212
+ /**
213
+ * → string[] of drifted target paths (empty = clean). Drift means rendered
214
+ * bytes differ from disk bytes, or the target is missing.
215
+ */
216
+ export async function checkRenderedInstructions({ repoRoot } = {}) {
217
+ const rendered = await renderInstructionsFromDisk({ repoRoot, write: false });
218
+ const drift = [];
219
+ for (const [target, content] of rendered) {
220
+ const file = path.join(repoRoot, target);
221
+ if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== content) {
222
+ drift.push(target);
223
+ }
224
+ }
225
+ return drift;
226
+ }