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