@ngockhoale/ukit 3.3.3 → 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 +40 -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
package/src/index/taskRouting.js
CHANGED
|
@@ -10,13 +10,73 @@ import {
|
|
|
10
10
|
CONTRACT_RISK_PROFILES,
|
|
11
11
|
EXECUTION_CONTRACTS,
|
|
12
12
|
EXECUTION_MODE_ORDER,
|
|
13
|
-
MODEL_TIER_BY_CONTRACT,
|
|
14
|
-
ROUTE_EFFORTS,
|
|
15
|
-
resolveModelTier,
|
|
16
13
|
} from '../core/executionContracts.js';
|
|
17
|
-
import { resolveModelRoles, readMergedRuntimeConfig, resolveConfigStage } from '../core/runtimeConfig.js';
|
|
14
|
+
import { resolveModelRoles, readMergedRuntimeConfig, resolveConfigStage, resolveSubagentOrchestratorStage } from '../core/runtimeConfig.js';
|
|
18
15
|
import { runShadowDecisions } from '../decision/shadow.js';
|
|
19
16
|
import { resolvePlaybook } from '../core/userPlaybooks.js';
|
|
17
|
+
import { resolvePlaybookRoute, loadRegistry } from './playbookRegistry.js';
|
|
18
|
+
import { extractHistorySignalsAsync } from './sessionHistoryExtractor.js';
|
|
19
|
+
import { applyMeasuredTier } from './tierSelection.js';
|
|
20
|
+
import { createDecisionClient } from '../decision/client.js';
|
|
21
|
+
import {
|
|
22
|
+
applyFixLoopEscalation,
|
|
23
|
+
isFixLoopEscalationDue,
|
|
24
|
+
} from './fixLoopEscalation.js';
|
|
25
|
+
import {
|
|
26
|
+
ROUTE_VERSION,
|
|
27
|
+
ROUTE_CONTRACT_VERSION,
|
|
28
|
+
ROUTE_SCHEMA_STAGES,
|
|
29
|
+
ROUTE_INTENT_KINDS,
|
|
30
|
+
ROUTE_MUTABILITIES,
|
|
31
|
+
ROUTE_RIGOR_LEVELS,
|
|
32
|
+
ROUTE_MODEL_TIERS,
|
|
33
|
+
ROUTE_RISK_FLOORS,
|
|
34
|
+
ROUTE_RISK_REASON_CODES,
|
|
35
|
+
ROUTE_EXECUTION_MODES,
|
|
36
|
+
resolveRouteSchemaStage,
|
|
37
|
+
resolveRouteStage,
|
|
38
|
+
deriveRiskFloor,
|
|
39
|
+
formatRiskFloorSegment,
|
|
40
|
+
deriveCeremonyLimits,
|
|
41
|
+
formatLimitsSegment,
|
|
42
|
+
deriveFastPath,
|
|
43
|
+
formatFastPathSegment,
|
|
44
|
+
isDeliveryOnlyRequest,
|
|
45
|
+
buildCompletionState,
|
|
46
|
+
validateResolvedRoute,
|
|
47
|
+
compactResolvedRoute,
|
|
48
|
+
deriveRouteFields,
|
|
49
|
+
resolveDecisionPlaneStage,
|
|
50
|
+
} from './routeResolver.js';
|
|
51
|
+
|
|
52
|
+
// TASK-004 (BL-006): the derivation primitives this file used to carry inline
|
|
53
|
+
// now live in routeResolver.js — the canonical half of the shared resolver the
|
|
54
|
+
// hook path consumes via route-resolver.mjs. Exported names stay identical via
|
|
55
|
+
// re-export (tests and the Codex instruction path import them from here).
|
|
56
|
+
export {
|
|
57
|
+
ROUTE_VERSION,
|
|
58
|
+
ROUTE_CONTRACT_VERSION,
|
|
59
|
+
ROUTE_SCHEMA_STAGES,
|
|
60
|
+
ROUTE_INTENT_KINDS,
|
|
61
|
+
ROUTE_MUTABILITIES,
|
|
62
|
+
ROUTE_RIGOR_LEVELS,
|
|
63
|
+
ROUTE_MODEL_TIERS,
|
|
64
|
+
ROUTE_RISK_FLOORS,
|
|
65
|
+
ROUTE_RISK_REASON_CODES,
|
|
66
|
+
ROUTE_EXECUTION_MODES,
|
|
67
|
+
resolveRouteSchemaStage,
|
|
68
|
+
resolveRouteStage,
|
|
69
|
+
deriveRiskFloor,
|
|
70
|
+
formatRiskFloorSegment,
|
|
71
|
+
deriveCeremonyLimits,
|
|
72
|
+
formatLimitsSegment,
|
|
73
|
+
deriveFastPath,
|
|
74
|
+
formatFastPathSegment,
|
|
75
|
+
buildCompletionState,
|
|
76
|
+
validateResolvedRoute,
|
|
77
|
+
compactResolvedRoute,
|
|
78
|
+
deriveRouteFields,
|
|
79
|
+
};
|
|
20
80
|
|
|
21
81
|
// --- v3 route-side advisory blocks (docs/pstack/SPEC-playbook-todo.md, ------------------
|
|
22
82
|
// SPEC-principle-index.md, SPEC-model-roles.md). All three are additive stdout text —
|
|
@@ -38,29 +98,33 @@ If the user says "new task", re-route — do not treat the message as the next s
|
|
|
38
98
|
"might help" is a hypothesis, not a fix; it does not ship.
|
|
39
99
|
4. Verify on the same surface: the original repro now passes. "Inconclusive" or
|
|
40
100
|
wrong-surface is not a pass. A unit test shows branch behavior, not bug absence.
|
|
41
|
-
5. Keep the rejected hypotheses — one line each, why ruled out.
|
|
101
|
+
5. Keep the rejected hypotheses — one line each, why ruled out. When rejections on
|
|
102
|
+
this same defect cross the fix-loop threshold, stop retrying this lane —
|
|
103
|
+
escalate to runtime-forensics (live symptom) or a deeper debug/verify lane.
|
|
42
104
|
Reply: what was broken, root cause, fix, how verified — paste failing-then-passing
|
|
43
105
|
repro output verbatim.
|
|
44
106
|
Ask the human only for: irreversible writes, a genuine preference call no experiment settles, or a real dead end. Everything else: do it, report it.`,
|
|
45
|
-
'
|
|
107
|
+
'feature-implementation': `You own this task. Normalize the goal, build, verify.
|
|
46
108
|
If the user says "new task", re-route — do not treat the message as the next step.
|
|
47
109
|
1. State the done condition as a checkable predicate before writing code.
|
|
48
110
|
2. Find the established analog — follow it unless you name why it does not fit.
|
|
111
|
+
(Skippable only when you can name why no analog exists.)
|
|
49
112
|
3. Name the data shape and its organizing structure before writing logic.
|
|
50
113
|
4. Implement the smallest change satisfying the predicate.
|
|
51
114
|
5. Verify against the predicate on the real artifact — not "it compiles".
|
|
52
115
|
6. Widen once: check the impact surface the route named, no broader.
|
|
53
116
|
Reply: what changed, the predicate, the evidence it now holds.
|
|
54
|
-
Ask the human only for: irreversible writes, a genuine preference call no experiment
|
|
117
|
+
Ask the human only for: irreversible writes, a genuine preference call no experiment
|
|
118
|
+
settles, or a real dead end. Everything else: do it, report it.`,
|
|
55
119
|
});
|
|
56
120
|
|
|
57
121
|
// Lane → policy (§2.2). tiny-fix/local-fix stay lean (Fast Path); review-release and
|
|
58
122
|
// informational carry no policy.
|
|
59
123
|
export const WORKFLOW_POLICY_BY_MODE = Object.freeze({
|
|
60
124
|
'find-cause': 'bug-fix',
|
|
61
|
-
'local-build': '
|
|
62
|
-
'shared-edit': '
|
|
63
|
-
'map-impact': '
|
|
125
|
+
'local-build': 'feature-implementation',
|
|
126
|
+
'shared-edit': 'feature-implementation',
|
|
127
|
+
'map-impact': 'feature-implementation',
|
|
64
128
|
});
|
|
65
129
|
|
|
66
130
|
// SPEC-principle-index §2.1: ~16-line index, one line per principle; group headers are
|
|
@@ -147,480 +211,6 @@ function deriveContextDocs({ taskType = null, intentMode = null } = {}) {
|
|
|
147
211
|
return unique(docs).slice(0, CONTEXT_DOCS_MAX);
|
|
148
212
|
}
|
|
149
213
|
|
|
150
|
-
// --- M01.1: additive ResolvedTaskRoute v1 (docs/pstack/CONTRACTS.md C01) -------------------
|
|
151
|
-
// Emitted only when `routing.routeSchema.stage` (runtime config, default "off") is not
|
|
152
|
-
// "off". All fields are additive on top of the existing routeSummary shape — legacy
|
|
153
|
-
// top-level fields are never removed or renamed, so older consumers keep working.
|
|
154
|
-
export const ROUTE_VERSION = 1;
|
|
155
|
-
export const ROUTE_CONTRACT_VERSION = 1;
|
|
156
|
-
export const ROUTE_SCHEMA_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
|
|
157
|
-
export const ROUTE_INTENT_KINDS = new Set(['informational', 'delivery', 'mutation', 'investigation', 'review']);
|
|
158
|
-
export const ROUTE_MUTABILITIES = new Set(['read-only', 'mutating', 'mixed']);
|
|
159
|
-
export const ROUTE_RIGOR_LEVELS = new Set(['R0', 'R1', 'R2', 'R3', 'R4']);
|
|
160
|
-
export const ROUTE_MODEL_TIERS = new Set(['lite', 'code', 'smart']);
|
|
161
|
-
export const ROUTE_RISK_FLOORS = new Set(['none', 'high-risk']);
|
|
162
|
-
// SPEC §5 FR-003: fixed table order — codes are emitted and printed in this order.
|
|
163
|
-
// The first six raise the floor to 'high-risk'; the last two are informational only.
|
|
164
|
-
export const ROUTE_RISK_REASON_CODES = Object.freeze([
|
|
165
|
-
'security-sensitive',
|
|
166
|
-
'shared-impact',
|
|
167
|
-
'public-contract',
|
|
168
|
-
'schema-change',
|
|
169
|
-
'destructive-action',
|
|
170
|
-
'cross-engine',
|
|
171
|
-
'established-precedent',
|
|
172
|
-
'explicit-local-target',
|
|
173
|
-
]);
|
|
174
|
-
const ROUTE_RISK_FLOOR_RAISING_CODES = new Set(ROUTE_RISK_REASON_CODES.slice(0, 6));
|
|
175
|
-
const ROUTE_RISK_ENGINES = ['claude code', 'codex', 'omp'];
|
|
176
|
-
|
|
177
|
-
// FR-004 (M01.3'): Fast Path eligibility vocabulary. The suppressed list is fixed
|
|
178
|
-
// text per SPEC — deliberately not configurable. Reasons are the informational
|
|
179
|
-
// risk codes (the last two ROUTE_RISK_REASON_CODES entries) present on the route.
|
|
180
|
-
const FAST_PATH_MODES = new Set(['tiny-fix', 'local-fix']);
|
|
181
|
-
const FAST_PATH_SUPPRESSED = Object.freeze(['design', 'smart', 'subagents', 'broad-verify']);
|
|
182
|
-
const FAST_PATH_REASON_CODES = new Set(ROUTE_RISK_REASON_CODES.slice(6));
|
|
183
|
-
|
|
184
|
-
// 'informational' is a real router emission (no completion contract) even though it is
|
|
185
|
-
// not part of the seven-lane EXECUTION_MODE_ORDER ladder.
|
|
186
|
-
export const ROUTE_EXECUTION_MODES = [...EXECUTION_MODE_ORDER, 'informational'];
|
|
187
|
-
const ROUTE_ESCALATION_CEILING = 'review-release';
|
|
188
|
-
const ROUTE_GOAL_MAX_LENGTH = 240;
|
|
189
|
-
|
|
190
|
-
// Stage keys treat absence as "off" (MIGRATION_ROLLBACK named-keys table). Unknown or
|
|
191
|
-
// malformed values degrade to "off" — the conservative reading that keeps the route
|
|
192
|
-
// byte-identical to the pre-M01.1 shape.
|
|
193
|
-
export function resolveRouteSchemaStage(config = null) {
|
|
194
|
-
const stage = config?.routing?.routeSchema?.stage;
|
|
195
|
-
return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// FR-002: generic stage resolver — every routing.<key>.stage shares the same
|
|
199
|
-
// absent/malformed → 'off' contract. resolveRouteSchemaStage stays as the
|
|
200
|
-
// routeSchema-specific spelling of this helper.
|
|
201
|
-
export function resolveRouteStage(config = null, key) {
|
|
202
|
-
const stage = config?.routing?.[key]?.stage;
|
|
203
|
-
return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// FR-003 (M01.2'): derive the additive riskFloor from hard signals. Codes are
|
|
207
|
-
// collected in ROUTE_RISK_REASON_CODES table order; the floor is 'high-risk' iff
|
|
208
|
-
// any of the first six (floor-raising) codes fired — informational codes never
|
|
209
|
-
// raise it. Detectors read the normalized signal text (same source the mode
|
|
210
|
-
// ladder uses) plus the raw target path and context preview.
|
|
211
|
-
export function deriveRiskFloor({
|
|
212
|
-
promptText = '',
|
|
213
|
-
commandText = '',
|
|
214
|
-
targetFile = null,
|
|
215
|
-
executionMode = null,
|
|
216
|
-
activeSkillIds = [],
|
|
217
|
-
contextPreview = null,
|
|
218
|
-
} = {}) {
|
|
219
|
-
const signalText = buildRouteSignalText(promptText, commandText);
|
|
220
|
-
const target = String(targetFile || '');
|
|
221
|
-
const skillIds = Array.isArray(activeSkillIds) ? activeSkillIds : [];
|
|
222
|
-
const codes = [];
|
|
223
|
-
if (
|
|
224
|
-
/\b(auth|security|token|permission|secret|credential|password|vulnerab|exploit|xss|injection)\b/i.test(signalText)
|
|
225
|
-
|| skillIds.includes('discover-security')
|
|
226
|
-
) {
|
|
227
|
-
codes.push('security-sensitive');
|
|
228
|
-
}
|
|
229
|
-
if (isSharedImpactFile(targetFile) || executionMode === 'shared-edit' || executionMode === 'map-impact') {
|
|
230
|
-
codes.push('shared-impact');
|
|
231
|
-
}
|
|
232
|
-
if (
|
|
233
|
-
/(^|\/)(package\.json|manifests\/|.*\.d\.ts$|(^|\/)api\/|openapi|swagger)/i.test(target)
|
|
234
|
-
|| /\b(public api|breaking change|api contract|semver)\b/i.test(signalText)
|
|
235
|
-
) {
|
|
236
|
-
codes.push('public-contract');
|
|
237
|
-
}
|
|
238
|
-
if (
|
|
239
|
-
/(^|\/)(migrations?|db|database|prisma|schema)/i.test(target)
|
|
240
|
-
|| /\b(migration|migrate|schema|alter table|add column|drop column)\b/i.test(signalText)
|
|
241
|
-
) {
|
|
242
|
-
codes.push('schema-change');
|
|
243
|
-
}
|
|
244
|
-
if (/\b(delete|drop|truncate|destroy|wipe|uninstall|rm -rf|purge)\b/i.test(signalText)) {
|
|
245
|
-
codes.push('destructive-action');
|
|
246
|
-
}
|
|
247
|
-
const engineHits = ROUTE_RISK_ENGINES.filter(
|
|
248
|
-
(name) => new RegExp(`\\b${name}\\b`, 'i').test(signalText),
|
|
249
|
-
).length;
|
|
250
|
-
if (
|
|
251
|
-
/\bcross[- ]engine\b|\ball engines\b/i.test(signalText)
|
|
252
|
-
|| engineHits >= 2
|
|
253
|
-
|| /^template_project\/\.(claude|codex|omp)\//i.test(target)
|
|
254
|
-
) {
|
|
255
|
-
codes.push('cross-engine');
|
|
256
|
-
}
|
|
257
|
-
if ((contextPreview?.analogFiles?.length ?? 0) > 0 || (contextPreview?.styleFiles?.length ?? 0) > 0) {
|
|
258
|
-
codes.push('established-precedent');
|
|
259
|
-
}
|
|
260
|
-
if (target && !isSharedImpactFile(targetFile)) {
|
|
261
|
-
codes.push('explicit-local-target');
|
|
262
|
-
}
|
|
263
|
-
return {
|
|
264
|
-
floor: codes.some((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code)) ? 'high-risk' : 'none',
|
|
265
|
-
codes,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Route-line segment (FR-003): on 'high-risk' only the floor-raising codes print;
|
|
270
|
-
// on 'none' the informational codes do. Empty list → no segment at all.
|
|
271
|
-
function formatRiskFloorSegment(riskFloor = null) {
|
|
272
|
-
if (!riskFloor) {
|
|
273
|
-
return null;
|
|
274
|
-
}
|
|
275
|
-
const printed = riskFloor.floor === 'high-risk'
|
|
276
|
-
? riskFloor.codes.filter((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code))
|
|
277
|
-
: riskFloor.codes;
|
|
278
|
-
return printed.length > 0 ? `risk=${riskFloor.floor}(${printed.join(',')})` : null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// FR-001 (M01.2' limits fragment): compile the contract's numeric budget keys
|
|
282
|
-
// into an advisory map. Only finite numbers survive — a non-numeric or missing
|
|
283
|
-
// key is simply absent. Zero is a real budget ("no read passes"), never
|
|
284
|
-
// filtered. Same key set as the ceremonyBudget.limits builder below.
|
|
285
|
-
export function deriveCeremonyLimits(executionContract = null) {
|
|
286
|
-
if (executionContract === null || typeof executionContract !== 'object') {
|
|
287
|
-
return {};
|
|
288
|
-
}
|
|
289
|
-
return Object.fromEntries(
|
|
290
|
-
['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
|
|
291
|
-
.filter((key) => Number.isFinite(executionContract[key]))
|
|
292
|
-
.map((key) => [key, executionContract[key]]),
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Route-line segment (FR-002): fixed reads,ctx,reassess order; only present
|
|
297
|
-
// keys print. Empty/absent map → null so the segment never appears.
|
|
298
|
-
export function formatLimitsSegment(limits = null) {
|
|
299
|
-
if (limits === null || typeof limits !== 'object') {
|
|
300
|
-
return null;
|
|
301
|
-
}
|
|
302
|
-
const parts = [
|
|
303
|
-
['reads', 'maxReadPasses'],
|
|
304
|
-
['ctx', 'maxContextPulls'],
|
|
305
|
-
['reassess', 'maxReadPassesBeforeReassess'],
|
|
306
|
-
]
|
|
307
|
-
.filter(([, key]) => Number.isFinite(limits[key]))
|
|
308
|
-
.map(([label, key]) => `${label}:${limits[key]}`);
|
|
309
|
-
return parts.length > 0 ? `limits=${parts.join(',')}` : null;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// FR-004 (M01.3'): Fast Path eligibility predicate. Returns null when no riskFloor
|
|
313
|
-
// was supplied — eligibility must never be derived without the floor check, so a
|
|
314
|
-
// missing floor means "not computed", not "none". Eligible iff the lane is
|
|
315
|
-
// tiny-fix/local-fix, exactly one local target is known, the floor is 'none',
|
|
316
|
-
// a bounded verification path exists (targeted commands or the tiny-fix
|
|
317
|
-
// 'minimal-or-targeted' contract policy), and the target is not shared-impact.
|
|
318
|
-
export function deriveFastPath({
|
|
319
|
-
executionMode = null,
|
|
320
|
-
targetFile = null,
|
|
321
|
-
riskFloor = null,
|
|
322
|
-
verificationRecommendation = null,
|
|
323
|
-
contextPreview = null,
|
|
324
|
-
} = {}) {
|
|
325
|
-
if (!riskFloor) {
|
|
326
|
-
return null;
|
|
327
|
-
}
|
|
328
|
-
const reasons = (riskFloor.codes ?? []).filter((code) => FAST_PATH_REASON_CODES.has(code));
|
|
329
|
-
const hasLocalTarget = Boolean(targetFile) || contextPreview?.primaryTargets?.length === 1;
|
|
330
|
-
const hasBoundedVerification = (verificationRecommendation?.commands?.length ?? 0) > 0
|
|
331
|
-
|| buildExecutionContract(executionMode)?.verificationPolicy === 'minimal-or-targeted';
|
|
332
|
-
const eligible = FAST_PATH_MODES.has(executionMode)
|
|
333
|
-
&& hasLocalTarget
|
|
334
|
-
&& riskFloor.floor === 'none'
|
|
335
|
-
&& hasBoundedVerification
|
|
336
|
-
&& !isSharedImpactFile(targetFile);
|
|
337
|
-
return {
|
|
338
|
-
eligible,
|
|
339
|
-
suppressed: eligible ? [...FAST_PATH_SUPPRESSED] : [],
|
|
340
|
-
reasons,
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Route-line segment (FR-004): emitted only for eligible routes — ineligible
|
|
345
|
-
// routes keep the routeSummary.fastPath field for telemetry but stay silent.
|
|
346
|
-
function formatFastPathSegment(fastPath = null) {
|
|
347
|
-
if (!fastPath?.eligible) {
|
|
348
|
-
return null;
|
|
349
|
-
}
|
|
350
|
-
const reasons = fastPath.reasons?.length ? ` (${fastPath.reasons.join(',')})` : '';
|
|
351
|
-
return `fastPath=on | suppress: ${FAST_PATH_SUPPRESSED.join(',')}${reasons}`;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
// A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
|
|
357
|
-
// repository mutation the ledger could ever receipt. With no edit/review/debug/build
|
|
358
|
-
// signal present, routing it to an investigation lane fabricates write debt and the
|
|
359
|
-
// completion gate then demands an edit that cannot exist. Extracted from
|
|
360
|
-
// deriveExecutionMode so the C01 intent.kind mapping can reuse the identical predicate
|
|
361
|
-
// (delivery-only → 'delivery') instead of duplicating the regexes.
|
|
362
|
-
function isDeliveryOnlyRequest({ signalText = '', scores = {}, targetFile = null } = {}) {
|
|
363
|
-
const signalRaw = String(signalText || '').toLowerCase();
|
|
364
|
-
const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
|
|
365
|
-
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
|
|
366
|
-
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
|
|
367
|
-
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
|
|
368
|
-
return deliveryWordSignal
|
|
369
|
-
&& scores.editCertainty === 0
|
|
370
|
-
&& !scores.implementSignal
|
|
371
|
-
&& !scores.reviewSignal
|
|
372
|
-
&& !scores.debugSignal
|
|
373
|
-
&& !scores.failureSignal
|
|
374
|
-
&& !scores.impactSignal
|
|
375
|
-
&& !scores.buildSignal
|
|
376
|
-
&& !scores.directTransformSignal
|
|
377
|
-
&& !scores.smallFixSignal
|
|
378
|
-
&& !scores.sharedRisk
|
|
379
|
-
&& !targetFile;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
// @decision-point route.intent-kind.v1 — intent kind is a registered
|
|
384
|
-
// consequential decision (DECISION_REGISTRY); this deterministic mapping is
|
|
385
|
-
// its fallback policy implementation.
|
|
386
|
-
// CONTRACTS.md "Intent vocabulary mapping": taskType informs mode priors, not
|
|
387
|
-
// intent.kind; intentMode maps to kind as question/explanation → informational,
|
|
388
|
-
// ship/deliver → delivery, code change → mutation, root-cause/diagnosis →
|
|
389
|
-
// investigation, review/audit → review.
|
|
390
|
-
function deriveRouteIntentKind({ executionMode = null, intentMode = null, deliveryOnly = false } = {}) {
|
|
391
|
-
if (executionMode === 'find-cause') return 'investigation';
|
|
392
|
-
if (executionMode === 'review-release') return 'review';
|
|
393
|
-
if (executionMode === 'informational') return deliveryOnly ? 'delivery' : 'informational';
|
|
394
|
-
if (executionMode) return 'mutation';
|
|
395
|
-
if (intentMode === 'review-specific') return 'review';
|
|
396
|
-
if (intentMode === 'debug-specific') return 'investigation';
|
|
397
|
-
if (intentMode === 'implement-specific' || intentMode === 'docs-specific') return 'mutation';
|
|
398
|
-
return 'informational';
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Existing read-only vs mutating classification: only the informational lane carries
|
|
402
|
-
// no write debt; every contract lane is mutating.
|
|
403
|
-
function deriveRouteMutability(executionMode = null) {
|
|
404
|
-
return executionMode && executionMode !== 'informational' ? 'mutating' : 'read-only';
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function compactRouteGoal(text = '') {
|
|
408
|
-
const goal = String(text || '').trim();
|
|
409
|
-
if (!goal) return null;
|
|
410
|
-
return goal.length > ROUTE_GOAL_MAX_LENGTH ? `${goal.slice(0, ROUTE_GOAL_MAX_LENGTH)}…` : goal;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Builds the additive C01 groups for one resolved route. rigor stays null until
|
|
414
|
-
// M01.2 derives it; ceremonyBudget/capabilityPolicy are empty shaped objects M02/M01.2
|
|
415
|
-
// populate; escalation.current mirrors the selected mode.
|
|
416
|
-
function buildResolvedRouteFields({
|
|
417
|
-
routingContext = {},
|
|
418
|
-
activeSkillIds = [],
|
|
419
|
-
executionMode = null,
|
|
420
|
-
executionContract = null,
|
|
421
|
-
completionState = null,
|
|
422
|
-
riskFloor = null,
|
|
423
|
-
} = {}) {
|
|
424
|
-
// Decision table v2 (FR-001/FR-002): tier + effort resolve together from the
|
|
425
|
-
// contract lane and the additive riskFloor. The router cannot observe host
|
|
426
|
-
// binding capabilities, so the emitted pair is advisory text by definition.
|
|
427
|
-
const tierDecision = resolveModelTier({ executionMode, riskFloor });
|
|
428
|
-
const signalText = buildRouteSignalText(routingContext.promptText, routingContext.commandText);
|
|
429
|
-
const deliveryOnly = isDeliveryOnlyRequest({
|
|
430
|
-
signalText,
|
|
431
|
-
scores: routingContext.executionScores ?? {},
|
|
432
|
-
targetFile: routingContext.targetFile ?? null,
|
|
433
|
-
});
|
|
434
|
-
return {
|
|
435
|
-
routeVersion: ROUTE_VERSION,
|
|
436
|
-
intent: {
|
|
437
|
-
kind: deriveRouteIntentKind({
|
|
438
|
-
executionMode,
|
|
439
|
-
intentMode: routingContext.intentMode ?? null,
|
|
440
|
-
deliveryOnly,
|
|
441
|
-
}),
|
|
442
|
-
mutability: deriveRouteMutability(executionMode),
|
|
443
|
-
goal: compactRouteGoal(routingContext.lastExplicitUserPromptText ?? routingContext.promptText),
|
|
444
|
-
doneConditions: [],
|
|
445
|
-
},
|
|
446
|
-
execution: {
|
|
447
|
-
mode: executionMode,
|
|
448
|
-
rigor: null,
|
|
449
|
-
riskFloor: riskFloor?.floor ?? null,
|
|
450
|
-
phase: null,
|
|
451
|
-
contractVersion: ROUTE_CONTRACT_VERSION,
|
|
452
|
-
modelTier: tierDecision.tier,
|
|
453
|
-
effort: tierDecision.effort,
|
|
454
|
-
},
|
|
455
|
-
evidence: {
|
|
456
|
-
observations: [],
|
|
457
|
-
riskSignals: riskFloor?.codes ?? [],
|
|
458
|
-
activationReasons: [],
|
|
459
|
-
suppressionReasons: [],
|
|
460
|
-
completionRequirements: unique(completionState?.missingEvidence ?? []),
|
|
461
|
-
},
|
|
462
|
-
ceremonyBudget: {
|
|
463
|
-
policyVersion: ROUTE_CONTRACT_VERSION,
|
|
464
|
-
rigor: null,
|
|
465
|
-
limits: riskFloor
|
|
466
|
-
? Object.fromEntries(
|
|
467
|
-
['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
|
|
468
|
-
.filter((key) => typeof executionContract?.[key] === 'number')
|
|
469
|
-
.map((key) => [key, executionContract[key]]),
|
|
470
|
-
)
|
|
471
|
-
: {},
|
|
472
|
-
consumed: {},
|
|
473
|
-
exceptions: [],
|
|
474
|
-
},
|
|
475
|
-
capabilityPolicy: {
|
|
476
|
-
policyVersion: ROUTE_CONTRACT_VERSION,
|
|
477
|
-
required: [],
|
|
478
|
-
recommended: [],
|
|
479
|
-
suppressed: [],
|
|
480
|
-
activeSkillIds: unique(activeSkillIds),
|
|
481
|
-
},
|
|
482
|
-
escalation: {
|
|
483
|
-
current: { mode: executionMode, rigor: null },
|
|
484
|
-
ceiling: ROUTE_ESCALATION_CEILING,
|
|
485
|
-
triggers: [],
|
|
486
|
-
history: [],
|
|
487
|
-
},
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// Plain-JS validator for the additive C01 groups. Returns { valid, errors }; it never
|
|
492
|
-
// throws and never inspects legacy fields — old consumers may carry anything else.
|
|
493
|
-
export function validateResolvedRoute(route = null) {
|
|
494
|
-
const errors = [];
|
|
495
|
-
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
496
|
-
if (!isObject(route)) {
|
|
497
|
-
return { valid: false, errors: ['route must be an object.'] };
|
|
498
|
-
}
|
|
499
|
-
if (route.routeVersion !== ROUTE_VERSION) {
|
|
500
|
-
errors.push(`routeVersion must be ${ROUTE_VERSION}.`);
|
|
501
|
-
}
|
|
502
|
-
if (!isObject(route.intent)) {
|
|
503
|
-
errors.push('intent must be an object.');
|
|
504
|
-
} else {
|
|
505
|
-
if (!ROUTE_INTENT_KINDS.has(route.intent.kind)) {
|
|
506
|
-
errors.push(`intent.kind must be one of: ${[...ROUTE_INTENT_KINDS].join(', ')}.`);
|
|
507
|
-
}
|
|
508
|
-
if (!ROUTE_MUTABILITIES.has(route.intent.mutability)) {
|
|
509
|
-
errors.push(`intent.mutability must be one of: ${[...ROUTE_MUTABILITIES].join(', ')}.`);
|
|
510
|
-
}
|
|
511
|
-
if (route.intent.goal !== null && typeof route.intent.goal !== 'string') {
|
|
512
|
-
errors.push('intent.goal must be a string or null.');
|
|
513
|
-
}
|
|
514
|
-
if (!Array.isArray(route.intent.doneConditions)) {
|
|
515
|
-
errors.push('intent.doneConditions must be an array.');
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
if (!isObject(route.execution)) {
|
|
519
|
-
errors.push('execution must be an object.');
|
|
520
|
-
} else {
|
|
521
|
-
if (route.execution.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.execution.mode)) {
|
|
522
|
-
errors.push(`execution.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
|
|
523
|
-
}
|
|
524
|
-
if (route.execution.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.execution.rigor)) {
|
|
525
|
-
errors.push(`execution.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
|
|
526
|
-
}
|
|
527
|
-
if (route.execution.riskFloor !== null && !ROUTE_RISK_FLOORS.has(route.execution.riskFloor)) {
|
|
528
|
-
errors.push(`execution.riskFloor must be null or one of: ${[...ROUTE_RISK_FLOORS].join(', ')}.`);
|
|
529
|
-
}
|
|
530
|
-
if (route.execution.contractVersion !== ROUTE_CONTRACT_VERSION) {
|
|
531
|
-
errors.push(`execution.contractVersion must be ${ROUTE_CONTRACT_VERSION}.`);
|
|
532
|
-
}
|
|
533
|
-
if (route.execution.modelTier !== null && !ROUTE_MODEL_TIERS.has(route.execution.modelTier)) {
|
|
534
|
-
errors.push(`execution.modelTier must be null or one of: ${[...ROUTE_MODEL_TIERS].join(', ')}.`);
|
|
535
|
-
}
|
|
536
|
-
if (route.execution.effort !== null && route.execution.effort !== undefined
|
|
537
|
-
&& !ROUTE_EFFORTS.has(route.execution.effort)) {
|
|
538
|
-
errors.push(`execution.effort must be null or one of: ${[...ROUTE_EFFORTS].join(', ')}.`);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
if (!isObject(route.evidence)) {
|
|
542
|
-
errors.push('evidence must be an object.');
|
|
543
|
-
} else {
|
|
544
|
-
for (const key of ['observations', 'riskSignals', 'activationReasons', 'suppressionReasons', 'completionRequirements']) {
|
|
545
|
-
if (!Array.isArray(route.evidence[key])) {
|
|
546
|
-
errors.push(`evidence.${key} must be an array.`);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
if (Array.isArray(route.evidence.riskSignals)
|
|
550
|
-
&& route.evidence.riskSignals.some(
|
|
551
|
-
(code) => typeof code !== 'string' || !ROUTE_RISK_REASON_CODES.includes(code),
|
|
552
|
-
)) {
|
|
553
|
-
errors.push(`evidence.riskSignals entries must be one of: ${ROUTE_RISK_REASON_CODES.join(', ')}.`);
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
if (!isObject(route.ceremonyBudget)) {
|
|
557
|
-
errors.push('ceremonyBudget must be an object.');
|
|
558
|
-
} else {
|
|
559
|
-
if (route.ceremonyBudget.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.ceremonyBudget.rigor)) {
|
|
560
|
-
errors.push(`ceremonyBudget.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
|
|
561
|
-
}
|
|
562
|
-
if (!isObject(route.ceremonyBudget.limits)) {
|
|
563
|
-
errors.push('ceremonyBudget.limits must be an object.');
|
|
564
|
-
}
|
|
565
|
-
if (!isObject(route.ceremonyBudget.consumed)) {
|
|
566
|
-
errors.push('ceremonyBudget.consumed must be an object.');
|
|
567
|
-
}
|
|
568
|
-
if (!Array.isArray(route.ceremonyBudget.exceptions)) {
|
|
569
|
-
errors.push('ceremonyBudget.exceptions must be an array.');
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
if (!isObject(route.capabilityPolicy)) {
|
|
573
|
-
errors.push('capabilityPolicy must be an object.');
|
|
574
|
-
} else {
|
|
575
|
-
for (const key of ['required', 'recommended', 'suppressed', 'activeSkillIds']) {
|
|
576
|
-
if (!Array.isArray(route.capabilityPolicy[key])) {
|
|
577
|
-
errors.push(`capabilityPolicy.${key} must be an array.`);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
if (!isObject(route.escalation)) {
|
|
582
|
-
errors.push('escalation must be an object.');
|
|
583
|
-
} else {
|
|
584
|
-
if (!isObject(route.escalation.current)) {
|
|
585
|
-
errors.push('escalation.current must be an object.');
|
|
586
|
-
} else {
|
|
587
|
-
if (route.escalation.current.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.escalation.current.mode)) {
|
|
588
|
-
errors.push(`escalation.current.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
|
|
589
|
-
}
|
|
590
|
-
if (route.escalation.current.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.escalation.current.rigor)) {
|
|
591
|
-
errors.push(`escalation.current.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
if (!Array.isArray(route.escalation.triggers)) {
|
|
595
|
-
errors.push('escalation.triggers must be an array.');
|
|
596
|
-
}
|
|
597
|
-
if (!Array.isArray(route.escalation.history)) {
|
|
598
|
-
errors.push('escalation.history must be an array.');
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
return { valid: errors.length === 0, errors };
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// Compact serialization whitelist (C01 compatibility rule): adapter/runtime consumers
|
|
605
|
-
// get a bounded view that may omit verbose evidence but never mode, rigor, required
|
|
606
|
-
// completion evidence, or escalation state. Returns null for legacy (stage-off)
|
|
607
|
-
// summaries so compact output stays byte-identical when the schema stage is off.
|
|
608
|
-
export function compactResolvedRoute(routeSummary = null) {
|
|
609
|
-
if (!routeSummary || typeof routeSummary !== 'object' || routeSummary.routeVersion == null) {
|
|
610
|
-
return null;
|
|
611
|
-
}
|
|
612
|
-
return {
|
|
613
|
-
routeVersion: routeSummary.routeVersion,
|
|
614
|
-
intent: routeSummary.intent ?? null,
|
|
615
|
-
execution: routeSummary.execution ?? null,
|
|
616
|
-
evidence: {
|
|
617
|
-
completionRequirements: unique(routeSummary.evidence?.completionRequirements ?? []),
|
|
618
|
-
},
|
|
619
|
-
ceremonyBudget: routeSummary.ceremonyBudget ?? null,
|
|
620
|
-
capabilityPolicy: routeSummary.capabilityPolicy ?? null,
|
|
621
|
-
escalation: routeSummary.escalation ?? null,
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
214
|
|
|
625
215
|
|
|
626
216
|
export async function deriveTaskRoute({
|
|
@@ -637,6 +227,24 @@ export async function deriveTaskRoute({
|
|
|
637
227
|
// TASK-004 (FR-008/FR-009): decision-plane runtime seam — transport/env/
|
|
638
228
|
// hostCapabilities overrides for the shadow evaluator. Unused at stage off.
|
|
639
229
|
decisionPlaneOptions = null,
|
|
230
|
+
// BL-013: optional transcript pointer + caller-known route keys for the
|
|
231
|
+
// bounded history extractor. Absent → historySignals degrades (zeroed struct
|
|
232
|
+
// with degraded:true), never throws, ≤50ms tail read.
|
|
233
|
+
transcriptPath = null,
|
|
234
|
+
// BL-018: prior emission for the same route — the fix-loop escalation's
|
|
235
|
+
// exhaustion check reads its escalatedLane. Absent → first emission.
|
|
236
|
+
previousRouteSummary = null,
|
|
237
|
+
// BL-018: injected seams for the lane-deepening decision + registry check
|
|
238
|
+
// ({ask, appendReceipt, runtimeForensicsPresent}); absent → real client +
|
|
239
|
+
// real registry. decisions.tsv rows are a caller concern on this surface.
|
|
240
|
+
escalationDecision = null,
|
|
241
|
+
// C89 TASK-005 (SPEC §6): injected seams for the typed delegation-advice
|
|
242
|
+
// consult ({ask, appendReceipt, override}); absent → real decision client
|
|
243
|
+
// when the decision plane is enabled, otherwise a typed local outcome.
|
|
244
|
+
// decisions.tsv rows are a caller concern on this surface.
|
|
245
|
+
roleAdviceDecision = null,
|
|
246
|
+
requestKey = null,
|
|
247
|
+
routeFingerprint = null,
|
|
640
248
|
} = {}) {
|
|
641
249
|
const absoluteRoot = path.resolve(rootDir);
|
|
642
250
|
// M01.1: additive route-schema stage. Absent config / absent key / unknown value all
|
|
@@ -768,6 +376,14 @@ export async function deriveTaskRoute({
|
|
|
768
376
|
? await checkHandoffBudget(absoluteRoot)
|
|
769
377
|
: null;
|
|
770
378
|
const worklogBudget = await checkWorklogBudget(absoluteRoot);
|
|
379
|
+
// BL-013: bounded session-history signals — extracted once per route on the
|
|
380
|
+
// hook-provided transcript path and merged into the route record. Degraded
|
|
381
|
+
// input yields the zeroed struct; the route still resolves.
|
|
382
|
+
const historySignals = await extractHistorySignalsAsync({
|
|
383
|
+
transcriptPath,
|
|
384
|
+
routeFingerprint,
|
|
385
|
+
requestKey,
|
|
386
|
+
});
|
|
771
387
|
// SPEC FR-009: the lane's workflow policy resolves through the playbook layer —
|
|
772
388
|
// project playbook > user playbook > builtin. resolvePlaybook already falls back to
|
|
773
389
|
// the builtin table, so a null result only means the lane has no policy at all.
|
|
@@ -798,9 +414,100 @@ export async function deriveTaskRoute({
|
|
|
798
414
|
routeSchemaStage,
|
|
799
415
|
runtimeConfig: resolvedRuntimeConfig,
|
|
800
416
|
resolvedWorkflowPolicy,
|
|
417
|
+
historySignals,
|
|
801
418
|
});
|
|
419
|
+
// TASK-007 (BL-009): playbook resolution rides the same route signals the
|
|
420
|
+
// shared resolver just derived — the registry degrades to playbookId: null +
|
|
421
|
+
// reason and never blocks the route. Merged at resolve time so hook and
|
|
422
|
+
// helper emit the identical playbookId/workGroup.
|
|
423
|
+
const playbookResolution = await resolvePlaybookRoute({
|
|
424
|
+
promptText: normalizedPrompt,
|
|
425
|
+
commandText: normalizedCommand,
|
|
426
|
+
targetFile: normalizedTarget,
|
|
427
|
+
executionMode,
|
|
428
|
+
escalationTriggers: routeSummary?.escalationTriggers ?? [],
|
|
429
|
+
projectRoot: absoluteRoot,
|
|
430
|
+
homeDir,
|
|
431
|
+
});
|
|
432
|
+
routeSummary.playbookId = playbookResolution.playbookId;
|
|
433
|
+
routeSummary.workGroup = playbookResolution.workGroup;
|
|
434
|
+
routeSummary.playbookReason = playbookResolution.reason;
|
|
435
|
+
routeSummary.playbookLoad = playbookResolution.playbookLoad;
|
|
436
|
+
// BL-010 (ARCH Task Contract): `path` = direct|handoff; `fastPathKind` is
|
|
437
|
+
// the ARCH `none|small-feature|bug-fix` enum — named Kind because
|
|
438
|
+
// routeSummary.fastPath already carries the FR-004 eligibility object.
|
|
439
|
+
// The keys only land when a routing-table row claimed the prompt (workGroup
|
|
440
|
+
// non-null): no-match/degraded routes keep the PLAYBOOK_ROUTE_KEYS-only
|
|
441
|
+
// shape so stage-off routes never grow stray keys.
|
|
442
|
+
if (playbookResolution.workGroup != null) {
|
|
443
|
+
routeSummary.path = playbookResolution.path;
|
|
444
|
+
routeSummary.fastPathKind = playbookResolution.fastPath;
|
|
445
|
+
} else {
|
|
446
|
+
delete routeSummary.path;
|
|
447
|
+
delete routeSummary.fastPathKind;
|
|
448
|
+
}
|
|
802
449
|
const approachSelector = routeSummary?.approachSelector ?? null;
|
|
803
450
|
|
|
451
|
+
// BL-018 (SPEC FR-006): fix-loop escalation — the same transition the hook
|
|
452
|
+
// path runs in route-task.mjs. historySignals.fixLoopCount is
|
|
453
|
+
// fingerprint-joined (same routeFingerprint/requestKey, never session-wide);
|
|
454
|
+
// at debugLoopThreshold the lane deepens via the laneDeepening/
|
|
455
|
+
// verificationDepth question, endpoint-down → deterministic fallback
|
|
456
|
+
// (fallback:tier-bump), deeper-lane exhaustion → BLOCKED. Additive
|
|
457
|
+
// escalatedLane/escalateReason/escalatedVerificationDepth fields — identical
|
|
458
|
+
// names on both surfaces.
|
|
459
|
+
const laneEscalationDue = isFixLoopEscalationDue({
|
|
460
|
+
historySignals: routeSummary.historySignals,
|
|
461
|
+
config: resolvedRuntimeConfig,
|
|
462
|
+
});
|
|
463
|
+
if (laneEscalationDue) {
|
|
464
|
+
const escalationClient = escalationDecision?.ask
|
|
465
|
+
? null
|
|
466
|
+
: createDecisionClient({
|
|
467
|
+
config: resolvedRuntimeConfig,
|
|
468
|
+
transport: decisionPlaneOptions?.transport,
|
|
469
|
+
env: decisionPlaneOptions?.env,
|
|
470
|
+
projectRoot: absoluteRoot,
|
|
471
|
+
homeDir,
|
|
472
|
+
now: decisionPlaneOptions?.now,
|
|
473
|
+
});
|
|
474
|
+
await applyFixLoopEscalation({
|
|
475
|
+
routeSummary,
|
|
476
|
+
routingContext: {
|
|
477
|
+
promptText: normalizedPrompt,
|
|
478
|
+
commandText: normalizedCommand,
|
|
479
|
+
targetFile: normalizedTarget,
|
|
480
|
+
taskType: inferredTaskType,
|
|
481
|
+
intentMode,
|
|
482
|
+
},
|
|
483
|
+
previousRouteSummary,
|
|
484
|
+
// Same-loop join key for the escalation record — the caller supplies it
|
|
485
|
+
// (the same routeFingerprint the history extractor joins on); absent →
|
|
486
|
+
// the record is stamped fingerprint-less and can never bind a prior
|
|
487
|
+
// emission's exhaustion (escalate fresh, never fabricate 'blocked').
|
|
488
|
+
routeFingerprint,
|
|
489
|
+
config: resolvedRuntimeConfig,
|
|
490
|
+
ask: escalationDecision?.ask
|
|
491
|
+
?? (escalationClient ? ({ batch }) => escalationClient.requestBatch(batch) : null),
|
|
492
|
+
appendReceipt: escalationDecision?.appendReceipt ?? null,
|
|
493
|
+
runtimeForensicsPresent: escalationDecision?.runtimeForensicsPresent
|
|
494
|
+
?? await (async () => {
|
|
495
|
+
try {
|
|
496
|
+
const records = await loadRegistry({ projectRoot: absoluteRoot, homeDir });
|
|
497
|
+
return records.some((record) => record?.frontmatterValid && record?.playbookId === 'runtime-forensics');
|
|
498
|
+
} catch {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
})(),
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// BL-019 (SPEC FR-001): fold the persisted measured map into the route —
|
|
506
|
+
// measuredTier is always emitted (nullable); the authoritative modelTier
|
|
507
|
+
// follows the map ONLY when tier-map.json says 'promote'. Absent/corrupt
|
|
508
|
+
// artifact → static stays; never throws.
|
|
509
|
+
await applyMeasuredTier({ projectRoot: absoluteRoot, routeSummary });
|
|
510
|
+
|
|
804
511
|
// TASK-004 (SPEC §5 FR-008/FR-009): decision-plane hook. Stage resolves via
|
|
805
512
|
// the shared resolver — absent/malformed → 'off' → zero cost, byte-identical
|
|
806
513
|
// route. shadow → redacted receipt only (deterministic policy authoritative);
|
|
@@ -840,6 +547,40 @@ export async function deriveTaskRoute({
|
|
|
840
547
|
}
|
|
841
548
|
|
|
842
549
|
|
|
550
|
+
// C89 TASK-005 (SPEC §6 FR-04): typed delegation advice consult — the
|
|
551
|
+
// optional unic-decision round only ever answers the owner-bounded role
|
|
552
|
+
// question; the deterministic fields the route already carries stay
|
|
553
|
+
// authoritative and every failure records a typed advice.model outcome.
|
|
554
|
+
// The consult seam defaults to the same decision client the plane uses.
|
|
555
|
+
if (routeSummary?.delegationAdvice) {
|
|
556
|
+
try {
|
|
557
|
+
const roleAdviceClient = !roleAdviceDecision?.ask
|
|
558
|
+
&& resolveDecisionPlaneStage(resolvedRuntimeConfig) !== 'off'
|
|
559
|
+
? createDecisionClient({
|
|
560
|
+
config: resolvedRuntimeConfig,
|
|
561
|
+
transport: decisionPlaneOptions?.transport,
|
|
562
|
+
env: decisionPlaneOptions?.env,
|
|
563
|
+
projectRoot: absoluteRoot,
|
|
564
|
+
homeDir,
|
|
565
|
+
now: decisionPlaneOptions?.now,
|
|
566
|
+
})
|
|
567
|
+
: null;
|
|
568
|
+
await applyRoleAdviceConsult({
|
|
569
|
+
routeSummary,
|
|
570
|
+
config: resolvedRuntimeConfig,
|
|
571
|
+
ask: roleAdviceDecision?.ask
|
|
572
|
+
?? (roleAdviceClient ? ({ batch }) => roleAdviceClient.requestBatch(batch) : null),
|
|
573
|
+
appendReceipt: roleAdviceDecision?.appendReceipt ?? null,
|
|
574
|
+
override: roleAdviceDecision?.override ?? null,
|
|
575
|
+
now: decisionPlaneOptions?.now ?? (() => Date.now()),
|
|
576
|
+
});
|
|
577
|
+
} catch (error) {
|
|
578
|
+
degradedWarnings.push(
|
|
579
|
+
`role-advice consult: unavailable (${error?.message ?? String(error)})`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
843
584
|
return {
|
|
844
585
|
activeSkills,
|
|
845
586
|
routingContext: {
|
|
@@ -867,6 +608,9 @@ export async function deriveTaskRoute({
|
|
|
867
608
|
}
|
|
868
609
|
|
|
869
610
|
export function buildRouteSummary({
|
|
611
|
+
// BL-013: precomputed bounded history struct — merged additively via the
|
|
612
|
+
// shared resolver below. Absent/null → no field, byte-identical route.
|
|
613
|
+
historySignals = null,
|
|
870
614
|
activeSkills = [],
|
|
871
615
|
routingContext = {},
|
|
872
616
|
contextRecommendation = null,
|
|
@@ -926,21 +670,51 @@ export function buildRouteSummary({
|
|
|
926
670
|
nextActionType: nextAction?.type ?? null,
|
|
927
671
|
completionState,
|
|
928
672
|
});
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
: null
|
|
673
|
+
// TASK-004 (BL-006): one shared resolver derives the deterministic route
|
|
674
|
+
// fields — the identical object skill-router.sh merges into hook state.
|
|
675
|
+
// The explicit routeSchemaStage param remains authoritative for the resolved
|
|
676
|
+
// groups (callers may force it independent of runtimeConfig); every other
|
|
677
|
+
// stage read comes from runtimeConfig inside deriveRouteFields.
|
|
678
|
+
const routeFields = deriveRouteFields({
|
|
679
|
+
promptText: routingContext.promptText,
|
|
680
|
+
commandText: routingContext.commandText,
|
|
681
|
+
targetFile: routingContext.targetFile ?? null,
|
|
682
|
+
intentMode: routingContext.intentMode ?? null,
|
|
683
|
+
taskType,
|
|
684
|
+
executionMode,
|
|
685
|
+
activeSkillIds: activeSkills.map((entry) => entry.id),
|
|
686
|
+
verificationRecommendation,
|
|
687
|
+
contextPreview: contextRecommendation?.preview ?? null,
|
|
688
|
+
executionScores: routingContext.executionScores ?? {},
|
|
689
|
+
lastExplicitUserPromptText: routingContext.lastExplicitUserPromptText ?? null,
|
|
690
|
+
historySignals,
|
|
691
|
+
config: routeSchemaStage === 'off'
|
|
692
|
+
? runtimeConfig
|
|
693
|
+
: {
|
|
694
|
+
...(runtimeConfig && typeof runtimeConfig === 'object' ? runtimeConfig : {}),
|
|
695
|
+
routing: {
|
|
696
|
+
...(runtimeConfig?.routing && typeof runtimeConfig.routing === 'object'
|
|
697
|
+
? runtimeConfig.routing
|
|
698
|
+
: {}),
|
|
699
|
+
routeSchema: { stage: routeSchemaStage },
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
const riskFloor = routeFields.riskFloor;
|
|
704
|
+
const fastPath = routeFields.fastPath;
|
|
705
|
+
const resolvedRouteFields = routeFields.resolved;
|
|
706
|
+
// C89 TASK-005 (SPEC §6/§11): typed delegation advice shadow — additive,
|
|
707
|
+
// gated on subagentOrchestrator.roleAdvice.stage ('off' → null, route
|
|
708
|
+
// byte-identical). Wraps the deterministic owner verdict; never spawns.
|
|
709
|
+
const delegationAdvice = deriveTypedDelegationAdvice({
|
|
710
|
+
activeSkills,
|
|
711
|
+
routingContext,
|
|
712
|
+
contextRecommendation,
|
|
713
|
+
verificationRecommendation,
|
|
714
|
+
autonomyLevel,
|
|
715
|
+
riskFloor,
|
|
716
|
+
config: runtimeConfig,
|
|
717
|
+
});
|
|
944
718
|
const riskSegment = formatRiskFloorSegment(riskFloor);
|
|
945
719
|
// FR-003 (M01.2' limits fragment): the rigor advisory — emitted whenever
|
|
946
720
|
// rigor.stage is on, independent of fastPath/escalation. Stage off → no
|
|
@@ -948,36 +722,7 @@ export function buildRouteSummary({
|
|
|
948
722
|
const limitsSegment = resolveRouteStage(runtimeConfig, 'rigor') !== 'off'
|
|
949
723
|
? formatLimitsSegment(deriveCeremonyLimits(executionContract))
|
|
950
724
|
: null;
|
|
951
|
-
// FR-004 (M01.3'): fastPath is emitted whenever its own stage is on — the field
|
|
952
|
-
// is always set then (eligible or not) so telemetry/harness can read it; the
|
|
953
|
-
// route-line segment prints only for eligible routes.
|
|
954
|
-
const fastPathStage = resolveRouteStage(runtimeConfig, 'fastPath');
|
|
955
|
-
const fastPath = fastPathStage !== 'off'
|
|
956
|
-
? {
|
|
957
|
-
eligible: false,
|
|
958
|
-
suppressed: [],
|
|
959
|
-
reasons: [],
|
|
960
|
-
...deriveFastPath({
|
|
961
|
-
executionMode,
|
|
962
|
-
targetFile: routingContext.targetFile ?? null,
|
|
963
|
-
riskFloor,
|
|
964
|
-
verificationRecommendation,
|
|
965
|
-
contextPreview: contextRecommendation?.preview ?? null,
|
|
966
|
-
}),
|
|
967
|
-
stage: fastPathStage,
|
|
968
|
-
}
|
|
969
|
-
: null;
|
|
970
725
|
const fastPathSegment = formatFastPathSegment(fastPath);
|
|
971
|
-
const resolvedRouteFields = routeSchemaStage !== 'off'
|
|
972
|
-
? buildResolvedRouteFields({
|
|
973
|
-
routingContext,
|
|
974
|
-
activeSkillIds: activeSkills.map((entry) => entry.id),
|
|
975
|
-
executionMode,
|
|
976
|
-
executionContract,
|
|
977
|
-
completionState,
|
|
978
|
-
riskFloor,
|
|
979
|
-
})
|
|
980
|
-
: null;
|
|
981
726
|
const helperHint = compactHelperHint(
|
|
982
727
|
compactHelperLane
|
|
983
728
|
? contextRecommendation?.command
|
|
@@ -1040,12 +785,26 @@ export function buildRouteSummary({
|
|
|
1040
785
|
approachSelector,
|
|
1041
786
|
executionContract,
|
|
1042
787
|
completionState,
|
|
788
|
+
// TASK-004 resolver fields — the schema-off contract requires these keys be
|
|
789
|
+
// absent entirely, so they ride the same `resolved` gate (schema stage != off).
|
|
790
|
+
...(resolvedRouteFields
|
|
791
|
+
? {
|
|
792
|
+
rigor: routeFields.rigor,
|
|
793
|
+
resumable: routeFields.resumable,
|
|
794
|
+
escalationTriggers: routeFields.escalationTriggers,
|
|
795
|
+
decisionShadow: routeFields.decisionShadowFields,
|
|
796
|
+
}
|
|
797
|
+
: {}),
|
|
1043
798
|
// M01.1 additive C01 groups — present only when routing.routeSchema.stage != "off".
|
|
1044
799
|
...(resolvedRouteFields ?? {}),
|
|
1045
800
|
// FR-003 additive top-level field — present only when a risk stage is on.
|
|
1046
801
|
...(riskFloor ? { riskFloor } : {}),
|
|
1047
802
|
// FR-004 additive top-level field — present only when fastPath stage is on.
|
|
1048
803
|
...(fastPath ? { fastPath } : {}),
|
|
804
|
+
// BL-013 additive top-level field — present only when the caller supplied
|
|
805
|
+
// a history struct (extraction ran, possibly degraded). Null/absent keeps
|
|
806
|
+
// the route byte-identical for callers without a transcript.
|
|
807
|
+
...(routeFields.historySignals ? { historySignals: routeFields.historySignals } : {}),
|
|
1049
808
|
continuationState,
|
|
1050
809
|
autonomyLevel,
|
|
1051
810
|
continuousExecution,
|
|
@@ -1069,6 +828,10 @@ export function buildRouteSummary({
|
|
|
1069
828
|
// TASK-004 additive field — present only when a caller supplies a
|
|
1070
829
|
// decision-plane result (stage != 'off').
|
|
1071
830
|
...(decisionPlane ? { decisionPlane } : {}),
|
|
831
|
+
// C89 TASK-005 additive field — typed delegation advice shadow; present
|
|
832
|
+
// only when subagentOrchestrator.roleAdvice.stage != 'off' (conditional
|
|
833
|
+
// spread keeps the stage-off route byte-identical).
|
|
834
|
+
...(delegationAdvice ? { delegationAdvice } : {}),
|
|
1072
835
|
line: summaryLine || 'task=unknown',
|
|
1073
836
|
};
|
|
1074
837
|
}
|
|
@@ -1195,34 +958,226 @@ function isInformationalPrompt({
|
|
|
1195
958
|
const strongQuestion = raw.includes('?')
|
|
1196
959
|
|| /^(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
|
|
1197
960
|
|| /(là\s+(?:[^\s]+\s+){0,2}gì|thế nào|như thế nào|bao nhiêu|khi nào|bao giờ|ở đâu)/.test(raw);
|
|
961
|
+
// C85-019: consult-shaped questions overrule subject-matter signal scores.
|
|
962
|
+
// "should I fix X?", "X chưa nhỉ?", "có nên/có cần sửa không?" mention
|
|
963
|
+
// mutation vocabulary as SUBJECT — the ask is a decision/answer, not an
|
|
964
|
+
// order. Tight on purpose: modal-subject inversion restricted to
|
|
965
|
+
// should/shall (decision asks) — "how do I fix…?" and "bạn có thể sửa…
|
|
966
|
+
// giúp tôi không?" are work-shaped questions, not consults. A bare
|
|
967
|
+
// imperative wearing a question mark ("sửa file cho tôi?") keeps its lane.
|
|
968
|
+
const folded = raw.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\u0111/g, 'd');
|
|
969
|
+
const politeAsk = /\b(?:giup|giuong|ho|dum)\b[^\n?,;]{0,60}\b(?:khong|không)\s*[?!.]?\s*$/.test(folded)
|
|
970
|
+
|| /\bcho\b[^\n?,;]{0,60}\b(?:khong|không)\s*[?!.]?\s*$/.test(folded);
|
|
971
|
+
const consultShape = /(?:^|[.!;\n]\s*)(?:should|shall)\s+(?:i|we)\s+(?!(?:i|we)\s)/.test(folded)
|
|
972
|
+
|| /\b(?:co|có)\s+(?:nen|can|phai)\s+/.test(folded)
|
|
973
|
+
|| /\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi)\s*[.!?]*\s*$/.test(folded)
|
|
974
|
+
|| (!politeAsk && /\b(?:khong|không)\s*[?.!]?\s*$/.test(folded))
|
|
975
|
+
|| /\bchi\s+(?:hoi|tu\s+van|giai\s+thich|xem)\b/.test(folded);
|
|
1198
976
|
if (scores) {
|
|
1199
|
-
if (
|
|
977
|
+
if (!consultShape && (
|
|
1200
978
|
scores.implementSignal
|
|
1201
979
|
|| scores.reviewSignal
|
|
1202
980
|
|| scores.debugSignal
|
|
1203
981
|
|| scores.impactSignal
|
|
1204
982
|
|| scores.smallFixSignal
|
|
1205
983
|
|| scores.directTransformSignal
|
|
1206
|
-
) {
|
|
984
|
+
)) {
|
|
1207
985
|
return false;
|
|
1208
986
|
}
|
|
1209
|
-
if (scores.failureSignal && !strongQuestion) {
|
|
987
|
+
if (scores.failureSignal && !strongQuestion && !consultShape) {
|
|
1210
988
|
return false;
|
|
1211
989
|
}
|
|
1212
990
|
}
|
|
991
|
+
// C85-019: consult-shape also overrides the vocabulary vetoes — the
|
|
992
|
+
// mutation/debug/lỗi words are the question's SUBJECT, not the ask.
|
|
1213
993
|
const implementWords = /(?<![A-Za-z0-9_])(implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|sửa|thêm|tạo|xóa|đổi|thay thế|cập nhật|viết|build|chạy|cài)(?![A-Za-z0-9_])/.test(raw);
|
|
1214
|
-
if (implementWords) return false;
|
|
994
|
+
if (implementWords && !consultShape) return false;
|
|
1215
995
|
const investigationWords = /\b(why|debug|triage|root cause|investigate|tại sao)\b/.test(raw);
|
|
1216
|
-
if (investigationWords) return false;
|
|
996
|
+
if (investigationWords && !consultShape) return false;
|
|
1217
997
|
// \b never matches around 'lỗi' (diacritics are not \w), so test it as a plain
|
|
1218
998
|
// substring: an error report is informational only when phrased as a question.
|
|
1219
|
-
if (raw.includes('lỗi') && !strongQuestion) return false;
|
|
999
|
+
if (raw.includes('lỗi') && !strongQuestion && !consultShape) return false;
|
|
1220
1000
|
const reviewWords = /\b(review|audit|verify)\b/.test(raw);
|
|
1221
|
-
if (reviewWords) return false;
|
|
1222
|
-
|
|
1001
|
+
if (reviewWords && !consultShape) return false;
|
|
1002
|
+
// C85-019: consult shape is itself a question signal (Vietnamese tails like
|
|
1003
|
+
// "chưa"/"rồi" never match the Latin interrogative regex above).
|
|
1004
|
+
return questionSignal || consultShape;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
// TASK-C85-019: an explicit no-change/advisory assertion overrides the signal
|
|
1009
|
+
// scores. A report or advisory question carrying fix vocabulary as SUBJECT
|
|
1010
|
+
// MATTER ("không tạo thay đổi", "giữ nguyên mã", "do not change anything",
|
|
1011
|
+
// "chỉ tư vấn") still fires implementSignal/smallFixSignal/failureSignal, which
|
|
1012
|
+
// used to route the turn mutating so the completion gate demanded an Edit the
|
|
1013
|
+
// user explicitly declined. The override fires only when an assertion phrase is
|
|
1014
|
+
// present AND no unambiguous implement order survives outside the
|
|
1015
|
+
// negated/advisory/quoted/report clauses: "sửa file X nhưng giữ nguyên Y"
|
|
1016
|
+
// keeps its mutation lane because 'sửa file X' is outside the keep-clause,
|
|
1017
|
+
// while "hook cần được cấu hình để chỉ yêu cầu bằng chứng sửa file" is a
|
|
1018
|
+
// description of the hook's behavior, not an order. Folded (diacritic-free)
|
|
1019
|
+
// text is matched so both Vietnamese spellings work. Interrogative-deontic
|
|
1020
|
+
// forms ("should I…?", "có cần sửa…?") count as advisory requests, not orders;
|
|
1021
|
+
// a bare "không biết…chưa" uncertainty is NOT an assertion and stays on the
|
|
1022
|
+
// plain question path.
|
|
1023
|
+
function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
|
|
1024
|
+
const folded = `${promptText ?? ''}\n${commandText ?? ''}`
|
|
1025
|
+
.toLowerCase()
|
|
1026
|
+
.normalize('NFD')
|
|
1027
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
1028
|
+
.replace(/\u0111/g, 'd');
|
|
1029
|
+
const trimmed = folded.trim();
|
|
1030
|
+
if (!trimmed) return false;
|
|
1031
|
+
const advisoryAssertion = /\b(?:do\s+not|don't|dont)\s+(?:change|edit|fix|modify|alter|touch)\b/
|
|
1032
|
+
.test(trimmed)
|
|
1033
|
+
|| /\bno\s+(?:changes?|edits?|updates?|modifications?)\b/.test(trimmed)
|
|
1034
|
+
|| /\bkeep\s+[^\n.,;]{0,60}\b(?:unchanged|as\s+is|the\s+same|intact)\b/.test(trimmed)
|
|
1035
|
+
|| /\badvisory\s+only\b/.test(trimmed)
|
|
1036
|
+
|| /\bonly\s+(?:advis|ask|question|answer|explain|consult|report)/.test(trimmed)
|
|
1037
|
+
|| /\bjust\s+(?:advis|ask|question|answer|explain|consult|report)/.test(trimmed)
|
|
1038
|
+
|| /(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b/.test(trimmed)
|
|
1039
|
+
|| /\bgiu\s+nguyen\b/.test(trimmed)
|
|
1040
|
+
|| /\bmuon\s+giu\b/.test(trimmed)
|
|
1041
|
+
|| /\bkhong\s+(?:can\s+|muon\s+|phai\s+)?(?:sua|thay\s+doi|tao\s+thay\s+doi|edit|fix|chinh\s+sua|trien\s+khai|cap\s+nhat)\b/
|
|
1042
|
+
.test(trimmed)
|
|
1043
|
+
|| /\bdung\s+(?:sua|thay\s+doi|edit|chinh)\b/.test(trimmed)
|
|
1044
|
+
|| /\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi)\b/.test(trimmed)
|
|
1045
|
+
|| /\bco\s+(?:can|nen)\s+sua\b/.test(trimmed)
|
|
1046
|
+
// C89-008 (user-reported 9-stop loop): a waiting/no-change turn asserts the
|
|
1047
|
+
// answer is advisory — "đang chờ kết quả test QAS, không có gì cần sửa" —
|
|
1048
|
+
// but matches none of the shapes above and is not question-shaped, so it
|
|
1049
|
+
// escaped every informational gate and the stop hook demanded an Edit the
|
|
1050
|
+
// honest turn could never produce. 'không có gì' needs its own trigger —
|
|
1051
|
+
// the generic 'không (cần) sửa' pattern cannot skip the 'có gì' gap.
|
|
1052
|
+
|| /\bkhong\s+co\s+gi\s+(?:can|phai|de|dang|con)\s+(?:sua|thay\s+doi|lam|chinh|edit|fix)\b/.test(trimmed)
|
|
1053
|
+
|| /\bnothing\s+(?:to\s+(?:fix|edit|change|update|modify|do)|needs?\s+(?:fixing|editing|changing|updating))\b/.test(trimmed)
|
|
1054
|
+
|| /\bdang\s+(?:cho|doi)\b/.test(trimmed)
|
|
1055
|
+
|| /\b(?:waiting|awaiting)\s+(?:for|on)\b/.test(trimmed)
|
|
1056
|
+
|| /\bchua\s+(?:can|nen|phai)\s+(?:sua|thay\s+doi|lam|chinh|edit|fix)\b/.test(trimmed)
|
|
1057
|
+
|| /\b(?:cho|doi)\s+(?:ket\s+qua|bao\s+cao|feedback|phe\s+duyet|duyet|phien\s+ban|ci|test|qas|review|build|deploy|approval|xong)\b/.test(trimmed);
|
|
1058
|
+
if (!advisoryAssertion) return false;
|
|
1059
|
+
// Strip quoted spans and every clause class that can carry mutation
|
|
1060
|
+
// vocabulary without being an order: the advisory/negated assertions
|
|
1061
|
+
// themselves, conditional or purpose clauses (nếu/để/cho/if), reported
|
|
1062
|
+
// demands of another agent (yêu cầu/ép/bắt/requires/forced), passive
|
|
1063
|
+
// necessity descriptions (cần được cấu hình/must be configured), and the
|
|
1064
|
+
// ledger's own evidence-class names. Whatever mutation verb remains after
|
|
1065
|
+
// the strip is an actual implement order and vetoes the advisory route.
|
|
1066
|
+
// C85-019c: clause strips must stop BEFORE a conjunction-joined imperative
|
|
1067
|
+
// — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
|
|
1068
|
+
// order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
|
|
1069
|
+
// TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
|
|
1070
|
+
const TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
1071
|
+
// C89-008: WAIT_TAIL ends a waiting-clause strip before any coordinating
|
|
1072
|
+
// conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything up
|
|
1073
|
+
// to a mutation verb, or at punctuation/EOS — "đang chờ kết quả nhưng sửa
|
|
1074
|
+
// file này" keeps 'sửa file này' as a surviving order.
|
|
1075
|
+
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
1076
|
+
const residue = trimmed
|
|
1077
|
+
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
1078
|
+
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
1079
|
+
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
1080
|
+
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|
|
1081
|
+
.replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
|
|
1082
|
+
.replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
|
|
1083
|
+
// C89-008: a waiting clause narrates what the TURN waits on, not what the
|
|
1084
|
+
// request orders — verbs inside it ("chờ sửa xong", "waiting for the fix")
|
|
1085
|
+
// belong to the wait, not to the implement order.
|
|
1086
|
+
.replace(new RegExp(`\\bdang\\s+(?:cho|doi)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
1087
|
+
.replace(new RegExp(`\\b(?:waiting|awaiting)\\s+(?:for|on)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
1088
|
+
.replace(new RegExp(`\\b(?:cho|doi)\\s+(?:ket\\s+qua|bao\\s+cao|feedback|phe\\s+duyet|duyet|phien\\s+ban|ci|test|qas|review|build|deploy|approval|xong)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
1089
|
+
.replace(new RegExp(`\\bnothing\\s+(?:to|needs?)\\b${TAIL}`, 'g'), ' ')
|
|
1090
|
+
// 'chưa cần/nên/phải sửa' defers the change — same class as 'không cần
|
|
1091
|
+
// sửa' but 'chưa' is not covered by the 'không' strip above. WAIT_TAIL
|
|
1092
|
+
// bounds it so '…nhưng sửa phần này' keeps the surviving order.
|
|
1093
|
+
.replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
|
|
1094
|
+
.replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
|
|
1095
|
+
.replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
|
|
1096
|
+
.replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
|
|
1097
|
+
.replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
|
|
1098
|
+
.replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
|
|
1099
|
+
.replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
|
|
1100
|
+
.replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
|
|
1101
|
+
.replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
|
|
1102
|
+
.replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
|
|
1103
|
+
.replace(/\b(?:just|only|simply)\s+(?:advis\w*|asking?|a\s+question|answering?|explaining?|consulting|reporting)\b[^.,;\n]*/g, ' ')
|
|
1104
|
+
.replace(/\badvisory\s+only\b[^.,;\n]*/g, ' ')
|
|
1105
|
+
.replace(new RegExp(`\\bkeep\\s+[^\\n.,;]{0,60}\\b(?:unchanged|as\\s+is|the\\s+same|intact)\\b${TAIL}`, 'g'), ' ');
|
|
1106
|
+
const mutationOrder = /(?<![A-Za-z0-9_])(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![A-Za-z0-9_])/.test(residue);
|
|
1107
|
+
return !mutationOrder;
|
|
1108
|
+
}
|
|
1109
|
+
// C85-019: mutation vocabulary ≠ mutation order. The same residue logic the
|
|
1110
|
+
// advisory gate uses — quotes, negated clauses, conditionals (nếu/để/cho/if),
|
|
1111
|
+
// reported demands (yêu cầu/ép/requires), necessity phrases (cần được/must be),
|
|
1112
|
+
// evidence-class names — decides whether ANY surviving verb is an imperative.
|
|
1113
|
+
// Used by hasAdvisoryOnlyAssertion (assertion + no order → advisory) and by
|
|
1114
|
+
// the question-shape gate below (question + no order → consult, even when the
|
|
1115
|
+
// subject vocabulary fires debug/failure/implement signal scores).
|
|
1116
|
+
function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
|
|
1117
|
+
const folded = `${promptText ?? ''}\n${commandText ?? ''}`
|
|
1118
|
+
.toLowerCase()
|
|
1119
|
+
.normalize('NFD')
|
|
1120
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
1121
|
+
.replace(/\u0111/g, 'd');
|
|
1122
|
+
const trimmed = folded.trim();
|
|
1123
|
+
if (!trimmed) return false;
|
|
1124
|
+
// C85-019c: clause strips must stop BEFORE a conjunction-joined imperative
|
|
1125
|
+
// — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
|
|
1126
|
+
// order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
|
|
1127
|
+
// TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
|
|
1128
|
+
const TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
1129
|
+
// C89-008: WAIT_TAIL ends a waiting-clause strip before a coordinating
|
|
1130
|
+
// conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything up
|
|
1131
|
+
// to a mutation verb, or at punctuation/EOS.
|
|
1132
|
+
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
1133
|
+
const residue = trimmed
|
|
1134
|
+
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
1135
|
+
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
1136
|
+
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
1137
|
+
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|
|
1138
|
+
.replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
|
|
1139
|
+
.replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
|
|
1140
|
+
// C89-008: a waiting clause narrates what the TURN waits on, not what the
|
|
1141
|
+
// request orders — verbs inside it belong to the wait.
|
|
1142
|
+
.replace(new RegExp(`\\bdang\\s+(?:cho|doi)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
1143
|
+
.replace(new RegExp(`\\b(?:waiting|awaiting)\\s+(?:for|on)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
1144
|
+
.replace(new RegExp(`\\b(?:cho|doi)\\s+(?:ket\\s+qua|bao\\s+cao|feedback|phe\\s+duyet|duyet|phien\\s+ban|ci|test|qas|review|build|deploy|approval|xong)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
1145
|
+
.replace(new RegExp(`\\bnothing\\s+(?:to|needs?)\\b${TAIL}`, 'g'), ' ')
|
|
1146
|
+
// 'chưa cần/nên/phải sửa' defers the change — WAIT_TAIL bounds it so a
|
|
1147
|
+
// '…nhưng sửa phần này' order survives.
|
|
1148
|
+
.replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
|
|
1149
|
+
.replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
|
|
1150
|
+
// C85-019b: a "khi <subject> <verb>" when-clause is temporal context —
|
|
1151
|
+
// "khi tôi cài hệ thống này" narrates WHEN something happens; the verb
|
|
1152
|
+
// inside it is never the order.
|
|
1153
|
+
.replace(new RegExp(`\\bkhi\\s+(?:toi|ta|ban|no|anh|chi|he|she|we)\\s+[^.,;\\n?]*?(?=\\s+(?:va|and)\\b|\\s*$)`, 'g'), ' ')
|
|
1154
|
+
.replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
|
|
1155
|
+
.replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
|
|
1156
|
+
.replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
|
|
1157
|
+
.replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
|
|
1158
|
+
.replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
|
|
1159
|
+
.replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
|
|
1160
|
+
.replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
|
|
1161
|
+
.replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
|
|
1162
|
+
.replace(/\b(?:just|only|simply)\s+(?:advis\w*|asking?|a\s+question|answering?|explaining?|consulting|reporting)\b[^.,;\n]*/g, ' ')
|
|
1163
|
+
.replace(/\badvisory\s+only\b[^.,;\n]*/g, ' ')
|
|
1164
|
+
.replace(new RegExp(`\\bkeep\\s+[^\\n.,;]{0,60}\\b(?:unchanged|as\\s+is|the\\s+same|intact)\\b${TAIL}`, 'g'), ' ')
|
|
1165
|
+
// C85-019b: question-tagged mutation verbs are asks, not orders. English:
|
|
1166
|
+
// "<modal> I/we <verb>" (should I fix). Vietnamese: S-V-order status
|
|
1167
|
+
// questions — "<verb> … (chưa|được không|rồi|nhỉ)?" means "has it been
|
|
1168
|
+
// done", never "do it". EXCLUDED: "… giúp/cho/hộ/dùm tôi không?" — the
|
|
1169
|
+
// 'không' there is a polite request marker, not a status tag, and the
|
|
1170
|
+
// mutation verb is a real order. These strips run LAST so a real
|
|
1171
|
+
// imperative mid-sentence ("sửa file X cho tôi") still survives.
|
|
1172
|
+
.replace(/\b(?<!\b(?:which|what|how)\s[^.,;?]{0,30})(?:should|shall)\s+(?:i|we)\s+[^\n?]*[?]?/g, ' ')
|
|
1173
|
+
.replace(/[^\n.,;?]{0,80}\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi|a)\s*[?]?\s*$/g, ' ')
|
|
1174
|
+
.replace(/^(?!.*\b(?:giup|giuong|cho|ho|dum)\b)[^\n.,;?]{0,80}\b(?:khong|không)\s*[?]?\s*$/g, ' ')
|
|
1175
|
+
.replace(/\b(?:co|có)\s+(?:nen|can|phai)\s+[^\n?]*[?]?/g, ' ');
|
|
1176
|
+
return /(?<![A-Za-z0-9_])(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![A-Za-z0-9_])/.test(residue);
|
|
1223
1177
|
}
|
|
1224
1178
|
|
|
1225
1179
|
|
|
1180
|
+
|
|
1226
1181
|
// @decision-point preflight.execution-lane.v1 — the execution lane is a
|
|
1227
1182
|
// registered preflight decision; this deterministic ladder is its
|
|
1228
1183
|
// 'deterministic-route' fallback policy implementation.
|
|
@@ -1274,31 +1229,43 @@ function deriveExecutionMode({
|
|
|
1274
1229
|
|| /^(?:lam sao|the nao|nhu the nao|nhu nao|vi sao|tai sao|khi nao|bao gio|bao nhieu|o dau|phai lam gi|lam gi|lam cach nao)\b/.test(foldedPromptText);
|
|
1275
1230
|
const questionShapedPrompt = /\?\s*$/.test(trimmedPromptText)
|
|
1276
1231
|
|| leadingInterrogative
|
|
1277
|
-
|| /\b(?:la gi|duoc khong)\s*$/.test(foldedPromptText)
|
|
1232
|
+
|| /\b(?:la gi|duoc khong)\s*$/.test(foldedPromptText)
|
|
1233
|
+
// C85-019: Vietnamese status/consult tails without a '?' — "… chưa",
|
|
1234
|
+
// "… chưa nhỉ", "… không nhỉ", "… đúng không". A question about a defect
|
|
1235
|
+
// must not mint the bug-fix floor's write/repro debt.
|
|
1236
|
+
|| /\b(?:chua|duoc khong|dung khong|khong nhi|khong|nhi)\s*[.!?]*\s*$/.test(foldedPromptText);
|
|
1278
1237
|
// Implement verbs (Vietnamese included — the English-only scores above cannot see
|
|
1279
1238
|
// them) mark an action order even when phrased as a question ("ban sua giup toi…?" is
|
|
1280
1239
|
// an edit order, not a consultation). A leading interrogative overrides them:
|
|
1281
1240
|
// "Lam sao ma do duoc khi toi cai…?" asks HOW something is done, it does not order
|
|
1282
1241
|
// the action performed.
|
|
1283
|
-
|
|
1242
|
+
// C85-019: question shape + no surviving imperative = consult. The
|
|
1243
|
+
// pre-C85-019 all-zero-scores clause consulted only when NOTHING in the
|
|
1244
|
+
// prompt resembled work — impossible for a question about code. Replacing
|
|
1245
|
+
// it with hasMutationOrder keeps "sửa file cho tôi?" (an order wearing a
|
|
1246
|
+
// question mark) mutating while "lỗi này chưa nhỉ" / "should I fix the
|
|
1247
|
+
// hook?" consult. A targetFile still vetoes.
|
|
1248
|
+
// C85-019b: leading interrogative alone does NOT consult — "How do I fix X?"
|
|
1249
|
+
// still carries the verb (a how-to ask is handled below by the no-mutation
|
|
1250
|
+
// residue path when the verb is genuinely absent). Consult requires either
|
|
1251
|
+
// a leading interrogative or a question tail AND no surviving imperative.
|
|
1284
1252
|
const consultationOnlySignal = questionShapedPrompt
|
|
1285
|
-
&& (
|
|
1286
|
-
&& scores.editCertainty === 0
|
|
1287
|
-
&& !scores.implementSignal
|
|
1288
|
-
&& !scores.reviewSignal
|
|
1289
|
-
&& !scores.debugSignal
|
|
1290
|
-
&& !scores.failureSignal
|
|
1291
|
-
&& !scores.impactSignal
|
|
1292
|
-
&& !scores.buildSignal
|
|
1293
|
-
&& !scores.directTransformSignal
|
|
1294
|
-
&& !scores.smallFixSignal
|
|
1295
|
-
&& !scores.sharedRisk
|
|
1253
|
+
&& !hasMutationOrder({ promptText, commandText })
|
|
1296
1254
|
&& !targetFile;
|
|
1297
1255
|
|
|
1298
1256
|
if (isInformationalPrompt({ promptText, commandText, scores })) {
|
|
1299
1257
|
return 'informational';
|
|
1300
1258
|
}
|
|
1301
1259
|
|
|
1260
|
+
// TASK-C85-019: an explicit no-change/advisory assertion beats every signal
|
|
1261
|
+
// score — the prompt mentions mutation vocabulary as subject matter, not as
|
|
1262
|
+
// an order. Checked before the review/delivery/consultation gates so an
|
|
1263
|
+
// advisory turn never mints write or verification debt; a surviving
|
|
1264
|
+
// imperative outside the negated clause still routes mutating.
|
|
1265
|
+
if (hasAdvisoryOnlyAssertion({ promptText, commandText })) {
|
|
1266
|
+
return 'informational';
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1302
1269
|
if (
|
|
1303
1270
|
releaseVerificationContinuation
|
|
1304
1271
|
|| ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
|
|
@@ -1492,38 +1459,6 @@ function buildApproachSelectorResult({
|
|
|
1492
1459
|
};
|
|
1493
1460
|
}
|
|
1494
1461
|
|
|
1495
|
-
function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
|
|
1496
|
-
if (!executionMode) {
|
|
1497
|
-
return null;
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
|
-
const contract = buildExecutionContract(executionMode);
|
|
1501
|
-
const missingEvidence = [...(contract?.completionEvidence ?? [])];
|
|
1502
|
-
const requiresVerification = missingEvidence.includes('verification-evidence');
|
|
1503
|
-
if (
|
|
1504
|
-
requiresVerification
|
|
1505
|
-
&& verificationRecommendation
|
|
1506
|
-
&& !(verificationRecommendation.commands?.length || verificationRecommendation.fallbackCommands?.length)
|
|
1507
|
-
) {
|
|
1508
|
-
missingEvidence.push('verification-plan');
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
let reason = 'completion evidence is still required';
|
|
1512
|
-
if (['tiny-fix', 'local-fix', 'local-build', 'shared-edit'].includes(executionMode)) {
|
|
1513
|
-
reason = 'implement request has not produced an edit yet';
|
|
1514
|
-
} else if (executionMode === 'review-release') {
|
|
1515
|
-
reason = 'review/release evidence is still required before final claim';
|
|
1516
|
-
} else if (executionMode === 'map-impact') {
|
|
1517
|
-
reason = 'impact evidence is still required before safe completion claim';
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
return {
|
|
1521
|
-
claimAllowed: false,
|
|
1522
|
-
missingEvidence,
|
|
1523
|
-
reason,
|
|
1524
|
-
};
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
1462
|
function buildContinuationState({
|
|
1528
1463
|
nextActionType = null,
|
|
1529
1464
|
completionState = null,
|
|
@@ -2105,6 +2040,254 @@ function deriveDelegationRecommendation({
|
|
|
2105
2040
|
return null;
|
|
2106
2041
|
}
|
|
2107
2042
|
|
|
2043
|
+
// --- C89 TASK-005 (SPEC §6 FR-04, §11): typed delegation advice (shadow) ------
|
|
2044
|
+
// Wraps the single route owner (deriveDelegationRecommendation above) — never
|
|
2045
|
+
// a second router, never a spawn path. Gated on
|
|
2046
|
+
// `subagentOrchestrator.roleAdvice.stage` ('off'/malformed → null, zero cost);
|
|
2047
|
+
// any other stage emits the typed advisory struct. The optional unic-decision
|
|
2048
|
+
// consult (applyRoleAdviceConsult) only ever answers an owner-bounded enum
|
|
2049
|
+
// question; deterministic fields stay authoritative and every failure records
|
|
2050
|
+
// a typed outcome on advice.model — never silent.
|
|
2051
|
+
export const DELEGATION_ADVICE_POLICY_VERSION = 'role-advice.v1';
|
|
2052
|
+
export const DELEGATION_ADVICE_DECISION_KEY = 'route.delegation-role.v1';
|
|
2053
|
+
// SPEC §6 initial role vocabulary — maps onto existing frontmatter agents,
|
|
2054
|
+
// no new agent file (retriever/reviewer reserved for role-bearing lanes;
|
|
2055
|
+
// C89-006 consumes role+contextMode only as advisory unless host-verified).
|
|
2056
|
+
export const DELEGATION_ADVICE_ROLES = Object.freeze(['retriever', 'reasoner', 'reviewer']);
|
|
2057
|
+
export const DELEGATION_ADVICE_CONTEXT_MODES = Object.freeze(['isolated', 'curated']);
|
|
2058
|
+
|
|
2059
|
+
// Delegation hint → {role, contextMode, reasonCode}. The owner hints all carry
|
|
2060
|
+
// reasoning work; the noisy debug lane needs isolation from the noisy loop,
|
|
2061
|
+
// the plan/feature lanes need the curated indexed bundle.
|
|
2062
|
+
const DELEGATION_HINT_ROLES = Object.freeze({
|
|
2063
|
+
'ukit-small-task-maintainer': { role: 'reasoner', contextMode: 'isolated', reasonCode: 'small-task-maintenance' },
|
|
2064
|
+
'subagent-driven-development': { role: 'reasoner', contextMode: 'curated', reasonCode: 'planned-batch-execution' },
|
|
2065
|
+
'bug-debugger': { role: 'reasoner', contextMode: 'isolated', reasonCode: 'noisy-debug-loop' },
|
|
2066
|
+
'feature-implementer': { role: 'reasoner', contextMode: 'curated', reasonCode: 'broad-implementation-lane' },
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
/**
|
|
2070
|
+
* Typed delegation advice (SPEC §6 FR-04). Reads the deterministic
|
|
2071
|
+
* `deriveDelegationRecommendation` verdict and annotates it as the typed
|
|
2072
|
+
* shadow receipt {delegate, role, contextMode, confidence, reasonCodes,
|
|
2073
|
+
* policyVersion, enforcement} — plus the audit fields hint/stage.
|
|
2074
|
+
*
|
|
2075
|
+
* `enforcement` is 'advisory' on every shipped host: this lane never spawns
|
|
2076
|
+
* and C89-006 owns the host-capability check that could one day mark a lane
|
|
2077
|
+
* 'enforced'. Trivial work resolves local by the deterministic fast path —
|
|
2078
|
+
* the same `null` the owner returns, typed.
|
|
2079
|
+
*
|
|
2080
|
+
* @returns {object|null} null when the roleAdvice stage is off/malformed.
|
|
2081
|
+
*/
|
|
2082
|
+
export function deriveTypedDelegationAdvice({
|
|
2083
|
+
activeSkills = [],
|
|
2084
|
+
routingContext = {},
|
|
2085
|
+
contextRecommendation = null,
|
|
2086
|
+
verificationRecommendation = null,
|
|
2087
|
+
autonomyLevel = 'balanced',
|
|
2088
|
+
riskFloor = null,
|
|
2089
|
+
config = null,
|
|
2090
|
+
} = {}) {
|
|
2091
|
+
const stage = resolveSubagentOrchestratorStage(config, 'roleAdvice');
|
|
2092
|
+
if (stage === 'off') {
|
|
2093
|
+
return null;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
const recommendation = deriveDelegationRecommendation({
|
|
2097
|
+
activeSkills,
|
|
2098
|
+
routingContext,
|
|
2099
|
+
contextRecommendation,
|
|
2100
|
+
verificationRecommendation,
|
|
2101
|
+
autonomyLevel,
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
const hint = recommendation?.hint ?? null;
|
|
2105
|
+
const reasonCodes = [];
|
|
2106
|
+
if (routingContext.taskType === 'trivial') {
|
|
2107
|
+
reasonCodes.push('trivial-fast-path');
|
|
2108
|
+
} else if (hint && DELEGATION_HINT_ROLES[hint]) {
|
|
2109
|
+
reasonCodes.push(DELEGATION_HINT_ROLES[hint].reasonCode);
|
|
2110
|
+
} else if (recommendation) {
|
|
2111
|
+
reasonCodes.push('owner-recommendation');
|
|
2112
|
+
} else {
|
|
2113
|
+
reasonCodes.push('no-delegation-signal');
|
|
2114
|
+
}
|
|
2115
|
+
if (riskFloor?.floor === 'high-risk') {
|
|
2116
|
+
reasonCodes.push('risk-floor-high-risk');
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
const delegate = hint !== null;
|
|
2120
|
+
let role = null;
|
|
2121
|
+
let contextMode = null;
|
|
2122
|
+
if (delegate) {
|
|
2123
|
+
if (routingContext.executionMode === 'review-release') {
|
|
2124
|
+
// SPEC §6: reviewer lanes default isolated — the reviewer receives the
|
|
2125
|
+
// task requirements/diff/tests/evidence, not implementer narrative.
|
|
2126
|
+
role = 'reviewer';
|
|
2127
|
+
contextMode = 'isolated';
|
|
2128
|
+
reasonCodes.push('review-release-lane');
|
|
2129
|
+
} else {
|
|
2130
|
+
const mapped = DELEGATION_HINT_ROLES[hint] ?? { role: 'reasoner', contextMode: 'curated' };
|
|
2131
|
+
role = mapped.role;
|
|
2132
|
+
contextMode = mapped.contextMode;
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
const confidence = routingContext.taskType === 'trivial'
|
|
2137
|
+
? 0.95
|
|
2138
|
+
: delegate ? 0.9 : 0.6;
|
|
2139
|
+
|
|
2140
|
+
return {
|
|
2141
|
+
delegate,
|
|
2142
|
+
role,
|
|
2143
|
+
contextMode,
|
|
2144
|
+
confidence,
|
|
2145
|
+
reasonCodes,
|
|
2146
|
+
policyVersion: DELEGATION_ADVICE_POLICY_VERSION,
|
|
2147
|
+
enforcement: 'advisory',
|
|
2148
|
+
stage,
|
|
2149
|
+
hint,
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
function delegationRiskFloorSignals(routeSummary) {
|
|
2154
|
+
const codes = routeSummary?.riskFloor?.codes;
|
|
2155
|
+
return Array.isArray(codes) ? codes : [];
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
/**
|
|
2159
|
+
* Optional bounded consult + recorded override for the typed advice (SPEC §6).
|
|
2160
|
+
* The unic-decision question is one owner-bounded choice over the role
|
|
2161
|
+
* shortlist — enum-only, redacted, never tools/spawn. Any auth rejection,
|
|
2162
|
+
* timeout, invalid or off-shortlist answer lands on `advice.model` as a typed
|
|
2163
|
+
* outcome; the deterministic fields are never rewritten and no receipt write
|
|
2164
|
+
* is silent (appendReceipt seam fires whenever a consult ran).
|
|
2165
|
+
*
|
|
2166
|
+
* The main-agent override is recorded verbatim (role + reason) when the role
|
|
2167
|
+
* is in the enum — stored for the later accepting lane; it never mutates the
|
|
2168
|
+
* deterministic advice. The hard risk floor is deterministic state on the
|
|
2169
|
+
* advice itself, so conflicting model noise can never flip a local route.
|
|
2170
|
+
*
|
|
2171
|
+
* Returns true when the route was annotated (a consult ran or an override was
|
|
2172
|
+
* recorded); false when no advice exists or the consult never ran.
|
|
2173
|
+
*/
|
|
2174
|
+
export async function applyRoleAdviceConsult({
|
|
2175
|
+
routeSummary = null,
|
|
2176
|
+
config = null,
|
|
2177
|
+
ask = null,
|
|
2178
|
+
appendReceipt = null,
|
|
2179
|
+
override = null,
|
|
2180
|
+
now = () => Date.now(),
|
|
2181
|
+
} = {}) {
|
|
2182
|
+
const advice = routeSummary?.delegationAdvice;
|
|
2183
|
+
if (!advice || typeof advice !== 'object') {
|
|
2184
|
+
return false;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
if (override && typeof override === 'object'
|
|
2188
|
+
&& DELEGATION_ADVICE_ROLES.includes(override.role)
|
|
2189
|
+
&& typeof override.reason === 'string' && override.reason.trim()) {
|
|
2190
|
+
advice.override = { role: override.role, reason: override.reason.trim() };
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
const emit = (receipt) => {
|
|
2194
|
+
if (typeof appendReceipt !== 'function') return;
|
|
2195
|
+
try {
|
|
2196
|
+
const pending = appendReceipt(receipt);
|
|
2197
|
+
if (pending?.catch) pending.catch(() => {});
|
|
2198
|
+
} catch {
|
|
2199
|
+
// Receipt loss is advisory — the typed advice stands regardless.
|
|
2200
|
+
}
|
|
2201
|
+
};
|
|
2202
|
+
|
|
2203
|
+
// Local routes never consult — there is no role to suggest and the
|
|
2204
|
+
// deterministic fast path needs no shadow annotation.
|
|
2205
|
+
if (advice.delegate !== true || typeof advice.role !== 'string') {
|
|
2206
|
+
return true;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// An explicit ask seam is the consult consent; the ambient path only builds
|
|
2210
|
+
// a client when the decision plane itself is enabled, so a role-advice-only
|
|
2211
|
+
// config never triggers endpoint work.
|
|
2212
|
+
const explicitAsk = typeof ask === 'function';
|
|
2213
|
+
if (!explicitAsk && resolveDecisionPlaneStage(config) === 'off') {
|
|
2214
|
+
return true;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
// Owner-bounded shortlist: the model may only answer inside the
|
|
2218
|
+
// deterministic candidate set — a singleton when the owner has one role.
|
|
2219
|
+
const candidates = [advice.role];
|
|
2220
|
+
const batch = {
|
|
2221
|
+
batchId: `role-advice-${now().toString(36)}`,
|
|
2222
|
+
boundary: 'role-advice',
|
|
2223
|
+
deadlineMs: Math.max(
|
|
2224
|
+
500,
|
|
2225
|
+
Number.isFinite(config?.decisionPlane?.timeoutMs) ? config.decisionPlane.timeoutMs : 2000,
|
|
2226
|
+
),
|
|
2227
|
+
statePacket: {
|
|
2228
|
+
stateVersion: 1,
|
|
2229
|
+
taskClass: routeSummary?.routingContext?.taskType ?? 'unknown',
|
|
2230
|
+
execution: routeSummary?.executionMode ? { mode: routeSummary.executionMode } : {},
|
|
2231
|
+
riskSignals: delegationRiskFloorSignals(routeSummary),
|
|
2232
|
+
},
|
|
2233
|
+
questions: [
|
|
2234
|
+
{
|
|
2235
|
+
decisionKey: DELEGATION_ADVICE_DECISION_KEY,
|
|
2236
|
+
kind: 'choice',
|
|
2237
|
+
instruction: 'Pick the delegation role from the owner-bounded shortlist.',
|
|
2238
|
+
candidates,
|
|
2239
|
+
},
|
|
2240
|
+
],
|
|
2241
|
+
};
|
|
2242
|
+
|
|
2243
|
+
let result = null;
|
|
2244
|
+
try {
|
|
2245
|
+
result = await ask({ batch });
|
|
2246
|
+
} catch {
|
|
2247
|
+
result = null;
|
|
2248
|
+
}
|
|
2249
|
+
if (!result) {
|
|
2250
|
+
result = { status: 'unavailable', fallbackCode: 'ask-failed', answers: [] };
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// Fold the typed outcome: only a valid in-shortlist answer becomes a
|
|
2254
|
+
// suggestion; everything else keeps suggestedRole null with a fallback code.
|
|
2255
|
+
const answers = Array.isArray(result.answers) ? result.answers : [];
|
|
2256
|
+
let suggestedRole = null;
|
|
2257
|
+
for (const answer of answers) {
|
|
2258
|
+
if (answer?.decisionKey !== DELEGATION_ADVICE_DECISION_KEY) continue;
|
|
2259
|
+
if (answer?.validationStatus !== 'valid') continue;
|
|
2260
|
+
const value = typeof answer?.value === 'string' ? answer.value.trim() : null;
|
|
2261
|
+
if (value && candidates.includes(value)) {
|
|
2262
|
+
suggestedRole = value;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
const outcomeClass = typeof result.status === 'string' ? result.status : 'unavailable';
|
|
2266
|
+
advice.model = {
|
|
2267
|
+
status: outcomeClass,
|
|
2268
|
+
suggestedRole,
|
|
2269
|
+
agreement: suggestedRole === null
|
|
2270
|
+
? 'unknown'
|
|
2271
|
+
: suggestedRole === advice.role ? 'agree' : 'disagree',
|
|
2272
|
+
fallbackCode: result.fallbackCode
|
|
2273
|
+
?? (suggestedRole === null && outcomeClass === 'accepted' ? 'off-shortlist' : null),
|
|
2274
|
+
};
|
|
2275
|
+
|
|
2276
|
+
emit({
|
|
2277
|
+
kind: outcomeClass === 'unavailable' ? 'fallback' : 'shadow',
|
|
2278
|
+
boundary: 'role-advice',
|
|
2279
|
+
stage: advice.stage,
|
|
2280
|
+
outcomeClass,
|
|
2281
|
+
checkpoint: `role-advice batch=${batch.batchId}`,
|
|
2282
|
+
latencyClass: result.latencyClass ?? 'unknown',
|
|
2283
|
+
decisionKeys: [DELEGATION_ADVICE_DECISION_KEY],
|
|
2284
|
+
agreement: advice.model.agreement,
|
|
2285
|
+
fallbackCode: advice.model.fallbackCode,
|
|
2286
|
+
now: now(),
|
|
2287
|
+
});
|
|
2288
|
+
return true;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2108
2291
|
function deriveContextIntent({ promptText, commandText, targetFile, selectedIds }) {
|
|
2109
2292
|
if (promptText.trim()) {
|
|
2110
2293
|
return promptText.trim();
|