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,4 +1,4 @@
1
- import { existsSync } from "node:fs"
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs"
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
3
3
  import { spawnSync } from "node:child_process"
4
4
  import { createHash, randomUUID } from "node:crypto"
@@ -7,11 +7,15 @@ import { analyzePlan, taskFiles, validatePlan } from "./task-graph.mjs"
7
7
  import { buildContextManifest } from "./context-manifest.mjs"
8
8
  import { relevantAcceptedLearnings } from "./learning-engine.mjs"
9
9
  import { validateVerificationReceipt } from "./evidence-receipt.mjs"
10
+ import { appendRuntimeEvent, readRuntimeEvents } from "./runtime-events.mjs"
11
+ import { createGateReceipt, validateGateReceipt } from "./gate-receipt.mjs"
12
+ import { classifyEngineeringTask } from "./orchestrator-policy.mjs"
10
13
 
11
14
  const WORK_DIR = ".ues-work"
12
15
  const STATE_SCHEMA = 3
13
16
  const LOCK_TIMEOUT_MS = 30_000
14
17
  const LOCK_STALE_MS = 120_000
18
+ const LOCK_HEARTBEAT_MS = 15_000
15
19
  const DEFAULT_TASK_LEASE_MS = 10 * 60_000
16
20
 
17
21
  function now() {
@@ -37,6 +41,7 @@ export function workPaths(root, slug) {
37
41
  plan: path.join(dir, "PLAN.json"),
38
42
  state: path.join(dir, "STATE.json"),
39
43
  evidence: path.join(dir, "EVIDENCE.json"),
44
+ events: path.join(dir, "EVENTS.jsonl"),
40
45
  tasks: path.join(dir, "tasks"),
41
46
  reports: path.join(dir, "reports"),
42
47
  lock: path.join(dir, ".state-lock"),
@@ -67,23 +72,43 @@ async function writeJson(file, value) {
67
72
  await atomicWrite(file, JSON.stringify(value, null, 2) + "\n")
68
73
  }
69
74
 
75
+ async function journal(paths, type, data = {}) {
76
+ return appendRuntimeEvent(paths.events, type, {
77
+ slug: path.basename(paths.dir),
78
+ ...data,
79
+ })
80
+ }
81
+
82
+ async function lockOwner(paths) {
83
+ return readJson(path.join(paths.lock, "owner.json"), null).catch(() => null)
84
+ }
85
+
70
86
  async function acquireWorkLock(paths) {
71
87
  const started = Date.now()
72
88
  while (true) {
89
+ const token = randomUUID()
73
90
  try {
74
91
  await mkdir(paths.lock)
92
+ const owner = { pid: process.pid, token, at: now(), heartbeatAt: now() }
75
93
  await writeFile(
76
94
  path.join(paths.lock, "owner.json"),
77
- JSON.stringify({ pid: process.pid, at: now() }) + "\n",
95
+ JSON.stringify(owner) + "\n",
78
96
  "utf8",
79
- ).catch(() => {})
80
- return
97
+ )
98
+ return owner
81
99
  } catch (error) {
82
100
  if (error?.code !== "EEXIST") throw error
83
- const info = await stat(paths.lock).catch(() => null)
101
+ const ownerFile = path.join(paths.lock, "owner.json")
102
+ const info = await stat(ownerFile).catch(() => stat(paths.lock).catch(() => null))
84
103
  if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
85
- await rm(paths.lock, { recursive: true, force: true }).catch(() => {})
86
- continue
104
+ const stale = paths.lock + ".stale-" + randomUUID()
105
+ try {
106
+ await rename(paths.lock, stale)
107
+ await rm(stale, { recursive: true, force: true }).catch(() => {})
108
+ continue
109
+ } catch {
110
+ // Another contender refreshed or took over the lock first.
111
+ }
87
112
  }
88
113
  if (Date.now() - started >= LOCK_TIMEOUT_MS) {
89
114
  throw new Error(`timed out waiting for UES state lock: ${paths.lock}`)
@@ -93,13 +118,36 @@ async function acquireWorkLock(paths) {
93
118
  }
94
119
  }
95
120
 
121
+ async function heartbeatWorkLock(paths, token) {
122
+ const owner = await lockOwner(paths)
123
+ if (!owner || owner.token !== token) return false
124
+ await writeFile(
125
+ path.join(paths.lock, "owner.json"),
126
+ JSON.stringify({ ...owner, heartbeatAt: now() }) + "\n",
127
+ "utf8",
128
+ )
129
+ return true
130
+ }
131
+
132
+ async function releaseWorkLock(paths, token) {
133
+ const owner = await lockOwner(paths)
134
+ if (!owner || owner.token !== token) return false
135
+ await rm(paths.lock, { recursive: true, force: true })
136
+ return true
137
+ }
138
+
96
139
  async function withWorkLock(root, slug, fn) {
97
140
  const paths = workPaths(root, slug)
98
- await acquireWorkLock(paths)
141
+ const owner = await acquireWorkLock(paths)
142
+ const heartbeat = setInterval(() => {
143
+ heartbeatWorkLock(paths, owner.token).catch(() => {})
144
+ }, LOCK_HEARTBEAT_MS)
145
+ heartbeat.unref?.()
99
146
  try {
100
147
  return await fn(paths)
101
148
  } finally {
102
- await rm(paths.lock, { recursive: true, force: true }).catch(() => {})
149
+ clearInterval(heartbeat)
150
+ await releaseWorkLock(paths, owner.token).catch(() => {})
103
151
  }
104
152
  }
105
153
 
@@ -115,19 +163,92 @@ function gitCapture(root, args) {
115
163
  })
116
164
  }
117
165
 
166
+ const FINGERPRINT_SKIP_DIRS = new Set([
167
+ ".git", ".ues-work", ".ues-learning", ".ues-dashboard", ".ues-sandboxes", ".ues-cache", ".ues-traces",
168
+ "node_modules", ".next", "dist", "build", "coverage", ".venv", "venv",
169
+ "Pods", "DerivedData", ".gradle", ".cache", ".turbo", "target", "bin", "obj",
170
+ ])
171
+
172
+ function nonGitWorkspaceFingerprint(root, options = {}) {
173
+ const maxFiles = Math.max(100, Number(options.maxFiles || 5000))
174
+ const maxFileBytes = Math.max(64 * 1024, Number(options.maxFileBytes || 2 * 1024 * 1024))
175
+ const files = []
176
+
177
+ function visit(dir) {
178
+ if (files.length >= maxFiles) return
179
+ let entries = []
180
+ try {
181
+ entries = readdirSync(dir, { withFileTypes: true })
182
+ } catch {
183
+ return
184
+ }
185
+ entries.sort((a, b) => a.name.localeCompare(b.name))
186
+ for (const entry of entries) {
187
+ if (files.length >= maxFiles) break
188
+ if (entry.isDirectory() && FINGERPRINT_SKIP_DIRS.has(entry.name)) continue
189
+ const full = path.join(dir, entry.name)
190
+ if (entry.isDirectory()) {
191
+ visit(full)
192
+ continue
193
+ }
194
+ if (!entry.isFile()) continue
195
+ const relative = path.relative(root, full).replaceAll("\\", "/")
196
+ let info
197
+ try { info = statSync(full) } catch { continue }
198
+ files.push({ full, relative, size: info.size, mtimeMs: info.mtimeMs })
199
+ }
200
+ }
201
+
202
+ visit(root)
203
+ const hash = createHash("sha256")
204
+ hash.update("non-git-workspace-v2\n")
205
+ hash.update("truncated=" + String(files.length >= maxFiles) + "\n")
206
+ for (const file of files) {
207
+ hash.update(file.relative + "\0" + file.size + "\0")
208
+ if (file.size <= maxFileBytes) {
209
+ try {
210
+ hash.update(readFileSync(file.full))
211
+ } catch {
212
+ hash.update("READ_ERROR:" + file.mtimeMs)
213
+ }
214
+ } else {
215
+ let fd = null
216
+ try {
217
+ fd = openSync(file.full, "r")
218
+ const sampleBytes = Math.min(64 * 1024, file.size)
219
+ const first = Buffer.allocUnsafe(sampleBytes)
220
+ const last = Buffer.allocUnsafe(sampleBytes)
221
+ readSync(fd, first, 0, sampleBytes, 0)
222
+ readSync(fd, last, 0, sampleBytes, Math.max(0, file.size - sampleBytes))
223
+ hash.update("LARGE_SAMPLE:")
224
+ hash.update(first)
225
+ hash.update(last)
226
+ } catch {
227
+ hash.update("READ_ERROR:" + file.mtimeMs)
228
+ } finally {
229
+ if (fd !== null) {
230
+ try { closeSync(fd) } catch {}
231
+ }
232
+ }
233
+ }
234
+ hash.update("\n")
235
+ }
236
+ return hash.digest("hex")
237
+ }
238
+
118
239
  export function workspaceFingerprint(root) {
119
240
  root = path.resolve(root)
120
241
  const inside = gitCapture(root, ["rev-parse", "--is-inside-work-tree"])
121
242
  if (inside.status !== 0) {
122
- return createHash("sha256").update("non-git:" + root).digest("hex")
243
+ return nonGitWorkspaceFingerprint(root)
123
244
  }
124
245
 
125
246
  const parts = []
126
247
  const commands = [
127
248
  ["rev-parse", "HEAD"],
128
- ["status", "--porcelain=v1", "--untracked-files=all", "--", ".", ":(exclude).ues-work", ":(exclude).ues-learning", ":(exclude).ues-dashboard", ":(exclude).ues-sandboxes"],
129
- ["diff", "--binary", "--no-ext-diff", "--", ".", ":(exclude).ues-work", ":(exclude).ues-learning", ":(exclude).ues-dashboard", ":(exclude).ues-sandboxes"],
130
- ["diff", "--cached", "--binary", "--no-ext-diff", "--", ".", ":(exclude).ues-work", ":(exclude).ues-learning", ":(exclude).ues-dashboard", ":(exclude).ues-sandboxes"],
249
+ ["status", "--porcelain=v1", "--untracked-files=all", "--", ".", ":(exclude).ues-work", ":(exclude).ues-learning", ":(exclude).ues-dashboard", ":(exclude).ues-sandboxes", ":(exclude).ues-cache", ":(exclude).ues-traces"],
250
+ ["diff", "--binary", "--no-ext-diff", "--", ".", ":(exclude).ues-work", ":(exclude).ues-learning", ":(exclude).ues-dashboard", ":(exclude).ues-sandboxes", ":(exclude).ues-cache", ":(exclude).ues-traces"],
251
+ ["diff", "--cached", "--binary", "--no-ext-diff", "--", ".", ":(exclude).ues-work", ":(exclude).ues-learning", ":(exclude).ues-dashboard", ":(exclude).ues-sandboxes", ":(exclude).ues-cache", ":(exclude).ues-traces"],
131
252
  ]
132
253
  for (const args of commands) {
133
254
  const result = gitCapture(root, args)
@@ -171,7 +292,9 @@ export async function initWork(root, slug, goal) {
171
292
  updatedAt: createdAt,
172
293
  entries: [],
173
294
  receipts: [],
295
+ gateReceipts: [],
174
296
  })
297
+ await journal(paths, "work.init", { goal: cleanGoal })
175
298
 
176
299
  return { ...paths, slug, goal: cleanGoal }
177
300
  }
@@ -185,6 +308,24 @@ export async function loadWork(root, slug) {
185
308
  return { paths, state, plan, evidence }
186
309
  }
187
310
 
311
+ function evidencePolicyForPlan(plan) {
312
+ const tasks = Array.isArray(plan?.tasks) ? plan.tasks : []
313
+ const highRisk = tasks.some((task) => ["high", "critical"].includes(task?.risk))
314
+ const longHorizon = tasks.length > 1 || plan?.mode === "long-horizon" || plan?.longHorizon === true
315
+ return {
316
+ schemaVersion: 1,
317
+ strict: highRisk || longHorizon,
318
+ receiptRequiredForTasks: highRisk || longHorizon,
319
+ structuredPlanApproval: highRisk || longHorizon,
320
+ structuredIntegrationApproval: highRisk || longHorizon,
321
+ reason: highRisk ? "high-risk-plan" : longHorizon ? "long-horizon-plan" : "standard-plan",
322
+ }
323
+ }
324
+
325
+ function currentEvidencePolicy(state, plan) {
326
+ return state?.evidencePolicy || evidencePolicyForPlan(plan)
327
+ }
328
+
188
329
  function taskBrief(task) {
189
330
  const files = taskFiles(task)
190
331
  return `# ${task.id}: ${task.title}\n\n## Summary\n\n${task.summary || "No summary provided."}\n\n## Dependencies\n\n${(task.dependsOn || []).map((id) => `- ${id}`).join("\n") || "- None"}\n\n## Files\n\n${files.map((file) => `- ${file}`).join("\n") || "- Scope must be established before editing."}\n\n## Acceptance criteria\n\n${(task.acceptance || []).map((item) => `- ${item}`).join("\n")}\n\n## Verification\n\n${(task.verification || []).map((item) => `- ${item}`).join("\n")}\n\n## Risk\n\n${task.risk || "medium"}\n`
@@ -246,17 +387,21 @@ export async function importPlan(root, slug, planInput) {
246
387
  planHash: hash,
247
388
  planApproval: { status: "pending", planHash: hash, at: updatedAt, evidence: null },
248
389
  integrationVerification: null,
390
+ evidencePolicy: evidencePolicyForPlan(plan),
249
391
  tasks: stateTasks,
250
392
  nextAction: "Run ues-plan-checker, then record PASS with 'ocskill work approve-plan'.",
251
393
  }
252
394
  await writeJson(loaded.paths.state, state)
395
+ await journal(loaded.paths, "plan.imported", {
396
+ planHash: hash,
397
+ taskCount: plan.tasks.length,
398
+ })
253
399
  return { plan, analysis, state, paths: loaded.paths }
254
400
  })
255
401
  }
256
402
 
257
- export async function approvePlan(root, slug, evidence) {
258
- const evidenceText = String(evidence || "").trim()
259
- if (!evidenceText) throw new Error("plan approval requires non-empty plan-checker evidence")
403
+ export async function approvePlan(root, slug, evidence, options = {}) {
404
+ const evidenceText = String(evidence || options.receipt?.evidence || "").trim()
260
405
 
261
406
  return withWorkLock(root, slug, async () => {
262
407
  const loaded = await loadWork(root, slug)
@@ -266,14 +411,43 @@ export async function approvePlan(root, slug, evidence) {
266
411
  throw new Error("PLAN.json changed after import; re-import it before approval")
267
412
  }
268
413
 
414
+ const policy = currentEvidencePolicy(loaded.state, loaded.plan)
415
+ let receipt = options.receipt || null
416
+ if (receipt) {
417
+ const validation = validateGateReceipt(receipt)
418
+ if (!validation.valid) {
419
+ const error = new Error("invalid plan verification receipt")
420
+ error.validation = validation
421
+ throw error
422
+ }
423
+ if (receipt.kind !== "plan-verification") throw new Error("plan approval requires a plan-verification receipt")
424
+ if (receipt.slug !== slug) throw new Error("plan verification receipt slug mismatch")
425
+ if (receipt.verdict !== "PASS") throw new Error("plan verification receipt must record PASS")
426
+ if (receipt.planHash !== hash) throw new Error("plan verification receipt planHash mismatch")
427
+ } else if (policy.structuredPlanApproval) {
428
+ throw new Error("strict plan approval requires a structured plan-verification receipt")
429
+ }
430
+
431
+ if (!evidenceText) throw new Error("plan approval requires non-empty plan-checker evidence")
432
+
269
433
  const timestamp = now()
270
434
  loaded.state.schemaVersion = STATE_SCHEMA
271
435
  loaded.state.planHash = hash
436
+ loaded.state.evidencePolicy = policy
272
437
  loaded.state.planApproval = {
273
438
  status: "passed",
274
439
  planHash: hash,
275
440
  at: timestamp,
276
441
  evidence: evidenceText,
442
+ receipt: receipt || null,
443
+ }
444
+ if (receipt) {
445
+ loaded.evidence.gateReceipts ??= []
446
+ if (!loaded.evidence.gateReceipts.some((item) => item.id === receipt.id)) {
447
+ loaded.evidence.gateReceipts.push({ ...receipt, recordedAt: timestamp })
448
+ }
449
+ loaded.evidence.updatedAt = timestamp
450
+ await writeJson(loaded.paths.evidence, loaded.evidence)
277
451
  }
278
452
  loaded.state.status = "ready"
279
453
  loaded.state.updatedAt = timestamp
@@ -282,6 +456,12 @@ export async function approvePlan(root, slug, evidence) {
282
456
  ? `Execute ready task(s): ${next.join(", ")}`
283
457
  : "No executable task; inspect dependencies and blockers."
284
458
  await writeJson(loaded.paths.state, loaded.state)
459
+ await journal(loaded.paths, "plan.approved", {
460
+ planHash: hash,
461
+ ready: next,
462
+ structured: Boolean(receipt),
463
+ receiptId: receipt?.id || null,
464
+ })
285
465
  return { planHash: hash, approval: loaded.state.planApproval, ready: next }
286
466
  })
287
467
  }
@@ -331,6 +511,9 @@ export async function workStatus(root, slug) {
331
511
  taskID,
332
512
  runId: value.runId || null,
333
513
  attempts: value.attempts || 0,
514
+ sessionID: value.owner?.sessionID || null,
515
+ executionDir: value.owner?.executionDir || null,
516
+ sandboxDir: value.owner?.sandboxDir || null,
334
517
  heartbeatAt: value.heartbeatAt || null,
335
518
  leaseExpiresAt: value.leaseExpiresAt || null,
336
519
  }))
@@ -355,6 +538,7 @@ export async function workStatus(root, slug) {
355
538
  completed,
356
539
  coverage: completed ? receiptBacked / completed : 0,
357
540
  receipts: (loaded.evidence.receipts || []).length,
541
+ gateReceipts: (loaded.evidence.gateReceipts || []).length,
358
542
  },
359
543
  nextAction: loaded.state.nextAction,
360
544
  updatedAt: loaded.state.updatedAt,
@@ -396,6 +580,13 @@ export async function startTask(root, slug, taskID, options = {}) {
396
580
  loaded.state.updatedAt = timestamp
397
581
  loaded.state.nextAction = `Complete ${taskID}, write its report, then record fresh verification evidence.`
398
582
  await writeJson(loaded.paths.state, loaded.state)
583
+ await journal(loaded.paths, "task.started", {
584
+ task: taskID,
585
+ runId: record.runId,
586
+ attempt: record.attempts,
587
+ owner: record.owner,
588
+ leaseExpiresAt: record.leaseExpiresAt,
589
+ })
399
590
  return { task, record: { ...record } }
400
591
  })
401
592
 
@@ -405,11 +596,46 @@ export async function startTask(root, slug, taskID, options = {}) {
405
596
 
406
597
  function assertRunFence(record, options = {}) {
407
598
  const runId = options.runId || null
408
- if (runId && record.runId && runId !== record.runId) {
599
+ if (record.runId && !runId) {
600
+ throw new Error("task run fence requires the active runId")
601
+ }
602
+ if (runId && record.runId !== runId) {
409
603
  throw new Error("task run fence mismatch; the executor lease is stale")
410
604
  }
411
605
  }
412
606
 
607
+ export async function attachTaskSession(root, slug, taskID, runId, sessionID, options = {}) {
608
+ const value = String(sessionID || "").trim()
609
+ if (!value) throw new Error("sessionID is required")
610
+ return withWorkLock(root, slug, async () => {
611
+ const loaded = await loadWork(root, slug)
612
+ const record = loaded.state.tasks?.[taskID]
613
+ if (!record) throw new Error(`task '${taskID}' is not tracked`)
614
+ if (record.status !== "running") throw new Error(`task '${taskID}' is not running`)
615
+ assertRunFence(record, { runId })
616
+ record.owner ??= { pid: process.pid, sessionID: null }
617
+ record.owner.sessionID = value
618
+ record.owner.executionDir = options.executionDir ? path.resolve(options.executionDir) : null
619
+ record.owner.sandboxDir = options.sandboxDir ? path.resolve(options.sandboxDir) : null
620
+ loaded.state.updatedAt = now()
621
+ await writeJson(loaded.paths.state, loaded.state)
622
+ await journal(loaded.paths, "task.session-attached", {
623
+ task: taskID,
624
+ runId: record.runId,
625
+ sessionID: value,
626
+ executionDir: record.owner.executionDir,
627
+ sandboxDir: record.owner.sandboxDir,
628
+ })
629
+ return {
630
+ taskID,
631
+ runId: record.runId,
632
+ sessionID: value,
633
+ executionDir: record.owner.executionDir,
634
+ sandboxDir: record.owner.sandboxDir,
635
+ }
636
+ })
637
+ }
638
+
413
639
  export async function heartbeatTask(root, slug, taskID, runId, options = {}) {
414
640
  return withWorkLock(root, slug, async () => {
415
641
  const loaded = await loadWork(root, slug)
@@ -423,10 +649,54 @@ export async function heartbeatTask(root, slug, taskID, runId, options = {}) {
423
649
  record.leaseExpiresAt = new Date(Date.now() + leaseMs).toISOString()
424
650
  loaded.state.updatedAt = timestamp
425
651
  await writeJson(loaded.paths.state, loaded.state)
652
+ await journal(loaded.paths, "task.heartbeat", {
653
+ task: taskID,
654
+ runId: record.runId,
655
+ heartbeatAt: record.heartbeatAt,
656
+ leaseExpiresAt: record.leaseExpiresAt,
657
+ })
426
658
  return { taskID, runId: record.runId, heartbeatAt: record.heartbeatAt, leaseExpiresAt: record.leaseExpiresAt }
427
659
  })
428
660
  }
429
661
 
662
+ export async function recoverTask(root, slug, taskID, options = {}) {
663
+ return withWorkLock(root, slug, async () => {
664
+ const loaded = await loadWork(root, slug)
665
+ const record = loaded.state.tasks?.[taskID]
666
+ if (!record) throw new Error(`task '${taskID}' is not tracked`)
667
+ if (record.status !== "running") throw new Error(`task '${taskID}' is not running`)
668
+
669
+ const expires = Date.parse(record.leaseExpiresAt || "")
670
+ const stale = options.force || !Number.isFinite(expires) || expires <= Date.now()
671
+ if (!stale) throw new Error(`task '${taskID}' lease is still active`)
672
+
673
+ const timestamp = now()
674
+ const previousOwner = record.owner ? { ...record.owner } : null
675
+ const previousRunId = record.runId || null
676
+ record.status = "failed"
677
+ record.lastFailure = String(options.reason || "stale executor lease recovered after interruption")
678
+ record.lastOwner = previousOwner
679
+ record.lastRunId = previousRunId
680
+ record.runId = null
681
+ record.owner = null
682
+ record.heartbeatAt = null
683
+ record.leaseExpiresAt = null
684
+
685
+ loaded.state.integrationVerification = null
686
+ loaded.state.status = "executing"
687
+ loaded.state.updatedAt = timestamp
688
+ loaded.state.nextAction = `Recovered stale task ${taskID}. Re-diagnose and retry from fresh evidence.`
689
+ await writeJson(loaded.paths.state, loaded.state)
690
+ await journal(loaded.paths, "task.recovered", {
691
+ tasks: [taskID],
692
+ previousRunId,
693
+ previousOwner,
694
+ forced: Boolean(options.force),
695
+ })
696
+ return { recovered: [taskID], previousRunId, previousOwner, state: loaded.state }
697
+ })
698
+ }
699
+
430
700
  export async function recoverStaleTasks(root, slug, options = {}) {
431
701
  return withWorkLock(root, slug, async () => {
432
702
  const loaded = await loadWork(root, slug)
@@ -437,8 +707,12 @@ export async function recoverStaleTasks(root, slug, options = {}) {
437
707
  const expires = Date.parse(record.leaseExpiresAt || "")
438
708
  const stale = options.force || !Number.isFinite(expires) || expires <= Date.now()
439
709
  if (!stale) continue
710
+ const previousOwner = record.owner ? { ...record.owner } : null
711
+ const previousRunId = record.runId || null
440
712
  record.status = "failed"
441
713
  record.lastFailure = "stale executor lease recovered after interruption"
714
+ record.lastOwner = previousOwner
715
+ record.lastRunId = previousRunId
442
716
  record.runId = null
443
717
  record.owner = null
444
718
  record.heartbeatAt = null
@@ -451,6 +725,7 @@ export async function recoverStaleTasks(root, slug, options = {}) {
451
725
  loaded.state.updatedAt = timestamp
452
726
  loaded.state.nextAction = `Recovered stale task(s): ${recovered.join(", ")}. Re-diagnose and retry from fresh evidence.`
453
727
  await writeJson(loaded.paths.state, loaded.state)
728
+ await journal(loaded.paths, "task.recovered", { tasks: recovered })
454
729
  }
455
730
  return { recovered, state: loaded.state }
456
731
  })
@@ -481,6 +756,12 @@ export async function recordVerificationReceipt(root, slug, taskID, receipt) {
481
756
  loaded.evidence.schemaVersion = STATE_SCHEMA
482
757
  loaded.evidence.updatedAt = normalized.recordedAt
483
758
  await writeJson(loaded.paths.evidence, loaded.evidence)
759
+ await journal(loaded.paths, "verification.receipt", {
760
+ task: taskID,
761
+ runId: record.runId,
762
+ receiptId: normalized.id,
763
+ passed: normalized.passed,
764
+ })
484
765
  return normalized
485
766
  })
486
767
  }
@@ -499,18 +780,31 @@ export async function completeTask(root, slug, taskID, options = {}) {
499
780
  assertRunFence(record, options)
500
781
 
501
782
  const timestamp = now()
783
+ const receipts = (loaded.evidence.receipts || []).filter((item) => item.task === taskID && (!record.runId || item.runId === record.runId))
784
+ const successfulReceipts = receipts.filter((item) => item.passed)
785
+ const evidencePolicy = currentEvidencePolicy(loaded.state, loaded.plan)
786
+ const currentFingerprint = workspaceFingerprint(loaded.paths.root)
787
+ const freshSuccessfulReceipts = successfulReceipts.filter(
788
+ (item) => item.workspaceAfter && item.workspaceAfter === currentFingerprint,
789
+ )
790
+ if (evidencePolicy.receiptRequiredForTasks && freshSuccessfulReceipts.length === 0) {
791
+ throw new Error("strict task completion requires a successful verification receipt for the active run and current workspace fingerprint")
792
+ }
793
+
794
+ const acceptedReceipts = evidencePolicy.receiptRequiredForTasks
795
+ ? freshSuccessfulReceipts
796
+ : successfulReceipts
797
+
502
798
  const reportPath = path.join(loaded.paths.reports, `${taskID}.md`)
503
799
  if (options.report) await atomicWrite(reportPath, String(options.report).trimEnd() + "\n")
504
800
  else if (!existsSync(reportPath)) {
505
801
  await atomicWrite(reportPath, `# ${taskID} report\n\nCompleted with evidence recorded in EVIDENCE.json.\n`)
506
802
  }
507
803
 
508
- const receipts = (loaded.evidence.receipts || []).filter((item) => item.task === taskID && (!record.runId || item.runId === record.runId))
509
- const successfulReceipts = receipts.filter((item) => item.passed)
510
804
  record.status = "completed"
511
805
  record.completedAt = timestamp
512
806
  record.report = path.relative(loaded.paths.root, reportPath).replaceAll("\\", "/")
513
- record.evidenceStrength = successfulReceipts.length ? "receipt-backed" : "narrative"
807
+ record.evidenceStrength = acceptedReceipts.length ? "receipt-backed" : "narrative"
514
808
  record.runId = null
515
809
  record.owner = null
516
810
  record.heartbeatAt = null
@@ -522,7 +816,7 @@ export async function completeTask(root, slug, taskID, options = {}) {
522
816
  evidence: evidenceText,
523
817
  verification: task.verification,
524
818
  evidenceStrength: record.evidenceStrength,
525
- receiptIDs: successfulReceipts.map((item) => item.id),
819
+ receiptIDs: acceptedReceipts.map((item) => item.id),
526
820
  })
527
821
  loaded.evidence.schemaVersion = STATE_SCHEMA
528
822
  loaded.evidence.updatedAt = timestamp
@@ -540,6 +834,11 @@ export async function completeTask(root, slug, taskID, options = {}) {
540
834
 
541
835
  await writeJson(loaded.paths.evidence, loaded.evidence)
542
836
  await writeJson(loaded.paths.state, loaded.state)
837
+ await journal(loaded.paths, "task.completed", {
838
+ task: taskID,
839
+ evidenceStrength: record.evidenceStrength,
840
+ receiptIDs: acceptedReceipts.map((item) => item.id),
841
+ })
543
842
  return { task, state: loaded.state, ready }
544
843
  })
545
844
  }
@@ -554,6 +853,7 @@ export async function failTask(root, slug, taskID, reason, options = {}) {
554
853
  taskByID(loaded.plan, taskID)
555
854
  const record = loaded.state.tasks?.[taskID]
556
855
  if (!record) throw new Error(`task '${taskID}' is not tracked`)
856
+ if (record.status !== "running") throw new Error(`task '${taskID}' must be running before it can fail`)
557
857
  assertRunFence(record, options)
558
858
 
559
859
  const timestamp = now()
@@ -570,6 +870,11 @@ export async function failTask(root, slug, taskID, reason, options = {}) {
570
870
  ? `Re-plan or escalate ${taskID}; it has failed ${record.attempts} attempt(s).`
571
871
  : `Re-diagnose ${taskID} from fresh evidence before retrying.`
572
872
  await writeJson(loaded.paths.state, loaded.state)
873
+ await journal(loaded.paths, "task.failed", {
874
+ task: taskID,
875
+ attempts: record.attempts,
876
+ reason: cleanReason,
877
+ })
573
878
  return { taskID, attempts: record.attempts, state: loaded.state }
574
879
  })
575
880
  }
@@ -627,12 +932,12 @@ export async function resolveBlocker(root, slug, blocker) {
627
932
  })
628
933
  }
629
934
 
630
- export async function recordIntegrationVerification(root, slug, verdict, evidence, report = null) {
935
+ export async function recordIntegrationVerification(root, slug, verdict, evidence, report = null, options = {}) {
631
936
  const status = String(verdict || "").trim().toUpperCase()
632
937
  if (!["PASS", "FAIL", "PARTIAL"].includes(status)) {
633
938
  throw new Error("integration verdict must be PASS, FAIL, or PARTIAL")
634
939
  }
635
- const evidenceText = String(evidence || "").trim()
940
+ const evidenceText = String(evidence || options.receipt?.evidence || "").trim()
636
941
  if (!evidenceText) throw new Error("integration verification requires non-empty evidence")
637
942
 
638
943
  return withWorkLock(root, slug, async () => {
@@ -650,12 +955,37 @@ export async function recordIntegrationVerification(root, slug, verdict, evidenc
650
955
 
651
956
  const timestamp = now()
652
957
  const fingerprint = workspaceFingerprint(loaded.paths.root)
958
+ const policy = currentEvidencePolicy(loaded.state, loaded.plan)
959
+ const receipt = options.receipt || null
960
+ if (receipt) {
961
+ const validation = validateGateReceipt(receipt)
962
+ if (!validation.valid) {
963
+ const error = new Error("invalid integration verification receipt")
964
+ error.validation = validation
965
+ throw error
966
+ }
967
+ if (receipt.kind !== "integration-verification") throw new Error("integration verification requires an integration-verification receipt")
968
+ if (receipt.slug !== slug) throw new Error("integration verification receipt slug mismatch")
969
+ if (receipt.verdict !== status) throw new Error("integration verification receipt verdict mismatch")
970
+ if (receipt.workspaceFingerprint !== fingerprint) {
971
+ throw new Error("integration verification receipt workspace fingerprint mismatch")
972
+ }
973
+ } else if (status === "PASS" && policy.structuredIntegrationApproval) {
974
+ throw new Error("strict integration PASS requires a structured integration-verification receipt")
975
+ }
653
976
  loaded.state.integrationVerification = {
654
977
  status,
655
978
  at: timestamp,
656
979
  evidence: evidenceText,
657
980
  report: report ? String(report) : null,
658
981
  fingerprint,
982
+ receipt: receipt || null,
983
+ }
984
+ if (receipt) {
985
+ loaded.evidence.gateReceipts ??= []
986
+ if (!loaded.evidence.gateReceipts.some((item) => item.id === receipt.id)) {
987
+ loaded.evidence.gateReceipts.push({ ...receipt, recordedAt: timestamp })
988
+ }
659
989
  }
660
990
  loaded.state.updatedAt = timestamp
661
991
  loaded.state.status = status === "PASS" ? "ready-to-finalize" : "integration-verification"
@@ -676,6 +1006,11 @@ export async function recordIntegrationVerification(root, slug, verdict, evidenc
676
1006
 
677
1007
  await writeJson(loaded.paths.evidence, loaded.evidence)
678
1008
  await writeJson(loaded.paths.state, loaded.state)
1009
+ await journal(loaded.paths, "integration.verified", {
1010
+ verdict: status,
1011
+ fingerprint,
1012
+ receiptId: receipt?.id || null,
1013
+ })
679
1014
  return loaded.state.integrationVerification
680
1015
  })
681
1016
  }
@@ -720,6 +1055,7 @@ export async function finalizeWork(root, slug, evidence) {
720
1055
 
721
1056
  await writeJson(loaded.paths.evidence, loaded.evidence)
722
1057
  await writeJson(loaded.paths.state, loaded.state)
1058
+ await journal(loaded.paths, "work.finalized", { fingerprint: currentFingerprint })
723
1059
  return { state: loaded.state, evidence: loaded.evidence.entries.at(-1) }
724
1060
  })
725
1061
  }
@@ -736,7 +1072,24 @@ async function buildContextPack(loaded, taskID) {
736
1072
  dependencyReports[dep] = (await readFile(file, "utf8")).slice(0, 12000)
737
1073
  }
738
1074
 
739
- const contextManifest = await buildContextManifest(loaded.paths.root, task, { budget: 24_000 }).catch(() => null)
1075
+ const taskText = [
1076
+ task.title,
1077
+ task.summary,
1078
+ ...(task.acceptance || []),
1079
+ ...(task.verification || []),
1080
+ ].filter(Boolean).join(" ")
1081
+ const contextPolicy = classifyEngineeringTask(taskText, {
1082
+ changedFiles: taskFiles(task).length,
1083
+ risk: task.risk,
1084
+ })
1085
+ const contextManifest = await buildContextManifest(
1086
+ loaded.paths.root,
1087
+ task,
1088
+ {
1089
+ budget: contextPolicy.contextBudget,
1090
+ strategy: contextPolicy.profile?.contextStrategy || "semantic+graph+git",
1091
+ },
1092
+ ).catch(() => null)
740
1093
  const learnings = await relevantAcceptedLearnings(
741
1094
  loaded.paths.root,
742
1095
  [task.title, task.summary, ...(task.acceptance || [])].join(" "),
@@ -744,6 +1097,7 @@ async function buildContextPack(loaded, taskID) {
744
1097
 
745
1098
  return {
746
1099
  schemaVersion: STATE_SCHEMA,
1100
+ contextPolicy,
747
1101
  slug: loaded.state.slug,
748
1102
  task,
749
1103
  taskBrief: path.relative(loaded.paths.root, path.join(loaded.paths.tasks, `${taskID}.md`)).replaceAll("\\", "/"),
@@ -761,10 +1115,47 @@ async function buildContextPack(loaded, taskID) {
761
1115
  }
762
1116
  }
763
1117
 
1118
+ export async function createPlanVerificationReceipt(root, slug, input = {}) {
1119
+ const loaded = await loadWork(root, slug)
1120
+ if (!loaded.plan) throw new Error("PLAN.json is missing")
1121
+ const hash = planHash(loaded.plan)
1122
+ return createGateReceipt({
1123
+ kind: "plan-verification",
1124
+ slug,
1125
+ verdict: input.verdict || "PASS",
1126
+ verifier: input.verifier || "ues-plan-checker",
1127
+ sessionID: input.sessionID || null,
1128
+ runId: input.runId || null,
1129
+ planHash: hash,
1130
+ evidence: input.evidence,
1131
+ report: input.report,
1132
+ })
1133
+ }
1134
+
1135
+ export async function createIntegrationVerificationReceipt(root, slug, input = {}) {
1136
+ await loadWork(root, slug)
1137
+ return createGateReceipt({
1138
+ kind: "integration-verification",
1139
+ slug,
1140
+ verdict: input.verdict || "PASS",
1141
+ verifier: input.verifier || "ues-integration-verifier",
1142
+ sessionID: input.sessionID || null,
1143
+ runId: input.runId || null,
1144
+ workspaceFingerprint: workspaceFingerprint(root),
1145
+ evidence: input.evidence,
1146
+ report: input.report,
1147
+ })
1148
+ }
1149
+
764
1150
  export async function contextPack(root, slug, taskID) {
765
1151
  return buildContextPack(await loadWork(root, slug), taskID)
766
1152
  }
767
1153
 
1154
+ export async function runtimeEvents(root, slug, options = {}) {
1155
+ const paths = workPaths(root, slug)
1156
+ return readRuntimeEvents(paths.events, options)
1157
+ }
1158
+
768
1159
  export async function resumeWork(root, slug) {
769
1160
  await recoverStaleTasks(root, slug).catch(() => ({ recovered: [] }))
770
1161
  const loaded = await loadWork(root, slug)
@@ -773,5 +1164,6 @@ export async function resumeWork(root, slug) {
773
1164
  planAnalysis: loaded.plan ? analyzePlan(loaded.plan) : null,
774
1165
  completedEvidence: loaded.evidence.entries || [],
775
1166
  decisions: loaded.state.decisions || [],
1167
+ recentEvents: await readRuntimeEvents(loaded.paths.events, { limit: 50 }),
776
1168
  }
777
1169
  }