opencode-agent-skill 7.7.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 +163 -0
- package/LICENSE +9 -0
- package/README.md +581 -0
- package/bin/ocskill.mjs +975 -0
- package/docs/DETERMINISTIC-TOOLS.md +88 -0
- package/docs/ENGINEERING-DESIGN.md +176 -0
- package/docs/EVALS.md +136 -0
- package/docs/NPM-PUBLISH.md +102 -0
- package/docs/OPENCODE-COMPAT.md +109 -0
- package/docs/RESEARCH-SOURCES.md +37 -0
- package/docs/TRACE-SCHEMA.md +109 -0
- package/docs/V7-INTELLIGENCE-RUNTIME.md +166 -0
- package/evals/live/fixtures/engineering-bench/package.json +1 -0
- package/evals/live/fixtures/engineering-bench/src/api-errors.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/authz.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/cache-tags.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/config.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/contract-consumer.mjs +6 -0
- package/evals/live/fixtures/engineering-bench/src/contract-producer.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/dedupe.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/dependency.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/discount.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/inventory.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/migration.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/money.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/pagination.mjs +5 -0
- package/evals/live/fixtures/engineering-bench/src/path-safe.mjs +5 -0
- package/evals/live/fixtures/engineering-bench/src/payment.mjs +5 -0
- package/evals/live/fixtures/engineering-bench/src/query-sort.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/react-state.mjs +8 -0
- package/evals/live/fixtures/engineering-bench/src/retry.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/rn-platform.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/upload.mjs +3 -0
- package/evals/live/fixtures/engineering-bench/src/webhook.mjs +3 -0
- package/evals/live/graders/engineering-bench.mjs +239 -0
- package/evals/live/tasks.json +126 -0
- package/evals/long/fixtures/long-horizon/package.json +1 -0
- package/evals/long/fixtures/long-horizon/src/auth.mjs +5 -0
- package/evals/long/fixtures/long-horizon/src/checkout.mjs +9 -0
- package/evals/long/fixtures/long-horizon/src/inventory.mjs +5 -0
- package/evals/long/fixtures/long-horizon/src/money.mjs +3 -0
- package/evals/long/fixtures/long-horizon/src/payment.mjs +7 -0
- package/evals/long/fixtures/long-horizon/src/product-api.mjs +10 -0
- package/evals/long/fixtures/long-horizon/src/product-cache.mjs +3 -0
- package/evals/long/fixtures/long-horizon/src/product-service.mjs +6 -0
- package/evals/long/fixtures/long-horizon/src/product-state.mjs +8 -0
- package/evals/long/fixtures/long-horizon/src/project-api.mjs +9 -0
- package/evals/long/fixtures/long-horizon/src/project-service.mjs +7 -0
- package/evals/long/fixtures/long-horizon/src/user-consumer.mjs +3 -0
- package/evals/long/fixtures/long-horizon/src/user-migration.mjs +7 -0
- package/evals/long/fixtures/long-horizon/src/user-serializer.mjs +3 -0
- package/evals/long/fixtures/long-horizon/src/user-validation.mjs +3 -0
- package/evals/long/graders/long-horizon.mjs +209 -0
- package/evals/long/tasks.json +36 -0
- package/evals/router-triggers.json +1238 -0
- package/evals/routing.json +321 -0
- package/global-config/AGENTS.md +212 -0
- package/global-config/agents/architect.md +38 -0
- package/global-config/agents/codebase-mapper.md +41 -0
- package/global-config/agents/critic.md +37 -0
- package/global-config/agents/debugger.md +36 -0
- package/global-config/agents/executor.md +48 -0
- package/global-config/agents/integration-verifier.md +43 -0
- package/global-config/agents/plan-checker.md +43 -0
- package/global-config/agents/researcher.md +30 -0
- package/global-config/agents/reviewer.md +30 -0
- package/global-config/agents/verifier.md +33 -0
- package/global-config/commands/audit.md +8 -0
- package/global-config/commands/critique.md +8 -0
- package/global-config/commands/debug.md +8 -0
- package/global-config/commands/feature.md +8 -0
- package/global-config/commands/fix.md +8 -0
- package/global-config/commands/plan.md +8 -0
- package/global-config/commands/research.md +8 -0
- package/global-config/commands/resume.md +22 -0
- package/global-config/commands/review.md +8 -0
- package/global-config/commands/run.md +29 -0
- package/global-config/commands/verify.md +8 -0
- package/global-config/plugins/ues-router/capabilities.js +20 -0
- package/global-config/plugins/ues-router/index.js +277 -0
- package/global-config/plugins/ues-router/router.js +74 -0
- package/global-config/plugins/ues-router/safety.js +16 -0
- package/global-config/skills/accessibility/SKILL.md +10 -0
- package/global-config/skills/accessibility/references/workflow.md +17 -0
- package/global-config/skills/api-contract/SKILL.md +12 -0
- package/global-config/skills/api-contract/references/workflow.md +17 -0
- package/global-config/skills/auth-security/SKILL.md +12 -0
- package/global-config/skills/auth-security/references/workflow.md +15 -0
- package/global-config/skills/bug-diagnosis/SKILL.md +28 -0
- package/global-config/skills/change-impact-analysis/SKILL.md +23 -0
- package/global-config/skills/code-review/SKILL.md +19 -0
- package/global-config/skills/context-engineering/SKILL.md +18 -0
- package/global-config/skills/context-engineering/references/large-repo.md +16 -0
- package/global-config/skills/database-engineering/SKILL.md +12 -0
- package/global-config/skills/database-engineering/references/workflow.md +15 -0
- package/global-config/skills/dependency-management/SKILL.md +12 -0
- package/global-config/skills/dependency-management/references/workflow.md +14 -0
- package/global-config/skills/devops-engineering/SKILL.md +10 -0
- package/global-config/skills/devops-engineering/references/workflow.md +11 -0
- package/global-config/skills/django-engineering/SKILL.md +10 -0
- package/global-config/skills/django-engineering/references/workflow.md +11 -0
- package/global-config/skills/documentation-engineering/SKILL.md +10 -0
- package/global-config/skills/documentation-engineering/references/workflow.md +15 -0
- package/global-config/skills/dotnet-engineering/SKILL.md +10 -0
- package/global-config/skills/dotnet-engineering/references/workflow.md +11 -0
- package/global-config/skills/ecommerce-engineering/SKILL.md +10 -0
- package/global-config/skills/ecommerce-engineering/references/workflow.md +17 -0
- package/global-config/skills/engineering-orchestrator/SKILL.md +29 -0
- package/global-config/skills/engineering-orchestrator/references/delegation.md +22 -0
- package/global-config/skills/engineering-orchestrator/references/evaluator-loop.md +18 -0
- package/global-config/skills/engineering-orchestrator/references/long-horizon.md +57 -0
- package/global-config/skills/engineering-orchestrator/references/model-escalation.md +19 -0
- package/global-config/skills/engineering-orchestrator/references/retry-policy.md +12 -0
- package/global-config/skills/engineering-orchestrator/references/routing.md +39 -0
- package/global-config/skills/engineering-orchestrator/references/verification-matrix.md +18 -0
- package/global-config/skills/fastapi-engineering/SKILL.md +10 -0
- package/global-config/skills/fastapi-engineering/references/workflow.md +13 -0
- package/global-config/skills/file-upload-engineering/SKILL.md +10 -0
- package/global-config/skills/file-upload-engineering/references/workflow.md +17 -0
- package/global-config/skills/flutter-engineering/SKILL.md +10 -0
- package/global-config/skills/flutter-engineering/references/workflow.md +13 -0
- package/global-config/skills/git-safety/SKILL.md +10 -0
- package/global-config/skills/git-safety/references/workflow.md +15 -0
- package/global-config/skills/implementation-engineer/SKILL.md +10 -0
- package/global-config/skills/implementation-engineer/references/workflow.md +14 -0
- package/global-config/skills/java-spring-engineering/SKILL.md +10 -0
- package/global-config/skills/java-spring-engineering/references/workflow.md +13 -0
- package/global-config/skills/long-task-state/SKILL.md +28 -0
- package/global-config/skills/long-task-state/references/context-ledger.md +27 -0
- package/global-config/skills/long-task-state/templates/STATE.md +48 -0
- package/global-config/skills/nestjs-engineering/SKILL.md +10 -0
- package/global-config/skills/nestjs-engineering/references/workflow.md +11 -0
- package/global-config/skills/nextjs-engineering/SKILL.md +12 -0
- package/global-config/skills/nextjs-engineering/references/workflow.md +15 -0
- package/global-config/skills/nodejs-engineering/SKILL.md +12 -0
- package/global-config/skills/nodejs-engineering/references/workflow.md +11 -0
- package/global-config/skills/payment-engineering/SKILL.md +12 -0
- package/global-config/skills/payment-engineering/references/workflow.md +19 -0
- package/global-config/skills/performance-engineering/SKILL.md +10 -0
- package/global-config/skills/performance-engineering/references/workflow.md +17 -0
- package/global-config/skills/python-engineering/SKILL.md +10 -0
- package/global-config/skills/python-engineering/references/workflow.md +11 -0
- package/global-config/skills/react-engineering/SKILL.md +14 -0
- package/global-config/skills/react-engineering/references/workflow.md +16 -0
- package/global-config/skills/react-native-engineering/SKILL.md +14 -0
- package/global-config/skills/react-native-engineering/references/workflow.md +16 -0
- package/global-config/skills/repo-explorer/SKILL.md +17 -0
- package/global-config/skills/research-verification/SKILL.md +18 -0
- package/global-config/skills/research-verification/references/source-hierarchy.md +12 -0
- package/global-config/skills/rest-api-design/SKILL.md +10 -0
- package/global-config/skills/rest-api-design/references/workflow.md +18 -0
- package/global-config/skills/software-architect/SKILL.md +10 -0
- package/global-config/skills/software-architect/references/workflow.md +18 -0
- package/global-config/skills/task-planner/SKILL.md +21 -0
- package/global-config/skills/task-planner/references/plan-schema.md +56 -0
- package/global-config/skills/test-driven-development/SKILL.md +22 -0
- package/global-config/skills/test-driven-development/references/writing-good-tests.md +21 -0
- package/global-config/skills/test-verification/SKILL.md +20 -0
- package/global-config/skills/ui-ux-engineering/SKILL.md +10 -0
- package/global-config/skills/ui-ux-engineering/references/workflow.md +17 -0
- package/global-config/skills/web-security-review/SKILL.md +10 -0
- package/global-config/skills/web-security-review/references/workflow.md +22 -0
- package/lib/context-manifest.mjs +101 -0
- package/lib/control-center.mjs +148 -0
- package/lib/eval-auth.mjs +21 -0
- package/lib/eval-report.mjs +72 -0
- package/lib/eval-telemetry.mjs +155 -0
- package/lib/evidence-receipt.mjs +50 -0
- package/lib/hermes-bridge.mjs +28 -0
- package/lib/ids.mjs +21 -0
- package/lib/installer.mjs +636 -0
- package/lib/learning-engine.mjs +161 -0
- package/lib/model-config.mjs +88 -0
- package/lib/model-policy.mjs +71 -0
- package/lib/opencode-compat.mjs +86 -0
- package/lib/orchestrator-policy.mjs +35 -0
- package/lib/process-runner.mjs +117 -0
- package/lib/repo-graph.mjs +135 -0
- package/lib/repo-inspect.mjs +264 -0
- package/lib/review-scope.mjs +98 -0
- package/lib/router-config.mjs +40 -0
- package/lib/task-engine.mjs +777 -0
- package/lib/task-graph.mjs +262 -0
- package/lib/update-resolver.mjs +43 -0
- package/lib/verification-plan.mjs +52 -0
- package/lib/version.mjs +47 -0
- package/lib/workspace-snapshot.mjs +45 -0
- package/lib/worktree-sandbox.mjs +59 -0
- package/package.json +69 -0
- package/scripts/check-working-tree.mjs +2 -0
- package/scripts/collect-evidence.mjs +2 -0
- package/scripts/control-center.mjs +37 -0
- package/scripts/detect-stack.mjs +2 -0
- package/scripts/detect-test-commands.mjs +2 -0
- package/scripts/eval-live.mjs +437 -0
- package/scripts/eval-report.mjs +49 -0
- package/scripts/eval-router.mjs +45 -0
- package/scripts/eval-skills.mjs +71 -0
- package/scripts/impact-map.mjs +4 -0
- package/scripts/install.mjs +61 -0
- package/scripts/repo-map.mjs +2 -0
- package/scripts/smoke-packed-install.mjs +326 -0
- package/scripts/syntax-check.mjs +35 -0
- package/scripts/uninstall.mjs +21 -0
- package/scripts/validate-live-suite.mjs +65 -0
- package/scripts/validate.mjs +133 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
|
|
3
|
+
import { spawnSync } from "node:child_process"
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { analyzePlan, taskFiles, validatePlan } from "./task-graph.mjs"
|
|
7
|
+
import { buildContextManifest } from "./context-manifest.mjs"
|
|
8
|
+
import { relevantAcceptedLearnings } from "./learning-engine.mjs"
|
|
9
|
+
import { validateVerificationReceipt } from "./evidence-receipt.mjs"
|
|
10
|
+
|
|
11
|
+
const WORK_DIR = ".ues-work"
|
|
12
|
+
const STATE_SCHEMA = 3
|
|
13
|
+
const LOCK_TIMEOUT_MS = 30_000
|
|
14
|
+
const LOCK_STALE_MS = 120_000
|
|
15
|
+
const DEFAULT_TASK_LEASE_MS = 10 * 60_000
|
|
16
|
+
|
|
17
|
+
function now() {
|
|
18
|
+
return new Date().toISOString()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function validWorkSlug(value) {
|
|
26
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(value || ""))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function workPaths(root, slug) {
|
|
30
|
+
root = path.resolve(root)
|
|
31
|
+
if (!validWorkSlug(slug)) throw new Error("work slug must use lowercase letters, numbers and dashes")
|
|
32
|
+
const dir = path.join(root, WORK_DIR, slug)
|
|
33
|
+
return {
|
|
34
|
+
root,
|
|
35
|
+
dir,
|
|
36
|
+
spec: path.join(dir, "SPEC.md"),
|
|
37
|
+
plan: path.join(dir, "PLAN.json"),
|
|
38
|
+
state: path.join(dir, "STATE.json"),
|
|
39
|
+
evidence: path.join(dir, "EVIDENCE.json"),
|
|
40
|
+
tasks: path.join(dir, "tasks"),
|
|
41
|
+
reports: path.join(dir, "reports"),
|
|
42
|
+
lock: path.join(dir, ".state-lock"),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readJson(file, fallback = null) {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(await readFile(file, "utf8"))
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.code === "ENOENT") return fallback
|
|
51
|
+
throw error
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function atomicWrite(file, content) {
|
|
56
|
+
const temp = file + "." + process.pid + "." + Date.now() + ".tmp"
|
|
57
|
+
await writeFile(temp, content, "utf8")
|
|
58
|
+
try {
|
|
59
|
+
await rename(temp, file)
|
|
60
|
+
} catch (error) {
|
|
61
|
+
await rm(temp, { force: true }).catch(() => {})
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function writeJson(file, value) {
|
|
67
|
+
await atomicWrite(file, JSON.stringify(value, null, 2) + "\n")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function acquireWorkLock(paths) {
|
|
71
|
+
const started = Date.now()
|
|
72
|
+
while (true) {
|
|
73
|
+
try {
|
|
74
|
+
await mkdir(paths.lock)
|
|
75
|
+
await writeFile(
|
|
76
|
+
path.join(paths.lock, "owner.json"),
|
|
77
|
+
JSON.stringify({ pid: process.pid, at: now() }) + "\n",
|
|
78
|
+
"utf8",
|
|
79
|
+
).catch(() => {})
|
|
80
|
+
return
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error?.code !== "EEXIST") throw error
|
|
83
|
+
const info = await stat(paths.lock).catch(() => null)
|
|
84
|
+
if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
85
|
+
await rm(paths.lock, { recursive: true, force: true }).catch(() => {})
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() - started >= LOCK_TIMEOUT_MS) {
|
|
89
|
+
throw new Error(`timed out waiting for UES state lock: ${paths.lock}`)
|
|
90
|
+
}
|
|
91
|
+
await sleep(25)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function withWorkLock(root, slug, fn) {
|
|
97
|
+
const paths = workPaths(root, slug)
|
|
98
|
+
await acquireWorkLock(paths)
|
|
99
|
+
try {
|
|
100
|
+
return await fn(paths)
|
|
101
|
+
} finally {
|
|
102
|
+
await rm(paths.lock, { recursive: true, force: true }).catch(() => {})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function planHash(plan) {
|
|
107
|
+
return createHash("sha256").update(JSON.stringify(plan)).digest("hex")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function gitCapture(root, args) {
|
|
111
|
+
return spawnSync("git", args, {
|
|
112
|
+
cwd: root,
|
|
113
|
+
encoding: "utf8",
|
|
114
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function workspaceFingerprint(root) {
|
|
119
|
+
root = path.resolve(root)
|
|
120
|
+
const inside = gitCapture(root, ["rev-parse", "--is-inside-work-tree"])
|
|
121
|
+
if (inside.status !== 0) {
|
|
122
|
+
return createHash("sha256").update("non-git:" + root).digest("hex")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const parts = []
|
|
126
|
+
const commands = [
|
|
127
|
+
["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"],
|
|
131
|
+
]
|
|
132
|
+
for (const args of commands) {
|
|
133
|
+
const result = gitCapture(root, args)
|
|
134
|
+
parts.push(result.status === 0 ? result.stdout : "ERROR:" + (result.stderr || result.stdout || ""))
|
|
135
|
+
}
|
|
136
|
+
return createHash("sha256").update(parts.join("\n---UES-FP---\n")).digest("hex")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function initWork(root, slug, goal) {
|
|
140
|
+
const paths = workPaths(root, slug)
|
|
141
|
+
if (existsSync(paths.state)) throw new Error(`work '${slug}' already exists`)
|
|
142
|
+
|
|
143
|
+
await mkdir(paths.tasks, { recursive: true })
|
|
144
|
+
await mkdir(paths.reports, { recursive: true })
|
|
145
|
+
const createdAt = now()
|
|
146
|
+
const cleanGoal = String(goal || "").trim() || "Define the requested engineering outcome."
|
|
147
|
+
|
|
148
|
+
await atomicWrite(
|
|
149
|
+
paths.spec,
|
|
150
|
+
`# ${slug}\n\n## Goal\n\n${cleanGoal}\n\n## Acceptance criteria\n\n- Replace this line with observable acceptance criteria before execution.\n\n## Constraints\n\n- Preserve unrelated user changes.\n`,
|
|
151
|
+
)
|
|
152
|
+
await writeJson(paths.state, {
|
|
153
|
+
schemaVersion: STATE_SCHEMA,
|
|
154
|
+
slug,
|
|
155
|
+
goal: cleanGoal,
|
|
156
|
+
status: "planning",
|
|
157
|
+
createdAt,
|
|
158
|
+
updatedAt: createdAt,
|
|
159
|
+
planImportedAt: null,
|
|
160
|
+
planHash: null,
|
|
161
|
+
planApproval: null,
|
|
162
|
+
integrationVerification: null,
|
|
163
|
+
tasks: {},
|
|
164
|
+
decisions: [],
|
|
165
|
+
blockers: [],
|
|
166
|
+
nextAction: "Complete SPEC.md, create PLAN.json, then run ocskill work plan.",
|
|
167
|
+
})
|
|
168
|
+
await writeJson(paths.evidence, {
|
|
169
|
+
schemaVersion: STATE_SCHEMA,
|
|
170
|
+
slug,
|
|
171
|
+
updatedAt: createdAt,
|
|
172
|
+
entries: [],
|
|
173
|
+
receipts: [],
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
return { ...paths, slug, goal: cleanGoal }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function loadWork(root, slug) {
|
|
180
|
+
const paths = workPaths(root, slug)
|
|
181
|
+
const state = await readJson(paths.state)
|
|
182
|
+
if (!state) throw new Error(`work '${slug}' does not exist`)
|
|
183
|
+
const plan = await readJson(paths.plan)
|
|
184
|
+
const evidence = await readJson(paths.evidence, { schemaVersion: STATE_SCHEMA, slug, entries: [] })
|
|
185
|
+
return { paths, state, plan, evidence }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function taskBrief(task) {
|
|
189
|
+
const files = taskFiles(task)
|
|
190
|
+
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`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function planIsApproved(state, plan) {
|
|
194
|
+
if (!plan) return false
|
|
195
|
+
const hash = state.planHash || planHash(plan)
|
|
196
|
+
return state.planApproval?.status === "passed" && state.planApproval?.planHash === hash
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function importPlan(root, slug, planInput) {
|
|
200
|
+
const plan = typeof planInput === "string"
|
|
201
|
+
? JSON.parse(await readFile(path.resolve(planInput), "utf8"))
|
|
202
|
+
: planInput
|
|
203
|
+
|
|
204
|
+
const validation = validatePlan(plan)
|
|
205
|
+
if (!validation.valid) {
|
|
206
|
+
const error = new Error("plan validation failed")
|
|
207
|
+
error.validation = validation
|
|
208
|
+
throw error
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const analysis = analyzePlan(plan)
|
|
212
|
+
return withWorkLock(root, slug, async () => {
|
|
213
|
+
const loaded = await loadWork(root, slug)
|
|
214
|
+
if (Object.values(loaded.state.tasks || {}).some((record) => record.status === "running")) {
|
|
215
|
+
throw new Error("cannot replace PLAN.json while a task is running")
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await writeJson(loaded.paths.plan, plan)
|
|
219
|
+
const hash = planHash(plan)
|
|
220
|
+
const stateTasks = {}
|
|
221
|
+
for (const task of plan.tasks) {
|
|
222
|
+
stateTasks[task.id] = {
|
|
223
|
+
status: "pending",
|
|
224
|
+
attempts: 0,
|
|
225
|
+
startedAt: null,
|
|
226
|
+
completedAt: null,
|
|
227
|
+
lastFailure: null,
|
|
228
|
+
report: null,
|
|
229
|
+
runId: null,
|
|
230
|
+
owner: null,
|
|
231
|
+
heartbeatAt: null,
|
|
232
|
+
leaseExpiresAt: null,
|
|
233
|
+
evidenceStrength: null,
|
|
234
|
+
}
|
|
235
|
+
await atomicWrite(path.join(loaded.paths.tasks, `${task.id}.md`), taskBrief(task))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const updatedAt = now()
|
|
239
|
+
const state = {
|
|
240
|
+
...loaded.state,
|
|
241
|
+
schemaVersion: STATE_SCHEMA,
|
|
242
|
+
goal: plan.goal,
|
|
243
|
+
status: "awaiting-plan-approval",
|
|
244
|
+
updatedAt,
|
|
245
|
+
planImportedAt: updatedAt,
|
|
246
|
+
planHash: hash,
|
|
247
|
+
planApproval: { status: "pending", planHash: hash, at: updatedAt, evidence: null },
|
|
248
|
+
integrationVerification: null,
|
|
249
|
+
tasks: stateTasks,
|
|
250
|
+
nextAction: "Run ues-plan-checker, then record PASS with 'ocskill work approve-plan'.",
|
|
251
|
+
}
|
|
252
|
+
await writeJson(loaded.paths.state, state)
|
|
253
|
+
return { plan, analysis, state, paths: loaded.paths }
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
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")
|
|
260
|
+
|
|
261
|
+
return withWorkLock(root, slug, async () => {
|
|
262
|
+
const loaded = await loadWork(root, slug)
|
|
263
|
+
if (!loaded.plan) throw new Error("PLAN.json is missing")
|
|
264
|
+
const hash = planHash(loaded.plan)
|
|
265
|
+
if (loaded.state.planHash && loaded.state.planHash !== hash) {
|
|
266
|
+
throw new Error("PLAN.json changed after import; re-import it before approval")
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const timestamp = now()
|
|
270
|
+
loaded.state.schemaVersion = STATE_SCHEMA
|
|
271
|
+
loaded.state.planHash = hash
|
|
272
|
+
loaded.state.planApproval = {
|
|
273
|
+
status: "passed",
|
|
274
|
+
planHash: hash,
|
|
275
|
+
at: timestamp,
|
|
276
|
+
evidence: evidenceText,
|
|
277
|
+
}
|
|
278
|
+
loaded.state.status = "ready"
|
|
279
|
+
loaded.state.updatedAt = timestamp
|
|
280
|
+
const next = readyTasks(loaded.plan, loaded.state)
|
|
281
|
+
loaded.state.nextAction = next.length
|
|
282
|
+
? `Execute ready task(s): ${next.join(", ")}`
|
|
283
|
+
: "No executable task; inspect dependencies and blockers."
|
|
284
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
285
|
+
return { planHash: hash, approval: loaded.state.planApproval, ready: next }
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function taskByID(plan, id) {
|
|
290
|
+
const task = plan?.tasks?.find((item) => item.id === id)
|
|
291
|
+
if (!task) throw new Error(`unknown task '${id}'`)
|
|
292
|
+
return task
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function completedSet(state) {
|
|
296
|
+
return new Set(Object.entries(state.tasks || {}).filter(([, value]) => value.status === "completed").map(([id]) => id))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function readyTasks(plan, state) {
|
|
300
|
+
if (!plan || !planIsApproved(state, plan)) return []
|
|
301
|
+
const completed = completedSet(state)
|
|
302
|
+
const runningFiles = new Set()
|
|
303
|
+
for (const task of plan.tasks) {
|
|
304
|
+
if (state.tasks?.[task.id]?.status === "running") {
|
|
305
|
+
for (const file of taskFiles(task)) runningFiles.add(file)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const ready = []
|
|
310
|
+
for (const task of plan.tasks) {
|
|
311
|
+
const status = state.tasks?.[task.id]?.status
|
|
312
|
+
if (!["pending", "failed"].includes(status)) continue
|
|
313
|
+
if (!(task.dependsOn || []).every((dep) => completed.has(dep))) continue
|
|
314
|
+
const files = taskFiles(task)
|
|
315
|
+
if (files.length === 0 && runningFiles.size > 0) continue
|
|
316
|
+
if (files.some((file) => runningFiles.has(file))) continue
|
|
317
|
+
ready.push(task.id)
|
|
318
|
+
}
|
|
319
|
+
return ready
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function workStatus(root, slug) {
|
|
323
|
+
const loaded = await loadWork(root, slug)
|
|
324
|
+
const counts = { pending: 0, running: 0, completed: 0, failed: 0 }
|
|
325
|
+
for (const value of Object.values(loaded.state.tasks || {})) {
|
|
326
|
+
if (Object.hasOwn(counts, value.status)) counts[value.status] += 1
|
|
327
|
+
}
|
|
328
|
+
const running = Object.entries(loaded.state.tasks || {})
|
|
329
|
+
.filter(([, value]) => value.status === "running")
|
|
330
|
+
.map(([taskID, value]) => ({
|
|
331
|
+
taskID,
|
|
332
|
+
runId: value.runId || null,
|
|
333
|
+
attempts: value.attempts || 0,
|
|
334
|
+
heartbeatAt: value.heartbeatAt || null,
|
|
335
|
+
leaseExpiresAt: value.leaseExpiresAt || null,
|
|
336
|
+
}))
|
|
337
|
+
const taskEntries = Object.values(loaded.state.tasks || {})
|
|
338
|
+
const receiptBacked = taskEntries.filter((value) => value.evidenceStrength === "receipt-backed").length
|
|
339
|
+
const completed = taskEntries.filter((value) => value.status === "completed").length
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
slug,
|
|
343
|
+
root: loaded.paths.root,
|
|
344
|
+
dir: loaded.paths.dir,
|
|
345
|
+
status: loaded.state.status,
|
|
346
|
+
goal: loaded.state.goal,
|
|
347
|
+
counts,
|
|
348
|
+
ready: readyTasks(loaded.plan, loaded.state),
|
|
349
|
+
running,
|
|
350
|
+
blockers: loaded.state.blockers || [],
|
|
351
|
+
planApproval: loaded.state.planApproval || null,
|
|
352
|
+
integrationVerification: loaded.state.integrationVerification || null,
|
|
353
|
+
evidence: {
|
|
354
|
+
receiptBacked,
|
|
355
|
+
completed,
|
|
356
|
+
coverage: completed ? receiptBacked / completed : 0,
|
|
357
|
+
receipts: (loaded.evidence.receipts || []).length,
|
|
358
|
+
},
|
|
359
|
+
nextAction: loaded.state.nextAction,
|
|
360
|
+
updatedAt: loaded.state.updatedAt,
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function startTask(root, slug, taskID, options = {}) {
|
|
365
|
+
const result = await withWorkLock(root, slug, async () => {
|
|
366
|
+
const loaded = await loadWork(root, slug)
|
|
367
|
+
if (!loaded.plan) throw new Error("PLAN.json is missing; import a valid plan first")
|
|
368
|
+
if (!planIsApproved(loaded.state, loaded.plan)) {
|
|
369
|
+
throw new Error("plan is not approved; run ues-plan-checker and 'ocskill work approve-plan' first")
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const task = taskByID(loaded.plan, taskID)
|
|
373
|
+
const record = loaded.state.tasks?.[taskID]
|
|
374
|
+
if (!record) throw new Error(`task '${taskID}' is not tracked in STATE.json`)
|
|
375
|
+
if (record.status === "completed") throw new Error(`task '${taskID}' is already completed`)
|
|
376
|
+
if (!readyTasks(loaded.plan, loaded.state).includes(taskID)) {
|
|
377
|
+
throw new Error(`task '${taskID}' is not ready; dependencies or file-overlap constraints are unresolved`)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const timestamp = now()
|
|
381
|
+
const leaseMs = Math.max(30_000, Number(options.leaseMs || DEFAULT_TASK_LEASE_MS))
|
|
382
|
+
record.status = "running"
|
|
383
|
+
record.attempts += 1
|
|
384
|
+
record.startedAt = timestamp
|
|
385
|
+
record.lastFailure = null
|
|
386
|
+
record.runId = randomUUID()
|
|
387
|
+
record.owner = {
|
|
388
|
+
pid: Number(options.pid || process.pid),
|
|
389
|
+
sessionID: options.sessionID || null,
|
|
390
|
+
}
|
|
391
|
+
record.heartbeatAt = timestamp
|
|
392
|
+
record.leaseExpiresAt = new Date(Date.now() + leaseMs).toISOString()
|
|
393
|
+
record.evidenceStrength = null
|
|
394
|
+
loaded.state.integrationVerification = null
|
|
395
|
+
loaded.state.status = "executing"
|
|
396
|
+
loaded.state.updatedAt = timestamp
|
|
397
|
+
loaded.state.nextAction = `Complete ${taskID}, write its report, then record fresh verification evidence.`
|
|
398
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
399
|
+
return { task, record: { ...record } }
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
return { ...result, contextPack: await contextPack(root, slug, taskID) }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
function assertRunFence(record, options = {}) {
|
|
407
|
+
const runId = options.runId || null
|
|
408
|
+
if (runId && record.runId && runId !== record.runId) {
|
|
409
|
+
throw new Error("task run fence mismatch; the executor lease is stale")
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function heartbeatTask(root, slug, taskID, runId, options = {}) {
|
|
414
|
+
return withWorkLock(root, slug, async () => {
|
|
415
|
+
const loaded = await loadWork(root, slug)
|
|
416
|
+
const record = loaded.state.tasks?.[taskID]
|
|
417
|
+
if (!record) throw new Error(`task '${taskID}' is not tracked`)
|
|
418
|
+
if (record.status !== "running") throw new Error(`task '${taskID}' is not running`)
|
|
419
|
+
assertRunFence(record, { runId })
|
|
420
|
+
const leaseMs = Math.max(30_000, Number(options.leaseMs || DEFAULT_TASK_LEASE_MS))
|
|
421
|
+
const timestamp = now()
|
|
422
|
+
record.heartbeatAt = timestamp
|
|
423
|
+
record.leaseExpiresAt = new Date(Date.now() + leaseMs).toISOString()
|
|
424
|
+
loaded.state.updatedAt = timestamp
|
|
425
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
426
|
+
return { taskID, runId: record.runId, heartbeatAt: record.heartbeatAt, leaseExpiresAt: record.leaseExpiresAt }
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function recoverStaleTasks(root, slug, options = {}) {
|
|
431
|
+
return withWorkLock(root, slug, async () => {
|
|
432
|
+
const loaded = await loadWork(root, slug)
|
|
433
|
+
const timestamp = now()
|
|
434
|
+
const recovered = []
|
|
435
|
+
for (const [taskID, record] of Object.entries(loaded.state.tasks || {})) {
|
|
436
|
+
if (record.status !== "running") continue
|
|
437
|
+
const expires = Date.parse(record.leaseExpiresAt || "")
|
|
438
|
+
const stale = options.force || !Number.isFinite(expires) || expires <= Date.now()
|
|
439
|
+
if (!stale) continue
|
|
440
|
+
record.status = "failed"
|
|
441
|
+
record.lastFailure = "stale executor lease recovered after interruption"
|
|
442
|
+
record.runId = null
|
|
443
|
+
record.owner = null
|
|
444
|
+
record.heartbeatAt = null
|
|
445
|
+
record.leaseExpiresAt = null
|
|
446
|
+
recovered.push(taskID)
|
|
447
|
+
}
|
|
448
|
+
if (recovered.length) {
|
|
449
|
+
loaded.state.integrationVerification = null
|
|
450
|
+
loaded.state.status = "executing"
|
|
451
|
+
loaded.state.updatedAt = timestamp
|
|
452
|
+
loaded.state.nextAction = `Recovered stale task(s): ${recovered.join(", ")}. Re-diagnose and retry from fresh evidence.`
|
|
453
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
454
|
+
}
|
|
455
|
+
return { recovered, state: loaded.state }
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function recordVerificationReceipt(root, slug, taskID, receipt) {
|
|
460
|
+
const validation = validateVerificationReceipt(receipt)
|
|
461
|
+
if (!validation.valid) {
|
|
462
|
+
const error = new Error("invalid verification receipt")
|
|
463
|
+
error.validation = validation
|
|
464
|
+
throw error
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return withWorkLock(root, slug, async () => {
|
|
468
|
+
const loaded = await loadWork(root, slug)
|
|
469
|
+
const record = loaded.state.tasks?.[taskID]
|
|
470
|
+
if (!record) throw new Error(`task '${taskID}' is not tracked`)
|
|
471
|
+
if (record.status !== "running") throw new Error(`task '${taskID}' must be running to record verification`)
|
|
472
|
+
assertRunFence(record, { runId: receipt.runId })
|
|
473
|
+
loaded.evidence.receipts ??= []
|
|
474
|
+
const normalized = {
|
|
475
|
+
...receipt,
|
|
476
|
+
task: taskID,
|
|
477
|
+
runId: record.runId,
|
|
478
|
+
recordedAt: now(),
|
|
479
|
+
}
|
|
480
|
+
loaded.evidence.receipts.push(normalized)
|
|
481
|
+
loaded.evidence.schemaVersion = STATE_SCHEMA
|
|
482
|
+
loaded.evidence.updatedAt = normalized.recordedAt
|
|
483
|
+
await writeJson(loaded.paths.evidence, loaded.evidence)
|
|
484
|
+
return normalized
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export async function completeTask(root, slug, taskID, options = {}) {
|
|
489
|
+
const evidenceText = String(options.evidence || "").trim()
|
|
490
|
+
if (!evidenceText) throw new Error("completion requires non-empty fresh evidence")
|
|
491
|
+
|
|
492
|
+
return withWorkLock(root, slug, async () => {
|
|
493
|
+
const loaded = await loadWork(root, slug)
|
|
494
|
+
if (!loaded.plan) throw new Error("PLAN.json is missing")
|
|
495
|
+
const task = taskByID(loaded.plan, taskID)
|
|
496
|
+
const record = loaded.state.tasks?.[taskID]
|
|
497
|
+
if (!record) throw new Error(`task '${taskID}' is not tracked`)
|
|
498
|
+
if (record.status !== "running") throw new Error(`task '${taskID}' must be running before it can complete`)
|
|
499
|
+
assertRunFence(record, options)
|
|
500
|
+
|
|
501
|
+
const timestamp = now()
|
|
502
|
+
const reportPath = path.join(loaded.paths.reports, `${taskID}.md`)
|
|
503
|
+
if (options.report) await atomicWrite(reportPath, String(options.report).trimEnd() + "\n")
|
|
504
|
+
else if (!existsSync(reportPath)) {
|
|
505
|
+
await atomicWrite(reportPath, `# ${taskID} report\n\nCompleted with evidence recorded in EVIDENCE.json.\n`)
|
|
506
|
+
}
|
|
507
|
+
|
|
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
|
+
record.status = "completed"
|
|
511
|
+
record.completedAt = timestamp
|
|
512
|
+
record.report = path.relative(loaded.paths.root, reportPath).replaceAll("\\", "/")
|
|
513
|
+
record.evidenceStrength = successfulReceipts.length ? "receipt-backed" : "narrative"
|
|
514
|
+
record.runId = null
|
|
515
|
+
record.owner = null
|
|
516
|
+
record.heartbeatAt = null
|
|
517
|
+
record.leaseExpiresAt = null
|
|
518
|
+
|
|
519
|
+
loaded.evidence.entries.push({
|
|
520
|
+
task: taskID,
|
|
521
|
+
at: timestamp,
|
|
522
|
+
evidence: evidenceText,
|
|
523
|
+
verification: task.verification,
|
|
524
|
+
evidenceStrength: record.evidenceStrength,
|
|
525
|
+
receiptIDs: successfulReceipts.map((item) => item.id),
|
|
526
|
+
})
|
|
527
|
+
loaded.evidence.schemaVersion = STATE_SCHEMA
|
|
528
|
+
loaded.evidence.updatedAt = timestamp
|
|
529
|
+
|
|
530
|
+
const allComplete = loaded.plan.tasks.every((item) => loaded.state.tasks[item.id]?.status === "completed")
|
|
531
|
+
const ready = allComplete ? [] : readyTasks(loaded.plan, loaded.state)
|
|
532
|
+
loaded.state.integrationVerification = null
|
|
533
|
+
loaded.state.status = allComplete ? "integration-verification" : "executing"
|
|
534
|
+
loaded.state.updatedAt = timestamp
|
|
535
|
+
loaded.state.nextAction = allComplete
|
|
536
|
+
? "Run ues-integration-verifier, then record its verdict with 'ocskill work verify-integration'."
|
|
537
|
+
: ready.length
|
|
538
|
+
? `Execute ready task(s): ${ready.join(", ")}`
|
|
539
|
+
: "Resolve blockers or failed dependencies before continuing."
|
|
540
|
+
|
|
541
|
+
await writeJson(loaded.paths.evidence, loaded.evidence)
|
|
542
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
543
|
+
return { task, state: loaded.state, ready }
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function failTask(root, slug, taskID, reason, options = {}) {
|
|
548
|
+
const cleanReason = String(reason || "").trim()
|
|
549
|
+
if (!cleanReason) throw new Error("failure reason is required")
|
|
550
|
+
|
|
551
|
+
return withWorkLock(root, slug, async () => {
|
|
552
|
+
const loaded = await loadWork(root, slug)
|
|
553
|
+
if (!loaded.plan) throw new Error("PLAN.json is missing")
|
|
554
|
+
taskByID(loaded.plan, taskID)
|
|
555
|
+
const record = loaded.state.tasks?.[taskID]
|
|
556
|
+
if (!record) throw new Error(`task '${taskID}' is not tracked`)
|
|
557
|
+
assertRunFence(record, options)
|
|
558
|
+
|
|
559
|
+
const timestamp = now()
|
|
560
|
+
record.status = "failed"
|
|
561
|
+
record.lastFailure = cleanReason
|
|
562
|
+
record.runId = null
|
|
563
|
+
record.owner = null
|
|
564
|
+
record.heartbeatAt = null
|
|
565
|
+
record.leaseExpiresAt = null
|
|
566
|
+
loaded.state.integrationVerification = null
|
|
567
|
+
loaded.state.updatedAt = timestamp
|
|
568
|
+
loaded.state.status = "executing"
|
|
569
|
+
loaded.state.nextAction = record.attempts >= 3
|
|
570
|
+
? `Re-plan or escalate ${taskID}; it has failed ${record.attempts} attempt(s).`
|
|
571
|
+
: `Re-diagnose ${taskID} from fresh evidence before retrying.`
|
|
572
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
573
|
+
return { taskID, attempts: record.attempts, state: loaded.state }
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export async function addDecision(root, slug, decision) {
|
|
578
|
+
const text = String(decision || "").trim()
|
|
579
|
+
if (!text) throw new Error("decision text is required")
|
|
580
|
+
|
|
581
|
+
return withWorkLock(root, slug, async () => {
|
|
582
|
+
const loaded = await loadWork(root, slug)
|
|
583
|
+
loaded.state.decisions ??= []
|
|
584
|
+
const timestamp = now()
|
|
585
|
+
loaded.state.decisions.push({ at: timestamp, text })
|
|
586
|
+
loaded.state.updatedAt = timestamp
|
|
587
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
588
|
+
return loaded.state
|
|
589
|
+
})
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export async function addBlocker(root, slug, blocker) {
|
|
593
|
+
const text = String(blocker || "").trim()
|
|
594
|
+
if (!text) throw new Error("blocker text is required")
|
|
595
|
+
|
|
596
|
+
return withWorkLock(root, slug, async () => {
|
|
597
|
+
const loaded = await loadWork(root, slug)
|
|
598
|
+
loaded.state.blockers ??= []
|
|
599
|
+
if (!loaded.state.blockers.includes(text)) loaded.state.blockers.push(text)
|
|
600
|
+
loaded.state.integrationVerification = null
|
|
601
|
+
loaded.state.updatedAt = now()
|
|
602
|
+
loaded.state.nextAction = "Resolve blocker: " + text
|
|
603
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
604
|
+
return loaded.state
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export async function resolveBlocker(root, slug, blocker) {
|
|
609
|
+
const text = String(blocker || "").trim()
|
|
610
|
+
if (!text) throw new Error("blocker text is required")
|
|
611
|
+
|
|
612
|
+
return withWorkLock(root, slug, async () => {
|
|
613
|
+
const loaded = await loadWork(root, slug)
|
|
614
|
+
loaded.state.blockers = (loaded.state.blockers || []).filter((item) => item !== text)
|
|
615
|
+
loaded.state.integrationVerification = null
|
|
616
|
+
loaded.state.updatedAt = now()
|
|
617
|
+
const ready = readyTasks(loaded.plan, loaded.state)
|
|
618
|
+
loaded.state.nextAction = loaded.state.blockers.length
|
|
619
|
+
? "Resolve remaining blocker(s) before continuing."
|
|
620
|
+
: ready.length
|
|
621
|
+
? `Execute ready task(s): ${ready.join(", ")}`
|
|
622
|
+
: loaded.state.status === "integration-verification"
|
|
623
|
+
? "Run integration verification and record a fresh verdict."
|
|
624
|
+
: loaded.state.nextAction
|
|
625
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
626
|
+
return loaded.state
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export async function recordIntegrationVerification(root, slug, verdict, evidence, report = null) {
|
|
631
|
+
const status = String(verdict || "").trim().toUpperCase()
|
|
632
|
+
if (!["PASS", "FAIL", "PARTIAL"].includes(status)) {
|
|
633
|
+
throw new Error("integration verdict must be PASS, FAIL, or PARTIAL")
|
|
634
|
+
}
|
|
635
|
+
const evidenceText = String(evidence || "").trim()
|
|
636
|
+
if (!evidenceText) throw new Error("integration verification requires non-empty evidence")
|
|
637
|
+
|
|
638
|
+
return withWorkLock(root, slug, async () => {
|
|
639
|
+
const loaded = await loadWork(root, slug)
|
|
640
|
+
if (!loaded.plan) throw new Error("PLAN.json is missing")
|
|
641
|
+
const incomplete = loaded.plan.tasks
|
|
642
|
+
.filter((task) => loaded.state.tasks?.[task.id]?.status !== "completed")
|
|
643
|
+
.map((task) => task.id)
|
|
644
|
+
if (incomplete.length) {
|
|
645
|
+
throw new Error("cannot record integration verification with incomplete tasks: " + incomplete.join(", "))
|
|
646
|
+
}
|
|
647
|
+
if (status === "PASS" && loaded.state.blockers?.length) {
|
|
648
|
+
throw new Error("cannot record integration PASS while blockers remain")
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const timestamp = now()
|
|
652
|
+
const fingerprint = workspaceFingerprint(loaded.paths.root)
|
|
653
|
+
loaded.state.integrationVerification = {
|
|
654
|
+
status,
|
|
655
|
+
at: timestamp,
|
|
656
|
+
evidence: evidenceText,
|
|
657
|
+
report: report ? String(report) : null,
|
|
658
|
+
fingerprint,
|
|
659
|
+
}
|
|
660
|
+
loaded.state.updatedAt = timestamp
|
|
661
|
+
loaded.state.status = status === "PASS" ? "ready-to-finalize" : "integration-verification"
|
|
662
|
+
loaded.state.nextAction = status === "PASS"
|
|
663
|
+
? "Integration PASS recorded. Finalize only if the workspace fingerprint is unchanged."
|
|
664
|
+
: "Resolve integration findings, re-run affected tasks/checks, then verify integration again."
|
|
665
|
+
|
|
666
|
+
loaded.evidence.entries.push({
|
|
667
|
+
task: "__integration_verification__",
|
|
668
|
+
at: timestamp,
|
|
669
|
+
evidence: evidenceText,
|
|
670
|
+
verdict: status,
|
|
671
|
+
fingerprint,
|
|
672
|
+
verification: ["fresh cross-task integration and acceptance verification"],
|
|
673
|
+
})
|
|
674
|
+
loaded.evidence.schemaVersion = STATE_SCHEMA
|
|
675
|
+
loaded.evidence.updatedAt = timestamp
|
|
676
|
+
|
|
677
|
+
await writeJson(loaded.paths.evidence, loaded.evidence)
|
|
678
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
679
|
+
return loaded.state.integrationVerification
|
|
680
|
+
})
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export async function finalizeWork(root, slug, evidence) {
|
|
684
|
+
const evidenceText = String(evidence || "").trim()
|
|
685
|
+
if (!evidenceText) throw new Error("finalization requires fresh integration evidence")
|
|
686
|
+
|
|
687
|
+
return withWorkLock(root, slug, async () => {
|
|
688
|
+
const loaded = await loadWork(root, slug)
|
|
689
|
+
if (!loaded.plan) throw new Error("PLAN.json is missing")
|
|
690
|
+
if (loaded.state.blockers?.length) throw new Error("cannot finalize while blockers remain")
|
|
691
|
+
const incomplete = loaded.plan.tasks
|
|
692
|
+
.filter((task) => loaded.state.tasks?.[task.id]?.status !== "completed")
|
|
693
|
+
.map((task) => task.id)
|
|
694
|
+
if (incomplete.length) throw new Error("cannot finalize with incomplete tasks: " + incomplete.join(", "))
|
|
695
|
+
|
|
696
|
+
const verification = loaded.state.integrationVerification
|
|
697
|
+
if (verification?.status !== "PASS") {
|
|
698
|
+
throw new Error("cannot finalize without a recorded integration PASS")
|
|
699
|
+
}
|
|
700
|
+
const currentFingerprint = workspaceFingerprint(loaded.paths.root)
|
|
701
|
+
if (verification.fingerprint !== currentFingerprint) {
|
|
702
|
+
throw new Error("workspace changed after integration PASS; re-run integration verification before finalizing")
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const timestamp = now()
|
|
706
|
+
loaded.evidence.entries.push({
|
|
707
|
+
task: "__integration__",
|
|
708
|
+
at: timestamp,
|
|
709
|
+
evidence: evidenceText,
|
|
710
|
+
fingerprint: currentFingerprint,
|
|
711
|
+
verification: ["cross-task integration and final acceptance verification"],
|
|
712
|
+
})
|
|
713
|
+
loaded.evidence.schemaVersion = STATE_SCHEMA
|
|
714
|
+
loaded.evidence.updatedAt = timestamp
|
|
715
|
+
|
|
716
|
+
loaded.state.status = "completed"
|
|
717
|
+
loaded.state.completedAt = timestamp
|
|
718
|
+
loaded.state.updatedAt = timestamp
|
|
719
|
+
loaded.state.nextAction = "Work item completed; preserve evidence and report any remaining limitations accurately."
|
|
720
|
+
|
|
721
|
+
await writeJson(loaded.paths.evidence, loaded.evidence)
|
|
722
|
+
await writeJson(loaded.paths.state, loaded.state)
|
|
723
|
+
return { state: loaded.state, evidence: loaded.evidence.entries.at(-1) }
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function buildContextPack(loaded, taskID) {
|
|
728
|
+
if (!loaded.plan) throw new Error("PLAN.json is missing")
|
|
729
|
+
const task = taskByID(loaded.plan, taskID)
|
|
730
|
+
const spec = await readFile(loaded.paths.spec, "utf8").catch(() => "")
|
|
731
|
+
const dependencyReports = {}
|
|
732
|
+
|
|
733
|
+
for (const dep of task.dependsOn || []) {
|
|
734
|
+
const file = path.join(loaded.paths.reports, `${dep}.md`)
|
|
735
|
+
if (!existsSync(file)) continue
|
|
736
|
+
dependencyReports[dep] = (await readFile(file, "utf8")).slice(0, 12000)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const contextManifest = await buildContextManifest(loaded.paths.root, task, { budget: 24_000 }).catch(() => null)
|
|
740
|
+
const learnings = await relevantAcceptedLearnings(
|
|
741
|
+
loaded.paths.root,
|
|
742
|
+
[task.title, task.summary, ...(task.acceptance || [])].join(" "),
|
|
743
|
+
).catch(() => [])
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
schemaVersion: STATE_SCHEMA,
|
|
747
|
+
slug: loaded.state.slug,
|
|
748
|
+
task,
|
|
749
|
+
taskBrief: path.relative(loaded.paths.root, path.join(loaded.paths.tasks, `${taskID}.md`)).replaceAll("\\", "/"),
|
|
750
|
+
spec: spec.slice(0, 24000),
|
|
751
|
+
dependencyReports,
|
|
752
|
+
decisions: loaded.state.decisions || [],
|
|
753
|
+
blockers: loaded.state.blockers || [],
|
|
754
|
+
planApproval: loaded.state.planApproval || null,
|
|
755
|
+
contextManifest,
|
|
756
|
+
acceptedLearnings: learnings,
|
|
757
|
+
workingState: {
|
|
758
|
+
status: loaded.state.status,
|
|
759
|
+
task: loaded.state.tasks?.[taskID],
|
|
760
|
+
},
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export async function contextPack(root, slug, taskID) {
|
|
765
|
+
return buildContextPack(await loadWork(root, slug), taskID)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export async function resumeWork(root, slug) {
|
|
769
|
+
await recoverStaleTasks(root, slug).catch(() => ({ recovered: [] }))
|
|
770
|
+
const loaded = await loadWork(root, slug)
|
|
771
|
+
return {
|
|
772
|
+
status: await workStatus(root, slug),
|
|
773
|
+
planAnalysis: loaded.plan ? analyzePlan(loaded.plan) : null,
|
|
774
|
+
completedEvidence: loaded.evidence.entries || [],
|
|
775
|
+
decisions: loaded.state.decisions || [],
|
|
776
|
+
}
|
|
777
|
+
}
|