@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.
- package/CHANGELOG.md +44 -0
- package/manifests/engineConformance.yaml +17 -1
- package/manifests/hostCapabilities.yaml +68 -1
- package/manifests/platform.full.yaml +138 -0
- package/manifests/platform.user.yaml +255 -3
- package/package.json +1 -1
- package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
- package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
- package/scripts/probe/codex-capability-probe.mjs +169 -0
- package/src/cli/commands/doctor.js +168 -0
- package/src/cli/commands/indexTools.js +7 -0
- package/src/cli/commands/metrics.js +66 -2
- package/src/cli/commands/playbook.js +4 -4
- package/src/cli/commands/vm.js +49 -8
- package/src/core/agentRuntime/adapters.js +328 -27
- package/src/core/agentRuntime/artifacts.js +89 -0
- package/src/core/agentRuntime/context.js +345 -1
- package/src/core/agentRuntime/contract.js +296 -0
- package/src/core/agentRuntime/eventStore.js +176 -0
- package/src/core/agentRuntime/shadowRun.js +481 -5
- package/src/core/agentRuntime/telemetry.js +121 -0
- package/src/core/observability/emit/lifecycle.js +68 -1
- package/src/core/observability/emit/sessionBoot.js +393 -0
- package/src/core/observability/privacy/allowlist.js +10 -1
- package/src/core/observability/schema/registry.js +10 -0
- package/src/core/runtimeConfig.js +133 -0
- package/src/core/userPlaybooks.js +18 -3
- package/src/decision/registry.js +19 -0
- package/src/diagnostics/feedbackEvents.js +7 -4
- package/src/diagnostics/routeOutcomes.js +51 -6
- package/src/diagnostics/skillAccuracy.js +43 -3
- package/src/index/crossCheckMatrix.js +412 -0
- package/src/index/fixLoopEscalation.js +453 -0
- package/src/index/playbookRegistry.js +691 -0
- package/src/index/reviewPolicy.js +368 -0
- package/src/index/routeResolver.js +915 -0
- package/src/index/sessionHistoryExtractor.js +359 -0
- package/src/index/taskRouting.js +764 -581
- package/src/index/tierSelection.js +308 -0
- package/src/index/verificationMap.js +404 -0
- package/template_project/.claude/hooks/observability-emit.mjs +14 -0
- package/template_project/.claude/hooks/record-execution.mjs +19 -1
- package/template_project/.claude/hooks/skill-router.sh +691 -25
- package/template_project/.claude/hooks/verification-guard.sh +230 -1
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
- package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
- package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
- package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
- package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
- package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
- package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
- package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
- package/template_project/.codex/README.md +8 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
- package/template_project/ukit/README.md +1 -1
- package/template_project/ukit/storage/config.json +20 -0
- package/template_user/playbooks/architecture-decision.md +28 -0
- package/template_user/playbooks/autonomous-run.md +43 -0
- package/template_user/playbooks/autopilot-full.md +59 -0
- package/template_user/playbooks/autopilot-stack.md +54 -0
- package/template_user/playbooks/babysit.md +39 -0
- package/template_user/playbooks/bug-fix.md +3 -1
- package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
- package/template_user/playbooks/hillclimb.md +44 -0
- package/template_user/playbooks/investigation.md +21 -0
- package/template_user/playbooks/migration.md +21 -0
- package/template_user/playbooks/open-pr.md +48 -0
- package/template_user/playbooks/orchestrate.md +45 -0
- package/template_user/playbooks/performance.md +33 -0
- package/template_user/playbooks/prototype.md +28 -0
- package/template_user/playbooks/refactor.md +19 -0
- package/template_user/playbooks/release.md +28 -0
- package/template_user/playbooks/runtime-forensics.md +23 -0
- package/template_user/playbooks/session-pickup.md +31 -0
- package/template_user/playbooks/shipping.md +53 -0
- package/template_user/playbooks/skill-evaluation.md +48 -0
- package/template_user/playbooks/small-feature.md +20 -0
- package/template_user/playbooks/verification-map.json +153 -0
- package/template_user/playbooks/verification.md +22 -0
- 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
|
+
}
|