principles-disciple 1.7.6 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/commands/context.js +5 -15
  2. package/dist/commands/evolution-status.js +2 -9
  3. package/dist/commands/export.js +61 -8
  4. package/dist/commands/nocturnal-review.d.ts +24 -0
  5. package/dist/commands/nocturnal-review.js +265 -0
  6. package/dist/commands/nocturnal-rollout.d.ts +27 -0
  7. package/dist/commands/nocturnal-rollout.js +671 -0
  8. package/dist/commands/nocturnal-train.d.ts +25 -0
  9. package/dist/commands/nocturnal-train.js +919 -0
  10. package/dist/commands/pain.js +8 -21
  11. package/dist/constants/tools.d.ts +2 -2
  12. package/dist/constants/tools.js +1 -1
  13. package/dist/core/adaptive-thresholds.d.ts +186 -0
  14. package/dist/core/adaptive-thresholds.js +300 -0
  15. package/dist/core/config.d.ts +2 -38
  16. package/dist/core/config.js +6 -61
  17. package/dist/core/event-log.d.ts +1 -2
  18. package/dist/core/event-log.js +0 -3
  19. package/dist/core/evolution-engine.js +1 -21
  20. package/dist/core/evolution-reducer.d.ts +7 -1
  21. package/dist/core/evolution-reducer.js +56 -4
  22. package/dist/core/evolution-types.d.ts +61 -9
  23. package/dist/core/evolution-types.js +31 -9
  24. package/dist/core/external-training-contract.d.ts +276 -0
  25. package/dist/core/external-training-contract.js +269 -0
  26. package/dist/core/local-worker-routing.d.ts +175 -0
  27. package/dist/core/local-worker-routing.js +525 -0
  28. package/dist/core/model-deployment-registry.d.ts +218 -0
  29. package/dist/core/model-deployment-registry.js +503 -0
  30. package/dist/core/model-training-registry.d.ts +295 -0
  31. package/dist/core/model-training-registry.js +475 -0
  32. package/dist/core/nocturnal-arbiter.d.ts +159 -0
  33. package/dist/core/nocturnal-arbiter.js +534 -0
  34. package/dist/core/nocturnal-candidate-scoring.d.ts +137 -0
  35. package/dist/core/nocturnal-candidate-scoring.js +266 -0
  36. package/dist/core/nocturnal-compliance.d.ts +175 -0
  37. package/dist/core/nocturnal-compliance.js +824 -0
  38. package/dist/core/nocturnal-dataset.d.ts +224 -0
  39. package/dist/core/nocturnal-dataset.js +443 -0
  40. package/dist/core/nocturnal-executability.d.ts +85 -0
  41. package/dist/core/nocturnal-executability.js +331 -0
  42. package/dist/core/nocturnal-export.d.ts +124 -0
  43. package/dist/core/nocturnal-export.js +275 -0
  44. package/dist/core/nocturnal-paths.d.ts +124 -0
  45. package/dist/core/nocturnal-paths.js +214 -0
  46. package/dist/core/nocturnal-trajectory-extractor.d.ts +242 -0
  47. package/dist/core/nocturnal-trajectory-extractor.js +307 -0
  48. package/dist/core/nocturnal-trinity.d.ts +311 -0
  49. package/dist/core/nocturnal-trinity.js +880 -0
  50. package/dist/core/paths.d.ts +6 -0
  51. package/dist/core/paths.js +6 -0
  52. package/dist/core/principle-training-state.d.ts +121 -0
  53. package/dist/core/principle-training-state.js +321 -0
  54. package/dist/core/promotion-gate.d.ts +238 -0
  55. package/dist/core/promotion-gate.js +529 -0
  56. package/dist/core/session-tracker.d.ts +10 -0
  57. package/dist/core/session-tracker.js +14 -0
  58. package/dist/core/shadow-observation-registry.d.ts +217 -0
  59. package/dist/core/shadow-observation-registry.js +308 -0
  60. package/dist/core/training-program.d.ts +233 -0
  61. package/dist/core/training-program.js +433 -0
  62. package/dist/core/trajectory.d.ts +95 -1
  63. package/dist/core/trajectory.js +220 -6
  64. package/dist/core/workspace-context.d.ts +0 -6
  65. package/dist/core/workspace-context.js +0 -12
  66. package/dist/hooks/bash-risk.d.ts +6 -6
  67. package/dist/hooks/bash-risk.js +8 -8
  68. package/dist/hooks/gate-block-helper.js +1 -1
  69. package/dist/hooks/gate.d.ts +1 -1
  70. package/dist/hooks/gate.js +2 -2
  71. package/dist/hooks/gfi-gate.d.ts +3 -3
  72. package/dist/hooks/gfi-gate.js +15 -14
  73. package/dist/hooks/pain.js +6 -9
  74. package/dist/hooks/progressive-trust-gate.d.ts +21 -49
  75. package/dist/hooks/progressive-trust-gate.js +51 -204
  76. package/dist/hooks/prompt.d.ts +11 -11
  77. package/dist/hooks/prompt.js +158 -72
  78. package/dist/hooks/subagent.js +43 -6
  79. package/dist/i18n/commands.js +8 -8
  80. package/dist/index.js +129 -28
  81. package/dist/service/evolution-worker.d.ts +42 -4
  82. package/dist/service/evolution-worker.js +321 -13
  83. package/dist/service/nocturnal-runtime.d.ts +183 -0
  84. package/dist/service/nocturnal-runtime.js +352 -0
  85. package/dist/service/nocturnal-service.d.ts +163 -0
  86. package/dist/service/nocturnal-service.js +787 -0
  87. package/dist/service/nocturnal-target-selector.d.ts +145 -0
  88. package/dist/service/nocturnal-target-selector.js +315 -0
  89. package/dist/service/phase3-input-filter.d.ts +2 -23
  90. package/dist/service/phase3-input-filter.js +3 -27
  91. package/dist/service/runtime-summary-service.d.ts +0 -10
  92. package/dist/service/runtime-summary-service.js +1 -54
  93. package/dist/tools/deep-reflect.js +2 -1
  94. package/dist/types/event-types.d.ts +2 -10
  95. package/dist/types/runtime-summary.d.ts +1 -8
  96. package/dist/types.d.ts +0 -3
  97. package/dist/types.js +0 -2
  98. package/openclaw.plugin.json +1 -1
  99. package/package.json +1 -1
  100. package/templates/langs/en/skills/pd-mentor/SKILL.md +5 -5
  101. package/templates/langs/zh/skills/pd-mentor/SKILL.md +5 -5
  102. package/templates/pain_settings.json +0 -6
  103. package/dist/commands/trust.d.ts +0 -4
  104. package/dist/commands/trust.js +0 -78
  105. package/dist/core/trust-engine.d.ts +0 -96
  106. package/dist/core/trust-engine.js +0 -286
@@ -0,0 +1,525 @@
1
+ /**
2
+ * Local Worker Routing Policy — Task Classification and Routing Decisions
3
+ * ======================================================================
4
+ *
5
+ * PURPOSE: Provide an explainable, testable policy that decides whether a given
6
+ * task can be delegated to a local-worker profile (local-reader or local-editor)
7
+ * or must stay on the main agent.
8
+ *
9
+ * ARCHITECTURE:
10
+ * - This module is POLICY ONLY — it makes routing decisions but does NOT execute them
11
+ * - The main agent (or a delegation hook in a future phase) is responsible for
12
+ * actually routing the task based on the RoutingDecision returned here
13
+ * - All decisions are deterministic and based on structured input fields
14
+ * - No model inference, no learning, no dynamic adaptation
15
+ *
16
+ * TASK CLASSIFICATION TAXONOMY:
17
+ * reader_eligible — clearly suitable for local-reader
18
+ * editor_eligible — clearly suitable for local-editor
19
+ * high_entropy_disallowed — high-complexity tasks that must stay on main agent
20
+ * risk_disallowed — tasks with destructive or high-risk signals
21
+ * ambiguous_scope — tasks that are unclear and need main-agent judgment
22
+ * deployment_unavailable — no enabled deployment exists for the target profile
23
+ *
24
+ * FAIL-CLOSED PRINCIPLE:
25
+ * - When in doubt → stay_main
26
+ * - Unclear intent → stay_main
27
+ * - High complexity → stay_main
28
+ * - Any risk signal → stay_main
29
+ * - No enabled deployment → stay_main
30
+ *
31
+ * DESIGN CONSTRAINTS:
32
+ * - No actual task execution
33
+ * - No automatic learning or route optimization
34
+ * - No Trinity or adaptive threshold logic
35
+ * - Routing decisions are fully explainable (return `reason` + `blockers[]`)
36
+ */
37
+ import { isRoutingEnabledForProfile, getDeployment, } from './model-deployment-registry.js';
38
+ import { isCheckpointDeployable } from './model-training-registry.js';
39
+ import { getPromotionState } from './promotion-gate.js';
40
+ // ---------------------------------------------------------------------------
41
+ // Keyword Classifiers
42
+ // ---------------------------------------------------------------------------
43
+ /**
44
+ * Keywords that indicate a task is suitable for `local-reader`.
45
+ * Matched against taskIntent + taskDescription.
46
+ */
47
+ const READER_KEYWORDS = [
48
+ 'read', 'view', 'show', 'get', 'find', 'search', 'grep', 'look',
49
+ 'inspect', 'examine', 'list', 'cat', 'head', 'tail', 'diff',
50
+ 'summary', 'summarize', 'extract', 'parse', 'review',
51
+ 'check', 'verify', 'status', 'describe', 'explain_what',
52
+ 'browse', 'fetch', 'show_content', 'file_content', 'code_read',
53
+ ];
54
+ /**
55
+ * Keywords that indicate a task is suitable for `local-editor`.
56
+ * Matched against taskIntent + taskDescription.
57
+ */
58
+ const EDITOR_KEYWORDS = [
59
+ 'edit', 'update', 'modify', 'change', 'fix', 'patch', 'replace',
60
+ 'add', 'remove', 'delete', 'insert', 'rewrite', 'refactor',
61
+ 'apply', 'execute', 'run', 'transform', 'convert', 'migrate',
62
+ 'write', 'create_file', 'append', 'touch', 'rename',
63
+ ];
64
+ /**
65
+ * Keywords that indicate HIGH ENTROPY — tasks that must stay on main agent.
66
+ * These indicate open-ended, multi-step, or ambiguous tasks.
67
+ */
68
+ const HIGH_ENTROPY_KEYWORDS = [
69
+ 'design', 'architect', 'plan', 'strategy', 'roadmap', 'propose',
70
+ 'research', 'investigate', 'explore', 'evaluate', 'compare',
71
+ 'decide', 'choose', 'recommend', 'suggest', 'analyze_tradeoffs',
72
+ 'unclear', 'vague', 'ambiguous', 'open_ended', 'multiple_options',
73
+ 'architecture', 'system_design', 'high_level', 'blueprint',
74
+ 'thinking', 'reasoning', '思考', '分析', '设计',
75
+ ];
76
+ /**
77
+ * Keywords that indicate RISK — tasks that must stay on main agent.
78
+ * These indicate destructive, irreversible, or production-sensitive operations.
79
+ */
80
+ const RISK_KEYWORDS = [
81
+ 'rm', 'delete', 'remove', 'drop', 'truncate', 'destroy',
82
+ 'force_push', 'force_reset', 'hard_reset', 'rebase_force',
83
+ 'exec', 'eval', 'sudo', 'chmod_777', 'shutdown', 'reboot',
84
+ 'production', 'prod', 'live', 'deploy', 'release',
85
+ 'database', 'db_', 'migration', 'seed',
86
+ 'secrets', 'credentials', 'password', 'api_key', 'token',
87
+ 'bulk', 'batch', 'all_files', 'entire_', 'recursive_delete',
88
+ ];
89
+ /**
90
+ * Risk file patterns — files whose modification indicates high risk.
91
+ * Matched against requestedFiles using simple includes check.
92
+ */
93
+ const RISK_FILE_PATTERNS = [
94
+ '.git/config',
95
+ 'package-lock.json',
96
+ 'yarn.lock',
97
+ 'pnpm-lock.yaml',
98
+ 'node_modules',
99
+ '.env',
100
+ 'secrets',
101
+ 'credentials',
102
+ '.aws/credentials',
103
+ '~/.ssh/',
104
+ '/etc/',
105
+ 'production',
106
+ '.k8s',
107
+ 'docker-compose.yml',
108
+ 'Dockerfile',
109
+ ];
110
+ /**
111
+ * Tools that are inherently risky and indicate a task must stay on main agent.
112
+ */
113
+ const RISK_TOOLS = [
114
+ 'bash', 'shell', 'exec', 'run_command', 'execute',
115
+ 'git_push', 'git_force_push', 'git_reset',
116
+ 'http_delete', 'DROP', 'DELETE *', 'truncate',
117
+ 'sudo', 'chmod', 'chown',
118
+ ];
119
+ // ---------------------------------------------------------------------------
120
+ // Classification Helpers
121
+ // ---------------------------------------------------------------------------
122
+ /**
123
+ * Simple case-insensitive keyword match.
124
+ */
125
+ function containsKeyword(text, keywords) {
126
+ if (!text)
127
+ return false;
128
+ const lower = text.toLowerCase();
129
+ return keywords.some((kw) => lower.includes(kw));
130
+ }
131
+ /**
132
+ * Check if any tool name looks like a risk tool.
133
+ */
134
+ function hasRiskTool(tools) {
135
+ if (!tools || tools.length === 0)
136
+ return false;
137
+ const combined = tools.join(' ').toLowerCase();
138
+ return RISK_TOOLS.some((rt) => combined.includes(rt));
139
+ }
140
+ /**
141
+ * Check if any requested file matches a risk pattern.
142
+ */
143
+ function hasRiskFile(files) {
144
+ if (!files || files.length === 0)
145
+ return false;
146
+ return files.some((f) => {
147
+ const lower = f.toLowerCase();
148
+ return RISK_FILE_PATTERNS.some((rp) => lower.includes(rp.toLowerCase()));
149
+ });
150
+ }
151
+ /**
152
+ * Compute a combined text from all input fields for keyword scanning.
153
+ */
154
+ function computeCombinedText(input) {
155
+ const parts = [];
156
+ if (input.taskIntent)
157
+ parts.push(input.taskIntent);
158
+ if (input.taskDescription)
159
+ parts.push(input.taskDescription);
160
+ if (input.expectedOutputShape)
161
+ parts.push(input.expectedOutputShape);
162
+ if (input.complexityHints)
163
+ parts.push(input.complexityHints.join(' '));
164
+ return parts.join(' ').toLowerCase();
165
+ }
166
+ // ---------------------------------------------------------------------------
167
+ // Core Classification Logic
168
+ // ---------------------------------------------------------------------------
169
+ /**
170
+ * Classify the task based on its input fields.
171
+ * Returns a raw classification category (before deployment check).
172
+ */
173
+ function classifyTaskKind(input) {
174
+ const text = computeCombinedText(input);
175
+ const { taskIntent, taskDescription, requestedTools, requestedFiles, riskSignals, complexityHints } = input;
176
+ // --- Step 1: Explicit risk signals always block ---
177
+ if (riskSignals && riskSignals.length > 0) {
178
+ return 'risk_disallowed';
179
+ }
180
+ // --- Step 2: High-entropy keyword detection ---
181
+ if (complexityHints?.some((h) => ['multi_step', 'cross_file', 'ambiguous', 'requires_planning', 'open_ended', 'unclear'].includes(h))) {
182
+ return 'high_entropy_disallowed';
183
+ }
184
+ if (containsKeyword(text, HIGH_ENTROPY_KEYWORDS)) {
185
+ return 'high_entropy_disallowed';
186
+ }
187
+ // Architecture design keywords in intent or description
188
+ if (containsKeyword(taskIntent, ['design', 'architect', 'plan', 'propose']) ||
189
+ containsKeyword(taskDescription, ['design', 'architect', 'plan', 'propose'])) {
190
+ return 'high_entropy_disallowed';
191
+ }
192
+ // --- Step 3: Risk keyword detection (intent/description text) ---
193
+ // Check text-based risk signals even without explicit risk tools/files.
194
+ // Must run BEFORE editor eligibility to prevent destructive keywords
195
+ // (delete, remove, drop, etc.) from being misclassified as editor-eligible.
196
+ if (containsKeyword(taskIntent, RISK_KEYWORDS) ||
197
+ containsKeyword(taskDescription, RISK_KEYWORDS)) {
198
+ return 'risk_disallowed';
199
+ }
200
+ // --- Step 4: Risk tool detection ---
201
+ if (hasRiskTool(requestedTools ?? [])) {
202
+ return 'risk_disallowed';
203
+ }
204
+ // --- Step 5: Risk file pattern detection ---
205
+ if (hasRiskFile(requestedFiles ?? [])) {
206
+ return 'risk_disallowed';
207
+ }
208
+ // --- Step 6: Reader eligibility ---
209
+ // Task intent and description both clearly indicate reading/gathering
210
+ const intentIsReader = containsKeyword(taskIntent, READER_KEYWORDS);
211
+ const descIsReader = containsKeyword(taskDescription, READER_KEYWORDS);
212
+ if (intentIsReader && (descIsReader || !taskDescription)) {
213
+ return 'reader_eligible';
214
+ }
215
+ // --- Step 7: Editor eligibility ---
216
+ // Bounded scope: editing a known set of files (1-3) is editor-eligible.
217
+ // Large-scale multi-file editing (4+ distinct files) is inherently high-entropy
218
+ // — requires coordinating changes across a large surface area and carries
219
+ // significant unintended-change risk. Must stay on main agent.
220
+ const uniqueFiles = requestedFiles
221
+ ? [...new Set(requestedFiles.filter((f) => f.trim().length > 0))]
222
+ : [];
223
+ const intentIsEditor = containsKeyword(taskIntent, EDITOR_KEYWORDS);
224
+ const descIsEditor = containsKeyword(taskDescription, EDITOR_KEYWORDS);
225
+ if (intentIsEditor && (descIsEditor || !taskDescription)) {
226
+ if (uniqueFiles.length >= 4) {
227
+ return 'high_entropy_disallowed';
228
+ }
229
+ return 'editor_eligible';
230
+ }
231
+ // --- Step 8: Ambiguous scope ---
232
+ // If we have some description but can't classify it clearly
233
+ if (taskDescription && taskDescription.trim().length > 0) {
234
+ const trimmed = taskDescription.trim();
235
+ // Very short or generic descriptions → ambiguous
236
+ if (trimmed.length < 20 || ['todo', 'fix', 'improve', 'change', 'update', 'something'].includes(trimmed.toLowerCase())) {
237
+ return 'ambiguous_scope';
238
+ }
239
+ // Contains question words suggesting open-ended reasoning
240
+ if (/\b(why|how|should|could|would|what if|should we|whether to)\b/i.test(trimmed)) {
241
+ return 'ambiguous_scope';
242
+ }
243
+ }
244
+ // --- Step 9: No sufficient information ---
245
+ if (!taskIntent && !taskDescription) {
246
+ return 'ambiguous_scope';
247
+ }
248
+ // Default to ambiguous rather than risky — fail to stay_main
249
+ return 'ambiguous_scope';
250
+ }
251
+ /**
252
+ * Build the reason string for a given classification.
253
+ */
254
+ function buildReason(classification, input) {
255
+ const { taskIntent, taskDescription } = input;
256
+ switch (classification) {
257
+ case 'reader_eligible':
258
+ return `Task "${taskIntent || taskDescription || '(unnamed)'}" is classified as reader_eligible. ` +
259
+ `Keywords indicate focused reading, inspection, or information retrieval. ` +
260
+ `No high-entropy or risk signals detected.`;
261
+ case 'editor_eligible':
262
+ return `Task "${taskIntent || taskDescription || '(unnamed)'}" is classified as editor_eligible. ` +
263
+ `Keywords indicate bounded editing, modification, or repair. ` +
264
+ `No high-entropy or risk signals detected.`;
265
+ case 'high_entropy_disallowed': {
266
+ const uniqueFiles = input.requestedFiles
267
+ ? [...new Set(input.requestedFiles.filter((f) => f.trim().length > 0))]
268
+ : [];
269
+ const isLargeScaleEdit = uniqueFiles.length >= 4;
270
+ if (isLargeScaleEdit) {
271
+ return `Task "${taskIntent || taskDescription || '(unnamed)'}" is blocked as high_entropy_disallowed. ` +
272
+ `Editing ${uniqueFiles.length} files simultaneously exceeds the bounded-scope limit for local-editor. ` +
273
+ `Large-scale multi-file edits require the main agent's coordination and risk judgment.`;
274
+ }
275
+ return `Task "${taskIntent || taskDescription || '(unnamed)'}" is blocked as high_entropy_disallowed. ` +
276
+ `Keywords indicate open-ended planning, architecture design, or ambiguous multi-step work. ` +
277
+ `These tasks require the main agent's full reasoning capability.`;
278
+ }
279
+ case 'risk_disallowed':
280
+ return `Task "${taskIntent || taskDescription || '(unnamed)'}" is blocked as risk_disallowed. ` +
281
+ `Detected destructive, production, or irreversible operation signals. ` +
282
+ `All risky operations must remain on the main agent with human oversight.`;
283
+ case 'ambiguous_scope':
284
+ return `Task "${taskIntent || taskDescription || '(unnamed)'}" is blocked as ambiguous_scope. ` +
285
+ `The task description is too vague, too short, or contains open-ended question words. ` +
286
+ `Main agent must clarify scope before delegation.`;
287
+ case 'profile_mismatch':
288
+ return `Task profile does not match the requested target profile. ` +
289
+ `The task's natural classification is incompatible with the specified worker profile. ` +
290
+ `Main agent must re-route or choose a compatible profile.`;
291
+ case 'deployment_unavailable':
292
+ return `No enabled deployment available for routing. ` +
293
+ `Either no checkpoint is bound to the profile, or routing has been disabled. ` +
294
+ `Main agent must handle this task.`;
295
+ }
296
+ }
297
+ /**
298
+ * Build the blockers list for a given classification.
299
+ */
300
+ function buildBlockers(classification, input) {
301
+ switch (classification) {
302
+ case 'reader_eligible':
303
+ return [];
304
+ case 'editor_eligible':
305
+ return [];
306
+ case 'high_entropy_disallowed': {
307
+ const uniqueFiles = input.requestedFiles
308
+ ? [...new Set(input.requestedFiles.filter((f) => f.trim().length > 0))]
309
+ : [];
310
+ const isLargeScaleEdit = uniqueFiles.length >= 4;
311
+ return [
312
+ isLargeScaleEdit
313
+ ? `large-scale multi-file edit detected (${uniqueFiles.length} files): scope too broad for local-editor`
314
+ : 'task contains high-entropy keywords (design/plan/architect/investigate)',
315
+ 'complexity hint indicates multi-step or open-ended work',
316
+ 'main agent required for full reasoning and judgment',
317
+ ];
318
+ }
319
+ case 'risk_disallowed':
320
+ return [
321
+ 'risk tool requested (bash/exec/sudo/DROP/DELETE)',
322
+ 'risk file pattern detected (production/secrets/.git/node_modules)',
323
+ 'explicit risk signal present in input',
324
+ 'main agent must supervise high-risk operations',
325
+ ];
326
+ case 'ambiguous_scope':
327
+ return [
328
+ 'task description too vague or generic',
329
+ 'task intent not provided or unclear',
330
+ 'open-ended question words detected',
331
+ 'main agent must clarify scope before delegation',
332
+ ];
333
+ case 'profile_mismatch':
334
+ return [
335
+ 'task natural profile incompatible with requested target profile',
336
+ 'main agent must re-route or select a compatible profile',
337
+ ];
338
+ case 'deployment_unavailable':
339
+ return [
340
+ 'no enabled deployment found for target profile',
341
+ 'routing may be disabled in deployment registry',
342
+ 'main agent must handle task directly',
343
+ ];
344
+ }
345
+ }
346
+ // ---------------------------------------------------------------------------
347
+ // Public API
348
+ // ---------------------------------------------------------------------------
349
+ /**
350
+ * Classify a task and produce a routing decision.
351
+ *
352
+ * This is the main entry point for routing policy evaluation.
353
+ * It:
354
+ * 1. Classifies the task kind based on keywords and heuristics
355
+ * 2. Checks deployment availability for the target profile
356
+ * 3. Returns a fully explainable RoutingDecision
357
+ *
358
+ * @param input - The routing input describing the task
359
+ * @param stateDir - Workspace state directory (for deployment registry lookup)
360
+ * @returns RoutingDecision with classification, reason, blockers, and routing verdict
361
+ */
362
+ export function classifyTask(input, stateDir) {
363
+ // --- Determine the raw task classification ---
364
+ const classification = classifyTaskKind(input);
365
+ // --- Determine the target profile ---
366
+ // If input specifies a target, use it. Otherwise, pick based on classification.
367
+ // NOTE: When explicitly specified, we must validate profile-task compatibility below.
368
+ const targetProfile = input.targetProfile ??
369
+ (classification === 'reader_eligible'
370
+ ? 'local-reader'
371
+ : classification === 'editor_eligible'
372
+ ? 'local-editor'
373
+ : null);
374
+ // --- Profile-task compatibility check ---
375
+ // Only applies when input.targetProfile is EXPLICITLY set.
376
+ // When auto-derived (input.targetProfile is null), compatibility is already
377
+ // guaranteed by the auto-derivation logic above (reader_eligible → local-reader).
378
+ // This check prevents routing a reader task to an editor profile (or vice versa)
379
+ // when the caller explicitly requests the wrong profile.
380
+ const isProfileCompatible = input.targetProfile === undefined
381
+ ? true // Auto-derived profile is always compatible by construction
382
+ : targetProfile === 'local-reader'
383
+ ? classification === 'reader_eligible'
384
+ : targetProfile === 'local-editor'
385
+ ? classification === 'editor_eligible'
386
+ : false;
387
+ // --- Deployment availability check ---
388
+ let deploymentCheck = {
389
+ performed: false,
390
+ profileAvailable: false,
391
+ routingEnabled: false,
392
+ checkpointDeployable: false,
393
+ };
394
+ if (targetProfile) {
395
+ const deployment = getDeployment(stateDir, targetProfile);
396
+ const activeCheckpointId = deployment?.activeCheckpointId ?? null;
397
+ // Re-check deployability on every routing decision — a checkpoint may have been revoked
398
+ const checkpointDeployable = activeCheckpointId
399
+ ? isCheckpointDeployable(stateDir, activeCheckpointId)
400
+ : false;
401
+ deploymentCheck = {
402
+ performed: true,
403
+ profileAvailable: deployment !== null,
404
+ routingEnabled: isRoutingEnabledForProfile(stateDir, targetProfile),
405
+ checkpointDeployable,
406
+ };
407
+ }
408
+ // --- Build the decision ---
409
+ const blockers = buildBlockers(classification, input);
410
+ const reason = buildReason(classification, input);
411
+ // FAIL-CLOSED: route_local only if:
412
+ // 1. Classification is eligible (reader_eligible or editor_eligible)
413
+ // 2. A target profile was identified
414
+ // 3. The task's natural profile is compatible with the target profile
415
+ // 4. Deployment is available and routing is enabled
416
+ const isEligibleForRouting = (classification === 'reader_eligible' || classification === 'editor_eligible') &&
417
+ targetProfile !== null &&
418
+ isProfileCompatible &&
419
+ deploymentCheck.routingEnabled;
420
+ const decision = isEligibleForRouting
421
+ ? 'route_local'
422
+ : 'stay_main';
423
+ // Derive the final classification — preserves the root cause of stay_main:
424
+ // - profile_mismatch: task would be eligible but wrong profile requested
425
+ // - deployment_unavailable: eligible and compatible but no routing enabled
426
+ // - raw classification: blocked by high_entropy / risk / ambiguous
427
+ const isEligible = classification === 'reader_eligible' || classification === 'editor_eligible';
428
+ const finalClassification = isEligibleForRouting
429
+ ? classification
430
+ : isEligible && targetProfile !== null && !isProfileCompatible
431
+ ? 'profile_mismatch'
432
+ : isEligible
433
+ ? 'deployment_unavailable'
434
+ : classification;
435
+ // Build explainability fields specific to the stay_main reason
436
+ let finalReason = reason;
437
+ let finalBlockers = blockers;
438
+ if (decision === 'stay_main') {
439
+ if (finalClassification === 'profile_mismatch') {
440
+ const wanted = classification === 'reader_eligible' ? 'local-reader' : 'local-editor';
441
+ finalReason = `Task is ${classification} but was explicitly targeted at ${targetProfile}. ` +
442
+ `Routing requires "${wanted}" profile. Ensure the task intent matches the requested profile.`;
443
+ finalBlockers = [
444
+ `profile mismatch: task is ${classification} but targetProfile is ${targetProfile}`,
445
+ `required profile: ${wanted}`,
446
+ ];
447
+ }
448
+ else if (finalClassification === 'deployment_unavailable') {
449
+ if (!deploymentCheck.performed) {
450
+ finalReason = reason;
451
+ }
452
+ else if (!deploymentCheck.profileAvailable) {
453
+ finalReason = `Task is ${classification} but no deployment exists for ${targetProfile}. ` +
454
+ `Bind a checkpoint via bindCheckpointToWorkerProfile() and enable routing.`;
455
+ finalBlockers = [`no deployment found for profile: ${targetProfile}`];
456
+ }
457
+ else if (!deploymentCheck.checkpointDeployable) {
458
+ finalReason = `Task is ${classification} but the active checkpoint has been revoked (no longer deployable). ` +
459
+ `Re-bind a passing checkpoint or re-evaluate the current one.`;
460
+ finalBlockers = [
461
+ `active checkpoint is no longer deployable: ${targetProfile}`,
462
+ 'revoked checkpoints must not be used for routing',
463
+ ];
464
+ }
465
+ else if (!deploymentCheck.routingEnabled) {
466
+ finalReason = `Task is ${classification} and deployment exists for ${targetProfile} but routing is not enabled. ` +
467
+ `Enable routing via enableRoutingForProfile() in the deployment registry.`;
468
+ finalBlockers = [`routing is disabled for profile: ${targetProfile}`];
469
+ }
470
+ }
471
+ }
472
+ // --- Get active checkpoint ID and state for shadow observation integration ---
473
+ let activeCheckpointId = null;
474
+ let activeCheckpointState = null;
475
+ if (targetProfile && deploymentCheck.performed) {
476
+ const deployment = getDeployment(stateDir, targetProfile);
477
+ activeCheckpointId = deployment?.activeCheckpointId ?? null;
478
+ if (activeCheckpointId) {
479
+ const promotionState = getPromotionState(stateDir, activeCheckpointId);
480
+ if (promotionState === 'shadow_ready' || promotionState === 'promotable' || promotionState === 'candidate_only') {
481
+ activeCheckpointState = promotionState;
482
+ }
483
+ }
484
+ }
485
+ return {
486
+ decision,
487
+ targetProfile: decision === 'route_local' ? targetProfile : null,
488
+ classification: finalClassification,
489
+ reason: finalReason,
490
+ blockers: decision === 'stay_main' ? finalBlockers : [],
491
+ deploymentCheck,
492
+ activeCheckpointId,
493
+ activeCheckpointState: activeCheckpointState ?? undefined,
494
+ shadowObservationId: undefined,
495
+ };
496
+ }
497
+ /**
498
+ * Convenience: check if a specific profile can handle a task.
499
+ * Equivalent to calling classifyTask with targetProfile set.
500
+ */
501
+ export function canRouteToProfile(input, stateDir, profile) {
502
+ const decision = classifyTask({ ...input, targetProfile: profile }, stateDir);
503
+ return decision.decision === 'route_local';
504
+ }
505
+ // ---------------------------------------------------------------------------
506
+ // Read-Only Query Helpers
507
+ // ---------------------------------------------------------------------------
508
+ /**
509
+ * Check if any local worker routing is currently enabled for any profile.
510
+ */
511
+ export function isAnyLocalRoutingEnabled(stateDir) {
512
+ return isRoutingEnabledForProfile(stateDir, 'local-reader') ||
513
+ isRoutingEnabledForProfile(stateDir, 'local-editor');
514
+ }
515
+ /**
516
+ * List all profiles that currently have routing enabled.
517
+ */
518
+ export function listEnabledProfiles(stateDir) {
519
+ const enabled = [];
520
+ if (isRoutingEnabledForProfile(stateDir, 'local-reader'))
521
+ enabled.push('local-reader');
522
+ if (isRoutingEnabledForProfile(stateDir, 'local-editor'))
523
+ enabled.push('local-editor');
524
+ return enabled;
525
+ }