@ngockhoale/ukit 3.3.2 → 3.4.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 (89) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/manifests/engineConformance.yaml +17 -1
  3. package/manifests/hostCapabilities.yaml +68 -1
  4. package/manifests/platform.full.yaml +138 -0
  5. package/manifests/platform.user.yaml +255 -3
  6. package/package.json +1 -1
  7. package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
  8. package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
  9. package/scripts/probe/codex-capability-probe.mjs +169 -0
  10. package/src/cli/commands/doctor.js +168 -0
  11. package/src/cli/commands/indexTools.js +7 -0
  12. package/src/cli/commands/metrics.js +66 -2
  13. package/src/cli/commands/playbook.js +4 -4
  14. package/src/cli/commands/vm.js +49 -8
  15. package/src/core/agentRuntime/adapters.js +328 -27
  16. package/src/core/agentRuntime/artifacts.js +89 -0
  17. package/src/core/agentRuntime/context.js +345 -1
  18. package/src/core/agentRuntime/contract.js +296 -0
  19. package/src/core/agentRuntime/eventStore.js +176 -0
  20. package/src/core/agentRuntime/shadowRun.js +481 -5
  21. package/src/core/agentRuntime/telemetry.js +121 -0
  22. package/src/core/observability/emit/lifecycle.js +68 -1
  23. package/src/core/observability/emit/sessionBoot.js +393 -0
  24. package/src/core/observability/privacy/allowlist.js +10 -1
  25. package/src/core/observability/schema/registry.js +10 -0
  26. package/src/core/runtimeConfig.js +133 -0
  27. package/src/core/userPlaybooks.js +18 -3
  28. package/src/decision/registry.js +19 -0
  29. package/src/diagnostics/feedbackEvents.js +7 -4
  30. package/src/diagnostics/routeOutcomes.js +51 -6
  31. package/src/diagnostics/skillAccuracy.js +43 -3
  32. package/src/index/crossCheckMatrix.js +412 -0
  33. package/src/index/fixLoopEscalation.js +453 -0
  34. package/src/index/playbookRegistry.js +691 -0
  35. package/src/index/reviewPolicy.js +368 -0
  36. package/src/index/routeResolver.js +915 -0
  37. package/src/index/sessionHistoryExtractor.js +359 -0
  38. package/src/index/taskRouting.js +764 -581
  39. package/src/index/tierSelection.js +308 -0
  40. package/src/index/verificationMap.js +404 -0
  41. package/template_project/.claude/hooks/observability-emit.mjs +14 -0
  42. package/template_project/.claude/hooks/record-execution.mjs +19 -1
  43. package/template_project/.claude/hooks/skill-router.sh +691 -25
  44. package/template_project/.claude/hooks/verification-guard.sh +230 -1
  45. package/template_project/.claude/settings.json +2 -2
  46. package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
  47. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
  48. package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
  49. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
  50. package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
  51. package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
  52. package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
  53. package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
  54. package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
  55. package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
  56. package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
  57. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
  58. package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
  59. package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
  60. package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
  61. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
  62. package/template_project/.codex/README.md +8 -0
  63. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  64. package/template_project/ukit/README.md +1 -1
  65. package/template_project/ukit/storage/config.json +20 -0
  66. package/template_user/playbooks/architecture-decision.md +28 -0
  67. package/template_user/playbooks/autonomous-run.md +43 -0
  68. package/template_user/playbooks/autopilot-full.md +59 -0
  69. package/template_user/playbooks/autopilot-stack.md +54 -0
  70. package/template_user/playbooks/babysit.md +39 -0
  71. package/template_user/playbooks/bug-fix.md +3 -1
  72. package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
  73. package/template_user/playbooks/hillclimb.md +44 -0
  74. package/template_user/playbooks/investigation.md +21 -0
  75. package/template_user/playbooks/migration.md +21 -0
  76. package/template_user/playbooks/open-pr.md +48 -0
  77. package/template_user/playbooks/orchestrate.md +45 -0
  78. package/template_user/playbooks/performance.md +33 -0
  79. package/template_user/playbooks/prototype.md +28 -0
  80. package/template_user/playbooks/refactor.md +19 -0
  81. package/template_user/playbooks/release.md +28 -0
  82. package/template_user/playbooks/runtime-forensics.md +23 -0
  83. package/template_user/playbooks/session-pickup.md +31 -0
  84. package/template_user/playbooks/shipping.md +53 -0
  85. package/template_user/playbooks/skill-evaluation.md +48 -0
  86. package/template_user/playbooks/small-feature.md +20 -0
  87. package/template_user/playbooks/verification-map.json +153 -0
  88. package/template_user/playbooks/verification.md +22 -0
  89. package/template_user/playbooks/worktree-cleanup.md +37 -0
@@ -0,0 +1,691 @@
1
+ // playbookRegistry.js — playbook registry + playbookId route resolution
2
+ // (C84 TASK-007, BL-009 + BL-012).
3
+ //
4
+ // A natural prompt resolves to a playbook with no /playbook command. This
5
+ // module owns two things: (1) the registry — scan project .ukit/playbooks >
6
+ // ~/.ukit/playbooks > packaged template_user/playbooks, parse `id:` + `lanes:`
7
+ // frontmatter (malformed → skipped with reason, surfaced via `ukit doctor`);
8
+ // (2) the ARCH §Routing Decision Table — all 15 work-group rows plus the
9
+ // handoff-cycle escape row — with confusable pairs resolved by discriminator
10
+ // signals, never trigger text alone.
11
+ //
12
+ // Unknown/malformed/missing ids degrade: playbookId stays null and a `reason`
13
+ // rides the route — routing never blocks on playbook resolution (ARCH
14
+ // §Failure Modes / GAP M01).
15
+ //
16
+ // Mirror parity: template_project/.claude/ukit/index/playbook-registry.mjs
17
+ // carries the identical logic (the mirror cannot import src/). Both twins are
18
+ // locked by tests/consistency/registryParity.test.js.
19
+
20
+ import fs from 'node:fs/promises';
21
+ import fsSync from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ import { deriveEscalationTriggers, deriveRiskFloor } from './routeResolver.js';
27
+
28
+ const PLAYBOOK_NAME_RE = /\.md$/i;
29
+ // src/index/playbookRegistry.js is two dirs below the package root —
30
+ // '../..' lands on it. The installed mirror
31
+ // (.claude/ukit/index/playbook-registry.mjs) walks four dirs instead so the
32
+ // repo checkout resolves the same template_user tree; on a real installed
33
+ // project the mirror's walk lands outside the project (no template_user
34
+ // dir), ENOENT skips the tier, and the ~/.ukit seed is the effective builtin.
35
+ // Install-aware callers may pass `builtinDir` explicitly.
36
+ const PACKAGE_BUILTIN_PLAYBOOK_DIR = path.resolve(
37
+ path.dirname(fileURLToPath(import.meta.url)),
38
+ '..', '..',
39
+ 'template_user', 'playbooks',
40
+ );
41
+ // SPEC §14 + BL-020: `issue-implementation.md` renamed to
42
+ // `feature-implementation.md`; the old id stays a reverse alias so user-dir
43
+ // installs seeded under the pre-rename filename still resolve the canonical row.
44
+ export const PLAYBOOK_ID_ALIASES = Object.freeze({
45
+ 'issue-implementation': 'feature-implementation',
46
+ });
47
+
48
+ // --- Shared signal vocabulary ------------------------------------------------
49
+ // Compiled once; every row's discriminator reads the same normalized signals so
50
+ // confusable pairs split on the discriminator column, never trigger text.
51
+ const CREATE_VERB_RE = /\b(create|add|write|build|implement|scaffold|tạo|thêm)\b/i;
52
+ const CHANGE_VERB_RE = /\b(fix|update|modify|change|rename|refactor|restructure|remove|delete|move|migrate|improve|patch|rework|sửa|đổi|cập nhật|thay thế|xóa)\b/i;
53
+ const INVESTIGATION_TRIGGER_RE = /\b(how does|how do|why does|why do|why is|why are|explain|what happens|investigate|understand how|validate the assumption|tại sao|như thế nào|thế nào)\b/i;
54
+ const DEFECT_TRIGGER_RE = /\b(fix|bug|defect|broken|crash(?:es|ed)?|regression|not working|doesn['’]?t work|won['’]?t work|fails?|failing|không hoạt động|lỗi)\b/i;
55
+ const CHECK_VERB_RE = /\b(verify|check|confirm|prove|validate|assert)\b/i;
56
+ const EXPLAIN_ASK_RE = /\b(how|why|explain|understand)\b/i;
57
+ const INTERMITTENT_RE = /\b(sometimes|intermittent|flaky|occasionally|random|sporadic|hangs?|hang|leak|in 5|one in \d+|about \d+ in \d+)\b/i;
58
+ const TEST_HARNESS_RE = /\b(spec|test|tests|assertion)\b/i;
59
+ const CONTRACT_SIGNAL_RE = /\b(oauth|sso|jwt|api|contract|schema|public|persistent|multi[- ]?(?:screen|file|step)|complex[- ]?state|auth\b|password|token|session|payment|billing|checkout|hard[- ]to[- ]reverse|irreversible|reset[- ]all|user[- ]data|destructive|delete all)\b/i;
60
+ const MULTI_SURFACE_RE = /\b(across files|multi(?:ple)? files|multi[- ]?screen|several screens|end[- ]?to[- ]?end|wiring)\b/i;
61
+ // BL-033: a spike ask that also demands wiring/shipping/integration into a
62
+ // shipped surface is a feature ask wearing spike vocabulary — the disposable
63
+ // signal does not apply. Confusable pair 5's feature-side veto.
64
+ const WIRE_OR_SHIP_RE = /\b(?:wir(?:e|ed|ing)|integrat\w*|ship|deploy)\b(?:\s+[\w-]{1,20}){0,4}\s+into\b/i;
65
+ const FORENSICS_TRIGGER_RE = /\b(hangs?|leaks?|cpuprofile|heap\s*(?:dump|snapshot|profile)|memory profile|dropped (?:a )?(?:cpu|heap|profile) artifact)\b/i;
66
+ const REFACTOR_TRIGGER_RE = /\b(extract|rename|restructure|refactor|split|move (?:this|the) (?:function|module|file|class|logic))\b/i;
67
+ const DATA_MOVE_RE = /\b(schema|table|column|database|db|migration|data(?:base)? move|move (?:the |this )?(?:data|rows?|records?|sessions?))\b/i;
68
+ const PERFORMANCE_TRIGGER_RE = /\b(slow|perf(?:ormance)?|takes? ~?\d|\d+ ?(?:ms|s|seconds?|minutes?)\b|latency|optimi[sz]e|speed up|baseline)\b/i;
69
+ const DECISION_TRIGGER_RE = /\b(should we|choose between|which is better|design fork|pick between|vs\b|versus\b)\b/i;
70
+ const PROTOTYPE_TRIGGER_RE = /\b(spike|prototype|try (?:a|out|the)|proof[- ]of[- ]concept|poc\b|experiment with)\b/i;
71
+ const MIGRATION_TRIGGER_RE = /\b(migrat\w*|move (?:the |this |all )?[\w-]*\b|new schema|port (?:the|to)|upgrade (?:the )?(?:schema|data))\b/i;
72
+ const VERIFICATION_TRIGGER_RE = /\b(verify|check|confirm|prove|validate|assert)\b/i;
73
+ const SKILL_EVAL_TRIGGER_RE = /\b(make (?:this|it)(?: [\w-]+)* a skill|(?:convert|turn|extract)[\w -]*skill|skill[- ]evaluation|did (?:this|the) [\w -]*(?:help|improve)|(?:new|the) [\w-]*skill (?:improve|help)|score (?:the|a) skill)\b/i;
74
+ // BL-026 review fix: skill-authoring asks carry defect vocabulary when they
75
+ // describe turning a past fix into a reusable skill — guards the bug-fix row.
76
+ const SKILL_ASK_RE = SKILL_EVAL_TRIGGER_RE;
77
+ const AUTONOMOUS_TRIGGER_RE = /\b(finish (?:this|the) backlog|bounded (?:long )?goal|finish the (?:task|list|queue))\b/i;
78
+ const RELEASE_TRIGGER_RE = /\b(publish|ship it|release|bump (?:the )?version|deploy|cut a release|tag (?:the )?release)\b/i;
79
+ const HANDOFF_CYCLE_TRIGGER_RE = /\b(handoff|multi[- ]?task cycle|task pipeline|execute the plan|worktree)\b/i;
80
+
81
+ // session-pickup discriminator needs the resumable-record check.
82
+ function hasResumableRecord(projectRoot) {
83
+ if (!projectRoot) return false;
84
+ try {
85
+ const runsDir = path.join(projectRoot, '.ukit', 'storage', 'runs');
86
+ const entries = fsSync.readdirSync(runsDir, { withFileTypes: true });
87
+ return entries.some((entry) => entry.isFile() && entry.name.endsWith('.json'));
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ // ARCH §Routing Decision Table — the 15 work-group rows + the handoff-cycle
94
+ // escape row (ARCH's 16th row: multi-task cycle / gated pipeline → per-task
95
+ // playbook, never a file of its own). Row order is ARCH order; a row matches
96
+ // when any trigger hits AND its discriminator passes. Discriminators split the
97
+ // confusable pairs named in ARCH — they are the contract, triggers are hints.
98
+ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
99
+ {
100
+ workGroup: 'small-feature',
101
+ playbookId: 'small-feature',
102
+ lanes: ['local-fix', 'local-build'],
103
+ triggers: [/create|add|write|build|implement|scaffold|tạo|thêm|update/i],
104
+ // confusable pair 1: no escalation trigger AND single-surface scope. The
105
+ // `!s.escalated` clause is the BL-010 fast-path guard — any derived
106
+ // floor-raising trigger (the six listed triggers map to risk codes +
107
+ // contract signals) escalates to feature-implementation.
108
+ discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && s.changeVerbOrCreate,
109
+ fastPath: 'small-feature',
110
+ },
111
+ {
112
+ workGroup: 'feature-implementation',
113
+ playbookId: 'feature-implementation',
114
+ lanes: ['local-build', 'shared-edit', 'map-impact'],
115
+ triggers: [/implement|add|create|build|integrat|ship|deliver|wir(?:e|ed|ing)/i],
116
+ // confusable pairs 1/5/8: articulated contract OR ≥1 escalation trigger,
117
+ // and the ask is not a disposable spike nor the ship chain itself.
118
+ discriminator: (s) => (s.contractSignal || s.multiSurface || s.escalated || s.wiredShipAsk) && !s.disposable && !s.releaseAsk,
119
+ },
120
+ {
121
+ workGroup: 'bug-fix',
122
+ playbookId: 'bug-fix',
123
+ lanes: ['find-cause'],
124
+ triggers: [/fix|bug|defect|broken|crash|regression|not working|doesn['’]?t work|won['’]?t work|fails?|failing|không hoạt động|lỗi/i],
125
+ // confusable pairs 3/4: deterministic repro (no live/intermittent signal,
126
+ // or a test-harness repro), and a change is requested — not an
127
+ // explanation ask, not a verification ask. ARCH §Fast-Path Separation:
128
+ // bug-fix is the second fast path (TASK-009 owns the completion floor).
129
+ fastPath: 'bug-fix',
130
+ // T-026 review fix: a skill-ask that happens to mention a fix ("make this
131
+ // repeated fix flow a skill") is skill-authoring, not a defect order.
132
+ discriminator: (s) => !s.explanationAsk && !s.questionAsk && !s.forensicsSignal && !s.checkFirst && !s.skillAsk,
133
+ },
134
+ {
135
+ workGroup: 'investigation',
136
+ playbookId: 'investigation',
137
+ lanes: ['find-cause', 'map-impact'],
138
+ triggers: [/how does|how do|why does|why do|why is|why are|explain|what happens|investigate|understand how|validate the assumption|tại sao|như thế nào|thế nào/i],
139
+ // confusable pair 4: explanation asked, no change requested.
140
+ discriminator: (s) => !s.changeRequested,
141
+ },
142
+ {
143
+ workGroup: 'runtime-forensics',
144
+ playbookId: 'runtime-forensics',
145
+ lanes: ['find-cause'],
146
+ triggers: [/\bhangs?\b|\bleaks?\b|sometimes|intermittent|flaky|occasionally|about \d+ in \d+|\bin 5\b|cpuprofile|\bheap\b|dropped.*artifact/i],
147
+ // confusable pair 3: no deterministic repro — a named test-harness
148
+ // failure IS a repro and stays bug-fix.
149
+ discriminator: (s) => !s.testHarness,
150
+ },
151
+ {
152
+ workGroup: 'refactor',
153
+ playbookId: 'refactor',
154
+ lanes: ['shared-edit', 'map-impact'],
155
+ triggers: [/extract|rename|restructure|refactor|split|migrate all callers/i],
156
+ // confusable pair 7: behavior must not change — no data/contract move.
157
+ discriminator: (s) => !s.dataMove,
158
+ },
159
+ {
160
+ workGroup: 'performance',
161
+ playbookId: 'performance',
162
+ lanes: ['local-build', 'find-cause'],
163
+ triggers: [/slow|perf|takes? ~?\d|latency|optimi[sz]e|speed up|\d+ ?(?:ms|s|seconds?|minutes?)\b|baseline/i],
164
+ // confusable pair: measured baseline exists or is step 1 — a live/
165
+ // intermittent symptom is runtime-forensics, not performance. BL-022:
166
+ // unmeasured slowness (trigger hit, !measurable, no other row claim)
167
+ // emits the investigation playbookId in resolvePlaybookId below — this
168
+ // row still refuses it here so measurable stays the split column.
169
+ discriminator: (s) => s.measurable && !s.forensicsSignal,
170
+ },
171
+ {
172
+ workGroup: 'architecture-decision',
173
+ playbookId: 'architecture-decision',
174
+ lanes: ['map-impact', 'shared-edit'],
175
+ triggers: [/should we|choose between|which is better|design fork|pick between|versus|\bvs\b/i],
176
+ // a decision must be recorded, not prototyped — and an explanation ask
177
+ // ("how does X pick") is investigation, not a fork.
178
+ discriminator: (s) => !s.explanationAsk && !s.disposable,
179
+ },
180
+ {
181
+ workGroup: 'prototype',
182
+ playbookId: 'prototype',
183
+ lanes: ['local-build'],
184
+ triggers: [/spike|prototype|try (?:a|out|the)|proof[- ]of[- ]concept|\bpoc\b|experiment/i],
185
+ // confusable pair 5: a disposable artifact settles the fork — a spike ask
186
+ // that demands wiring/shipping is a feature ask, not disposable.
187
+ discriminator: (s) => s.disposable,
188
+ },
189
+ {
190
+ workGroup: 'migration',
191
+ playbookId: 'migration',
192
+ lanes: ['shared-edit', 'map-impact'],
193
+ triggers: [/migrat|move (?:the |this |all )?[\w-]*|new schema|port (?:the|to)/i],
194
+ // confusable pair 7: behavior change + data/contract move (rollback needed).
195
+ discriminator: (s) => s.dataMove,
196
+ },
197
+ {
198
+ workGroup: 'verification',
199
+ playbookId: 'verification',
200
+ lanes: ['local-fix', 'review-release'],
201
+ triggers: [/\bverify\b|\bcheck\b|\bconfirm\b|\bprove\b|\bvalidate\b|\bassert\b/i],
202
+ // the ask IS a check — a check verb leads, or no defect verb exists at all.
203
+ discriminator: (s) => s.checkFirst || s.defectIdx === -1,
204
+ },
205
+ {
206
+ workGroup: 'skill-evaluation',
207
+ playbookId: 'skill-evaluation',
208
+ lanes: ['local-build', 'review-release'],
209
+ triggers: [/make (?:this|it)(?: [\w-]+)* a skill|(?:convert|turn|extract)[\w -]*skill|did (?:this|the|a) [\w -]*(?:help|improve)|skill[- ]evaluation|(?:new|the) [\w-]*skill (?:improve|help)|score (?:the|a) skill/i],
210
+ // confusable pair 6: measuring a behavior change, not checking an artifact.
211
+ discriminator: () => true,
212
+ },
213
+ {
214
+ workGroup: 'session-pickup',
215
+ playbookId: 'session-pickup',
216
+ lanes: ['local-fix', 'local-build'],
217
+ triggers: [/làm tiếp|resume|continue|pick (?:it|this) back up/i],
218
+ // confusable pair 2: a prior resumable record must exist — never
219
+ // fabricate continuity on a fresh goal.
220
+ discriminator: (s) => s.hasResumableRecord,
221
+ },
222
+ {
223
+ workGroup: 'autonomous-run',
224
+ playbookId: 'autonomous-run',
225
+ lanes: ['shared-edit', 'map-impact'],
226
+ triggers: [/finish (?:this|the) backlog|bounded (?:long )?goal|finish the (?:task|list|queue)/i],
227
+ // confusable pair 2: a fresh bounded goal — a prior resumable record
228
+ // would make this session-pickup territory.
229
+ discriminator: (s) => !s.hasResumableRecord,
230
+ },
231
+ {
232
+ workGroup: 'release',
233
+ playbookId: 'release',
234
+ lanes: ['review-release'],
235
+ triggers: [/publish|ship it|release|bump (?:the )?version|cut a release|tag (?:the )?release|deploy/i],
236
+ // confusable pair 8: the ask IS the ship chain — "add changelog entry"
237
+ // stays small-feature.
238
+ discriminator: (s) => s.releaseAsk,
239
+ },
240
+ {
241
+ // ARCH row 16: multi-task cycle / gated change → per-task playbook inside
242
+ // the handoff pipeline. There is no file for this row; it resolves a
243
+ // workGroup so the route names the pipeline instead of a playbook.
244
+ workGroup: 'handoff-cycle',
245
+ playbookId: null,
246
+ lanes: [],
247
+ triggers: [/handoff|multi[- ]?task cycle|task pipeline|execute the (?:plan|pipeline)/i],
248
+ path: 'handoff',
249
+ discriminator: () => true,
250
+ },
251
+ ]);
252
+
253
+ // workGroup → canonical playbookId (inverse lookup for registry records).
254
+ const WORKGROUP_TO_PLAYBOOK = Object.freeze(Object.fromEntries(
255
+ PLAYBOOK_ROUTING_TABLE
256
+ .filter((row) => row.playbookId)
257
+ .map((row) => [row.workGroup, row.playbookId]),
258
+ ));
259
+ const PLAYBOOK_TO_WORKGROUP = Object.freeze(Object.fromEntries(
260
+ PLAYBOOK_ROUTING_TABLE
261
+ .filter((row) => row.playbookId)
262
+ .map((row) => [row.playbookId, row.workGroup]),
263
+ ));
264
+
265
+ // C88 FR-009: sub-playbooks are file-level records on an existing work
266
+ // group's row — they ship no routing-table row of their own (SPEC §3), so
267
+ // this map is the bridge from record id to host workGroup. Record hygiene
268
+ // only: `loadRegistry` would otherwise emit `workGroup: null` for these
269
+ // files; routing never resolves them as targets.
270
+ export const SUB_PLAYBOOK_WORKGROUP = Object.freeze({
271
+ hillclimb: 'performance',
272
+ babysit: 'release',
273
+ shipping: 'release',
274
+ orchestrate: 'autonomous-run',
275
+ 'autopilot-full': 'release',
276
+ 'autopilot-stack': 'release',
277
+ 'worktree-cleanup': 'session-pickup',
278
+ 'open-pr': 'release',
279
+ });
280
+
281
+ const PERFORMANCE_ROW = PLAYBOOK_ROUTING_TABLE.find((row) => row.workGroup === 'performance');
282
+ const INVESTIGATION_ROW = PLAYBOOK_ROUTING_TABLE.find((row) => row.workGroup === 'investigation');
283
+
284
+ function matchFirst(text, regexes) {
285
+ for (const regex of regexes) {
286
+ const index = text.search(regex);
287
+ if (index !== -1) return index;
288
+ }
289
+ return -1;
290
+ }
291
+
292
+ function buildRouteSignals({
293
+ signalText,
294
+ executionMode,
295
+ escalationTriggers,
296
+ projectRoot,
297
+ }) {
298
+ // C85-019b: question-tagged mutation verbs are asks, not orders. Strip
299
+ // interrogative clauses from the verb scan BEFORE computing changeIdx —
300
+ // "should I fix X?" and "X … chưa?" mention the verb as subject matter,
301
+ // never as an imperative. A real order ("sửa file X cho tôi?") survives
302
+ // because no consult pattern consumes its clause.
303
+ const verbScanText = signalText
304
+ .replace(/(?:^|[.!;\n]\s*)(?:should|shall|do|does|did|can|could|would|will|is|are|am)\s+(?:i|we|you)\s+[^\n?]*/g, ' ')
305
+ .replace(/[^\n.,;?]{0,80}\b(?:chưa|chua|được không|duoc khong|đúng không|dung khong|không nhỉ|khong nhi|không|khong|rồi|roi|nhỉ|nhi|ạ|a)\s*[?.!]?\s*$/g, ' ')
306
+ .replace(/\b(?:có|co)\s+(?:nên|nen|cần|can|phải|phai)\s+[^\n?]*/g, ' ');
307
+ const changeIdx = verbScanText.search(CHANGE_VERB_RE);
308
+ const createIdx = verbScanText.search(CREATE_VERB_RE);
309
+ const explainIdx = signalText.search(EXPLAIN_ASK_RE);
310
+ const defectIdx = signalText.search(DEFECT_TRIGGER_RE);
311
+ const checkIdx = signalText.search(CHECK_VERB_RE);
312
+ const investigationIdx = signalText.search(INVESTIGATION_TRIGGER_RE);
313
+ const consultLead = /(?:^|[.!;\n]\s*)(?:should|shall|do|does|did|can|could|would|will|is|are|am)\s+(?:i|we|you)\s+/
314
+ .test(signalText)
315
+ || /\b(?:co|có)\s+(?:nên|nen|cần|can|phải|phai)\s+/.test(signalText)
316
+ || /\b(?:chưa|chua|được không|duoc khong|đúng không|dung khong|không nhỉ|khong nhi|nhỉ|nhi|rồi|roi)\s*[?.!]?\s*$/.test(signalText);
317
+ const firstVerbIdx = [changeIdx, createIdx].filter((i) => i !== -1).sort((a, b) => a - b)[0] ?? -1;
318
+ const explanationAsk = explainIdx !== -1 && (firstVerbIdx === -1 || explainIdx < firstVerbIdx);
319
+ const changeRequested = !consultLead && firstVerbIdx !== -1 && (explainIdx === -1 || firstVerbIdx < explainIdx);
320
+ const checkFirst = checkIdx !== -1 && (defectIdx === -1 || checkIdx < defectIdx);
321
+ // C85-019: a QUESTION about a defect is not a defect order. The bug-fix floor
322
+ // demands repro+root-cause receipts — demanding them on a "did it get fixed
323
+ // yet?" turn forces edits nobody asked for (user-reported lock). Question
324
+ // shape = '?', a leading/standalone interrogative in English or Vietnamese
325
+ // (diacritics break \b, so Vietnamese particles test raw substrings), or a
326
+ // status-check ask ('đã … chưa', '… or not'). Any interrogative shape with
327
+ // NO change verb before it is a consult, never a fix order; 'fix X' without
328
+ // a question mark still routes bug-fix.
329
+ const questionShaped = /\?\s*$/.test(signalText)
330
+ || /\b(how|why|what|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(signalText)
331
+ || /(?:có|co)\s+(?:phải|phai)\s+/.test(signalText)
332
+ || /(?:chưa|chua|được không|duoc khong|đúng không|dung khong|không nhỉ|khong nhi|thế nào|the nao|như thế nào|nhu the nao|tại sao|tai sao|là gì|la gi|bao giờ|bao gio|khi nào|khi nao|ở đâu|o dau|không biết|khong biet)/.test(signalText)
333
+ || /(?:chưa|chua|đúng không|dung khong|không|khong)\s*[?.!]?\s*$/.test(signalText);
334
+ const questionAsk = questionShaped && !changeRequested;
335
+ return {
336
+ signalText,
337
+ executionMode,
338
+ changeIdx,
339
+ createIdx,
340
+ explainIdx,
341
+ defectIdx,
342
+ checkIdx,
343
+ investigationIdx,
344
+ explanationAsk,
345
+ changeRequested,
346
+ questionAsk,
347
+ checkFirst,
348
+ changeVerbOrCreate: changeIdx !== -1 || createIdx !== -1,
349
+ contractSignal: CONTRACT_SIGNAL_RE.test(signalText) || /\boauth\b/i.test(signalText),
350
+ multiSurface: MULTI_SURFACE_RE.test(signalText),
351
+ escalated: Array.isArray(escalationTriggers) && escalationTriggers.length > 0,
352
+ disposable: (PROTOTYPE_TRIGGER_RE.test(signalText) || /\b(spike|disposable|throwaway)\b/i.test(signalText))
353
+ && !WIRE_OR_SHIP_RE.test(signalText),
354
+ wiredShipAsk: WIRE_OR_SHIP_RE.test(signalText),
355
+ releaseAsk: RELEASE_TRIGGER_RE.test(signalText),
356
+ forensicsSignal: FORENSICS_TRIGGER_RE.test(signalText)
357
+ || (INTERMITTENT_RE.test(signalText) && !TEST_HARNESS_RE.test(signalText)),
358
+ testHarness: TEST_HARNESS_RE.test(signalText),
359
+ measurable: /\d/.test(signalText) || MEASURABLE_RE.test(signalText),
360
+ dataMove: DATA_MOVE_RE.test(signalText),
361
+ hasResumableRecord: hasResumableRecord(projectRoot),
362
+ // BL-026 review fix: ask to convert/author a skill — beats defect verbs in
363
+ // the same sentence ("make this repeated fix flow a skill").
364
+ skillAsk: SKILL_ASK_RE.test(signalText),
365
+ };
366
+ }
367
+
368
+ // BL-022: measurable = a stated or referenced measurement — a unit-bearing
369
+ // duration, a named measure (latency/baseline/benchmark/profile), or an
370
+ // explicit resource (cpu/memory/heap). Bare "slow"/"faster" vocabulary is a
371
+ // symptom, not a measurement: those asks route investigation instead.
372
+ const MEASURABLE_RE = /\b(latency|baseline|benchmark|profile|cpu|memory|heap|takes?|in \d+ ?(?:ms|s|seconds?|minutes?)|\d+ ?(?:ms|s|seconds?|minutes?)|seconds?|minutes?|ms\b)\b/i;
373
+
374
+ // --- Playbook file frontmatter (registry-strict variant) ---------------------
375
+ // Same fence grammar as src/core/userPlaybooks.js but `id:` + `lanes:` are
376
+ // REQUIRED — a registry file without them is malformed (FR-007), where the
377
+ // workflow-policy loader tolerates filename-stem ids.
378
+ function parsePlaybookMeta(raw, filePath) {
379
+ if (!raw.startsWith('---\n')) {
380
+ return null;
381
+ }
382
+ const fenceIndex = raw.indexOf('\n---', 3);
383
+ if (fenceIndex === -1) {
384
+ return null; // unterminated frontmatter
385
+ }
386
+ const metaText = raw.slice(4, fenceIndex);
387
+ const meta = {};
388
+ for (const line of metaText.split('\n')) {
389
+ const match = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
390
+ if (match) {
391
+ meta[match[1]] = match[2].trim();
392
+ }
393
+ }
394
+ const id = typeof meta.id === 'string' && meta.id !== '' ? meta.id : null;
395
+ if (id === null) {
396
+ return null; // no usable id
397
+ }
398
+ const lanesValue = meta.lanes ?? '';
399
+ const inner = lanesValue.startsWith('[') && lanesValue.endsWith(']')
400
+ ? lanesValue.slice(1, -1)
401
+ : lanesValue;
402
+ const lanes = inner
403
+ .split(',')
404
+ .map((lane) => lane.trim().replace(/^['"]|['"]$/g, ''))
405
+ .filter(Boolean);
406
+ // `lanes:` is REQUIRED for registry files — absent or empty means the file
407
+ // declares no lane ownership and is malformed, not "any lane".
408
+ if (lanes.length === 0) {
409
+ return null;
410
+ }
411
+ const body = raw.slice(fenceIndex + 4).replace(/^\n/, '');
412
+ if (body.trim() === '') {
413
+ return null; // empty body → not a playbook
414
+ }
415
+ return { id, lanes };
416
+ }
417
+
418
+ function registryCandidateDirs({ projectRoot, homeDir, builtinDir } = {}) {
419
+ const candidates = [];
420
+ if (projectRoot) {
421
+ candidates.push({ dir: path.join(projectRoot, '.ukit', 'playbooks'), source: 'project' });
422
+ }
423
+ if (homeDir !== null) {
424
+ const base = homeDir || os.homedir();
425
+ candidates.push({ dir: path.join(base, '.ukit', 'playbooks'), source: 'user' });
426
+ }
427
+ const resolvedBuiltinDir = builtinDir ?? PACKAGE_BUILTIN_PLAYBOOK_DIR;
428
+ candidates.push({ dir: resolvedBuiltinDir, source: 'builtin' });
429
+ return candidates;
430
+ }
431
+
432
+ function inferWorkGroup(playbookId, recordId) {
433
+ return PLAYBOOK_TO_WORKGROUP[playbookId]
434
+ ?? Object.entries(PLAYBOOK_ID_ALIASES).find(([, fileId]) => fileId === recordId)?.[0]
435
+ // BL-020 reverse alias: a record id that IS an alias (pre-rename user-dir
436
+ // file) resolves the workGroup through its canonical playbook id.
437
+ ?? PLAYBOOK_TO_WORKGROUP[PLAYBOOK_ID_ALIASES[recordId]]
438
+ // C88 FR-009: sub-playbook records (no routing row) resolve their host
439
+ // work-group through the frozen map.
440
+ ?? SUB_PLAYBOOK_WORKGROUP[recordId]
441
+ ?? null;
442
+ }
443
+
444
+ // Scan the three precedence dirs, first-wins per playbookId. Returns
445
+ // PlaybookRecord[] — valid records deduplicated by id (project > user >
446
+ // builtin); malformed files appear with frontmatterValid: false + reason so
447
+ // `ukit doctor` can warn without the route path paying for them.
448
+ export async function loadRegistry({ projectRoot = null, homeDir = null, builtinDir = null } = {}) {
449
+ const byId = new Map();
450
+ const records = [];
451
+ for (const { dir, source } of registryCandidateDirs({ projectRoot, homeDir, builtinDir })) {
452
+ let entries;
453
+ try {
454
+ entries = await fs.readdir(dir, { withFileTypes: true });
455
+ } catch {
456
+ continue; // missing/unreadable tier — fall through
457
+ }
458
+ for (const entry of entries) {
459
+ if (!entry.isFile() || !PLAYBOOK_NAME_RE.test(entry.name)) {
460
+ continue;
461
+ }
462
+ const filePath = path.join(dir, entry.name);
463
+ let raw;
464
+ try {
465
+ raw = await fs.readFile(filePath, 'utf8');
466
+ } catch {
467
+ continue;
468
+ }
469
+ const meta = parsePlaybookMeta(raw, filePath);
470
+ if (meta === null) {
471
+ records.push({
472
+ playbookId: null,
473
+ file: filePath,
474
+ workGroup: null,
475
+ lanes: [],
476
+ frontmatterValid: false,
477
+ source,
478
+ reason: 'malformed-frontmatter',
479
+ });
480
+ continue;
481
+ }
482
+ if (byId.has(meta.id)) {
483
+ continue; // higher-precedence tier already registered this id
484
+ }
485
+ const record = {
486
+ playbookId: meta.id,
487
+ file: filePath,
488
+ workGroup: inferWorkGroup(meta.id, meta.id),
489
+ lanes: meta.lanes,
490
+ frontmatterValid: true,
491
+ source,
492
+ };
493
+ byId.set(meta.id, record);
494
+ records.push(record);
495
+ }
496
+ }
497
+ return records;
498
+ }
499
+
500
+ function findRegistryRecord(records, playbookId) {
501
+ const direct = records.find((r) => r.frontmatterValid && r.playbookId === playbookId);
502
+ if (direct) return direct;
503
+ // Aliases work both directions (BL-020): canonical → file name, and file
504
+ // name → canonical when the record itself carries the pre-rename id.
505
+ const aliasId = PLAYBOOK_ID_ALIASES[playbookId];
506
+ if (aliasId) {
507
+ const viaAlias = records.find((r) => r.frontmatterValid && r.playbookId === aliasId);
508
+ if (viaAlias) return viaAlias;
509
+ }
510
+ const reverse = records.find(
511
+ (r) => r.frontmatterValid && PLAYBOOK_ID_ALIASES[r.playbookId] === playbookId,
512
+ );
513
+ return reverse ?? null;
514
+ }
515
+
516
+ // resolvePlaybookId({promptText, executionMode, intentMode, escalationTriggers,
517
+ // projectRoot, homeDir, builtinDir, registry}) →
518
+ // { playbookId|null, workGroup|null, lane, reason?, file?, source? }.
519
+ // Never throws on IO failure — the degrade contract is part of the shape.
520
+ export async function resolvePlaybookId({
521
+ promptText = '',
522
+ commandText = '',
523
+ targetFile = null,
524
+ executionMode = null,
525
+ intentMode = null,
526
+ escalationTriggers = [],
527
+ projectRoot = null,
528
+ homeDir = null,
529
+ builtinDir = null,
530
+ registry = null,
531
+ riskSignals = [],
532
+ } = {}) {
533
+ const lane = executionMode ?? null;
534
+ try {
535
+ const signalText = [promptText, commandText]
536
+ .map((value) => String(value || '').trim().toLowerCase())
537
+ .filter(Boolean)
538
+ .join('\n');
539
+ if (signalText === '') {
540
+ return { playbookId: null, workGroup: null, lane, path: null, fastPath: null, reason: 'no-route-match' };
541
+ }
542
+ // Merge caller-supplied triggers with the deterministic derive — a caller
543
+ // that computed none (schema stages off) must not blind the contract
544
+ // discriminators to real escalation signals in the prompt.
545
+ const floor = deriveRiskFloor({
546
+ promptText,
547
+ commandText,
548
+ targetFile,
549
+ executionMode,
550
+ riskSignals,
551
+ });
552
+ const mergedTriggers = [...new Set([
553
+ ...(Array.isArray(escalationTriggers) ? escalationTriggers : []),
554
+ ...deriveEscalationTriggers(floor),
555
+ ])];
556
+ const signals = buildRouteSignals({
557
+ signalText,
558
+ executionMode,
559
+ escalationTriggers: mergedTriggers,
560
+ projectRoot,
561
+ });
562
+ const matched = PLAYBOOK_ROUTING_TABLE.filter((row) => {
563
+ if (matchFirst(signalText, row.triggers) === -1) {
564
+ return false;
565
+ }
566
+ try {
567
+ return row.discriminator(signals) === true;
568
+ } catch {
569
+ return false;
570
+ }
571
+ });
572
+ // BL-022 (SPEC §14): unmeasured slowness — the performance trigger hit but
573
+ // the row refused (!measurable / !forensicsSignal) and no other row
574
+ // claimed the prompt — resolves through the investigation row instead of
575
+ // degrading: the honest next step is "measure first". Emitted here rather
576
+ // than as an investigation-row trigger so prompts that name slowness AND
577
+ // hit a second row (e.g. "why is the migration slow") keep their existing
578
+ // resolution instead of turning ambiguous.
579
+ const unmeasuredSlowness =
580
+ matched.length === 0
581
+ && matchFirst(signalText, PERFORMANCE_ROW.triggers) !== -1
582
+ && !signals.measurable
583
+ && !signals.forensicsSignal;
584
+ if (matched.length === 0 && !unmeasuredSlowness) {
585
+ // BL-010: a real prompt that no row claims is still a direct-in-session
586
+ // route (ARCH §Routing Decision Table Path column) — path: 'direct',
587
+ // fastPath: 'none' rather than null so route state records it honestly.
588
+ return { playbookId: null, workGroup: null, lane, path: 'direct', fastPath: 'none', reason: 'no-route-match' };
589
+ }
590
+ if (matched.length > 1) {
591
+ return {
592
+ playbookId: null,
593
+ workGroup: null,
594
+ lane,
595
+ path: 'direct',
596
+ fastPath: 'none',
597
+ reason: 'ambiguous-route',
598
+ candidates: matched.map((row) => row.workGroup),
599
+ };
600
+ }
601
+ const row = unmeasuredSlowness ? INVESTIGATION_ROW : matched[0];
602
+ const resolvedLane = row.lanes.includes(executionMode)
603
+ ? executionMode
604
+ : (row.lanes[0] ?? lane);
605
+ if (!row.playbookId) {
606
+ // handoff-cycle: a pipeline row, never a file.
607
+ return { playbookId: null, workGroup: row.workGroup, lane: resolvedLane, path: 'handoff', fastPath: 'none', reason: 'handoff-pipeline' };
608
+ }
609
+ const records = registry ?? await loadRegistry({ projectRoot, homeDir, builtinDir });
610
+ const record = findRegistryRecord(records, row.playbookId);
611
+ if (!record) {
612
+ // Row matched but the file is not registered (deleted post-install, or
613
+ // a playbook this cycle does not ship) — degrade to plain mode with the
614
+ // group named; the route record notes playbookLoad: missing.
615
+ return {
616
+ playbookId: null,
617
+ workGroup: row.workGroup,
618
+ lane: resolvedLane,
619
+ path: row.path ?? 'direct',
620
+ // The route STILL resolves the fast path — the playbook file may be
621
+ // deleted post-install, but the work group and its ceremony class are
622
+ // what the escalation trigger contract operates on.
623
+ fastPath: row.fastPath ?? 'none',
624
+ reason: 'playbook-missing',
625
+ };
626
+ }
627
+ return {
628
+ playbookId: row.playbookId,
629
+ workGroup: record.workGroup ?? row.workGroup,
630
+ lane: resolvedLane,
631
+ path: row.path ?? 'direct',
632
+ fastPath: row.fastPath ?? 'none',
633
+ file: record.file,
634
+ source: record.source,
635
+ };
636
+ } catch {
637
+ return { playbookId: null, workGroup: null, lane, path: null, fastPath: null, reason: 'registry-error' };
638
+ }
639
+ }
640
+
641
+ // Call-site helper: derive escalation triggers from the route signals and
642
+ // resolve the playbook in one step. Both router surfaces merge the result into
643
+ // the route record — playbookId/workGroup on state + audit rows.
644
+ export async function resolvePlaybookRoute({
645
+ promptText = '',
646
+ commandText = '',
647
+ targetFile = null,
648
+ executionMode = null,
649
+ intentMode = null,
650
+ escalationTriggers = [],
651
+ projectRoot = null,
652
+ homeDir = null,
653
+ builtinDir = null,
654
+ registry = null,
655
+ } = {}) {
656
+ const floor = deriveRiskFloor({
657
+ promptText,
658
+ commandText,
659
+ targetFile,
660
+ executionMode,
661
+ });
662
+ const mergedTriggers = [...new Set([
663
+ ...(Array.isArray(escalationTriggers) ? escalationTriggers : []),
664
+ ...deriveEscalationTriggers(floor),
665
+ ])];
666
+ const resolution = await resolvePlaybookId({
667
+ promptText,
668
+ commandText,
669
+ targetFile,
670
+ executionMode,
671
+ intentMode,
672
+ escalationTriggers: mergedTriggers,
673
+ projectRoot,
674
+ homeDir,
675
+ builtinDir,
676
+ registry,
677
+ });
678
+ return {
679
+ playbookId: resolution.playbookId ?? null,
680
+ workGroup: resolution.workGroup ?? null,
681
+ lane: resolution.lane ?? executionMode ?? null,
682
+ // BL-010 (ARCH Task Contract): `path` = direct|handoff, `fastPath` =
683
+ // none|small-feature|bug-fix. Route state names the enum `fastPathKind`
684
+ // because routeSummary.fastPath already carries the FR-004 eligibility
685
+ // object — one field, two consumers, no collision.
686
+ path: resolution.path ?? null,
687
+ fastPath: resolution.fastPath ?? null,
688
+ reason: resolution.reason ?? null,
689
+ playbookLoad: resolution.reason === 'playbook-missing' ? 'missing' : null,
690
+ };
691
+ }