opencode-agent-skill 9.0.0 → 10.0.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.
@@ -1,11 +1,19 @@
1
1
  import { Plugin } from "@opencode/plugin"
2
- import { existsSync, readFileSync } from "node:fs"
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs"
3
3
  import { fileURLToPath } from "node:url"
4
4
  import path from "node:path"
5
5
  import { spawnSync } from "node:child_process"
6
- import { classifyIntent, routeSkills } from "./router.js"
6
+ import { classifyIntent, routeSkillsForPolicy } from "./router.js"
7
7
  import { destructiveShellRisk } from "./safety.js"
8
8
  import { runtimeCapabilities } from "./capabilities.js"
9
+ import {
10
+ budgetToolResult,
11
+ classifyProviderFailure,
12
+ createRuntimeGuard,
13
+ providerRecoveryPlan,
14
+ progressWatchdogDecision,
15
+ stableRuntimeHash,
16
+ } from "./runtime-guard.js"
9
17
 
10
18
  const CONFIG_FILE = path.resolve(
11
19
  path.dirname(fileURLToPath(import.meta.url)),
@@ -114,6 +122,40 @@ function taskHasWrites(task) {
114
122
  )
115
123
  }
116
124
 
125
+ function workspaceSignal(root) {
126
+ const scope = [
127
+ "--",
128
+ ".",
129
+ ":(exclude).ues-work",
130
+ ":(exclude).ues-learning",
131
+ ":(exclude).ues-dashboard",
132
+ ":(exclude).ues-sandboxes",
133
+ ":(exclude).ues-cache",
134
+ ":(exclude).ues-traces",
135
+ ]
136
+ const commands = [
137
+ ["status", "--porcelain=v1", "--untracked-files=all", ...scope],
138
+ ["diff", "--binary", "--no-ext-diff", ...scope],
139
+ ["diff", "--cached", "--binary", "--no-ext-diff", ...scope],
140
+ ]
141
+ const parts = []
142
+ for (const args of commands) {
143
+ const result = spawnSync("git", args, {
144
+ cwd: root,
145
+ encoding: "utf8",
146
+ maxBuffer: 2 * 1024 * 1024,
147
+ })
148
+ if (result.status !== 0) return stableRuntimeHash("non-git:" + root)
149
+ parts.push(result.stdout || "")
150
+ }
151
+ return stableRuntimeHash(parts.join("\n---UES-WORKSPACE-SIGNAL---\n"))
152
+ }
153
+
154
+ function sessionContextDigest(messages) {
155
+ const recent = Array.isArray(messages) ? messages.slice(-12) : messages
156
+ return stableRuntimeHash(recent || [])
157
+ }
158
+
117
159
  function policySkills(policy) {
118
160
  const selected = []
119
161
  const add = (id) => { if (id && !selected.includes(id)) selected.push(id) }
@@ -166,6 +208,218 @@ export default Plugin.define({
166
208
  async setup(ctx) {
167
209
  const projectRoot = ctx.location.project?.canonical || ctx.location.directory
168
210
  const capabilities = runtimeCapabilities(ctx)
211
+ const runtimeGuard = createRuntimeGuard({ duplicateLimit: 3, loopLimit: 6 })
212
+ const sessionAssignments = new Map()
213
+ const eventController = new AbortController()
214
+
215
+ if (typeof ctx.event?.subscribe === "function") {
216
+ void (async () => {
217
+ try {
218
+ for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
219
+ if (!["message.part.updated", "message.part.delta", "message.updated"].includes(event?.type)) continue
220
+ const properties = event?.properties || {}
221
+ const sessionID =
222
+ properties.sessionID ||
223
+ properties.part?.sessionID ||
224
+ properties.info?.sessionID ||
225
+ null
226
+ if (sessionID && sessionAssignments.has(sessionID)) {
227
+ runtimeGuard.touch(sessionID)
228
+ }
229
+ }
230
+ } catch {}
231
+ })()
232
+ }
233
+
234
+ const leaseSupervisor = setInterval(() => {
235
+ const workRoot = path.join(projectRoot, ".ues-work")
236
+ if (!existsSync(workRoot)) return
237
+ let entries = []
238
+ try {
239
+ entries = readdirSync(workRoot, { withFileTypes: true })
240
+ } catch {
241
+ return
242
+ }
243
+ for (const entry of entries) {
244
+ if (!entry.isDirectory() || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.name)) continue
245
+ try {
246
+ runOcskill(["work", "recover", entry.name, projectRoot], projectRoot)
247
+ } catch {}
248
+ }
249
+ }, 60_000)
250
+ leaseSupervisor.unref?.()
251
+
252
+ async function waitForExecutorProgress(sessionID, options = {}) {
253
+ const timeoutMs = Math.max(30_000, Number(options.timeoutMs || 10 * 60_000))
254
+ const stallMs = Math.max(30_000, Math.min(Number(options.stallMs || 60_000), 5 * 60_000))
255
+ const startedAt = Date.now()
256
+ let lastContext = null
257
+ let polling = false
258
+ let rejectWatchdog = null
259
+
260
+ try {
261
+ const initial = await ctx.session.context({ sessionID })
262
+ lastContext = sessionContextDigest(initial)
263
+ } catch {}
264
+ runtimeGuard.touch(sessionID)
265
+
266
+ const watchdog = new Promise((_, reject) => {
267
+ rejectWatchdog = reject
268
+ })
269
+ const timer = setInterval(async () => {
270
+ if (polling) return
271
+ polling = true
272
+ try {
273
+ const now = Date.now()
274
+ if (now - startedAt >= timeoutMs) {
275
+ const error = new Error("UES executor timed out after " + timeoutMs + "ms")
276
+ error.code = "UES_TIMEOUT"
277
+ rejectWatchdog?.(error)
278
+ return
279
+ }
280
+
281
+ try {
282
+ const messages = await ctx.session.context({ sessionID })
283
+ const digest = sessionContextDigest(messages)
284
+ if (lastContext === null || digest !== lastContext) {
285
+ lastContext = digest
286
+ runtimeGuard.touch(sessionID, now)
287
+ }
288
+ } catch {}
289
+
290
+ const snapshot = runtimeGuard.snapshot(sessionID)
291
+ const watchdogState = progressWatchdogDecision(snapshot, now, stallMs)
292
+ if (watchdogState.stalled) {
293
+ const error = new Error("UES no-progress watchdog: executor stalled for " + watchdogState.idleMs + "ms")
294
+ error.code = "UES_STALLED"
295
+ rejectWatchdog?.(error)
296
+ }
297
+ } finally {
298
+ polling = false
299
+ }
300
+ }, 5_000)
301
+ timer.unref?.()
302
+
303
+ try {
304
+ await Promise.race([
305
+ ctx.session.wait({ sessionID }),
306
+ watchdog,
307
+ ])
308
+ } finally {
309
+ clearInterval(timer)
310
+ }
311
+
312
+ const assignment = sessionAssignments.get(sessionID)
313
+ if (assignment?.resumeRequired) {
314
+ const error = new Error("UES no-progress post-compaction resume: checkpoint.nextAction was not executed before session completion")
315
+ error.code = "UES_RESUME_STALLED"
316
+ throw error
317
+ }
318
+ return ctx.session.context({ sessionID })
319
+ }
320
+
321
+ if (typeof ctx.tool?.hook === "function") {
322
+ await ctx.tool.hook("execute.before", (event) => {
323
+ const assignment = sessionAssignments.get(event.sessionID)
324
+ if (!assignment) return
325
+ const signal = workspaceSignal(assignment.executionDir || projectRoot)
326
+ const decision = runtimeGuard.before({
327
+ sessionID: event.sessionID,
328
+ tool: event.tool,
329
+ input: event.input,
330
+ callID: event.callID,
331
+ cwd: assignment.executionDir || projectRoot,
332
+ workspaceSignal: signal,
333
+ })
334
+ if (decision.blocked) throw new Error(decision.message)
335
+
336
+ if (assignment.resumeRequired) {
337
+ try {
338
+ const args = [
339
+ "work", "checkpoint-resumed", assignment.slug, assignment.task, projectRoot,
340
+ "--run-id", assignment.runId,
341
+ "--reason", "tool-action-observed-after-compaction",
342
+ ]
343
+ runOcskill(args, projectRoot)
344
+ } catch {}
345
+ assignment.resumeRequired = false
346
+ }
347
+ })
348
+
349
+ await ctx.tool.hook("execute.after", (event) => {
350
+ const assignment = sessionAssignments.get(event.sessionID)
351
+ if (!assignment) return
352
+ const signal = workspaceSignal(assignment.executionDir || projectRoot)
353
+ if (event.status === "completed") {
354
+ const shellInput = JSON.stringify(event.input || {})
355
+ const budgetTool =
356
+ event.tool === "bash" && /(?:ocskill\s+repo-graph|\brg\b|\bgrep\b|\bglob\b)/i.test(shellInput)
357
+ ? "repo-graph"
358
+ : event.tool
359
+ event.result = budgetToolResult(budgetTool, event.result)
360
+ runtimeGuard.after({
361
+ sessionID: event.sessionID,
362
+ tool: event.tool,
363
+ input: event.input,
364
+ callID: event.callID,
365
+ cwd: assignment.executionDir || projectRoot,
366
+ workspaceSignal: signal,
367
+ status: "completed",
368
+ result: event.result,
369
+ })
370
+ } else {
371
+ runtimeGuard.after({
372
+ sessionID: event.sessionID,
373
+ tool: event.tool,
374
+ input: event.input,
375
+ callID: event.callID,
376
+ cwd: assignment.executionDir || projectRoot,
377
+ workspaceSignal: signal,
378
+ status: "error",
379
+ error: event.error,
380
+ })
381
+ }
382
+ })
383
+ }
384
+
385
+ if (capabilities.sessionHook) {
386
+ await ctx.session.hook("retry", (event) => {
387
+ const kind = classifyProviderFailure(event.error || {})
388
+ if (kind === "AUTH" || kind === "CONTEXT_TOO_LARGE") {
389
+ event.decision = { retry: false }
390
+ return
391
+ }
392
+ if (["RATE_LIMIT", "PROVIDER_5XX", "TIMEOUT", "NO_TOKEN"].includes(kind) && event.attempt < 3) {
393
+ event.decision = {
394
+ retry: true,
395
+ delay: kind === "RATE_LIMIT" ? Math.min(10_000, 1_000 * event.attempt) : 0,
396
+ }
397
+ }
398
+ })
399
+
400
+ await ctx.session.hook("compaction", (event) => {
401
+ const assignment = sessionAssignments.get(event.sessionID)
402
+ if (!assignment) return
403
+ try {
404
+ const checkpoint = runOcskillJSON([
405
+ "work", "checkpoint", assignment.slug, assignment.task, projectRoot,
406
+ "--run-id", assignment.runId,
407
+ "--reason", "pre-compaction",
408
+ ], projectRoot)
409
+ assignment.checkpoint = checkpoint
410
+ assignment.resumeRequired = true
411
+ assignment.compactionAt = Date.now()
412
+ runtimeGuard.compacted(event.sessionID, assignment.compactionAt)
413
+ event.system.push({
414
+ type: "text",
415
+ text:
416
+ "UES durable checkpoint persisted before compaction. Preserve currentTaskId/runId/planHash/evidence pointers. " +
417
+ "After compaction, execute checkpoint.nextAction before explanatory prose. Checkpoint: " +
418
+ JSON.stringify(checkpoint),
419
+ })
420
+ } catch {}
421
+ })
422
+ }
169
423
 
170
424
  await ctx.tool.transform((editor) => {
171
425
  editor.namespace({
@@ -400,6 +654,7 @@ export default Plugin.define({
400
654
  slug: { type: "string" },
401
655
  task: { type: "string" },
402
656
  timeoutMs: { type: "integer", minimum: 30000, maximum: 3600000 },
657
+ stallMs: { type: "integer", minimum: 30000, maximum: 300000 },
403
658
  isolate: { type: "boolean" },
404
659
  integrate: { type: "boolean" },
405
660
  },
@@ -441,6 +696,10 @@ export default Plugin.define({
441
696
  30_000,
442
697
  Math.min(Number(input.timeoutMs || (taskPolicy.mode === "long-horizon" ? 20 * 60_000 : 10 * 60_000)), 60 * 60_000),
443
698
  )
699
+ const stallMs = Math.max(
700
+ 30_000,
701
+ Math.min(Number(input.stallMs || 60_000), 5 * 60_000),
702
+ )
444
703
  const policyArgs = ["model-policy", "executor", "--attempt", String(attempt)]
445
704
  if (taskText) policyArgs.push("--text", taskText)
446
705
  const policy = runOcskillJSON(policyArgs, projectRoot)
@@ -477,57 +736,134 @@ export default Plugin.define({
477
736
  }, 30_000)
478
737
 
479
738
  try {
480
- const created = await ctx.session.create({
481
- title: "UES " + input.slug + " " + input.task,
482
- location: { directory: executionDir },
483
- })
484
- appendTrace(traceID, "dispatch.session-created", {
485
- sessionID: created.id,
486
- executionDir,
487
- isolated: executionDir !== projectRoot,
488
- }, projectRoot)
489
- {
490
- const attachArgs = [
491
- "work", "attach-session", input.slug, input.task, projectRoot,
492
- "--session-id", created.id,
493
- ]
494
- if (runId) attachArgs.push("--run-id", runId)
495
- attachArgs.push("--execution-dir", executionDir)
496
- if (sandbox?.dir) attachArgs.push("--sandbox-dir", sandbox.dir)
497
- runOcskill(attachArgs, projectRoot)
498
- }
499
- await ctx.session.switchAgent({ sessionID: created.id, agent: "ues-executor" })
500
- const selectedModel = modelRef(policy?.model)
501
- if (selectedModel) {
502
- await ctx.session.switchModel({ sessionID: created.id, model: selectedModel })
503
- }
739
+ const escalationArgs = ["model-policy", "executor", "--attempt", String(attempt + 1)]
740
+ if (taskText) escalationArgs.push("--text", taskText)
741
+ const escalatedPolicy = runOcskillJSON(escalationArgs, projectRoot)
742
+ let selectedPolicy = policy
743
+ let created = null
744
+ let messages = null
745
+ const providerRecovery = []
746
+ let physicalAttempt = 0
504
747
 
505
- await ctx.session.prompt({
506
- sessionID: created.id,
507
- text:
508
- "Implement exactly this approved UES task in the current repository. " +
509
- "Do not broaden scope or launch child agents. Run the declared verification and return the executor report.\n\n" +
510
- JSON.stringify(started.contextPack, null, 2),
511
- })
748
+ while (physicalAttempt < 3) {
749
+ physicalAttempt += 1
750
+ created = await ctx.session.create({
751
+ title: "UES " + input.slug + " " + input.task + " p" + physicalAttempt,
752
+ location: { directory: executionDir },
753
+ })
754
+ sessionAssignments.set(created.id, {
755
+ slug: input.slug,
756
+ task: input.task,
757
+ runId,
758
+ executionDir,
759
+ sandboxDir: sandbox?.dir || null,
760
+ resumeRequired: false,
761
+ checkpoint: null,
762
+ compactionAt: null,
763
+ })
764
+ runtimeGuard.touch(created.id)
765
+
766
+ appendTrace(traceID, "dispatch.session-created", {
767
+ sessionID: created.id,
768
+ executionDir,
769
+ isolated: executionDir !== projectRoot,
770
+ physicalAttempt,
771
+ model: selectedPolicy?.model || null,
772
+ }, projectRoot)
773
+
774
+ {
775
+ const attachArgs = [
776
+ "work", "attach-session", input.slug, input.task, projectRoot,
777
+ "--session-id", created.id,
778
+ ]
779
+ if (runId) attachArgs.push("--run-id", runId)
780
+ attachArgs.push("--execution-dir", executionDir)
781
+ if (sandbox?.dir) attachArgs.push("--sandbox-dir", sandbox.dir)
782
+ runOcskill(attachArgs, projectRoot)
783
+ }
784
+
785
+ await ctx.session.switchAgent({ sessionID: created.id, agent: "ues-executor" })
786
+ const selectedModel = modelRef(selectedPolicy?.model)
787
+ if (selectedModel) {
788
+ await ctx.session.switchModel({ sessionID: created.id, model: selectedModel })
789
+ }
790
+
791
+ const recovery = started?.contextPack?.contextPolicy?.recovery
792
+ const recoveryText = recovery?.requireDiagnosis
793
+ ? " This is recovery attempt " + attempt + ". Diagnose the previous failure from fresh evidence before editing. " +
794
+ (recovery.directives || []).join("; ") + "."
795
+ : ""
796
+ const providerRecoveryText = providerRecovery.length
797
+ ? " Provider/session recovery is active. Continue from durable .ues-work state and current workspace evidence; do not repeat already-proven exploration."
798
+ : ""
799
+ const dispatchContext = physicalAttempt === 1
800
+ ? started.contextPack
801
+ : runOcskillJSON(["context-pack", input.slug, input.task, projectRoot], projectRoot)
802
+
803
+ await ctx.session.prompt({
804
+ sessionID: created.id,
805
+ text:
806
+ "Implement exactly this approved UES task in the current repository. " +
807
+ "Do not broaden scope or launch child agents. Run the declared verification and return the executor report." +
808
+ recoveryText + providerRecoveryText + "\n\n" +
809
+ JSON.stringify(dispatchContext, null, 2),
810
+ })
512
811
 
513
- let timer = null
514
- try {
515
- await Promise.race([
516
- ctx.session.wait({ sessionID: created.id }),
517
- new Promise((_, reject) => {
518
- timer = setTimeout(
519
- () => reject(new Error("UES executor timed out after " + timeoutMs + "ms")),
520
- timeoutMs,
521
- )
522
- }),
523
- ])
524
- } catch (error) {
525
812
  try {
526
- await ctx.session.interrupt({ sessionID: created.id, continue: false })
527
- } catch {}
528
- throw error
529
- } finally {
530
- if (timer) clearTimeout(timer)
813
+ messages = await waitForExecutorProgress(created.id, { timeoutMs, stallMs })
814
+ sessionAssignments.delete(created.id)
815
+ runtimeGuard.clear(created.id)
816
+ break
817
+ } catch (error) {
818
+ try {
819
+ await ctx.session.interrupt({ sessionID: created.id, continue: false })
820
+ } catch {}
821
+
822
+ const failureKind = classifyProviderFailure(error)
823
+ const escalationModel = modelRef(escalatedPolicy?.model)
824
+ const hasEscalationModel = Boolean(
825
+ escalationModel &&
826
+ escalatedPolicy?.model &&
827
+ escalatedPolicy.model !== selectedPolicy?.model,
828
+ )
829
+ const decision = providerRecoveryPlan(failureKind, physicalAttempt, {
830
+ hasEscalationModel,
831
+ })
832
+ providerRecovery.push({
833
+ physicalAttempt,
834
+ sessionID: created.id,
835
+ failureKind,
836
+ action: decision.action,
837
+ model: selectedPolicy?.model || null,
838
+ })
839
+ appendTrace(traceID, "dispatch.provider-recovery", {
840
+ task: input.task,
841
+ runId,
842
+ physicalAttempt,
843
+ sessionID: created.id,
844
+ failureKind,
845
+ decision,
846
+ model: selectedPolicy?.model || null,
847
+ escalationModel: escalatedPolicy?.model || null,
848
+ }, projectRoot)
849
+
850
+ sessionAssignments.delete(created.id)
851
+ runtimeGuard.clear(created.id)
852
+
853
+ if (!decision.retry || physicalAttempt >= 3) throw error
854
+ if (decision.action === "fresh-session-escalated-model") {
855
+ selectedPolicy = escalatedPolicy
856
+ }
857
+ await tool.progress({
858
+ status:
859
+ "provider recovery " + decision.action +
860
+ " after " + failureKind.toLowerCase().replaceAll("_", "-"),
861
+ })
862
+ }
863
+ }
864
+
865
+ if (!created || !messages) {
866
+ throw new Error("UES provider recovery exhausted without a completed executor session")
531
867
  }
532
868
 
533
869
  const afterWait = runOcskillJSON(["work", "status", input.slug, projectRoot], projectRoot)
@@ -538,12 +874,12 @@ export default Plugin.define({
538
874
  throw new Error("UES executor attempt is no longer active; refusing post-cancel integration or completion handoff")
539
875
  }
540
876
 
541
- const messages = await ctx.session.context({ sessionID: created.id })
542
877
  appendTrace(traceID, "dispatch.completed", {
543
878
  sessionID: created.id,
544
879
  task: input.task,
545
880
  runId,
546
881
  messageCount: Array.isArray(messages) ? messages.length : null,
882
+ providerRecovery,
547
883
  }, projectRoot)
548
884
  let integration = null
549
885
  if (sandbox && input.integrate === true) {
@@ -561,12 +897,14 @@ export default Plugin.define({
561
897
  runId,
562
898
  traceID,
563
899
  taskPolicy,
564
- model: policy,
900
+ model: selectedPolicy,
565
901
  timeoutMs,
902
+ stallMs,
566
903
  executionDir,
567
904
  isolated: executionDir !== projectRoot,
568
905
  sandbox,
569
906
  integration,
907
+ providerRecovery,
570
908
  messages: messageExcerpt(messages),
571
909
  next: sandbox
572
910
  ? "Inspect and verify the isolated worktree first. If accepted, integrate it with ocskill sandbox integrate <worktree> . before recording work complete."
@@ -604,12 +942,21 @@ export default Plugin.define({
604
942
 
605
943
  if (capabilities.sessionHook) {
606
944
  await ctx.session.hook("context", (event) => {
607
- if (event.agent === "title" || event.agent === "summary" || event.agent === "compaction") return
608
- event.system.push({
609
- type: "text",
610
- text: "UES runtime: choose FAST/STANDARD/DEEP from deterministic task policy, prefer bounded semantic/ACI evidence over broad scans, trust durable .ues-work state and EVENTS.jsonl over conversation memory, never treat lexical matches as semantic proof, require fresh verification for non-trivial completion, and fail closed when a high-risk capability is missing.",
945
+ event.system.push({
946
+ type: "text",
947
+ text: "UES: use the minimum context that preserves correctness. FAST reads the target and nearest evidence with direct skills only; STANDARD/DEEP expand when risk or evidence requires it. Preserve exact contracts, verify fresh behavior, and escalate after failed attempts instead of stacking patches.",
948
+ })
949
+ const assignment = sessionAssignments.get(event.sessionID)
950
+ if (assignment?.resumeRequired && assignment.checkpoint) {
951
+ event.system.push({
952
+ type: "text",
953
+ text:
954
+ "UES post-compaction resume is mandatory. Before explanatory prose, execute this deterministic nextAction now: " +
955
+ JSON.stringify(assignment.checkpoint.nextAction) +
956
+ ". Resume only from .ues-work state/evidence and the current workspace; do not replay external/destructive side effects.",
957
+ })
958
+ }
611
959
  })
612
- })
613
960
 
614
961
  await ctx.session.hook("prompt", (event) => {
615
962
  const config = routerConfig()
@@ -625,7 +972,7 @@ export default Plugin.define({
625
972
  Math.min(config.maxSkills, Number(policy?.maxSkills || config.maxSkills)),
626
973
  )
627
974
  const selected = []
628
- for (const id of [...routeSkills(event.prompt.text, effectiveMaxSkills, routingFacts), ...policySkills(policy)]) {
975
+ for (const id of [...routeSkillsForPolicy(event.prompt.text, policy, effectiveMaxSkills, routingFacts), ...policySkills(policy)]) {
629
976
  if (!selected.includes(id)) selected.push(id)
630
977
  if (selected.length >= effectiveMaxSkills) break
631
978
  }
@@ -649,7 +996,7 @@ export default Plugin.define({
649
996
  feedbackDomains: routingFacts.feedbackDomains,
650
997
  acceptedLearningCount: routingFacts.acceptedLearningCount,
651
998
  },
652
- version: 6,
999
+ version: 10,
653
1000
  effectiveMaxSkills,
654
1001
  },
655
1002
  }
@@ -666,5 +1013,12 @@ export default Plugin.define({
666
1013
  event.message = "UES safety gate: confirm destructive/high-impact shell action (" + risk.id + ")."
667
1014
  })
668
1015
  }
1016
+
1017
+ return () => {
1018
+ eventController.abort()
1019
+ clearInterval(leaseSupervisor)
1020
+ for (const sessionID of sessionAssignments.keys()) runtimeGuard.clear(sessionID)
1021
+ sessionAssignments.clear()
1022
+ }
669
1023
  },
670
1024
  })
@@ -189,3 +189,38 @@ export function routeSkills(text, maxSkills = 4, facts = {}) {
189
189
 
190
190
  return routed.slice(0, limit)
191
191
  }
192
+
193
+ export function routeSkillsForPolicy(text, policy = {}, maxSkills = 4, facts = {}) {
194
+ const limit = Number.isInteger(maxSkills) ? Math.max(1, Math.min(maxSkills, 6)) : 4
195
+ const routed = routeSkills(text, 6, facts)
196
+ const fast =
197
+ policy?.executionProfile === "fast" &&
198
+ policy?.risk !== "high" &&
199
+ policy?.mode !== "long-horizon"
200
+
201
+ if (!fast) return routed.slice(0, limit)
202
+
203
+ const eligible = routed.filter((id) =>
204
+ ![
205
+ "ues-engineering-orchestrator",
206
+ "ues-change-impact-analysis",
207
+ "ues-task-planner",
208
+ "ues-long-task-state",
209
+ ].includes(id),
210
+ )
211
+ const selected = [
212
+ ...eligible.filter((id) => !PROCESS_SKILLS.has(id)),
213
+ ...eligible.filter((id) => PROCESS_SKILLS.has(id)),
214
+ ]
215
+
216
+ const value = String(text || "").toLowerCase()
217
+ if (/(review|audit|kiểm tra code|đánh giá)/.test(value)) add(selected, "ues-code-review")
218
+ if (/(verify|verification|test|tests|kiểm thử|xác minh)/.test(value)) add(selected, "ues-test-verification")
219
+
220
+ if (selected.length === 0 && /(code|repository|repo|project|function|class|endpoint|hàm|lớp|dự án)/.test(value)) {
221
+ add(selected, "ues-repo-explorer")
222
+ }
223
+
224
+ return selected.slice(0, limit)
225
+ }
226
+