opencode-agent-skill 7.7.0 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -3
- package/README.md +396 -281
- package/bin/ocskill.mjs +382 -156
- package/docs/DETERMINISTIC-TOOLS.md +25 -8
- package/docs/ENGINEERING-DESIGN.md +31 -13
- package/docs/EVALS.md +34 -12
- package/docs/NPM-PUBLISH.md +6 -6
- package/docs/OPENCODE-COMPAT.md +11 -8
- package/docs/TRACE-SCHEMA.md +15 -2
- package/docs/V8-INTELLIGENCE-RELIABILITY.md +206 -0
- package/docs/V9-SPEED-INTELLIGENCE.md +102 -0
- package/evals/live/tasks.json +6 -6
- package/evals/polyglot/fixtures/polyglot-bench/api/generated/client.ts +2 -0
- package/evals/polyglot/fixtures/polyglot-bench/api/openapi.json +25 -0
- package/evals/polyglot/fixtures/polyglot-bench/db/migrations/20260920_add_order_key.sql +1 -0
- package/evals/polyglot/fixtures/polyglot-bench/dotnet/OrderService.cs +8 -0
- package/evals/polyglot/fixtures/polyglot-bench/java/PriceService.java +5 -0
- package/evals/polyglot/fixtures/polyglot-bench/monorepo/package.json +6 -0
- package/evals/polyglot/fixtures/polyglot-bench/monorepo/packages/api/package.json +4 -0
- package/evals/polyglot/fixtures/polyglot-bench/monorepo/packages/web/package.json +7 -0
- package/evals/polyglot/fixtures/polyglot-bench/monorepo/pnpm-lock.yaml +5 -0
- package/evals/polyglot/fixtures/polyglot-bench/next/app/api/products/route.ts +7 -0
- package/evals/polyglot/fixtures/polyglot-bench/python/tenant_auth.py +4 -0
- package/evals/polyglot/fixtures/polyglot-bench/react-native/keyboard.ts +3 -0
- package/evals/polyglot/graders/polyglot-bench.mjs +101 -0
- package/evals/polyglot/tasks.json +54 -0
- package/global-config/AGENTS.md +78 -160
- package/global-config/agents/integration-verifier.md +1 -1
- package/global-config/agents/plan-checker.md +1 -1
- package/global-config/commands/run.md +9 -5
- package/global-config/plugins/ues-router/capabilities.js +4 -0
- package/global-config/plugins/ues-router/index.js +784 -37
- package/global-config/plugins/ues-router/router.js +175 -23
- package/global-config/plugins/ues-router/runtime-guard.js +265 -0
- package/global-config/skills/engineering-orchestrator/references/long-horizon.md +6 -4
- package/lib/aci.mjs +128 -0
- package/lib/benchmark-confidence.mjs +173 -0
- package/lib/cli-utils.mjs +41 -0
- package/lib/container-sandbox.mjs +102 -0
- package/lib/context-manifest.mjs +300 -22
- package/lib/control-center.mjs +36 -3
- package/lib/eval-ablation.mjs +104 -0
- package/lib/eval-order.mjs +9 -0
- package/lib/eval-report.mjs +11 -0
- package/lib/eval-telemetry.mjs +8 -2
- package/lib/gate-receipt.mjs +52 -0
- package/lib/installer.mjs +39 -25
- package/lib/learning-engine.mjs +236 -38
- package/lib/model-policy.mjs +6 -0
- package/lib/opencode-compat.mjs +25 -10
- package/lib/orchestrator-policy.mjs +195 -21
- package/lib/process-runner.mjs +30 -9
- package/lib/runtime-events.mjs +31 -0
- package/lib/semantic-index.mjs +318 -0
- package/lib/task-engine.mjs +557 -28
- package/lib/trajectory.mjs +89 -0
- package/lib/windows-shim.mjs +227 -0
- package/lib/worktree-sandbox.mjs +85 -3
- package/package.json +11 -4
- package/scripts/check-release-tag.mjs +22 -0
- package/scripts/control-center.mjs +25 -0
- package/scripts/eval-ablation.mjs +44 -0
- package/scripts/eval-live.mjs +39 -58
- package/scripts/eval-matrix.mjs +166 -0
- package/scripts/smoke-packed-install.mjs +138 -4
- package/scripts/smoke-plain-install.mjs +91 -0
- package/scripts/validate-live-suite.mjs +3 -3
- package/scripts/validate.mjs +27 -5
package/lib/task-engine.mjs
CHANGED
|
@@ -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, recoveryPolicyForAttempt } from "./orchestrator-policy.mjs"
|
|
10
13
|
|
|
11
14
|
const WORK_DIR = ".ues-work"
|
|
12
|
-
const STATE_SCHEMA =
|
|
15
|
+
const STATE_SCHEMA = 4
|
|
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(
|
|
95
|
+
JSON.stringify(owner) + "\n",
|
|
78
96
|
"utf8",
|
|
79
|
-
)
|
|
80
|
-
return
|
|
97
|
+
)
|
|
98
|
+
return owner
|
|
81
99
|
} catch (error) {
|
|
82
100
|
if (error?.code !== "EEXIST") throw error
|
|
83
|
-
const
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
@@ -163,6 +284,7 @@ export async function initWork(root, slug, goal) {
|
|
|
163
284
|
tasks: {},
|
|
164
285
|
decisions: [],
|
|
165
286
|
blockers: [],
|
|
287
|
+
checkpoint: null,
|
|
166
288
|
nextAction: "Complete SPEC.md, create PLAN.json, then run ocskill work plan.",
|
|
167
289
|
})
|
|
168
290
|
await writeJson(paths.evidence, {
|
|
@@ -171,7 +293,9 @@ export async function initWork(root, slug, goal) {
|
|
|
171
293
|
updatedAt: createdAt,
|
|
172
294
|
entries: [],
|
|
173
295
|
receipts: [],
|
|
296
|
+
gateReceipts: [],
|
|
174
297
|
})
|
|
298
|
+
await journal(paths, "work.init", { goal: cleanGoal })
|
|
175
299
|
|
|
176
300
|
return { ...paths, slug, goal: cleanGoal }
|
|
177
301
|
}
|
|
@@ -185,6 +309,24 @@ export async function loadWork(root, slug) {
|
|
|
185
309
|
return { paths, state, plan, evidence }
|
|
186
310
|
}
|
|
187
311
|
|
|
312
|
+
function evidencePolicyForPlan(plan) {
|
|
313
|
+
const tasks = Array.isArray(plan?.tasks) ? plan.tasks : []
|
|
314
|
+
const highRisk = tasks.some((task) => ["high", "critical"].includes(task?.risk))
|
|
315
|
+
const longHorizon = tasks.length > 1 || plan?.mode === "long-horizon" || plan?.longHorizon === true
|
|
316
|
+
return {
|
|
317
|
+
schemaVersion: 1,
|
|
318
|
+
strict: highRisk || longHorizon,
|
|
319
|
+
receiptRequiredForTasks: highRisk || longHorizon,
|
|
320
|
+
structuredPlanApproval: highRisk || longHorizon,
|
|
321
|
+
structuredIntegrationApproval: highRisk || longHorizon,
|
|
322
|
+
reason: highRisk ? "high-risk-plan" : longHorizon ? "long-horizon-plan" : "standard-plan",
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function currentEvidencePolicy(state, plan) {
|
|
327
|
+
return state?.evidencePolicy || evidencePolicyForPlan(plan)
|
|
328
|
+
}
|
|
329
|
+
|
|
188
330
|
function taskBrief(task) {
|
|
189
331
|
const files = taskFiles(task)
|
|
190
332
|
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 +388,22 @@ export async function importPlan(root, slug, planInput) {
|
|
|
246
388
|
planHash: hash,
|
|
247
389
|
planApproval: { status: "pending", planHash: hash, at: updatedAt, evidence: null },
|
|
248
390
|
integrationVerification: null,
|
|
391
|
+
checkpoint: null,
|
|
392
|
+
evidencePolicy: evidencePolicyForPlan(plan),
|
|
249
393
|
tasks: stateTasks,
|
|
250
394
|
nextAction: "Run ues-plan-checker, then record PASS with 'ocskill work approve-plan'.",
|
|
251
395
|
}
|
|
252
396
|
await writeJson(loaded.paths.state, state)
|
|
397
|
+
await journal(loaded.paths, "plan.imported", {
|
|
398
|
+
planHash: hash,
|
|
399
|
+
taskCount: plan.tasks.length,
|
|
400
|
+
})
|
|
253
401
|
return { plan, analysis, state, paths: loaded.paths }
|
|
254
402
|
})
|
|
255
403
|
}
|
|
256
404
|
|
|
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")
|
|
405
|
+
export async function approvePlan(root, slug, evidence, options = {}) {
|
|
406
|
+
const evidenceText = String(evidence || options.receipt?.evidence || "").trim()
|
|
260
407
|
|
|
261
408
|
return withWorkLock(root, slug, async () => {
|
|
262
409
|
const loaded = await loadWork(root, slug)
|
|
@@ -266,14 +413,43 @@ export async function approvePlan(root, slug, evidence) {
|
|
|
266
413
|
throw new Error("PLAN.json changed after import; re-import it before approval")
|
|
267
414
|
}
|
|
268
415
|
|
|
416
|
+
const policy = currentEvidencePolicy(loaded.state, loaded.plan)
|
|
417
|
+
let receipt = options.receipt || null
|
|
418
|
+
if (receipt) {
|
|
419
|
+
const validation = validateGateReceipt(receipt)
|
|
420
|
+
if (!validation.valid) {
|
|
421
|
+
const error = new Error("invalid plan verification receipt")
|
|
422
|
+
error.validation = validation
|
|
423
|
+
throw error
|
|
424
|
+
}
|
|
425
|
+
if (receipt.kind !== "plan-verification") throw new Error("plan approval requires a plan-verification receipt")
|
|
426
|
+
if (receipt.slug !== slug) throw new Error("plan verification receipt slug mismatch")
|
|
427
|
+
if (receipt.verdict !== "PASS") throw new Error("plan verification receipt must record PASS")
|
|
428
|
+
if (receipt.planHash !== hash) throw new Error("plan verification receipt planHash mismatch")
|
|
429
|
+
} else if (policy.structuredPlanApproval) {
|
|
430
|
+
throw new Error("strict plan approval requires a structured plan-verification receipt")
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!evidenceText) throw new Error("plan approval requires non-empty plan-checker evidence")
|
|
434
|
+
|
|
269
435
|
const timestamp = now()
|
|
270
436
|
loaded.state.schemaVersion = STATE_SCHEMA
|
|
271
437
|
loaded.state.planHash = hash
|
|
438
|
+
loaded.state.evidencePolicy = policy
|
|
272
439
|
loaded.state.planApproval = {
|
|
273
440
|
status: "passed",
|
|
274
441
|
planHash: hash,
|
|
275
442
|
at: timestamp,
|
|
276
443
|
evidence: evidenceText,
|
|
444
|
+
receipt: receipt || null,
|
|
445
|
+
}
|
|
446
|
+
if (receipt) {
|
|
447
|
+
loaded.evidence.gateReceipts ??= []
|
|
448
|
+
if (!loaded.evidence.gateReceipts.some((item) => item.id === receipt.id)) {
|
|
449
|
+
loaded.evidence.gateReceipts.push({ ...receipt, recordedAt: timestamp })
|
|
450
|
+
}
|
|
451
|
+
loaded.evidence.updatedAt = timestamp
|
|
452
|
+
await writeJson(loaded.paths.evidence, loaded.evidence)
|
|
277
453
|
}
|
|
278
454
|
loaded.state.status = "ready"
|
|
279
455
|
loaded.state.updatedAt = timestamp
|
|
@@ -282,6 +458,12 @@ export async function approvePlan(root, slug, evidence) {
|
|
|
282
458
|
? `Execute ready task(s): ${next.join(", ")}`
|
|
283
459
|
: "No executable task; inspect dependencies and blockers."
|
|
284
460
|
await writeJson(loaded.paths.state, loaded.state)
|
|
461
|
+
await journal(loaded.paths, "plan.approved", {
|
|
462
|
+
planHash: hash,
|
|
463
|
+
ready: next,
|
|
464
|
+
structured: Boolean(receipt),
|
|
465
|
+
receiptId: receipt?.id || null,
|
|
466
|
+
})
|
|
285
467
|
return { planHash: hash, approval: loaded.state.planApproval, ready: next }
|
|
286
468
|
})
|
|
287
469
|
}
|
|
@@ -309,7 +491,7 @@ export function readyTasks(plan, state) {
|
|
|
309
491
|
const ready = []
|
|
310
492
|
for (const task of plan.tasks) {
|
|
311
493
|
const status = state.tasks?.[task.id]?.status
|
|
312
|
-
if (!["pending", "failed"].includes(status)) continue
|
|
494
|
+
if (!["pending", "failed", "retryable"].includes(status)) continue
|
|
313
495
|
if (!(task.dependsOn || []).every((dep) => completed.has(dep))) continue
|
|
314
496
|
const files = taskFiles(task)
|
|
315
497
|
if (files.length === 0 && runningFiles.size > 0) continue
|
|
@@ -321,7 +503,7 @@ export function readyTasks(plan, state) {
|
|
|
321
503
|
|
|
322
504
|
export async function workStatus(root, slug) {
|
|
323
505
|
const loaded = await loadWork(root, slug)
|
|
324
|
-
const counts = { pending: 0, running: 0, completed: 0, failed: 0 }
|
|
506
|
+
const counts = { pending: 0, running: 0, completed: 0, failed: 0, retryable: 0 }
|
|
325
507
|
for (const value of Object.values(loaded.state.tasks || {})) {
|
|
326
508
|
if (Object.hasOwn(counts, value.status)) counts[value.status] += 1
|
|
327
509
|
}
|
|
@@ -331,6 +513,9 @@ export async function workStatus(root, slug) {
|
|
|
331
513
|
taskID,
|
|
332
514
|
runId: value.runId || null,
|
|
333
515
|
attempts: value.attempts || 0,
|
|
516
|
+
sessionID: value.owner?.sessionID || null,
|
|
517
|
+
executionDir: value.owner?.executionDir || null,
|
|
518
|
+
sandboxDir: value.owner?.sandboxDir || null,
|
|
334
519
|
heartbeatAt: value.heartbeatAt || null,
|
|
335
520
|
leaseExpiresAt: value.leaseExpiresAt || null,
|
|
336
521
|
}))
|
|
@@ -355,8 +540,10 @@ export async function workStatus(root, slug) {
|
|
|
355
540
|
completed,
|
|
356
541
|
coverage: completed ? receiptBacked / completed : 0,
|
|
357
542
|
receipts: (loaded.evidence.receipts || []).length,
|
|
543
|
+
gateReceipts: (loaded.evidence.gateReceipts || []).length,
|
|
358
544
|
},
|
|
359
545
|
nextAction: loaded.state.nextAction,
|
|
546
|
+
checkpoint: loaded.state.checkpoint || null,
|
|
360
547
|
updatedAt: loaded.state.updatedAt,
|
|
361
548
|
}
|
|
362
549
|
}
|
|
@@ -396,6 +583,13 @@ export async function startTask(root, slug, taskID, options = {}) {
|
|
|
396
583
|
loaded.state.updatedAt = timestamp
|
|
397
584
|
loaded.state.nextAction = `Complete ${taskID}, write its report, then record fresh verification evidence.`
|
|
398
585
|
await writeJson(loaded.paths.state, loaded.state)
|
|
586
|
+
await journal(loaded.paths, "task.started", {
|
|
587
|
+
task: taskID,
|
|
588
|
+
runId: record.runId,
|
|
589
|
+
attempt: record.attempts,
|
|
590
|
+
owner: record.owner,
|
|
591
|
+
leaseExpiresAt: record.leaseExpiresAt,
|
|
592
|
+
})
|
|
399
593
|
return { task, record: { ...record } }
|
|
400
594
|
})
|
|
401
595
|
|
|
@@ -405,11 +599,46 @@ export async function startTask(root, slug, taskID, options = {}) {
|
|
|
405
599
|
|
|
406
600
|
function assertRunFence(record, options = {}) {
|
|
407
601
|
const runId = options.runId || null
|
|
408
|
-
if (
|
|
602
|
+
if (record.runId && !runId) {
|
|
603
|
+
throw new Error("task run fence requires the active runId")
|
|
604
|
+
}
|
|
605
|
+
if (runId && record.runId !== runId) {
|
|
409
606
|
throw new Error("task run fence mismatch; the executor lease is stale")
|
|
410
607
|
}
|
|
411
608
|
}
|
|
412
609
|
|
|
610
|
+
export async function attachTaskSession(root, slug, taskID, runId, sessionID, options = {}) {
|
|
611
|
+
const value = String(sessionID || "").trim()
|
|
612
|
+
if (!value) throw new Error("sessionID is required")
|
|
613
|
+
return withWorkLock(root, slug, async () => {
|
|
614
|
+
const loaded = await loadWork(root, slug)
|
|
615
|
+
const record = loaded.state.tasks?.[taskID]
|
|
616
|
+
if (!record) throw new Error(`task '${taskID}' is not tracked`)
|
|
617
|
+
if (record.status !== "running") throw new Error(`task '${taskID}' is not running`)
|
|
618
|
+
assertRunFence(record, { runId })
|
|
619
|
+
record.owner ??= { pid: process.pid, sessionID: null }
|
|
620
|
+
record.owner.sessionID = value
|
|
621
|
+
record.owner.executionDir = options.executionDir ? path.resolve(options.executionDir) : null
|
|
622
|
+
record.owner.sandboxDir = options.sandboxDir ? path.resolve(options.sandboxDir) : null
|
|
623
|
+
loaded.state.updatedAt = now()
|
|
624
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
625
|
+
await journal(loaded.paths, "task.session-attached", {
|
|
626
|
+
task: taskID,
|
|
627
|
+
runId: record.runId,
|
|
628
|
+
sessionID: value,
|
|
629
|
+
executionDir: record.owner.executionDir,
|
|
630
|
+
sandboxDir: record.owner.sandboxDir,
|
|
631
|
+
})
|
|
632
|
+
return {
|
|
633
|
+
taskID,
|
|
634
|
+
runId: record.runId,
|
|
635
|
+
sessionID: value,
|
|
636
|
+
executionDir: record.owner.executionDir,
|
|
637
|
+
sandboxDir: record.owner.sandboxDir,
|
|
638
|
+
}
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
|
|
413
642
|
export async function heartbeatTask(root, slug, taskID, runId, options = {}) {
|
|
414
643
|
return withWorkLock(root, slug, async () => {
|
|
415
644
|
const loaded = await loadWork(root, slug)
|
|
@@ -423,10 +652,54 @@ export async function heartbeatTask(root, slug, taskID, runId, options = {}) {
|
|
|
423
652
|
record.leaseExpiresAt = new Date(Date.now() + leaseMs).toISOString()
|
|
424
653
|
loaded.state.updatedAt = timestamp
|
|
425
654
|
await writeJson(loaded.paths.state, loaded.state)
|
|
655
|
+
await journal(loaded.paths, "task.heartbeat", {
|
|
656
|
+
task: taskID,
|
|
657
|
+
runId: record.runId,
|
|
658
|
+
heartbeatAt: record.heartbeatAt,
|
|
659
|
+
leaseExpiresAt: record.leaseExpiresAt,
|
|
660
|
+
})
|
|
426
661
|
return { taskID, runId: record.runId, heartbeatAt: record.heartbeatAt, leaseExpiresAt: record.leaseExpiresAt }
|
|
427
662
|
})
|
|
428
663
|
}
|
|
429
664
|
|
|
665
|
+
export async function recoverTask(root, slug, taskID, options = {}) {
|
|
666
|
+
return withWorkLock(root, slug, async () => {
|
|
667
|
+
const loaded = await loadWork(root, slug)
|
|
668
|
+
const record = loaded.state.tasks?.[taskID]
|
|
669
|
+
if (!record) throw new Error(`task '${taskID}' is not tracked`)
|
|
670
|
+
if (record.status !== "running") throw new Error(`task '${taskID}' is not running`)
|
|
671
|
+
|
|
672
|
+
const expires = Date.parse(record.leaseExpiresAt || "")
|
|
673
|
+
const stale = options.force || !Number.isFinite(expires) || expires <= Date.now()
|
|
674
|
+
if (!stale) throw new Error(`task '${taskID}' lease is still active`)
|
|
675
|
+
|
|
676
|
+
const timestamp = now()
|
|
677
|
+
const previousOwner = record.owner ? { ...record.owner } : null
|
|
678
|
+
const previousRunId = record.runId || null
|
|
679
|
+
record.status = "retryable"
|
|
680
|
+
record.lastFailure = String(options.reason || "stale executor lease recovered after interruption")
|
|
681
|
+
record.lastOwner = previousOwner
|
|
682
|
+
record.lastRunId = previousRunId
|
|
683
|
+
record.runId = null
|
|
684
|
+
record.owner = null
|
|
685
|
+
record.heartbeatAt = null
|
|
686
|
+
record.leaseExpiresAt = null
|
|
687
|
+
|
|
688
|
+
loaded.state.integrationVerification = null
|
|
689
|
+
loaded.state.status = "executing"
|
|
690
|
+
loaded.state.updatedAt = timestamp
|
|
691
|
+
loaded.state.nextAction = `Recovered stale task ${taskID}. Re-diagnose and retry from fresh evidence.`
|
|
692
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
693
|
+
await journal(loaded.paths, "task.recovered", {
|
|
694
|
+
tasks: [taskID],
|
|
695
|
+
previousRunId,
|
|
696
|
+
previousOwner,
|
|
697
|
+
forced: Boolean(options.force),
|
|
698
|
+
})
|
|
699
|
+
return { recovered: [taskID], previousRunId, previousOwner, state: loaded.state }
|
|
700
|
+
})
|
|
701
|
+
}
|
|
702
|
+
|
|
430
703
|
export async function recoverStaleTasks(root, slug, options = {}) {
|
|
431
704
|
return withWorkLock(root, slug, async () => {
|
|
432
705
|
const loaded = await loadWork(root, slug)
|
|
@@ -437,8 +710,12 @@ export async function recoverStaleTasks(root, slug, options = {}) {
|
|
|
437
710
|
const expires = Date.parse(record.leaseExpiresAt || "")
|
|
438
711
|
const stale = options.force || !Number.isFinite(expires) || expires <= Date.now()
|
|
439
712
|
if (!stale) continue
|
|
440
|
-
record.
|
|
713
|
+
const previousOwner = record.owner ? { ...record.owner } : null
|
|
714
|
+
const previousRunId = record.runId || null
|
|
715
|
+
record.status = "retryable"
|
|
441
716
|
record.lastFailure = "stale executor lease recovered after interruption"
|
|
717
|
+
record.lastOwner = previousOwner
|
|
718
|
+
record.lastRunId = previousRunId
|
|
442
719
|
record.runId = null
|
|
443
720
|
record.owner = null
|
|
444
721
|
record.heartbeatAt = null
|
|
@@ -451,6 +728,7 @@ export async function recoverStaleTasks(root, slug, options = {}) {
|
|
|
451
728
|
loaded.state.updatedAt = timestamp
|
|
452
729
|
loaded.state.nextAction = `Recovered stale task(s): ${recovered.join(", ")}. Re-diagnose and retry from fresh evidence.`
|
|
453
730
|
await writeJson(loaded.paths.state, loaded.state)
|
|
731
|
+
await journal(loaded.paths, "task.recovered", { tasks: recovered })
|
|
454
732
|
}
|
|
455
733
|
return { recovered, state: loaded.state }
|
|
456
734
|
})
|
|
@@ -481,6 +759,12 @@ export async function recordVerificationReceipt(root, slug, taskID, receipt) {
|
|
|
481
759
|
loaded.evidence.schemaVersion = STATE_SCHEMA
|
|
482
760
|
loaded.evidence.updatedAt = normalized.recordedAt
|
|
483
761
|
await writeJson(loaded.paths.evidence, loaded.evidence)
|
|
762
|
+
await journal(loaded.paths, "verification.receipt", {
|
|
763
|
+
task: taskID,
|
|
764
|
+
runId: record.runId,
|
|
765
|
+
receiptId: normalized.id,
|
|
766
|
+
passed: normalized.passed,
|
|
767
|
+
})
|
|
484
768
|
return normalized
|
|
485
769
|
})
|
|
486
770
|
}
|
|
@@ -499,18 +783,31 @@ export async function completeTask(root, slug, taskID, options = {}) {
|
|
|
499
783
|
assertRunFence(record, options)
|
|
500
784
|
|
|
501
785
|
const timestamp = now()
|
|
786
|
+
const receipts = (loaded.evidence.receipts || []).filter((item) => item.task === taskID && (!record.runId || item.runId === record.runId))
|
|
787
|
+
const successfulReceipts = receipts.filter((item) => item.passed)
|
|
788
|
+
const evidencePolicy = currentEvidencePolicy(loaded.state, loaded.plan)
|
|
789
|
+
const currentFingerprint = workspaceFingerprint(loaded.paths.root)
|
|
790
|
+
const freshSuccessfulReceipts = successfulReceipts.filter(
|
|
791
|
+
(item) => item.workspaceAfter && item.workspaceAfter === currentFingerprint,
|
|
792
|
+
)
|
|
793
|
+
if (evidencePolicy.receiptRequiredForTasks && freshSuccessfulReceipts.length === 0) {
|
|
794
|
+
throw new Error("strict task completion requires a successful verification receipt for the active run and current workspace fingerprint")
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const acceptedReceipts = evidencePolicy.receiptRequiredForTasks
|
|
798
|
+
? freshSuccessfulReceipts
|
|
799
|
+
: successfulReceipts
|
|
800
|
+
|
|
502
801
|
const reportPath = path.join(loaded.paths.reports, `${taskID}.md`)
|
|
503
802
|
if (options.report) await atomicWrite(reportPath, String(options.report).trimEnd() + "\n")
|
|
504
803
|
else if (!existsSync(reportPath)) {
|
|
505
804
|
await atomicWrite(reportPath, `# ${taskID} report\n\nCompleted with evidence recorded in EVIDENCE.json.\n`)
|
|
506
805
|
}
|
|
507
806
|
|
|
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
807
|
record.status = "completed"
|
|
511
808
|
record.completedAt = timestamp
|
|
512
809
|
record.report = path.relative(loaded.paths.root, reportPath).replaceAll("\\", "/")
|
|
513
|
-
record.evidenceStrength =
|
|
810
|
+
record.evidenceStrength = acceptedReceipts.length ? "receipt-backed" : "narrative"
|
|
514
811
|
record.runId = null
|
|
515
812
|
record.owner = null
|
|
516
813
|
record.heartbeatAt = null
|
|
@@ -522,7 +819,7 @@ export async function completeTask(root, slug, taskID, options = {}) {
|
|
|
522
819
|
evidence: evidenceText,
|
|
523
820
|
verification: task.verification,
|
|
524
821
|
evidenceStrength: record.evidenceStrength,
|
|
525
|
-
receiptIDs:
|
|
822
|
+
receiptIDs: acceptedReceipts.map((item) => item.id),
|
|
526
823
|
})
|
|
527
824
|
loaded.evidence.schemaVersion = STATE_SCHEMA
|
|
528
825
|
loaded.evidence.updatedAt = timestamp
|
|
@@ -540,6 +837,11 @@ export async function completeTask(root, slug, taskID, options = {}) {
|
|
|
540
837
|
|
|
541
838
|
await writeJson(loaded.paths.evidence, loaded.evidence)
|
|
542
839
|
await writeJson(loaded.paths.state, loaded.state)
|
|
840
|
+
await journal(loaded.paths, "task.completed", {
|
|
841
|
+
task: taskID,
|
|
842
|
+
evidenceStrength: record.evidenceStrength,
|
|
843
|
+
receiptIDs: acceptedReceipts.map((item) => item.id),
|
|
844
|
+
})
|
|
543
845
|
return { task, state: loaded.state, ready }
|
|
544
846
|
})
|
|
545
847
|
}
|
|
@@ -554,6 +856,7 @@ export async function failTask(root, slug, taskID, reason, options = {}) {
|
|
|
554
856
|
taskByID(loaded.plan, taskID)
|
|
555
857
|
const record = loaded.state.tasks?.[taskID]
|
|
556
858
|
if (!record) throw new Error(`task '${taskID}' is not tracked`)
|
|
859
|
+
if (record.status !== "running") throw new Error(`task '${taskID}' must be running before it can fail`)
|
|
557
860
|
assertRunFence(record, options)
|
|
558
861
|
|
|
559
862
|
const timestamp = now()
|
|
@@ -570,6 +873,11 @@ export async function failTask(root, slug, taskID, reason, options = {}) {
|
|
|
570
873
|
? `Re-plan or escalate ${taskID}; it has failed ${record.attempts} attempt(s).`
|
|
571
874
|
: `Re-diagnose ${taskID} from fresh evidence before retrying.`
|
|
572
875
|
await writeJson(loaded.paths.state, loaded.state)
|
|
876
|
+
await journal(loaded.paths, "task.failed", {
|
|
877
|
+
task: taskID,
|
|
878
|
+
attempts: record.attempts,
|
|
879
|
+
reason: cleanReason,
|
|
880
|
+
})
|
|
573
881
|
return { taskID, attempts: record.attempts, state: loaded.state }
|
|
574
882
|
})
|
|
575
883
|
}
|
|
@@ -627,12 +935,12 @@ export async function resolveBlocker(root, slug, blocker) {
|
|
|
627
935
|
})
|
|
628
936
|
}
|
|
629
937
|
|
|
630
|
-
export async function recordIntegrationVerification(root, slug, verdict, evidence, report = null) {
|
|
938
|
+
export async function recordIntegrationVerification(root, slug, verdict, evidence, report = null, options = {}) {
|
|
631
939
|
const status = String(verdict || "").trim().toUpperCase()
|
|
632
940
|
if (!["PASS", "FAIL", "PARTIAL"].includes(status)) {
|
|
633
941
|
throw new Error("integration verdict must be PASS, FAIL, or PARTIAL")
|
|
634
942
|
}
|
|
635
|
-
const evidenceText = String(evidence || "").trim()
|
|
943
|
+
const evidenceText = String(evidence || options.receipt?.evidence || "").trim()
|
|
636
944
|
if (!evidenceText) throw new Error("integration verification requires non-empty evidence")
|
|
637
945
|
|
|
638
946
|
return withWorkLock(root, slug, async () => {
|
|
@@ -650,12 +958,37 @@ export async function recordIntegrationVerification(root, slug, verdict, evidenc
|
|
|
650
958
|
|
|
651
959
|
const timestamp = now()
|
|
652
960
|
const fingerprint = workspaceFingerprint(loaded.paths.root)
|
|
961
|
+
const policy = currentEvidencePolicy(loaded.state, loaded.plan)
|
|
962
|
+
const receipt = options.receipt || null
|
|
963
|
+
if (receipt) {
|
|
964
|
+
const validation = validateGateReceipt(receipt)
|
|
965
|
+
if (!validation.valid) {
|
|
966
|
+
const error = new Error("invalid integration verification receipt")
|
|
967
|
+
error.validation = validation
|
|
968
|
+
throw error
|
|
969
|
+
}
|
|
970
|
+
if (receipt.kind !== "integration-verification") throw new Error("integration verification requires an integration-verification receipt")
|
|
971
|
+
if (receipt.slug !== slug) throw new Error("integration verification receipt slug mismatch")
|
|
972
|
+
if (receipt.verdict !== status) throw new Error("integration verification receipt verdict mismatch")
|
|
973
|
+
if (receipt.workspaceFingerprint !== fingerprint) {
|
|
974
|
+
throw new Error("integration verification receipt workspace fingerprint mismatch")
|
|
975
|
+
}
|
|
976
|
+
} else if (status === "PASS" && policy.structuredIntegrationApproval) {
|
|
977
|
+
throw new Error("strict integration PASS requires a structured integration-verification receipt")
|
|
978
|
+
}
|
|
653
979
|
loaded.state.integrationVerification = {
|
|
654
980
|
status,
|
|
655
981
|
at: timestamp,
|
|
656
982
|
evidence: evidenceText,
|
|
657
983
|
report: report ? String(report) : null,
|
|
658
984
|
fingerprint,
|
|
985
|
+
receipt: receipt || null,
|
|
986
|
+
}
|
|
987
|
+
if (receipt) {
|
|
988
|
+
loaded.evidence.gateReceipts ??= []
|
|
989
|
+
if (!loaded.evidence.gateReceipts.some((item) => item.id === receipt.id)) {
|
|
990
|
+
loaded.evidence.gateReceipts.push({ ...receipt, recordedAt: timestamp })
|
|
991
|
+
}
|
|
659
992
|
}
|
|
660
993
|
loaded.state.updatedAt = timestamp
|
|
661
994
|
loaded.state.status = status === "PASS" ? "ready-to-finalize" : "integration-verification"
|
|
@@ -676,6 +1009,11 @@ export async function recordIntegrationVerification(root, slug, verdict, evidenc
|
|
|
676
1009
|
|
|
677
1010
|
await writeJson(loaded.paths.evidence, loaded.evidence)
|
|
678
1011
|
await writeJson(loaded.paths.state, loaded.state)
|
|
1012
|
+
await journal(loaded.paths, "integration.verified", {
|
|
1013
|
+
verdict: status,
|
|
1014
|
+
fingerprint,
|
|
1015
|
+
receiptId: receipt?.id || null,
|
|
1016
|
+
})
|
|
679
1017
|
return loaded.state.integrationVerification
|
|
680
1018
|
})
|
|
681
1019
|
}
|
|
@@ -720,6 +1058,7 @@ export async function finalizeWork(root, slug, evidence) {
|
|
|
720
1058
|
|
|
721
1059
|
await writeJson(loaded.paths.evidence, loaded.evidence)
|
|
722
1060
|
await writeJson(loaded.paths.state, loaded.state)
|
|
1061
|
+
await journal(loaded.paths, "work.finalized", { fingerprint: currentFingerprint })
|
|
723
1062
|
return { state: loaded.state, evidence: loaded.evidence.entries.at(-1) }
|
|
724
1063
|
})
|
|
725
1064
|
}
|
|
@@ -736,7 +1075,34 @@ async function buildContextPack(loaded, taskID) {
|
|
|
736
1075
|
dependencyReports[dep] = (await readFile(file, "utf8")).slice(0, 12000)
|
|
737
1076
|
}
|
|
738
1077
|
|
|
739
|
-
const
|
|
1078
|
+
const taskText = [
|
|
1079
|
+
task.title,
|
|
1080
|
+
task.summary,
|
|
1081
|
+
...(task.acceptance || []),
|
|
1082
|
+
...(task.verification || []),
|
|
1083
|
+
].filter(Boolean).join(" ")
|
|
1084
|
+
const contextPolicy = classifyEngineeringTask(taskText, {
|
|
1085
|
+
changedFiles: taskFiles(task).length,
|
|
1086
|
+
risk: task.risk,
|
|
1087
|
+
})
|
|
1088
|
+
const attempt = Math.max(1, Number(loaded.state.tasks?.[taskID]?.attempts || 1))
|
|
1089
|
+
const recovery = recoveryPolicyForAttempt(contextPolicy, attempt)
|
|
1090
|
+
const effectiveContextPolicy = {
|
|
1091
|
+
...contextPolicy,
|
|
1092
|
+
contextBudget: recovery.contextBudget,
|
|
1093
|
+
maxSkills: recovery.maxSkills,
|
|
1094
|
+
effectiveContextBudget: recovery.contextBudget,
|
|
1095
|
+
effectiveMaxSkills: recovery.maxSkills,
|
|
1096
|
+
recovery,
|
|
1097
|
+
}
|
|
1098
|
+
const contextManifest = await buildContextManifest(
|
|
1099
|
+
loaded.paths.root,
|
|
1100
|
+
task,
|
|
1101
|
+
{
|
|
1102
|
+
budget: recovery.contextBudget,
|
|
1103
|
+
strategy: recovery.contextStrategy,
|
|
1104
|
+
},
|
|
1105
|
+
).catch(() => null)
|
|
740
1106
|
const learnings = await relevantAcceptedLearnings(
|
|
741
1107
|
loaded.paths.root,
|
|
742
1108
|
[task.title, task.summary, ...(task.acceptance || [])].join(" "),
|
|
@@ -744,6 +1110,8 @@ async function buildContextPack(loaded, taskID) {
|
|
|
744
1110
|
|
|
745
1111
|
return {
|
|
746
1112
|
schemaVersion: STATE_SCHEMA,
|
|
1113
|
+
contextPolicy: effectiveContextPolicy,
|
|
1114
|
+
attempt,
|
|
747
1115
|
slug: loaded.state.slug,
|
|
748
1116
|
task,
|
|
749
1117
|
taskBrief: path.relative(loaded.paths.root, path.join(loaded.paths.tasks, `${taskID}.md`)).replaceAll("\\", "/"),
|
|
@@ -757,14 +1125,174 @@ async function buildContextPack(loaded, taskID) {
|
|
|
757
1125
|
workingState: {
|
|
758
1126
|
status: loaded.state.status,
|
|
759
1127
|
task: loaded.state.tasks?.[taskID],
|
|
1128
|
+
checkpoint: loaded.state.checkpoint || null,
|
|
760
1129
|
},
|
|
761
1130
|
}
|
|
762
1131
|
}
|
|
763
1132
|
|
|
1133
|
+
export async function createPlanVerificationReceipt(root, slug, input = {}) {
|
|
1134
|
+
const loaded = await loadWork(root, slug)
|
|
1135
|
+
if (!loaded.plan) throw new Error("PLAN.json is missing")
|
|
1136
|
+
const hash = planHash(loaded.plan)
|
|
1137
|
+
return createGateReceipt({
|
|
1138
|
+
kind: "plan-verification",
|
|
1139
|
+
slug,
|
|
1140
|
+
verdict: input.verdict || "PASS",
|
|
1141
|
+
verifier: input.verifier || "ues-plan-checker",
|
|
1142
|
+
sessionID: input.sessionID || null,
|
|
1143
|
+
runId: input.runId || null,
|
|
1144
|
+
planHash: hash,
|
|
1145
|
+
evidence: input.evidence,
|
|
1146
|
+
report: input.report,
|
|
1147
|
+
})
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
export async function createIntegrationVerificationReceipt(root, slug, input = {}) {
|
|
1151
|
+
await loadWork(root, slug)
|
|
1152
|
+
return createGateReceipt({
|
|
1153
|
+
kind: "integration-verification",
|
|
1154
|
+
slug,
|
|
1155
|
+
verdict: input.verdict || "PASS",
|
|
1156
|
+
verifier: input.verifier || "ues-integration-verifier",
|
|
1157
|
+
sessionID: input.sessionID || null,
|
|
1158
|
+
runId: input.runId || null,
|
|
1159
|
+
workspaceFingerprint: workspaceFingerprint(root),
|
|
1160
|
+
evidence: input.evidence,
|
|
1161
|
+
report: input.report,
|
|
1162
|
+
})
|
|
1163
|
+
}
|
|
1164
|
+
|
|
764
1165
|
export async function contextPack(root, slug, taskID) {
|
|
765
1166
|
return buildContextPack(await loadWork(root, slug), taskID)
|
|
766
1167
|
}
|
|
767
1168
|
|
|
1169
|
+
function checkpointEvidencePointers(evidence = {}) {
|
|
1170
|
+
const pointers = []
|
|
1171
|
+
for (const entry of (evidence.entries || []).slice(-12)) {
|
|
1172
|
+
pointers.push({
|
|
1173
|
+
kind: "evidence-entry",
|
|
1174
|
+
task: entry.task || null,
|
|
1175
|
+
at: entry.at || entry.recordedAt || null,
|
|
1176
|
+
runId: entry.runId || null,
|
|
1177
|
+
})
|
|
1178
|
+
}
|
|
1179
|
+
for (const receipt of (evidence.receipts || []).slice(-12)) {
|
|
1180
|
+
pointers.push({
|
|
1181
|
+
kind: "verification-receipt",
|
|
1182
|
+
id: receipt.id || null,
|
|
1183
|
+
task: receipt.task || null,
|
|
1184
|
+
runId: receipt.runId || null,
|
|
1185
|
+
verdict: receipt.verdict || null,
|
|
1186
|
+
})
|
|
1187
|
+
}
|
|
1188
|
+
for (const receipt of (evidence.gateReceipts || []).slice(-8)) {
|
|
1189
|
+
pointers.push({
|
|
1190
|
+
kind: "gate-receipt",
|
|
1191
|
+
id: receipt.id || null,
|
|
1192
|
+
receiptKind: receipt.kind || null,
|
|
1193
|
+
verdict: receipt.verdict || null,
|
|
1194
|
+
})
|
|
1195
|
+
}
|
|
1196
|
+
return pointers.slice(-24)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function checkpointNextAction(loaded, taskID, runId) {
|
|
1200
|
+
const record = taskID ? loaded.state.tasks?.[taskID] : null
|
|
1201
|
+
if (record?.status === "running") {
|
|
1202
|
+
return {
|
|
1203
|
+
type: "continue-task",
|
|
1204
|
+
taskId: taskID,
|
|
1205
|
+
runId: runId || record.runId || null,
|
|
1206
|
+
instruction: "Continue the active scoped task from durable evidence; execute the next required tool/action before narrative summary.",
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const ready = readyTasks(loaded.plan, loaded.state)
|
|
1211
|
+
if (ready.length) {
|
|
1212
|
+
const nextTask = ready[0]
|
|
1213
|
+
return {
|
|
1214
|
+
type: "dispatch-task",
|
|
1215
|
+
taskId: nextTask,
|
|
1216
|
+
attempt: Number(loaded.state.tasks?.[nextTask]?.attempts || 0) + 1,
|
|
1217
|
+
instruction: "Dispatch the next ready task in a fresh executor from .ues-work.",
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
return {
|
|
1222
|
+
type: "inspect-state",
|
|
1223
|
+
instruction: loaded.state.nextAction || "Inspect durable work state and choose the next deterministic action.",
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
export async function checkpointWork(root, slug, options = {}) {
|
|
1228
|
+
return withWorkLock(root, slug, async () => {
|
|
1229
|
+
const loaded = await loadWork(root, slug)
|
|
1230
|
+
const taskID = String(options.taskID || "").trim() ||
|
|
1231
|
+
Object.entries(loaded.state.tasks || {}).find(([, value]) => value.status === "running")?.[0] ||
|
|
1232
|
+
null
|
|
1233
|
+
const record = taskID ? loaded.state.tasks?.[taskID] : null
|
|
1234
|
+
if (taskID && !record) throw new Error(`task '${taskID}' is not tracked`)
|
|
1235
|
+
if (record?.status === "running") assertRunFence(record, { runId: options.runId || null })
|
|
1236
|
+
|
|
1237
|
+
const timestamp = now()
|
|
1238
|
+
const checkpoint = {
|
|
1239
|
+
schemaVersion: 1,
|
|
1240
|
+
reason: String(options.reason || "runtime-checkpoint"),
|
|
1241
|
+
createdAt: timestamp,
|
|
1242
|
+
currentTaskId: taskID,
|
|
1243
|
+
runId: record?.runId || options.runId || null,
|
|
1244
|
+
planHash: loaded.plan ? planHash(loaded.plan) : loaded.state.planHash || null,
|
|
1245
|
+
nextAction: checkpointNextAction(loaded, taskID, record?.runId || options.runId || null),
|
|
1246
|
+
workspaceFingerprint: workspaceFingerprint(loaded.paths.root),
|
|
1247
|
+
evidencePointers: checkpointEvidencePointers(loaded.evidence),
|
|
1248
|
+
resumedAt: null,
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
loaded.state.schemaVersion = STATE_SCHEMA
|
|
1252
|
+
loaded.state.checkpoint = checkpoint
|
|
1253
|
+
loaded.state.updatedAt = timestamp
|
|
1254
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
1255
|
+
await journal(loaded.paths, "work.checkpoint", {
|
|
1256
|
+
task: taskID,
|
|
1257
|
+
runId: checkpoint.runId,
|
|
1258
|
+
planHash: checkpoint.planHash,
|
|
1259
|
+
reason: checkpoint.reason,
|
|
1260
|
+
nextAction: checkpoint.nextAction,
|
|
1261
|
+
})
|
|
1262
|
+
return checkpoint
|
|
1263
|
+
})
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
export async function markCheckpointResumed(root, slug, options = {}) {
|
|
1267
|
+
return withWorkLock(root, slug, async () => {
|
|
1268
|
+
const loaded = await loadWork(root, slug)
|
|
1269
|
+
const checkpoint = loaded.state.checkpoint
|
|
1270
|
+
if (!checkpoint) return null
|
|
1271
|
+
if (options.taskID && checkpoint.currentTaskId && checkpoint.currentTaskId !== options.taskID) {
|
|
1272
|
+
throw new Error("checkpoint task mismatch")
|
|
1273
|
+
}
|
|
1274
|
+
if (checkpoint.runId && options.runId && checkpoint.runId !== options.runId) {
|
|
1275
|
+
throw new Error("checkpoint runId mismatch")
|
|
1276
|
+
}
|
|
1277
|
+
const timestamp = now()
|
|
1278
|
+
checkpoint.resumedAt = timestamp
|
|
1279
|
+
checkpoint.resumeReason = String(options.reason || "deterministic-action-observed")
|
|
1280
|
+
loaded.state.updatedAt = timestamp
|
|
1281
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
1282
|
+
await journal(loaded.paths, "work.checkpoint-resumed", {
|
|
1283
|
+
task: checkpoint.currentTaskId || null,
|
|
1284
|
+
runId: checkpoint.runId || null,
|
|
1285
|
+
reason: checkpoint.resumeReason,
|
|
1286
|
+
})
|
|
1287
|
+
return checkpoint
|
|
1288
|
+
})
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
export async function runtimeEvents(root, slug, options = {}) {
|
|
1292
|
+
const paths = workPaths(root, slug)
|
|
1293
|
+
return readRuntimeEvents(paths.events, options)
|
|
1294
|
+
}
|
|
1295
|
+
|
|
768
1296
|
export async function resumeWork(root, slug) {
|
|
769
1297
|
await recoverStaleTasks(root, slug).catch(() => ({ recovered: [] }))
|
|
770
1298
|
const loaded = await loadWork(root, slug)
|
|
@@ -773,5 +1301,6 @@ export async function resumeWork(root, slug) {
|
|
|
773
1301
|
planAnalysis: loaded.plan ? analyzePlan(loaded.plan) : null,
|
|
774
1302
|
completedEvidence: loaded.evidence.entries || [],
|
|
775
1303
|
decisions: loaded.state.decisions || [],
|
|
1304
|
+
recentEvents: await readRuntimeEvents(loaded.paths.events, { limit: 50 }),
|
|
776
1305
|
}
|
|
777
1306
|
}
|