opencode-agent-skill 14.4.0 → 15.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.
Files changed (44) hide show
  1. package/README.md +33 -15
  2. package/docs/PI-COMPAT.md +151 -2
  3. package/docs/V15-MANAGED-RUNTIME.md +128 -0
  4. package/global-config/agents/architect.md +63 -1
  5. package/global-config/agents/executor.md +7 -0
  6. package/global-config/agents/plan-checker.md +8 -1
  7. package/global-config/agents/verifier.md +3 -3
  8. package/global-config/skills/database-engineering/references/workflow.md +15 -0
  9. package/global-config/skills/engineering-orchestrator/references/long-horizon.md +21 -11
  10. package/lib/activity-deadline.mjs +70 -0
  11. package/lib/adaptive-context-budget.mjs +25 -5
  12. package/lib/affected-tests.mjs +1 -1
  13. package/lib/capability-fabric.mjs +17 -0
  14. package/lib/completion-auditor.mjs +25 -5
  15. package/lib/evidence-store.mjs +92 -8
  16. package/lib/execution-contract.mjs +579 -0
  17. package/lib/leaf-runtime-optimizer.mjs +76 -0
  18. package/lib/pi-child-invocation.mjs +65 -0
  19. package/lib/pi-rpc-pool.mjs +61 -9
  20. package/lib/plan-salvage.mjs +124 -0
  21. package/lib/planning-speed-policy.mjs +131 -0
  22. package/lib/repo-graph.mjs +1 -1
  23. package/lib/repo-inspect.mjs +1 -0
  24. package/lib/runtime-artifacts.mjs +42 -0
  25. package/lib/runtime-events.mjs +33 -1
  26. package/lib/safety.mjs +1 -0
  27. package/lib/semantic-index.mjs +1 -1
  28. package/lib/service-manager.mjs +526 -0
  29. package/lib/session-display.mjs +49 -0
  30. package/lib/task-graph.mjs +87 -0
  31. package/lib/task-policy.mjs +150 -4
  32. package/lib/trajectory.mjs +111 -1
  33. package/lib/turbo-fast-path.mjs +44 -0
  34. package/lib/workspace-fingerprint.mjs +2 -11
  35. package/lib/workspace-root.mjs +49 -0
  36. package/lib/worktree-sandbox.mjs +234 -11
  37. package/package.json +8 -5
  38. package/pi/extensions/ues-child-runtime.ts +145 -2
  39. package/pi/extensions/ues.ts +1760 -160
  40. package/scripts/check-release-consistency.mjs +25 -2
  41. package/scripts/check-source-integrity.mjs +471 -4
  42. package/scripts/eval-pi.mjs +76 -36
  43. package/scripts/smoke-pi-extension.mjs +1 -1
  44. package/pi/prompts/ues-run.md +0 -17
@@ -0,0 +1,526 @@
1
+ import { spawn } from "node:child_process"
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"
3
+ import net from "node:net"
4
+ import path from "node:path"
5
+ import { putEvidence } from "./evidence-store.mjs"
6
+ import { terminateProcessTree } from "./process-supervisor.mjs"
7
+ import { resolveWindowsCommand } from "./windows-shim.mjs"
8
+
9
+ const SERVICES = new Map()
10
+ const MAX_SERVICES = 8
11
+ const DEFAULT_READY_TIMEOUT_MS = 30_000
12
+ const DEFAULT_LOG_LIMIT_BYTES = 4 * 1024 * 1024
13
+ const DEFAULT_TAIL_CHARS = 64 * 1024
14
+ const DEFAULT_LIFETIME_MS = 30 * 60_000
15
+
16
+ function clamp(value, fallback, min, max) {
17
+ const parsed = Number(value)
18
+ if (!Number.isFinite(parsed)) return fallback
19
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
20
+ }
21
+
22
+ function sleep(ms) {
23
+ return new Promise((resolve) => setTimeout(resolve, ms))
24
+ }
25
+
26
+ function normalizeName(name) {
27
+ const value = String(name || "").trim()
28
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)) {
29
+ throw new Error("UES service name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}")
30
+ }
31
+ return value
32
+ }
33
+
34
+ function within(root, candidate) {
35
+ const base = path.resolve(root)
36
+ const full = path.resolve(candidate)
37
+ return full === base || full.startsWith(base + path.sep)
38
+ }
39
+
40
+ function safeRootAndCwd(root, requestedCwd) {
41
+ const base = realpathSync(path.resolve(root))
42
+ const cwdCandidate = path.resolve(base, requestedCwd || ".")
43
+ if (!within(base, cwdCandidate)) throw new Error("UES service cwd escapes workspace root")
44
+ const cwd = realpathSync(cwdCandidate)
45
+ if (!within(base, cwd)) throw new Error("UES service cwd resolves outside workspace root")
46
+ return { root: base, cwd }
47
+ }
48
+
49
+ function serviceDir(root) {
50
+ return path.join(root, ".ues-services")
51
+ }
52
+
53
+ function servicePaths(root, name) {
54
+ const dir = serviceDir(root)
55
+ return {
56
+ dir,
57
+ meta: path.join(dir, name + ".json"),
58
+ log: path.join(dir, name + ".log"),
59
+ }
60
+ }
61
+
62
+ function keyFor(root, name) {
63
+ return path.resolve(root) + "\u0000" + normalizeName(name)
64
+ }
65
+
66
+ function boundedArgs(args) {
67
+ if (!Array.isArray(args)) return []
68
+ if (args.length > 128) throw new Error("UES service args exceed 128 items")
69
+ return args.map((item) => {
70
+ const value = String(item)
71
+ if (value.length > 16_384) throw new Error("UES service argument exceeds 16 KiB")
72
+ if (value.includes("\u0000")) throw new Error("UES service argument contains NUL")
73
+ return value
74
+ })
75
+ }
76
+
77
+ function executionFor(command) {
78
+ const value = String(command || "").trim()
79
+ if (!value || value.includes("\u0000")) throw new Error("UES service command is required")
80
+ if (process.platform !== "win32") return { executable: value, argsPrefix: [], source: value }
81
+ const resolved = resolveWindowsCommand(value)
82
+ if (!resolved) throw new Error("UES service command could not be resolved safely on Windows: " + value)
83
+ return resolved
84
+ }
85
+
86
+ function publicState(state) {
87
+ return {
88
+ schemaVersion: 1,
89
+ name: state.name,
90
+ pid: state.proc?.pid || state.pid || null,
91
+ status: state.status,
92
+ alive: processIsAlive(state),
93
+ command: state.command,
94
+ args: [...state.args],
95
+ cwd: state.cwd,
96
+ startedAt: state.startedAt,
97
+ finishedAt: state.finishedAt || null,
98
+ exitCode: state.exitCode ?? null,
99
+ signal: state.signal || null,
100
+ ready: state.ready === true,
101
+ readiness: {
102
+ host: state.readyHost || null,
103
+ port: state.readyPort || null,
104
+ log: state.readyLog || null,
105
+ },
106
+ logFile: path.relative(state.root, state.logFile).replaceAll("\\", "/"),
107
+ logBytes: state.logBytes,
108
+ logTruncated: state.logTruncated,
109
+ lifetimeMs: state.lifetimeMs,
110
+ idleTimeoutMs: state.idleTimeoutMs || 0,
111
+ lastOutputAt: state.lastOutputAt ? new Date(state.lastOutputAt).toISOString() : null,
112
+ stopReason: state.stopReason || null,
113
+ }
114
+ }
115
+
116
+ function persist(state) {
117
+ try {
118
+ mkdirSync(path.dirname(state.metaFile), { recursive: true })
119
+ writeFileSync(state.metaFile, JSON.stringify(publicState(state), null, 2) + "\n", "utf8")
120
+ } catch {}
121
+ }
122
+
123
+ function appendLog(state, stream, chunk) {
124
+ const text = String(chunk || "")
125
+ if (!text) return
126
+ state.lastOutputAt = Date.now()
127
+ const tagged = `[${stream}] ${text}`
128
+ state.tail = (state.tail + tagged).slice(-DEFAULT_TAIL_CHARS)
129
+ const bytes = Buffer.byteLength(tagged)
130
+ if (state.logBytes >= state.logLimitBytes) {
131
+ if (!state.logTruncated) {
132
+ state.logTruncated = true
133
+ try { appendFileSync(state.logFile, "\n[ues] service log capture limit reached; further output omitted\n", "utf8") } catch {}
134
+ }
135
+ return
136
+ }
137
+ const remaining = state.logLimitBytes - state.logBytes
138
+ const buffer = Buffer.from(tagged, "utf8")
139
+ const slice = buffer.subarray(0, Math.min(buffer.length, remaining))
140
+ if (slice.length) {
141
+ try { appendFileSync(state.logFile, slice) } catch {}
142
+ state.logBytes += slice.length
143
+ }
144
+ if (slice.length < buffer.length) state.logTruncated = true
145
+ }
146
+
147
+ function processIsAlive(state) {
148
+ return Boolean(
149
+ state?.proc &&
150
+ state.proc.exitCode == null &&
151
+ !["exited", "failed", "stopped"].includes(String(state.status || "")),
152
+ )
153
+ }
154
+
155
+ async function portReady(host, port) {
156
+ return await new Promise((resolve) => {
157
+ const socket = new net.Socket()
158
+ let settled = false
159
+ const finish = (value) => {
160
+ if (settled) return
161
+ settled = true
162
+ socket.destroy()
163
+ resolve(value)
164
+ }
165
+ socket.setTimeout(600)
166
+ socket.once("connect", () => finish(true))
167
+ socket.once("timeout", () => finish(false))
168
+ socket.once("error", () => finish(false))
169
+ socket.connect(port, host)
170
+ })
171
+ }
172
+
173
+ function logContains(state, needle) {
174
+ if (!needle) return true
175
+ if (state.tail.includes(needle)) return true
176
+ try {
177
+ const text = readFileSync(state.logFile, "utf8")
178
+ return text.slice(-DEFAULT_TAIL_CHARS).includes(needle)
179
+ } catch {
180
+ return false
181
+ }
182
+ }
183
+
184
+ async function readiness(state) {
185
+ if (!processIsAlive(state)) {
186
+ return { ready: false, reason: "process-exited" }
187
+ }
188
+ const checks = []
189
+ if (state.readyPort) {
190
+ checks.push({
191
+ kind: "port",
192
+ ready: await portReady(state.readyHost || "127.0.0.1", state.readyPort),
193
+ })
194
+ }
195
+ if (state.readyLog) {
196
+ checks.push({ kind: "log", ready: logContains(state, state.readyLog) })
197
+ }
198
+ if (!checks.length) return { ready: true, reason: "process-running", checks: [] }
199
+ return {
200
+ ready: checks.every((item) => item.ready),
201
+ reason: checks.every((item) => item.ready) ? "readiness-satisfied" : "readiness-pending",
202
+ checks,
203
+ }
204
+ }
205
+
206
+ export function looksLikeLongRunningServiceCommand(command) {
207
+ const text = String(command || "").trim()
208
+ if (!text) return false
209
+ const patterns = [
210
+ /^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start(?::[\w.-]+)?|serve|preview)(?:\s|$)/i,
211
+ /^(?:npx\s+)?(?:next\s+(?:dev|start)|vite(?:\s|$)|nest\s+start|react-scripts\s+start|expo\s+start)(?:\s|$)/i,
212
+ /^(?:node|bun)\s+["']?[^;&|\r\n]*(?:^|[\\/])(?:main|server|app)\.(?:mjs|cjs|js|ts)(?:["']?\s|$)/i,
213
+ /^(?:python|python3|py)\s+(?:-m\s+http\.server|[^\r\n]*(?:uvicorn|gunicorn|manage\.py\s+runserver))(?:\s|$)/i,
214
+ /^(?:uvicorn|gunicorn|flask\s+run|dotnet\s+run|mvn\s+spring-boot:run|gradle\s+bootRun)(?:\s|$)/i,
215
+ /^docker\s+compose\s+up(?![^\r\n]*\s-d(?:\s|$))/i,
216
+ ]
217
+ return patterns.some((pattern) => pattern.test(text))
218
+ }
219
+
220
+ export async function waitForService(root, name, options = {}) {
221
+ name = normalizeName(name)
222
+ root = realpathSync(path.resolve(root))
223
+ const state = SERVICES.get(keyFor(root, name))
224
+ if (!state) {
225
+ return { schemaVersion: 1, name, ready: false, status: "not-owned", reason: "service-not-active-in-this-runtime" }
226
+ }
227
+
228
+ const timeoutMs = clamp(options.timeoutMs, DEFAULT_READY_TIMEOUT_MS, 100, 10 * 60_000)
229
+ const pollMs = clamp(options.pollMs, 150, 50, 2_000)
230
+ const deadline = Date.now() + timeoutMs
231
+ do {
232
+ const probe = await readiness(state)
233
+ if (probe.ready) {
234
+ state.ready = true
235
+ persist(state)
236
+ return { ...publicState(state), ready: true, reason: probe.reason, checks: probe.checks || [] }
237
+ }
238
+ if (!processIsAlive(state)) {
239
+ persist(state)
240
+ return { ...publicState(state), ready: false, reason: "process-exited", checks: probe.checks || [] }
241
+ }
242
+ await sleep(pollMs)
243
+ } while (Date.now() < deadline)
244
+
245
+ const probe = await readiness(state)
246
+ state.ready = probe.ready
247
+ persist(state)
248
+ return {
249
+ ...publicState(state),
250
+ ready: probe.ready,
251
+ reason: probe.ready ? probe.reason : "readiness-timeout",
252
+ checks: probe.checks || [],
253
+ }
254
+ }
255
+
256
+ export async function startService(root = process.cwd(), options = {}) {
257
+ const name = normalizeName(options.name)
258
+ const scoped = safeRootAndCwd(root, options.cwd)
259
+ root = scoped.root
260
+ const key = keyFor(root, name)
261
+ const existing = SERVICES.get(key)
262
+ if (existing && processIsAlive(existing)) {
263
+ throw new Error("UES service already running: " + name)
264
+ }
265
+ if ([...SERVICES.values()].filter(processIsAlive).length >= MAX_SERVICES) {
266
+ throw new Error("UES service limit reached (" + MAX_SERVICES + ")")
267
+ }
268
+
269
+ const command = String(options.command || "").trim()
270
+ const args = boundedArgs(options.args)
271
+ const execution = executionFor(command)
272
+ const readyPort = options.readyPort == null
273
+ ? null
274
+ : clamp(options.readyPort, 0, 1, 65535)
275
+ if (options.readyPort != null && !readyPort) throw new Error("UES service readyPort must be 1..65535")
276
+ const readyLog = options.readyLog == null ? null : String(options.readyLog)
277
+ if (readyLog && readyLog.length > 512) throw new Error("UES service readyLog exceeds 512 characters")
278
+ const readyHost = String(options.readyHost || "127.0.0.1").trim() || "127.0.0.1"
279
+ if (readyHost.length > 255) throw new Error("UES service readyHost is too long")
280
+
281
+ const paths = servicePaths(root, name)
282
+ mkdirSync(paths.dir, { recursive: true })
283
+ writeFileSync(paths.log, "", "utf8")
284
+
285
+ const state = {
286
+ name,
287
+ root,
288
+ cwd: scoped.cwd,
289
+ command,
290
+ args,
291
+ executable: execution.executable,
292
+ executionArgs: [...(execution.argsPrefix || []), ...args],
293
+ readyPort,
294
+ readyHost,
295
+ readyLog,
296
+ ready: false,
297
+ status: "starting",
298
+ startedAt: new Date().toISOString(),
299
+ finishedAt: null,
300
+ exitCode: null,
301
+ signal: null,
302
+ proc: null,
303
+ metaFile: paths.meta,
304
+ logFile: paths.log,
305
+ logBytes: 0,
306
+ logLimitBytes: clamp(options.logLimitBytes, DEFAULT_LOG_LIMIT_BYTES, 64 * 1024, 32 * 1024 * 1024),
307
+ logTruncated: false,
308
+ tail: "",
309
+ lastOutputAt: Date.now(),
310
+ lifetimeMs: clamp(options.lifetimeMs, DEFAULT_LIFETIME_MS, 10_000, 2 * 60 * 60_000),
311
+ idleTimeoutMs: options.idleTimeoutMs == null
312
+ ? 0
313
+ : clamp(options.idleTimeoutMs, 0, 5_000, 60 * 60_000),
314
+ lifecycleTimer: null,
315
+ stopReason: null,
316
+ startOptions: {
317
+ name,
318
+ command,
319
+ args,
320
+ cwd: path.relative(root, scoped.cwd) || ".",
321
+ readyPort,
322
+ readyHost,
323
+ readyLog,
324
+ timeoutMs: options.timeoutMs,
325
+ logLimitBytes: options.logLimitBytes,
326
+ lifetimeMs: options.lifetimeMs,
327
+ idleTimeoutMs: options.idleTimeoutMs,
328
+ },
329
+ }
330
+
331
+ const proc = spawn(execution.executable, state.executionArgs, {
332
+ cwd: scoped.cwd,
333
+ env: options.env && typeof options.env === "object"
334
+ ? { ...process.env, ...Object.fromEntries(Object.entries(options.env).map(([key, value]) => [String(key), String(value)])) }
335
+ : process.env,
336
+ shell: false,
337
+ detached: process.platform !== "win32",
338
+ windowsHide: true,
339
+ stdio: ["ignore", "pipe", "pipe"],
340
+ })
341
+ state.proc = proc
342
+ state.status = "running"
343
+ SERVICES.set(key, state)
344
+ persist(state)
345
+
346
+ proc.stdout?.on("data", (chunk) => appendLog(state, "stdout", chunk))
347
+ proc.stderr?.on("data", (chunk) => appendLog(state, "stderr", chunk))
348
+
349
+ state.lifecycleTimer = setInterval(() => {
350
+ if (!processIsAlive(state)) return
351
+ const now = Date.now()
352
+ const started = Date.parse(state.startedAt)
353
+ if (Number.isFinite(started) && now - started >= state.lifetimeMs) {
354
+ state.stopReason = "lifetime-expired"
355
+ void stopService(root, name).catch(() => {})
356
+ return
357
+ }
358
+ if (state.idleTimeoutMs > 0 && now - state.lastOutputAt >= state.idleTimeoutMs) {
359
+ state.stopReason = "idle-output-timeout"
360
+ void stopService(root, name).catch(() => {})
361
+ }
362
+ }, 1000)
363
+ state.lifecycleTimer.unref?.()
364
+
365
+ proc.on("error", (error) => {
366
+ appendLog(state, "error", String(error?.message || error) + "\n")
367
+ state.status = "failed"
368
+ state.finishedAt = new Date().toISOString()
369
+ state.exitCode = 1
370
+ persist(state)
371
+ })
372
+ proc.on("exit", (code, signal) => {
373
+ if (state.lifecycleTimer) clearInterval(state.lifecycleTimer)
374
+ state.lifecycleTimer = null
375
+ state.exitCode = Number.isInteger(code) ? code : null
376
+ state.signal = signal || null
377
+ state.status = state.status === "stopping" ? "stopped" : "exited"
378
+ state.finishedAt = new Date().toISOString()
379
+ persist(state)
380
+ })
381
+
382
+ const shouldWait = options.waitReady !== false
383
+ if (!shouldWait) return { ...publicState(state), ready: false, reason: "readiness-not-requested" }
384
+
385
+ // Give spawn errors and immediate exits a chance to surface even when no
386
+ // explicit readiness probe was supplied.
387
+ if (!readyPort && !readyLog) await sleep(200)
388
+ const result = await waitForService(root, name, {
389
+ timeoutMs: readyPort || readyLog ? options.timeoutMs : Math.min(1000, Number(options.timeoutMs || 500)),
390
+ })
391
+ if (!readyPort && !readyLog && result.ready) state.ready = true
392
+ persist(state)
393
+
394
+ if (result.ready) {
395
+ const evidence = await serviceLogs(root, name, { maxChars: 32_000, evidence: true }).catch(() => null)
396
+ return { ...result, evidenceRef: evidence?.evidenceRef || null }
397
+ }
398
+ return result
399
+ }
400
+
401
+ export async function serviceStatus(root = process.cwd(), name) {
402
+ name = normalizeName(name)
403
+ root = realpathSync(path.resolve(root))
404
+ const state = SERVICES.get(keyFor(root, name))
405
+ if (state) {
406
+ const probe = await readiness(state)
407
+ state.ready = probe.ready
408
+ persist(state)
409
+ return { ...publicState(state), ready: probe.ready, reason: probe.reason, checks: probe.checks || [] }
410
+ }
411
+
412
+ const paths = servicePaths(root, name)
413
+ if (!existsSync(paths.meta)) {
414
+ return { schemaVersion: 1, name, status: "not-found", alive: false, ready: false }
415
+ }
416
+ try {
417
+ const prior = JSON.parse(readFileSync(paths.meta, "utf8"))
418
+ return {
419
+ ...prior,
420
+ status: "not-owned",
421
+ alive: false,
422
+ ready: false,
423
+ reason: "historical-service-metadata-only",
424
+ }
425
+ } catch {
426
+ return { schemaVersion: 1, name, status: "unknown", alive: false, ready: false }
427
+ }
428
+ }
429
+
430
+ export async function serviceLogs(root = process.cwd(), name, options = {}) {
431
+ name = normalizeName(name)
432
+ root = realpathSync(path.resolve(root))
433
+ const state = SERVICES.get(keyFor(root, name))
434
+ const paths = servicePaths(root, name)
435
+ const maxChars = clamp(options.maxChars, 24_000, 256, 128_000)
436
+ let content = state?.tail || ""
437
+ if (!content && existsSync(paths.log)) {
438
+ try { content = readFileSync(paths.log, "utf8").slice(-maxChars) } catch {}
439
+ } else {
440
+ content = content.slice(-maxChars)
441
+ }
442
+
443
+ let evidenceRef = null
444
+ if (options.evidence !== false && content) {
445
+ const evidence = await putEvidence(root, content, {
446
+ kind: "service-log",
447
+ source: "ues-service:" + name,
448
+ summary: "Bounded managed-service log snapshot for " + name,
449
+ })
450
+ evidenceRef = evidence.ref
451
+ }
452
+ return {
453
+ schemaVersion: 1,
454
+ name,
455
+ content,
456
+ returnedChars: content.length,
457
+ truncated: Boolean(state?.logTruncated),
458
+ evidenceRef,
459
+ logFile: path.relative(root, paths.log).replaceAll("\\", "/"),
460
+ }
461
+ }
462
+
463
+ export async function stopService(root = process.cwd(), name, options = {}) {
464
+ name = normalizeName(name)
465
+ root = realpathSync(path.resolve(root))
466
+ const key = keyFor(root, name)
467
+ const state = SERVICES.get(key)
468
+ if (!state) {
469
+ return { schemaVersion: 1, name, stopped: false, reason: "service-not-active-in-this-runtime" }
470
+ }
471
+ if (!processIsAlive(state)) {
472
+ SERVICES.delete(key)
473
+ persist(state)
474
+ return { ...publicState(state), stopped: true, reason: "already-exited" }
475
+ }
476
+
477
+ state.status = "stopping"
478
+ if (state.lifecycleTimer) clearInterval(state.lifecycleTimer)
479
+ state.lifecycleTimer = null
480
+ persist(state)
481
+ terminateProcessTree(state.proc, { graceMs: clamp(options.graceMs, 1500, 0, 30_000) })
482
+
483
+ const deadline = Date.now() + clamp(options.timeoutMs, 5_000, 100, 30_000)
484
+ while (processIsAlive(state) && Date.now() < deadline) await sleep(50)
485
+ if (processIsAlive(state)) {
486
+ try { state.proc.kill("SIGKILL") } catch {}
487
+ await sleep(50)
488
+ }
489
+ const stillAlive = processIsAlive(state)
490
+ state.status = stillAlive ? "stop-timeout" : "stopped"
491
+ state.finishedAt ||= new Date().toISOString()
492
+ persist(state)
493
+ // Never forget ownership of a process that is still alive. Keeping the
494
+ // handle lets session shutdown or an explicit retry attempt cleanup again.
495
+ if (!stillAlive) SERVICES.delete(key)
496
+ return { ...publicState(state), stopped: !stillAlive, reason: stillAlive ? "stop-timeout" : "stopped" }
497
+ }
498
+
499
+ export async function restartService(root = process.cwd(), name, options = {}) {
500
+ name = normalizeName(name)
501
+ root = realpathSync(path.resolve(root))
502
+ const state = SERVICES.get(keyFor(root, name))
503
+ if (!state?.startOptions) throw new Error("UES service cannot restart an unowned service: " + name)
504
+ const startOptions = { ...state.startOptions, ...options, name }
505
+ await stopService(root, name, options)
506
+ return startService(root, startOptions)
507
+ }
508
+
509
+ export async function stopAllServices(root = null) {
510
+ const base = root ? realpathSync(path.resolve(root)) : null
511
+ const targets = [...SERVICES.values()].filter((state) => !base || state.root === base)
512
+ const results = []
513
+ for (const state of targets) {
514
+ results.push(await stopService(state.root, state.name).catch((error) => ({
515
+ schemaVersion: 1,
516
+ name: state.name,
517
+ stopped: false,
518
+ reason: error instanceof Error ? error.message : String(error),
519
+ })))
520
+ }
521
+ return results
522
+ }
523
+
524
+ export function managedServiceCount() {
525
+ return [...SERVICES.values()].filter(processIsAlive).length
526
+ }
@@ -0,0 +1,49 @@
1
+ const COMMAND_LABELS = Object.freeze({
2
+ run: "UES",
3
+ resume: "UES Resume",
4
+ fix: "UES Fix",
5
+ feature: "UES Feature",
6
+ debug: "UES Debug",
7
+ review: "UES Review",
8
+ audit: "UES Audit",
9
+ plan: "UES Plan",
10
+ research: "UES Research",
11
+ critique: "UES Critique",
12
+ verify: "UES Verify",
13
+ })
14
+
15
+ function clipUnicode(value, maxLength) {
16
+ const chars = Array.from(String(value || ""))
17
+ if (chars.length <= maxLength) return chars.join("")
18
+ if (maxLength <= 1) return "…"
19
+ return chars.slice(0, maxLength - 1).join("") + "…"
20
+ }
21
+
22
+ export function compactSessionSubject(value, maxLength = 56) {
23
+ const compact = String(value || "")
24
+ .replace(/\r?\n+/g, " ")
25
+ .replace(/\s+/g, " ")
26
+ .replace(/^[-*#>\s]+/, "")
27
+ .trim()
28
+ return clipUnicode(compact, Math.max(8, Number(maxLength || 56)))
29
+ }
30
+
31
+ function workspaceLabel(cwd) {
32
+ const normalized = String(cwd || "").replaceAll("\\", "/").replace(/\/+$/, "")
33
+ return normalized.split("/").filter(Boolean).at(-1) || "workspace"
34
+ }
35
+
36
+ export function uesSessionName(kind, subject = "", cwd = "", maxLength = 72) {
37
+ const normalizedKind = String(kind || "run").toLowerCase()
38
+ const label = COMMAND_LABELS[normalizedKind] || "UES"
39
+ const subjectBudget = Math.max(8, Number(maxLength || 72) - label.length - 2)
40
+ const detail = compactSessionSubject(subject, subjectBudget) || compactSessionSubject(workspaceLabel(cwd), subjectBudget)
41
+ return clipUnicode(label + ": " + detail, Math.max(16, Number(maxLength || 72)))
42
+ }
43
+
44
+ export function sessionNameFromUesInput(input, cwd = "", maxLength = 72) {
45
+ const text = String(input || "").trim()
46
+ const match = text.match(/^\/ues-(run|resume|fix|feature|debug|review|audit|plan|research|critique|verify)(?:\s+([\s\S]*))?$/i)
47
+ if (!match) return null
48
+ return uesSessionName(match[1], match[2] || "", cwd, maxLength)
49
+ }
@@ -2,6 +2,93 @@ import path from "node:path"
2
2
 
3
3
  const VALID_RISKS = new Set(["low", "medium", "high", "critical"])
4
4
 
5
+ function firstDefined(...values) {
6
+ return values.find((value) => value !== undefined && value !== null)
7
+ }
8
+
9
+ function normalizeStringList(value, aliases = []) {
10
+ const sources = [value, ...aliases].filter((source) => source !== undefined && source !== null)
11
+ if (!sources.length) return undefined
12
+
13
+ for (const source of sources) {
14
+ const items = Array.isArray(source) ? source : [source]
15
+ const normalized = items
16
+ .map((item) => {
17
+ if (typeof item === "string") return item.trim()
18
+ if (!item || typeof item !== "object" || Array.isArray(item)) return ""
19
+ return String(
20
+ firstDefined(
21
+ item.criterion,
22
+ item.criteria,
23
+ item.check,
24
+ item.description,
25
+ item.expected,
26
+ item.outcome,
27
+ item.text,
28
+ item.command,
29
+ ) || "",
30
+ ).trim()
31
+ })
32
+ .filter(Boolean)
33
+ if (normalized.length) return normalized
34
+ }
35
+
36
+ return []
37
+ }
38
+
39
+ function normalizeRisk(value, fallback = "medium") {
40
+ const raw = String(value ?? "").trim()
41
+ if (!raw) return { risk: fallback, riskNotes: "" }
42
+ const lower = raw.toLowerCase()
43
+ if (VALID_RISKS.has(lower)) return { risk: lower, riskNotes: "" }
44
+
45
+ const token = lower.match(/\b(low|medium|high|critical)\b/)?.[1]
46
+ if (token && VALID_RISKS.has(token)) {
47
+ return { risk: token, riskNotes: raw }
48
+ }
49
+
50
+ // Unknown prose in a risk field is never downgraded. Preserve it as notes
51
+ // and choose a conservative level so weak-model formatting errors cannot
52
+ // silently weaken verification requirements.
53
+ return { risk: "high", riskNotes: raw }
54
+ }
55
+
56
+ export function normalizePlanForValidation(plan) {
57
+ if (!plan || typeof plan !== "object" || Array.isArray(plan)) return plan
58
+ const tasks = Array.isArray(plan.tasks)
59
+ ? plan.tasks.map((task) => {
60
+ if (!task || typeof task !== "object" || Array.isArray(task)) return task
61
+
62
+ const acceptance = normalizeStringList(
63
+ task.acceptance,
64
+ [task.acceptanceCriteria, task.criteria, task.successCriteria],
65
+ )
66
+ const verification = normalizeStringList(
67
+ task.verification,
68
+ [task.verificationChecks, task.checks, task.verify, task.validation],
69
+ )
70
+ const riskSource = firstDefined(task.risk, task.riskLevel, task.severity)
71
+ const normalizedRisk = normalizeRisk(riskSource, "medium")
72
+ const dependencies = firstDefined(task.dependsOn, task.dependencies)
73
+
74
+ return {
75
+ ...task,
76
+ ...(dependencies !== undefined ? { dependsOn: Array.isArray(dependencies) ? dependencies : [dependencies].filter(Boolean) } : {}),
77
+ ...(acceptance !== undefined ? { acceptance } : {}),
78
+ ...(verification !== undefined ? { verification } : {}),
79
+ risk: normalizedRisk.risk,
80
+ ...(normalizedRisk.riskNotes && !task.riskNotes ? { riskNotes: normalizedRisk.riskNotes } : {}),
81
+ }
82
+ })
83
+ : plan.tasks
84
+
85
+ return {
86
+ ...plan,
87
+ schemaVersion: Number(plan.schemaVersion || 1),
88
+ tasks,
89
+ }
90
+ }
91
+
5
92
  function unsafeRepoPath(value) {
6
93
  const raw = String(value || "").replaceAll("\\", "/")
7
94
  if (!raw) return false