opencode-agent-skill 7.7.0 → 9.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 (63) hide show
  1. package/CHANGELOG.md +63 -3
  2. package/README.md +675 -581
  3. package/bin/ocskill.mjs +358 -156
  4. package/docs/DETERMINISTIC-TOOLS.md +25 -8
  5. package/docs/ENGINEERING-DESIGN.md +31 -13
  6. package/docs/EVALS.md +34 -12
  7. package/docs/NPM-PUBLISH.md +6 -6
  8. package/docs/OPENCODE-COMPAT.md +11 -8
  9. package/docs/TRACE-SCHEMA.md +15 -2
  10. package/docs/V8-INTELLIGENCE-RELIABILITY.md +206 -0
  11. package/docs/V9-SPEED-INTELLIGENCE.md +102 -0
  12. package/evals/live/tasks.json +6 -6
  13. package/evals/polyglot/fixtures/polyglot-bench/api/generated/client.ts +2 -0
  14. package/evals/polyglot/fixtures/polyglot-bench/api/openapi.json +25 -0
  15. package/evals/polyglot/fixtures/polyglot-bench/db/migrations/20260920_add_order_key.sql +1 -0
  16. package/evals/polyglot/fixtures/polyglot-bench/dotnet/OrderService.cs +8 -0
  17. package/evals/polyglot/fixtures/polyglot-bench/java/PriceService.java +5 -0
  18. package/evals/polyglot/fixtures/polyglot-bench/monorepo/package.json +6 -0
  19. package/evals/polyglot/fixtures/polyglot-bench/monorepo/packages/api/package.json +4 -0
  20. package/evals/polyglot/fixtures/polyglot-bench/monorepo/packages/web/package.json +7 -0
  21. package/evals/polyglot/fixtures/polyglot-bench/monorepo/pnpm-lock.yaml +5 -0
  22. package/evals/polyglot/fixtures/polyglot-bench/next/app/api/products/route.ts +7 -0
  23. package/evals/polyglot/fixtures/polyglot-bench/python/tenant_auth.py +4 -0
  24. package/evals/polyglot/fixtures/polyglot-bench/react-native/keyboard.ts +3 -0
  25. package/evals/polyglot/graders/polyglot-bench.mjs +101 -0
  26. package/evals/polyglot/tasks.json +54 -0
  27. package/global-config/AGENTS.md +10 -7
  28. package/global-config/agents/integration-verifier.md +1 -1
  29. package/global-config/agents/plan-checker.md +1 -1
  30. package/global-config/commands/run.md +9 -5
  31. package/global-config/plugins/ues-router/capabilities.js +4 -0
  32. package/global-config/plugins/ues-router/index.js +414 -21
  33. package/global-config/plugins/ues-router/router.js +140 -23
  34. package/global-config/skills/engineering-orchestrator/references/long-horizon.md +6 -4
  35. package/lib/aci.mjs +128 -0
  36. package/lib/benchmark-confidence.mjs +135 -0
  37. package/lib/cli-utils.mjs +41 -0
  38. package/lib/container-sandbox.mjs +102 -0
  39. package/lib/context-manifest.mjs +300 -22
  40. package/lib/control-center.mjs +36 -3
  41. package/lib/eval-order.mjs +9 -0
  42. package/lib/eval-telemetry.mjs +5 -2
  43. package/lib/gate-receipt.mjs +52 -0
  44. package/lib/installer.mjs +39 -25
  45. package/lib/learning-engine.mjs +236 -38
  46. package/lib/opencode-compat.mjs +25 -10
  47. package/lib/orchestrator-policy.mjs +101 -20
  48. package/lib/process-runner.mjs +30 -9
  49. package/lib/runtime-events.mjs +31 -0
  50. package/lib/semantic-index.mjs +318 -0
  51. package/lib/task-engine.mjs +416 -24
  52. package/lib/trajectory.mjs +89 -0
  53. package/lib/windows-shim.mjs +227 -0
  54. package/lib/worktree-sandbox.mjs +85 -3
  55. package/package.json +10 -4
  56. package/scripts/check-release-tag.mjs +22 -0
  57. package/scripts/control-center.mjs +25 -0
  58. package/scripts/eval-live.mjs +39 -58
  59. package/scripts/eval-matrix.mjs +155 -0
  60. package/scripts/smoke-packed-install.mjs +138 -4
  61. package/scripts/smoke-plain-install.mjs +91 -0
  62. package/scripts/validate-live-suite.mjs +3 -3
  63. package/scripts/validate.mjs +27 -5
@@ -1,10 +1,22 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { readFile, stat } from "node:fs/promises"
3
+ import { spawnSync } from "node:child_process"
3
4
  import path from "node:path"
4
5
  import { buildRepoGraph } from "./repo-graph.mjs"
5
6
  import { taskFiles } from "./task-graph.mjs"
7
+ import { querySemanticIndex } from "./semantic-index.mjs"
6
8
 
7
- const INSTRUCTION_FILES = ["AGENTS.md", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts"]
9
+ const ROOT_INSTRUCTIONS = [
10
+ "AGENTS.md",
11
+ "package.json",
12
+ "pyproject.toml",
13
+ "Cargo.toml",
14
+ "go.mod",
15
+ "pom.xml",
16
+ "build.gradle",
17
+ "build.gradle.kts",
18
+ ]
19
+ const SOURCE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|php|java|kt|kts|cs|go|rs|swift|dart|vue|svelte|sql)$/i
8
20
 
9
21
  function rel(root, file) {
10
22
  return path.relative(root, file).replaceAll("\\", "/")
@@ -14,31 +26,248 @@ function unique(values) {
14
26
  return [...new Set(values.filter(Boolean))]
15
27
  }
16
28
 
29
+ function safeFull(root, relative) {
30
+ const base = path.resolve(root)
31
+ const full = path.resolve(base, relative)
32
+ if (full !== base && !full.startsWith(base + path.sep)) return null
33
+ return full
34
+ }
35
+
17
36
  async function fileInfo(root, relative) {
18
- const full = path.resolve(root, relative)
19
- if (!full.startsWith(path.resolve(root) + path.sep) && full !== path.resolve(root)) return null
37
+ const full = safeFull(root, relative)
38
+ if (!full) return null
20
39
  const info = await stat(full).catch(() => null)
21
40
  if (!info?.isFile()) return null
22
41
  return { path: rel(root, full), bytes: info.size }
23
42
  }
24
43
 
25
- async function excerpt(root, relative, limit) {
26
- const full = path.resolve(root, relative)
44
+ function centeredSlice(source, terms, limit) {
45
+ if (source.length <= limit) return { text: source, startOffset: 0, centerTerm: null }
46
+ const lower = source.toLowerCase()
47
+ let matchIndex = -1
48
+ let centerTerm = null
49
+ for (const term of terms || []) {
50
+ const index = lower.indexOf(term.toLowerCase())
51
+ if (index >= 0 && (matchIndex < 0 || index < matchIndex)) {
52
+ matchIndex = index
53
+ centerTerm = term
54
+ }
55
+ }
56
+ if (matchIndex < 0) return { text: source.slice(0, limit) + "\n...[truncated]", startOffset: 0, centerTerm: null }
57
+ const start = Math.max(0, Math.min(matchIndex - Math.floor(limit / 3), source.length - limit))
58
+ const prefix = start > 0 ? "...[truncated]\n" : ""
59
+ const suffix = start + limit < source.length ? "\n...[truncated]" : ""
60
+ return {
61
+ text: prefix + source.slice(start, start + limit) + suffix,
62
+ startOffset: start,
63
+ centerTerm,
64
+ }
65
+ }
66
+
67
+ async function excerpt(root, relative, limit, terms = []) {
68
+ const full = safeFull(root, relative)
69
+ if (!full) return null
27
70
  const source = await readFile(full, "utf8").catch(() => "")
28
71
  if (!source) return null
72
+ const slice = centeredSlice(source, terms, limit)
29
73
  return {
30
74
  path: rel(root, full),
31
- text: source.length <= limit ? source : source.slice(0, limit) + "\n...[truncated]",
75
+ ...slice,
32
76
  }
33
77
  }
34
78
 
79
+ function taskTerms(task) {
80
+ const source = [
81
+ task?.title,
82
+ task?.summary,
83
+ ...(task?.acceptance || []),
84
+ ...(task?.verification || []),
85
+ ].filter(Boolean).join(" ").toLowerCase()
86
+
87
+ const stop = new Set([
88
+ "this","that","with","from","into","then","than","when","where","what","your",
89
+ "task","test","tests","file","files","code","change","changes","should","must",
90
+ "được","các","cho","với","trong","này","một","những","không","theo","sau",
91
+ ])
92
+ return unique(
93
+ source
94
+ .split(/[^\p{L}\p{N}_$.-]+/u)
95
+ .map((item) => item.replace(/^[-.$]+|[-.$]+$/g, ""))
96
+ .filter((item) => item.length >= 4 && !stop.has(item)),
97
+ ).slice(0, 24)
98
+ }
99
+
100
+ function tokenCounts(text, wantedTerms) {
101
+ const counts = new Map()
102
+ const wanted = new Set(wantedTerms)
103
+ const tokens = String(text || "")
104
+ .toLowerCase()
105
+ .split(/[^\p{L}\p{N}_$.-]+/u)
106
+ .filter(Boolean)
107
+ for (const token of tokens) {
108
+ if (!wanted.has(token)) continue
109
+ counts.set(token, (counts.get(token) || 0) + 1)
110
+ }
111
+ return { counts, length: tokens.length }
112
+ }
113
+
114
+ function symbolHits(source, terms, limit = 12) {
115
+ const hits = []
116
+ const lines = String(source || "").split(/\r?\n/)
117
+ const patterns = [
118
+ /(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/,
119
+ /(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/,
120
+ /(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/,
121
+ /(?:export\s+)?type\s+([A-Za-z_$][\w$]*)/,
122
+ /(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/,
123
+ /^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)/,
124
+ /^\s*class\s+([A-Za-z_][\w]*)/,
125
+ /^\s*(?:public|private|protected|internal|static|final|virtual|override|async|synchronized|abstract|sealed|partial|readonly|\s)+[\w<>,?\[\].:]+\s+([A-Za-z_$][\w$]*)\s*\(/,
126
+ ]
127
+ for (let index = 0; index < lines.length && hits.length < limit; index += 1) {
128
+ const line = lines[index]
129
+ let symbol = null
130
+ for (const pattern of patterns) {
131
+ const match = line.match(pattern)
132
+ if (match?.[1]) {
133
+ symbol = match[1]
134
+ break
135
+ }
136
+ }
137
+ if (!symbol) continue
138
+ const haystack = (symbol + " " + line).toLowerCase()
139
+ const matches = terms.filter((term) => haystack.includes(term.toLowerCase()))
140
+ if (!matches.length) continue
141
+ hits.push({ symbol, line: index + 1, matches: unique(matches).slice(0, 6), preview: line.trim().slice(0, 240) })
142
+ }
143
+ return hits
144
+ }
145
+
146
+ function gitChangedFiles(root) {
147
+ const result = spawnSync(
148
+ "git",
149
+ ["status", "--porcelain=v1", "--untracked-files=all"],
150
+ { cwd: root, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 },
151
+ )
152
+ if (result.status !== 0) return []
153
+ return unique(
154
+ result.stdout
155
+ .split(/\r?\n/)
156
+ .filter(Boolean)
157
+ .map((line) => line.slice(3).trim())
158
+ .map((value) => value.includes(" -> ") ? value.split(" -> ").at(-1) : value)
159
+ .map((value) => value.replaceAll("\\", "/")),
160
+ )
161
+ }
162
+
163
+ async function nearbyInstructions(root, declared) {
164
+ const found = new Set()
165
+ for (const name of ROOT_INSTRUCTIONS) {
166
+ if (existsSync(path.join(root, name))) found.add(name)
167
+ }
168
+
169
+ for (const file of declared) {
170
+ let dir = path.dirname(file)
171
+ while (dir && dir !== ".") {
172
+ for (const name of ["AGENTS.md", "package.json", "pyproject.toml", "Cargo.toml", "go.mod"]) {
173
+ const candidate = path.posix.join(dir.replaceAll("\\", "/"), name)
174
+ if (existsSync(path.join(root, candidate))) found.add(candidate)
175
+ }
176
+ const next = path.dirname(dir)
177
+ if (next === dir) break
178
+ dir = next
179
+ }
180
+ }
181
+ return [...found]
182
+ }
183
+
184
+ async function rankedReferences(root, nodes, terms, declared, changed, limit = 24) {
185
+ if (!terms.length) return []
186
+ const declaredSet = new Set(declared)
187
+ const changedSet = new Set(changed)
188
+ const candidates = nodes
189
+ .map((node) => node.path)
190
+ .filter((file) => SOURCE_EXT.test(file))
191
+ .filter((file) => !declaredSet.has(file))
192
+ .slice(0, 500)
193
+
194
+ const docs = []
195
+ const documentFrequency = new Map(terms.map((term) => [term, 0]))
196
+ for (const file of candidates) {
197
+ const full = safeFull(root, file)
198
+ const info = full ? await stat(full).catch(() => null) : null
199
+ if (!info?.isFile() || info.size > 512 * 1024) continue
200
+ const source = await readFile(full, "utf8").catch(() => "")
201
+ if (!source) continue
202
+ const stats = tokenCounts(source, terms)
203
+ for (const term of terms) {
204
+ if ((stats.counts.get(term) || 0) > 0) {
205
+ documentFrequency.set(term, (documentFrequency.get(term) || 0) + 1)
206
+ }
207
+ }
208
+ docs.push({ file, source, stats })
209
+ }
210
+
211
+ const population = Math.max(1, docs.length)
212
+ const scored = []
213
+ for (const doc of docs) {
214
+ const lowerPath = doc.file.toLowerCase()
215
+ let score = changedSet.has(doc.file) ? 8 : 0
216
+ const matches = []
217
+ for (const term of terms) {
218
+ if (lowerPath.includes(term)) score += 6
219
+ const tf = doc.stats.counts.get(term) || 0
220
+ if (tf > 0) {
221
+ const df = documentFrequency.get(term) || 0
222
+ const idf = Math.log((population + 1) / (df + 1)) + 1
223
+ score += (1 + Math.log(tf)) * idf * 3
224
+ if (matches.length < 8) matches.push(term)
225
+ }
226
+ }
227
+ const symbols = symbolHits(doc.source, terms)
228
+ score += symbols.length * 4
229
+ if (score > 0) {
230
+ scored.push({
231
+ path: doc.file,
232
+ score: Number(score.toFixed(3)),
233
+ matches,
234
+ symbolHits: symbols,
235
+ })
236
+ }
237
+ }
238
+
239
+ return scored
240
+ .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
241
+ .slice(0, limit)
242
+ }
243
+
35
244
  export async function buildContextManifest(root, task, options = {}) {
36
245
  root = path.resolve(root)
37
246
  const budget = Math.max(4_000, Number(options.budget ?? 24_000))
38
247
  const declared = taskFiles(task)
39
- const graph = await buildRepoGraph(root, { maxFiles: options.maxFiles ?? 2500 })
248
+ const terms = taskTerms(task)
249
+ const changed = gitChangedFiles(root)
250
+ const strategy = String(options.strategy || "semantic+graph+git")
251
+ const queryText = [
252
+ task?.title,
253
+ task?.summary,
254
+ ...(task?.acceptance || []),
255
+ ...(task?.verification || []),
256
+ ].filter(Boolean).join(" ")
257
+
258
+ const semantic = await querySemanticIndex(root, queryText, {
259
+ limit: 32,
260
+ maxFiles: options.semanticMaxFiles ?? 6000,
261
+ }).catch(() => null)
262
+ const semanticPaths = semantic?.results?.map((item) => item.path) || []
263
+
264
+ const useGraph = strategy.includes("graph")
265
+ const graph = useGraph
266
+ ? await buildRepoGraph(root, { maxFiles: options.maxFiles ?? 2500 })
267
+ : { nodes: [], edges: [], hotspots: [], scannedFiles: 0, truncated: false }
40
268
  const nodeMap = new Map(graph.nodes.map((node) => [node.path, node]))
41
269
  const incoming = new Map()
270
+
42
271
  for (const edge of graph.edges) {
43
272
  const list = incoming.get(edge.to) || []
44
273
  list.push(edge.from)
@@ -52,45 +281,93 @@ export async function buildContextManifest(root, task, options = {}) {
52
281
  }
53
282
 
54
283
  const baseNames = declared.map((file) => path.basename(file, path.extname(file)).toLowerCase())
55
- const tests = graph.nodes
56
- .map((node) => node.path)
284
+ const graphTestCandidates = graph.nodes.map((node) => node.path)
285
+ const semanticTestCandidates = semanticPaths
286
+ const tests = unique([...graphTestCandidates, ...semanticTestCandidates])
57
287
  .filter((file) => /(^|\/)(test|tests|__tests__|spec)(\/|$)|\.(test|spec)\./i.test(file))
58
- .filter((file) => baseNames.some((name) => name && file.toLowerCase().includes(name)))
59
- .slice(0, 20)
288
+ .map((file) => ({
289
+ file,
290
+ score: baseNames.reduce((sum, name) => sum + (name && file.toLowerCase().includes(name) ? 2 : 0), 0) +
291
+ terms.reduce((sum, term) => sum + (file.toLowerCase().includes(term) ? 1 : 0), 0),
292
+ }))
293
+ .filter((item) => item.score > 0 || semanticPaths.includes(item.file))
294
+ .sort((a, b) => b.score - a.score || a.file.localeCompare(b.file))
295
+ .slice(0, 24)
296
+ .map((item) => item.file)
60
297
 
61
- const instructions = []
62
- for (const name of INSTRUCTION_FILES) {
63
- if (existsSync(path.join(root, name))) instructions.push(name)
64
- }
298
+ const instructions = await nearbyInstructions(root, declared)
299
+ const ranked = semantic?.results?.length
300
+ ? semantic.results.map((item) => ({
301
+ path: item.path,
302
+ score: item.score,
303
+ matches: item.reasons || [],
304
+ symbolHits: item.definitions || [],
305
+ evidenceLevel: semantic.evidenceLevel,
306
+ }))
307
+ : useGraph
308
+ ? await rankedReferences(root, graph.nodes, terms, declared, changed)
309
+ : []
310
+ const changedRelevant = changed
311
+ .filter((file) =>
312
+ declared.includes(file) ||
313
+ semanticPaths.includes(file) ||
314
+ graph.nodes.some((node) => node.path === file),
315
+ )
316
+ .slice(0, 30)
317
+
318
+ const priority = unique([
319
+ ...declared,
320
+ ...tests,
321
+ ...related.slice(0, 20),
322
+ ...ranked.map((item) => item.path),
323
+ ...changedRelevant,
324
+ ...instructions,
325
+ ])
65
326
 
66
- const candidates = unique([...declared, ...related.slice(0, 30), ...tests, ...instructions])
67
327
  const files = []
68
- for (const file of candidates) {
328
+ for (const file of priority) {
69
329
  const info = await fileInfo(root, file)
70
330
  if (info) files.push(info)
71
331
  }
72
332
 
73
333
  let remaining = budget
74
334
  const excerpts = []
75
- for (const file of unique([...declared, ...tests, ...related.slice(0, 12), ...instructions])) {
335
+ for (const file of priority) {
76
336
  if (remaining <= 0) break
77
- const perFile = Math.min(4_000, remaining)
78
- const item = await excerpt(root, file, perFile)
337
+ const isDeclared = declared.includes(file)
338
+ const isTest = tests.includes(file)
339
+ const isInstruction = instructions.includes(file)
340
+ const desired = isDeclared ? 6_000 : isTest ? 4_000 : isInstruction ? 3_000 : 2_500
341
+ const perFile = Math.min(desired, remaining)
342
+ const item = await excerpt(root, file, perFile, terms)
79
343
  if (!item) continue
80
344
  remaining -= item.text.length
81
- excerpts.push(item)
345
+ excerpts.push({
346
+ ...item,
347
+ role: isDeclared ? "declared" : isTest ? "test" : isInstruction ? "instruction" : "reference",
348
+ })
82
349
  }
83
350
 
84
351
  return {
85
- schemaVersion: 1,
352
+ schemaVersion: 4,
86
353
  task: task?.id || null,
354
+ strategy,
355
+ queryTerms: terms,
87
356
  declared,
88
357
  related: unique(related).filter((file) => !declared.includes(file)).slice(0, 30),
89
358
  tests,
90
359
  instructions,
360
+ changed: changedRelevant,
361
+ rankedReferences: ranked,
91
362
  files,
92
363
  excerpts,
364
+ semantic: semantic ? {
365
+ evidenceLevel: semantic.evidenceLevel,
366
+ resultCount: semantic.results.length,
367
+ stats: semantic.stats,
368
+ } : null,
93
369
  graph: {
370
+ enabled: useGraph,
94
371
  scannedFiles: graph.scannedFiles,
95
372
  truncated: graph.truncated,
96
373
  hotspots: graph.hotspots.slice(0, 12),
@@ -99,3 +376,4 @@ export async function buildContextManifest(root, task, options = {}) {
99
376
  used: budget - remaining,
100
377
  }
101
378
  }
379
+
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs"
2
2
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"
3
3
  import path from "node:path"
4
4
  import { readLearningState } from "./learning-engine.mjs"
5
+ import { readRuntimeEvents } from "./runtime-events.mjs"
5
6
 
6
7
  function escapeHtml(value) {
7
8
  return String(value ?? "")
@@ -22,10 +23,11 @@ async function collectWork(root) {
22
23
  for (const entry of entries) {
23
24
  if (!entry.isDirectory()) continue
24
25
  const dir = path.join(workRoot, entry.name)
25
- const [state, plan, evidence] = await Promise.all([
26
+ const [state, plan, evidence, events] = await Promise.all([
26
27
  readJson(path.join(dir, "STATE.json"), {}),
27
28
  readJson(path.join(dir, "PLAN.json"), {}),
28
29
  readJson(path.join(dir, "EVIDENCE.json"), {}),
30
+ readRuntimeEvents(path.join(dir, "EVENTS.jsonl"), { limit: 30 }),
29
31
  ])
30
32
  items.push({
31
33
  slug: entry.name,
@@ -39,7 +41,8 @@ async function collectWork(root) {
39
41
  risk: task.risk || "medium",
40
42
  state: state.tasks?.[task.id] || {},
41
43
  })),
42
- receipts: (evidence.receipts || []).length,
44
+ receipts: evidence.receipts || [],
45
+ recentEvents: events,
43
46
  updatedAt: state.updatedAt || null,
44
47
  })
45
48
  }
@@ -92,12 +95,15 @@ h1{margin:0;font-size:30px}.muted{color:#9da9bd}.grid{display:grid;grid-template
92
95
  .task{display:grid;grid-template-columns:1fr auto;gap:8px;border-top:1px solid #243049;padding:9px 0}.ok{color:#7ee787}.warn{color:#f2cc60}.bad{color:#ff7b72}
93
96
  table{width:100%;border-collapse:collapse;margin-top:10px}th,td{text-align:left;padding:8px;border-bottom:1px solid #26324a;font-size:13px}
94
97
  pre{white-space:pre-wrap;word-break:break-word;background:#0a0f1c;padding:10px;border-radius:10px}.section{margin-top:28px}
98
+ button{margin-top:10px;border:1px solid #3b4966;border-radius:8px;background:#18233a;color:#e7ecf5;padding:7px 10px;cursor:pointer}button:disabled{opacity:.55;cursor:wait}
99
+ details{margin-top:10px;border-top:1px solid #243049;padding-top:8px}summary{cursor:pointer;color:#c9d4e7}
95
100
  </style>
96
101
  </head>
97
102
  <body><div class="shell">
98
103
  <div class="top"><div><h1>UES Control Center</h1><div class="muted" id="root"></div></div><div class="muted" id="generated"></div></div>
99
104
  <div class="section"><h2>Long-horizon work</h2><div class="grid" id="work"></div></div>
100
105
  <div class="section"><h2>Learning loop</h2><div class="grid" id="learning"></div></div>
106
+ <div class="section"><h2>Recent runtime events</h2><div class="card"><table><thead><tr><th>Work</th><th>Event</th><th>Task</th><th>Time</th></tr></thead><tbody id="events"></tbody></table></div></div>
101
107
  <div class="section"><h2>Recent evaluations</h2><div class="card"><table><thead><tr><th>Suite</th><th>Model</th><th>Baseline</th><th>UES</th></tr></thead><tbody id="evals"></tbody></table></div></div>
102
108
  </div>
103
109
  <script>
@@ -111,11 +117,22 @@ function render(next){
111
117
  work.innerHTML="";
112
118
  for(const item of data.work){
113
119
  const tasks=item.tasks.map(t=>'<div class="task"><span>'+esc(t.id)+' · '+esc(t.title||'')+'</span><span class="pill">'+esc(t.state.status||'pending')+'</span></div>').join('');
114
- work.insertAdjacentHTML("beforeend",'<div class="card"><div><span class="pill">'+esc(item.status)+'</span></div><h3>'+esc(item.slug)+'</h3><div class="muted">'+esc(item.goal)+'</div><p>'+esc(item.nextAction)+'</p>'+tasks+'<div class="muted">receipts: '+item.receipts+' · blockers: '+item.blockers.length+'</div></div>');
120
+ const recover=(item.tasks||[]).some(t=>t.state?.status==="running")?'<button data-recover="'+esc(item.slug)+'">Recover stale</button>':'';
121
+ const receipts=(item.receipts||[]).length
122
+ ? '<details><summary>Inspect receipts ('+item.receipts.length+')</summary><pre>'+esc(JSON.stringify(item.receipts,null,2))+'</pre></details>'
123
+ : '';
124
+ work.insertAdjacentHTML("beforeend",'<div class="card"><div><span class="pill">'+esc(item.status)+'</span></div><h3>'+esc(item.slug)+'</h3><div class="muted">'+esc(item.goal)+'</div><p>'+esc(item.nextAction)+'</p>'+tasks+'<div class="muted">receipts: '+item.receipts.length+' · blockers: '+item.blockers.length+'</div>'+recover+receipts+'</div>');
115
125
  }
116
126
  if(!data.work.length) work.innerHTML='<div class="card muted">No .ues-work items found.</div>';
117
127
  const learning=document.querySelector("#learning");
118
128
  learning.innerHTML='<div class="card"><h3>Accepted lessons</h3><strong>'+data.learning.accepted.length+'</strong></div><div class="card"><h3>Proposals</h3><strong>'+data.learning.proposals.length+'</strong></div>';
129
+ const eventBody=document.querySelector("#events");
130
+ eventBody.innerHTML="";
131
+ for(const item of data.work){
132
+ for(const event of (item.recentEvents||[]).slice(-10).reverse()){
133
+ eventBody.insertAdjacentHTML("beforeend",'<tr><td>'+esc(item.slug)+'</td><td>'+esc(event.type)+'</td><td>'+esc(event.task||"—")+'</td><td>'+esc(event.at||"")+'</td></tr>');
134
+ }
135
+ }
119
136
  const tbody=document.querySelector("#evals");
120
137
  tbody.innerHTML="";
121
138
  for(const row of data.evals){
@@ -123,6 +140,22 @@ function render(next){
123
140
  tbody.insertAdjacentHTML("beforeend",'<tr><td>'+esc(row.suite)+'</td><td>'+esc(row.model)+'</td><td>'+fmt(row.summary.baseline)+'</td><td>'+fmt(row.summary.ues)+'</td></tr>');
124
141
  }
125
142
  }
143
+ document.addEventListener("click",async(event)=>{
144
+ const button=event.target.closest?.("[data-recover]");
145
+ if(!button) return;
146
+ button.disabled=true;
147
+ try{
148
+ const response=await fetch("/api/recover",{
149
+ method:"POST",
150
+ headers:{"content-type":"application/json"},
151
+ body:JSON.stringify({slug:button.dataset.recover})
152
+ });
153
+ if(!response.ok) throw new Error(await response.text());
154
+ const refreshed=await fetch("/data.json",{cache:"no-store"});
155
+ if(refreshed.ok) render(await refreshed.json());
156
+ }catch(error){ alert(String(error?.message||error)); }
157
+ finally{ button.disabled=false; }
158
+ });
126
159
  render(data);
127
160
  if(location.protocol==="http:" || location.protocol==="https:"){
128
161
  setInterval(async()=>{
@@ -0,0 +1,9 @@
1
+ export function evalModeOrder(requestedMode, taskIndex = 0, trial = 1) {
2
+ if (requestedMode === "baseline" || requestedMode === "ues") return [requestedMode]
3
+ if (requestedMode !== "both") throw new Error("requestedMode must be baseline, ues, or both")
4
+
5
+ const normalizedTaskIndex = Number.isInteger(taskIndex) && taskIndex >= 0 ? taskIndex : 0
6
+ const normalizedTrial = Number.isInteger(trial) && trial > 0 ? trial : 1
7
+ const baselineFirst = (normalizedTaskIndex + normalizedTrial) % 2 === 1
8
+ return baselineFirst ? ["baseline", "ues"] : ["ues", "baseline"]
9
+ }
@@ -19,8 +19,11 @@ function visit(value, callback, path = []) {
19
19
  }
20
20
 
21
21
  function usageFromObject(object) {
22
- const usage = object?.usage
23
- if (!usage || typeof usage !== "object") return null
22
+ const usage =
23
+ object?.usage && typeof object.usage === "object" ? object.usage :
24
+ object?.tokens && typeof object.tokens === "object" ? object.tokens :
25
+ null
26
+ if (!usage) return null
24
27
 
25
28
  const input =
26
29
  asNumber(usage.inputTokens) ||
@@ -0,0 +1,52 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+
3
+ const KINDS = new Set(["plan-verification", "integration-verification"])
4
+ const VERDICTS = new Set(["PASS", "FAIL", "PARTIAL"])
5
+
6
+ function digest(value) {
7
+ return createHash("sha256").update(String(value || "")).digest("hex")
8
+ }
9
+
10
+ export function createGateReceipt(input = {}) {
11
+ const kind = String(input.kind || "")
12
+ const verdict = String(input.verdict || "").toUpperCase()
13
+ const evidence = String(input.evidence || "").trim()
14
+ const report = input.report == null ? null : String(input.report)
15
+ return {
16
+ schemaVersion: 1,
17
+ id: randomUUID(),
18
+ kind,
19
+ slug: String(input.slug || ""),
20
+ verdict,
21
+ verifier: String(input.verifier || ""),
22
+ sessionID: input.sessionID ? String(input.sessionID) : null,
23
+ runId: input.runId ? String(input.runId) : null,
24
+ planHash: input.planHash ? String(input.planHash) : null,
25
+ workspaceFingerprint: input.workspaceFingerprint ? String(input.workspaceFingerprint) : null,
26
+ evidence,
27
+ reportHash: report == null ? null : digest(report),
28
+ createdAt: input.createdAt || new Date().toISOString(),
29
+ }
30
+ }
31
+
32
+ export function validateGateReceipt(receipt) {
33
+ const errors = []
34
+ if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) {
35
+ return { valid: false, errors: ["receipt must be an object"] }
36
+ }
37
+ if (receipt.schemaVersion !== 1) errors.push("schemaVersion must be 1")
38
+ if (!KINDS.has(receipt.kind)) errors.push("invalid gate receipt kind")
39
+ if (!VERDICTS.has(receipt.verdict)) errors.push("invalid gate receipt verdict")
40
+ if (!String(receipt.id || "").trim()) errors.push("receipt id is required")
41
+ if (!String(receipt.slug || "").trim()) errors.push("slug is required")
42
+ if (!String(receipt.verifier || "").trim()) errors.push("verifier is required")
43
+ if (!String(receipt.evidence || "").trim()) errors.push("evidence is required")
44
+ if (!Number.isFinite(Date.parse(receipt.createdAt || ""))) errors.push("createdAt must be an ISO timestamp")
45
+ if (receipt.kind === "plan-verification" && !String(receipt.planHash || "").trim()) {
46
+ errors.push("plan verification receipt requires planHash")
47
+ }
48
+ if (receipt.kind === "integration-verification" && !String(receipt.workspaceFingerprint || "").trim()) {
49
+ errors.push("integration verification receipt requires workspaceFingerprint")
50
+ }
51
+ return { valid: errors.length === 0, errors }
52
+ }