opencode-agent-skill 11.0.0 → 13.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +119 -14
  3. package/bin/ocskill.mjs +416 -83
  4. package/docs/DETERMINISTIC-TOOLS.md +1 -1
  5. package/docs/ENGINEERING-DESIGN.md +4 -4
  6. package/docs/EVALS.md +3 -3
  7. package/docs/GITHUB-RULESET.md +50 -0
  8. package/docs/NPM-PUBLISH.md +22 -8
  9. package/docs/OPENCODE-COMPAT.md +14 -4
  10. package/docs/TRACE-SCHEMA.md +1 -1
  11. package/docs/V11-PERCEPTION-ADAPTIVE.md +2 -2
  12. package/docs/V12-WEAK-MODEL-INTELLIGENCE.md +27 -0
  13. package/docs/V13-PARALLEL-WEAK-MODEL-RUNTIME.md +75 -0
  14. package/evals/repo-scale/tasks.json +62 -0
  15. package/global-config/AGENTS.md +4 -0
  16. package/global-config/commands/resume.md +4 -1
  17. package/global-config/commands/run.md +9 -7
  18. package/global-config/plugins/ues-router/capabilities.js +1 -0
  19. package/global-config/plugins/ues-router/command-runtime.js +79 -0
  20. package/global-config/plugins/ues-router/index.js +364 -43
  21. package/global-config/plugins/ues-router/parallel-runtime.js +271 -0
  22. package/global-config/plugins/ues-router/text-runtime.js +115 -0
  23. package/global-config/plugins/ues-router/verifier-runtime.js +33 -0
  24. package/global-config/skills/dynamic-workflow/SKILL.md +7 -6
  25. package/global-config/skills/dynamic-workflow/references/workflow.md +8 -6
  26. package/global-config/skills/engineering-orchestrator/references/long-horizon.md +7 -5
  27. package/lib/cli-utils.mjs +17 -4
  28. package/lib/context-engine-v11.mjs +4 -0
  29. package/lib/context-quality.mjs +59 -0
  30. package/lib/decision-policy.mjs +23 -0
  31. package/lib/installer.mjs +49 -16
  32. package/lib/model-config.mjs +13 -1
  33. package/lib/model-performance.mjs +113 -0
  34. package/lib/model-policy.mjs +9 -2
  35. package/lib/repo-scale-fixture.mjs +45 -0
  36. package/lib/task-engine.mjs +36 -12
  37. package/lib/task-graph.mjs +32 -0
  38. package/lib/text-encoding.mjs +74 -0
  39. package/lib/work-plan-scope.mjs +49 -0
  40. package/lib/worktree-sandbox.mjs +141 -12
  41. package/package.json +8 -4
  42. package/scripts/check-release-consistency.mjs +260 -0
  43. package/scripts/smoke-packed-install.mjs +39 -3
  44. package/scripts/smoke-plain-install.mjs +27 -7
  45. package/scripts/validate-repo-scale-suite.mjs +27 -0
  46. package/scripts/validate-v12-foundation.mjs +24 -0
  47. package/scripts/validate.mjs +18 -1
@@ -0,0 +1,74 @@
1
+ import { readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ function swappedUtf16(buffer) {
5
+ const evenLength = buffer.length - (buffer.length % 2)
6
+ const swapped = Buffer.allocUnsafe(evenLength)
7
+ for (let index = 0; index < evenLength; index += 2) {
8
+ swapped[index] = buffer[index + 1]
9
+ swapped[index + 1] = buffer[index]
10
+ }
11
+ return swapped.toString("utf16le")
12
+ }
13
+
14
+ function utf16Heuristic(buffer) {
15
+ if (buffer.length < 4) return null
16
+ let evenNulls = 0
17
+ let oddNulls = 0
18
+ let evenCount = 0
19
+ let oddCount = 0
20
+ for (let index = 0; index < buffer.length; index += 1) {
21
+ if (index % 2 === 0) {
22
+ evenCount += 1
23
+ if (buffer[index] === 0) evenNulls += 1
24
+ } else {
25
+ oddCount += 1
26
+ if (buffer[index] === 0) oddNulls += 1
27
+ }
28
+ }
29
+ const evenRatio = evenCount ? evenNulls / evenCount : 0
30
+ const oddRatio = oddCount ? oddNulls / oddCount : 0
31
+ if (oddRatio >= 0.25 && evenRatio <= 0.05) return "utf16le"
32
+ if (evenRatio >= 0.25 && oddRatio <= 0.05) return "utf16be"
33
+ return null
34
+ }
35
+
36
+ export function decodeTextBuffer(value, options = {}) {
37
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value || "")
38
+ if (buffer.length === 0) return ""
39
+
40
+ if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
41
+ return buffer.subarray(3).toString("utf8")
42
+ }
43
+ if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
44
+ return buffer.subarray(2).toString("utf16le")
45
+ }
46
+ if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
47
+ return swappedUtf16(buffer.subarray(2))
48
+ }
49
+
50
+ const guessed = utf16Heuristic(buffer)
51
+ if (guessed === "utf16le") return buffer.toString("utf16le")
52
+ if (guessed === "utf16be") return swappedUtf16(buffer)
53
+
54
+ let nulBytes = 0
55
+ for (const byte of buffer) if (byte === 0) nulBytes += 1
56
+ if (nulBytes > Math.max(2, Math.floor(buffer.length * 0.01)) && options.allowBinary !== true) {
57
+ const error = new Error("file appears to be binary; refusing lossy text decoding")
58
+ error.code = "UES_BINARY_TEXT"
59
+ error.exitCode = 2
60
+ throw error
61
+ }
62
+
63
+ return buffer.toString("utf8")
64
+ }
65
+
66
+ export function readTextAuto(file, options = {}) {
67
+ return decodeTextBuffer(readFileSync(path.resolve(file)), options)
68
+ }
69
+
70
+ export function writeUtf8Text(file, text) {
71
+ const resolved = path.resolve(file)
72
+ writeFileSync(resolved, String(text ?? ""), "utf8")
73
+ return resolved
74
+ }
@@ -0,0 +1,49 @@
1
+ import { existsSync } from "node:fs"
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
3
+ import path from "node:path"
4
+
5
+ function validPlanHash(value) {
6
+ return /^[a-f0-9]{64}$/i.test(String(value || ""))
7
+ }
8
+
9
+ export function planScopePaths(paths, planHash) {
10
+ if (!validPlanHash(planHash)) throw new Error("plan scope requires a SHA-256 plan hash")
11
+ const dir = path.join(paths.plans, planHash)
12
+ return { dir, plan: path.join(dir, "PLAN.json"), metadata: path.join(dir, "SCOPE.json") }
13
+ }
14
+
15
+ export async function persistPlanScope(paths, planHash, plan, metadata = {}) {
16
+ const scoped = planScopePaths(paths, planHash)
17
+ await mkdir(scoped.dir, { recursive: true })
18
+ await writeFile(scoped.plan, JSON.stringify(plan, null, 2) + "\n", "utf8")
19
+ const record = {
20
+ schemaVersion: 1,
21
+ planHash,
22
+ importedAt: metadata.importedAt || new Date().toISOString(),
23
+ previousPlanHash: metadata.previousPlanHash || null,
24
+ relativeDir: path.relative(paths.root, scoped.dir).replaceAll("\\", "/"),
25
+ }
26
+ await writeFile(scoped.metadata, JSON.stringify(record, null, 2) + "\n", "utf8")
27
+ await writeFile(paths.activePlan, JSON.stringify(record, null, 2) + "\n", "utf8")
28
+ return record
29
+ }
30
+
31
+ export async function readActivePlanScope(paths) {
32
+ if (!existsSync(paths.activePlan)) return null
33
+ try { return JSON.parse(await readFile(paths.activePlan, "utf8")) }
34
+ catch { throw new Error("ACTIVE_PLAN.json is invalid; re-import the plan before execution") }
35
+ }
36
+
37
+ export async function assertActivePlanScope(paths, expectedPlanHash) {
38
+ const active = await readActivePlanScope(paths)
39
+ if (!active) return { legacy: true, planHash: expectedPlanHash || null }
40
+ if (!validPlanHash(active.planHash)) throw new Error("ACTIVE_PLAN.json contains an invalid plan hash")
41
+ if (expectedPlanHash && active.planHash !== expectedPlanHash) {
42
+ throw new Error("active plan scope mismatch; re-import PLAN.json before executing tasks")
43
+ }
44
+ const scoped = planScopePaths(paths, active.planHash)
45
+ if (!existsSync(scoped.plan) || !existsSync(scoped.metadata)) {
46
+ throw new Error("active plan scope snapshot is incomplete; re-import PLAN.json")
47
+ }
48
+ return { ...active, legacy: false }
49
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs"
2
- import { mkdir, rm } from "node:fs/promises"
2
+ import { createHash } from "node:crypto"
3
+ import { copyFile, mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises"
3
4
  import { spawnSync } from "node:child_process"
4
5
  import path from "node:path"
5
6
 
@@ -27,11 +28,69 @@ function safeName(value) {
27
28
  return String(value || "").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "")
28
29
  }
29
30
 
31
+ function metadataFile(dir) {
32
+ return path.resolve(dir) + ".ues-meta.json"
33
+ }
34
+
35
+ async function readSandboxMetadata(dir) {
36
+ try {
37
+ return JSON.parse(await readFile(metadataFile(dir), "utf8"))
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ async function writeSandboxMetadata(dir, value) {
44
+ await writeFile(metadataFile(dir), JSON.stringify(value, null, 2) + "\n", "utf8")
45
+ }
46
+
47
+ async function inheritDirtySnapshot(root, dir) {
48
+ const inheritedUntrackedHashes = {}
49
+ const diff = git(root, ["diff", "--binary", "--no-ext-diff", "HEAD", "--"])
50
+ if (diff.status !== 0) throw new Error((diff.stderr || diff.stdout || "git diff HEAD failed").trim())
51
+ if (diff.stdout) {
52
+ const applied = git(dir, ["apply", "--whitespace=nowarn", "-"], { input: diff.stdout })
53
+ if (applied.status !== 0) throw new Error((applied.stderr || applied.stdout || "git apply inherited root diff failed").trim())
54
+ }
55
+
56
+ const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z"])
57
+ if (untracked.status !== 0) throw new Error((untracked.stderr || untracked.stdout || "git ls-files failed").trim())
58
+ for (const relative of untracked.stdout.split("\0").filter(Boolean)) {
59
+ const source = path.join(root, relative)
60
+ const target = path.join(dir, relative)
61
+ const bytes = await readFile(source)
62
+ inheritedUntrackedHashes[relative.replaceAll("\\", "/")] = createHash("sha256").update(bytes).digest("hex")
63
+ await mkdir(path.dirname(target), { recursive: true })
64
+ await copyFile(source, target)
65
+ }
66
+
67
+ const add = git(dir, ["add", "-A"])
68
+ if (add.status !== 0) throw new Error((add.stderr || add.stdout || "git add inherited snapshot failed").trim())
69
+ const commit = git(dir, [
70
+ "-c", "user.name=UES Parallel Runtime",
71
+ "-c", "user.email=ues-parallel@example.invalid",
72
+ "commit", "-m", "chore(ues): inherited parallel integration snapshot",
73
+ ])
74
+ if (commit.status !== 0) {
75
+ const status = git(dir, ["status", "--porcelain=v1", "--untracked-files=all"])
76
+ if (status.status !== 0 || status.stdout.trim()) {
77
+ throw new Error((commit.stderr || commit.stdout || "git commit inherited snapshot failed").trim())
78
+ }
79
+ }
80
+ const head = git(dir, ["rev-parse", "HEAD"])
81
+ if (head.status !== 0) throw new Error((head.stderr || head.stdout || "git rev-parse inherited snapshot failed").trim())
82
+ return {
83
+ head: head.stdout.trim(),
84
+ inheritedUntrackedHashes,
85
+ }
86
+ }
87
+
30
88
  export async function createTaskSandbox(root, slug, taskID, options = {}) {
31
89
  root = path.resolve(root)
32
90
  const status = git(root, ["status", "--porcelain=v1", "--untracked-files=all"])
33
91
  if (status.status !== 0) throw new Error((status.stderr || status.stdout || "git status failed").trim())
34
- if (status.stdout.trim() && options.allowDirtyRoot !== true) {
92
+ const rootDirty = Boolean(status.stdout.trim())
93
+ if (rootDirty && options.allowDirtyRoot !== true && options.inheritDirtyRoot !== true) {
35
94
  throw new Error("sandbox creation requires a clean root working tree so the isolated executor cannot miss existing uncommitted changes")
36
95
  }
37
96
  const base = options.baseDir
@@ -48,7 +107,31 @@ export async function createTaskSandbox(root, slug, taskID, options = {}) {
48
107
  const result = git(root, ["worktree", "add", "-b", branch, dir, startPoint])
49
108
  if (result.status !== 0) throw new Error((result.stderr || result.stdout || "git worktree add failed").trim())
50
109
 
51
- return { schemaVersion: 1, root, dir, branch, slug, taskID, startPoint }
110
+ let integrationBase = git(dir, ["rev-parse", "HEAD"]).stdout.trim()
111
+ let inheritedDirtyRoot = false
112
+ let inheritedUntrackedHashes = {}
113
+ if (rootDirty && options.inheritDirtyRoot === true) {
114
+ const inherited = await inheritDirtySnapshot(root, dir)
115
+ integrationBase = inherited.head
116
+ inheritedUntrackedHashes = inherited.inheritedUntrackedHashes
117
+ inheritedDirtyRoot = true
118
+ }
119
+
120
+ const metadata = {
121
+ schemaVersion: 2,
122
+ root,
123
+ dir,
124
+ branch,
125
+ slug,
126
+ taskID,
127
+ startPoint,
128
+ integrationBase,
129
+ inheritedDirtyRoot,
130
+ inheritedUntrackedHashes,
131
+ createdAt: new Date().toISOString(),
132
+ }
133
+ await writeSandboxMetadata(dir, metadata)
134
+ return metadata
52
135
  }
53
136
 
54
137
  export async function integrateTaskSandbox(root, dir, options = {}) {
@@ -61,13 +144,16 @@ export async function integrateTaskSandbox(root, dir, options = {}) {
61
144
  if (rootTop.status !== 0 || sandboxTop.status !== 0) throw new Error("root and sandbox must be Git worktrees")
62
145
  if (path.resolve(sandboxTop.stdout.trim()) !== dir) throw new Error("sandbox path is not the worktree root")
63
146
 
64
- // Resolve the merge base against the canonical root HEAD explicitly.
65
- const rootHead = git(root, ["rev-parse", "HEAD"])
66
- const sandboxHead = git(dir, ["rev-parse", "HEAD"])
67
- if (rootHead.status !== 0 || sandboxHead.status !== 0) throw new Error("could not resolve worktree HEADs")
68
- const base = git(dir, ["merge-base", rootHead.stdout.trim(), sandboxHead.stdout.trim()])
69
- if (base.status !== 0) throw new Error((base.stderr || base.stdout || "git merge-base failed").trim())
70
- const baseSha = base.stdout.trim()
147
+ const metadata = await readSandboxMetadata(dir)
148
+ let baseSha = metadata?.integrationBase || null
149
+ if (!baseSha) {
150
+ const rootHead = git(root, ["rev-parse", "HEAD"])
151
+ const sandboxHead = git(dir, ["rev-parse", "HEAD"])
152
+ if (rootHead.status !== 0 || sandboxHead.status !== 0) throw new Error("could not resolve worktree HEADs")
153
+ const base = git(dir, ["merge-base", rootHead.stdout.trim(), sandboxHead.stdout.trim()])
154
+ if (base.status !== 0) throw new Error((base.stderr || base.stdout || "git merge-base failed").trim())
155
+ baseSha = base.stdout.trim()
156
+ }
71
157
 
72
158
  // Intent-to-add exposes untracked files in a binary diff without committing them.
73
159
  const addIntent = git(dir, ["add", "-N", "."])
@@ -83,8 +169,30 @@ export async function integrateTaskSandbox(root, dir, options = {}) {
83
169
 
84
170
  const rootDirty = new Set(statusFiles(root))
85
171
  const overlap = changed.filter((file) => rootDirty.has(file))
86
- if (overlap.length) {
87
- throw new Error("sandbox integration conflicts with existing root changes: " + overlap.join(", "))
172
+ const unsafeOverlap = []
173
+ for (const file of overlap) {
174
+ if (!metadata?.inheritedDirtyRoot) {
175
+ unsafeOverlap.push(file)
176
+ continue
177
+ }
178
+
179
+ const inheritedUntrackedHash = metadata?.inheritedUntrackedHashes?.[file]
180
+ if (inheritedUntrackedHash) {
181
+ try {
182
+ const current = await readFile(path.join(root, file))
183
+ const currentHash = createHash("sha256").update(current).digest("hex")
184
+ if (currentHash !== inheritedUntrackedHash) unsafeOverlap.push(file)
185
+ } catch {
186
+ unsafeOverlap.push(file)
187
+ }
188
+ continue
189
+ }
190
+
191
+ const check = git(root, ["diff", "--quiet", baseSha, "--", file])
192
+ if (check.status !== 0) unsafeOverlap.push(file)
193
+ }
194
+ if (unsafeOverlap.length) {
195
+ throw new Error("sandbox integration conflicts with existing root changes: " + unsafeOverlap.join(", "))
88
196
  }
89
197
 
90
198
  const patch = git(dir, ["diff", "--binary", "--no-ext-diff", baseSha, "--"])
@@ -121,9 +229,30 @@ export async function removeTaskSandbox(root, dir, options = {}) {
121
229
  throw new Error((deleted.stderr || deleted.stdout || "git branch delete failed").trim())
122
230
  }
123
231
  }
232
+ await unlink(metadataFile(dir)).catch(() => {})
124
233
  return { removed: !existsSync(dir), dir, branch: branch || null }
125
234
  }
126
235
 
236
+ export async function rollbackTaskSandbox(root, dir, options = {}) {
237
+ root = path.resolve(root)
238
+ dir = path.resolve(dir)
239
+ if (!existsSync(dir)) throw new Error("sandbox does not exist: " + dir)
240
+ const metadata = await readSandboxMetadata(dir)
241
+ if (!metadata?.integrationBase) throw new Error("sandbox rollback metadata is missing")
242
+
243
+ const patch = git(dir, ["diff", "--binary", "--no-ext-diff", metadata.integrationBase, "--"])
244
+ if (patch.status !== 0) throw new Error((patch.stderr || patch.stdout || "git diff for rollback failed").trim())
245
+ if (patch.stdout) {
246
+ const reversed = git(root, ["apply", "--reverse", "--whitespace=nowarn", "-"], { input: patch.stdout })
247
+ if (reversed.status !== 0) throw new Error((reversed.stderr || reversed.stdout || "git rollback apply failed").trim())
248
+ }
249
+
250
+ if (!options.keep) {
251
+ await removeTaskSandbox(root, dir, { force: true, deleteBranch: true })
252
+ }
253
+ return { rolledBack: true, dir, base: metadata.integrationBase }
254
+ }
255
+
127
256
  export function listTaskSandboxes(root) {
128
257
  const result = git(path.resolve(root), ["worktree", "list", "--porcelain"])
129
258
  if (result.status !== 0) throw new Error((result.stderr || result.stdout || "git worktree list failed").trim())
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-agent-skill",
3
- "version": "11.0.0",
4
- "description": "Perception-aware evidence-first engineering runtime with adaptive context, capability routing, visual/browser verification, weak-model recovery and benchmark-gated execution for OpenCode",
3
+ "version": "13.0.0-beta.0",
4
+ "description": "Parallel weak-model engineering runtime with isolated same-model workers, event-driven DAG execution, transactional integration, adaptive context and evidence-gated verification for OpenCode",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ocskill": "bin/ocskill.mjs"
@@ -23,7 +23,7 @@
23
23
  "evals": "node scripts/eval-skills.mjs",
24
24
  "evals:live:validate": "node scripts/validate-live-suite.mjs",
25
25
  "test": "node --test test/*.test.mjs",
26
- "ci": "npm run syntax && npm run validate && npm run evals && npm run evals:router && npm run evals:v11:validate && npm run evals:live:validate && npm run evals:long:validate && npm run evals:polyglot:validate && npm test && npm pack --dry-run && npm run smoke:pack && npm run smoke:plain-install",
26
+ "ci": "npm run syntax && npm run validate && npm run docs:check && npm run evals && npm run evals:router && npm run evals:v11:validate && npm run evals:v12:validate && npm run evals:v13 && npm run evals:repo-scale:validate && npm run evals:live:validate && npm run evals:long:validate && npm run evals:polyglot:validate && npm test && npm pack --dry-run && npm run smoke:pack && npm run smoke:plain-install",
27
27
  "postinstall": "node scripts/install.mjs",
28
28
  "preuninstall": "node scripts/uninstall.mjs",
29
29
  "prepublishOnly": "npm run ci",
@@ -42,13 +42,17 @@
42
42
  "dashboard": "node scripts/control-center.mjs",
43
43
  "evals:matrix": "node scripts/eval-matrix.mjs",
44
44
  "smoke:plain-install": "node scripts/smoke-plain-install.mjs",
45
+ "docs:check": "node scripts/check-release-consistency.mjs",
45
46
  "release:check-tag": "node scripts/check-release-tag.mjs",
46
47
  "evals:polyglot:validate": "node scripts/validate-live-suite.mjs --suite polyglot",
47
48
  "evals:polyglot": "node scripts/eval-live.mjs --suite polyglot",
48
49
  "evals:matrix:gate": "node scripts/eval-matrix.mjs --require-confidence",
49
50
  "evals:ablation": "node scripts/eval-ablation.mjs",
50
51
  "evals:v11": "node --test test/*-v11.test.mjs",
51
- "evals:v11:validate": "node scripts/validate-v11-suite.mjs"
52
+ "evals:v11:validate": "node scripts/validate-v11-suite.mjs",
53
+ "evals:repo-scale:validate": "node scripts/validate-repo-scale-suite.mjs",
54
+ "evals:v12:validate": "node scripts/validate-v12-foundation.mjs",
55
+ "evals:v13": "node --test test/*-v13.test.mjs"
52
56
  },
53
57
  "keywords": [
54
58
  "opencode",
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
4
+ import path from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
8
+
9
+ function readJson(root, relative) {
10
+ const file = path.join(root, relative)
11
+ if (!existsSync(file)) {
12
+ return { ok: false, error: `${relative}: file not found`, value: null }
13
+ }
14
+ try {
15
+ return { ok: true, error: null, value: JSON.parse(readFileSync(file, "utf8")) }
16
+ } catch (e) {
17
+ return { ok: false, error: `${relative}: invalid JSON (${e.message})`, value: null }
18
+ }
19
+ }
20
+
21
+ function readText(root, relative) {
22
+ const file = path.join(root, relative)
23
+ if (!existsSync(file)) {
24
+ return { ok: false, error: `${relative}: file not found`, value: null }
25
+ }
26
+ return { ok: true, error: null, value: readFileSync(file, "utf8") }
27
+ }
28
+
29
+ function countSkillDirs(root) {
30
+ const fullPath = path.join(root, "global-config", "skills")
31
+ if (!existsSync(fullPath)) return { ok: false, error: "global-config/skills: directory not found", value: 0 }
32
+ const entries = readdirSync(fullPath, { withFileTypes: true })
33
+ const count = entries.filter((entry) => entry.isDirectory() && existsSync(path.join(fullPath, entry.name, "SKILL.md"))).length
34
+ return { ok: true, error: null, value: count }
35
+ }
36
+
37
+ function countDir(root, dirPath, filter) {
38
+ const fullPath = path.join(root, dirPath)
39
+ if (!existsSync(fullPath)) {
40
+ return { ok: false, error: `${dirPath}: directory not found`, value: 0 }
41
+ }
42
+ const entries = readdirSync(fullPath, { withFileTypes: true })
43
+ const count = filter ? entries.filter(filter).length : entries.length
44
+ return { ok: true, error: null, value: count }
45
+ }
46
+
47
+ export function checkReleaseConsistency(root) {
48
+ root = root || DEFAULT_ROOT
49
+ const errors = []
50
+ const warnings = []
51
+
52
+ function rJson(relative) {
53
+ return readJson(root, relative)
54
+ }
55
+ function rText(relative) {
56
+ return readText(root, relative)
57
+ }
58
+ function rCount(dirPath, filter) {
59
+ return countDir(root, dirPath, filter)
60
+ }
61
+
62
+ // 1. package.json version == package-lock.json version
63
+ const pkg = rJson("package.json")
64
+ const lock = rJson("package-lock.json")
65
+ if (pkg.ok && lock.ok) {
66
+ const pkgVersion = pkg.value.version
67
+ const lockVersion = lock.value.version
68
+ if (pkgVersion !== lockVersion) {
69
+ errors.push(`package.json version (${pkgVersion}) != package-lock.json version (${lockVersion})`)
70
+ }
71
+ } else {
72
+ if (!pkg.ok) errors.push(pkg.error)
73
+ if (!lock.ok) errors.push(lock.error)
74
+ }
75
+
76
+ // 2. Count skills, commands, subagents
77
+ const skillRes = countSkillDirs(root)
78
+ const commandRes = rCount("global-config/commands", (e) => e.isFile() && e.name.endsWith(".md"))
79
+ const agentRes = rCount("global-config/agents", (e) => e.isFile() && e.name.endsWith(".md"))
80
+ const skillCount = skillRes.ok ? skillRes.value : 0
81
+ const commandCount = commandRes.ok ? commandRes.value : 0
82
+ const agentCount = agentRes.ok ? agentRes.value : 0
83
+ if (!skillRes.ok) errors.push(skillRes.error)
84
+ if (!commandRes.ok) errors.push(commandRes.error)
85
+ if (!agentRes.ok) errors.push(agentRes.error)
86
+
87
+ const expectedVersion = pkg.ok ? pkg.value.version : "unknown"
88
+ const routing = rJson("evals/routing.json")
89
+ const routerTriggers = rJson("evals/router-triggers.json")
90
+ const staticScenarioCount = routing.ok && Array.isArray(routing.value.scenarios) ? routing.value.scenarios.length : 0
91
+ const routerCaseCount = routerTriggers.ok && Array.isArray(routerTriggers.value.cases) ? routerTriggers.value.cases.length : 0
92
+ if (!routing.ok) errors.push(routing.error)
93
+ if (!routerTriggers.ok) errors.push(routerTriggers.error)
94
+
95
+ // 3. Check README current version block
96
+ const readme = rText("README.md")
97
+ if (readme.ok) {
98
+ const versionMatch = readme.value.match(/Phiên bản hiện tại:\s*\n```text\n(\S+)/)
99
+ if (versionMatch && versionMatch[1] !== expectedVersion) {
100
+ errors.push(`README.md: current version says ${versionMatch[1]}, expected ${expectedVersion}`)
101
+ } else if (!versionMatch) {
102
+ errors.push("README.md: could not find current version block")
103
+ }
104
+ if (staticScenarioCount && !readme.value.includes("**" + staticScenarioCount + " static skill-routing scenarios**")) {
105
+ errors.push("README.md: static routing scenario count drift (actual: " + staticScenarioCount + ")")
106
+ }
107
+ if (routerCaseCount && !readme.value.includes("**" + routerCaseCount + " V2 router cases**")) {
108
+ errors.push("README.md: router case count drift (actual: " + routerCaseCount + ")")
109
+ }
110
+ } else {
111
+ errors.push(readme.error)
112
+ }
113
+
114
+ // 4. Check V11 docs for stale status markers
115
+ const v11Docs = ["docs/V11-PERCEPTION-ADAPTIVE.md", "docs/V11-PERCEPTION-ADAPTIVE-EXECUTION.md"]
116
+ for (const doc of v11Docs) {
117
+ const content = rText(doc)
118
+ if (!content.ok) {
119
+ errors.push(content.error)
120
+ continue
121
+ }
122
+ if (content.value.includes("Status: development")) {
123
+ errors.push(`${doc}: still marked as development`)
124
+ }
125
+ if (content.value.includes("11.0.0-dev.")) {
126
+ errors.push(`${doc}: still references dev version 11.0.0-dev.*`)
127
+ }
128
+ if (content.value.includes("npm `latest` remains V10")) {
129
+ errors.push(`${doc}: still says V10 remains npm latest`)
130
+ }
131
+ }
132
+
133
+ // 4b. V13 prerelease contract must stay synchronized with compatibility docs.
134
+ if (expectedVersion.startsWith("13.")) {
135
+ const v13 = rText("docs/V13-PARALLEL-WEAK-MODEL-RUNTIME.md")
136
+ if (!v13.ok) {
137
+ errors.push(v13.error)
138
+ } else {
139
+ if (!v13.value.includes("Status: beta prerelease (`" + expectedVersion + "`).")) {
140
+ errors.push("docs/V13-PARALLEL-WEAK-MODEL-RUNTIME.md: prerelease version/status drift")
141
+ }
142
+ for (const required of ["ues.dispatch_parallel", "same configured model", "OpenCode"]) {
143
+ if (!v13.value.includes(required)) {
144
+ errors.push("docs/V13-PARALLEL-WEAK-MODEL-RUNTIME.md: missing V13 contract marker " + required)
145
+ }
146
+ }
147
+ }
148
+
149
+ const compat = rText("docs/OPENCODE-COMPAT.md")
150
+ if (!compat.ok) {
151
+ errors.push(compat.error)
152
+ } else {
153
+ if (!compat.value.includes("V13 ships one npm package")) {
154
+ errors.push("docs/OPENCODE-COMPAT.md: current compatibility header is stale for V13")
155
+ }
156
+ if (!compat.value.includes("ues.dispatch_parallel")) {
157
+ errors.push("docs/OPENCODE-COMPAT.md: missing V13 parallel capability boundary")
158
+ }
159
+ }
160
+ }
161
+
162
+ // 5. Verify docs reflect actual counts (skip historical sections)
163
+ const docsToCheck = ["README.md", "docs/ENGINEERING-DESIGN.md", "docs/OPENCODE-COMPAT.md"]
164
+ for (const doc of docsToCheck) {
165
+ const content = rText(doc)
166
+ if (!content.ok) {
167
+ errors.push(content.error)
168
+ continue
169
+ }
170
+ const sections = content.value.split(/\n(?=#{1,6}\s)/)
171
+ for (const section of sections) {
172
+ const isHistorical = /^\s*#{1,6}\s+.*(V\d+\.|UES\s+\d+\.|historical|Historical)/im.test(section)
173
+ if (isHistorical) continue
174
+ if (section.includes("39 skills") || section.includes("39 namespaced skills")) {
175
+ errors.push(`${doc}: current section still references 39 skills (actual: ${skillCount})`)
176
+ }
177
+ if (section.includes("10 namespaced subagents") || section.includes("10 subagents")) {
178
+ errors.push(`${doc}: current section still references 10 subagents (actual: ${agentCount})`)
179
+ }
180
+ if (section.includes("34 static") && section.includes("scenarios")) {
181
+ errors.push(`${doc}: current section still references 34 static scenarios (actual: ${staticScenarioCount})`)
182
+ }
183
+ }
184
+ }
185
+
186
+ // 6. Check CI workflow
187
+ const ciYaml = rText(".github/workflows/ci.yml")
188
+ if (ciYaml.ok) {
189
+ if (!ciYaml.value.includes("evals:v11:validate")) {
190
+ errors.push(".github/workflows/ci.yml: missing evals:v11:validate job")
191
+ }
192
+ if (!/^\s*name:\s*CI Gate\s*$/m.test(ciYaml.value)) errors.push(".github/workflows/ci.yml: missing CI Gate aggregate job name")
193
+ if (!/needs:\s*\[\s*static\s*,\s*unit\s*,\s*package\s*\]/m.test(ciYaml.value)) errors.push(".github/workflows/ci.yml: CI Gate must depend on static, unit and package")
194
+ if (!/if:\s*always\(\)/m.test(ciYaml.value)) errors.push(".github/workflows/ci.yml: aggregate gate must use if: always()")
195
+ if (!ciYaml.value.includes("docs:check")) errors.push(".github/workflows/ci.yml: missing docs:check job")
196
+ for (const check of ["evals:v12:validate", ...(expectedVersion.startsWith("13.") ? ["evals:v13"] : []), "evals:repo-scale:validate"]) {
197
+ if (!ciYaml.value.includes(check)) errors.push(".github/workflows/ci.yml: missing " + check + " job")
198
+ }
199
+ } else {
200
+ errors.push(ciYaml.error)
201
+ }
202
+
203
+ // 7. Check Security workflow
204
+ const securityYaml = rText(".github/workflows/security.yml")
205
+ if (securityYaml.ok) {
206
+ if (!/^\s*name:\s*Security Gate\s*$/m.test(securityYaml.value)) errors.push(".github/workflows/security.yml: missing Security Gate aggregate job name")
207
+ if (!/needs:\s*\[\s*codeql\s*,\s*dependency-review\s*\]/m.test(securityYaml.value)) errors.push(".github/workflows/security.yml: Security Gate must depend on codeql and dependency-review")
208
+ } else {
209
+ errors.push(securityYaml.error)
210
+ }
211
+
212
+ // 8. Check publish workflow idempotency
213
+ const publishYaml = rText(".github/workflows/publish.yml")
214
+ if (publishYaml.ok) {
215
+ if (!publishYaml.value.includes("already published")) {
216
+ errors.push(".github/workflows/publish.yml: missing idempotency check for existing versions")
217
+ }
218
+ } else {
219
+ errors.push(publishYaml.error)
220
+ }
221
+
222
+ // 9. Check npm script docs:check exists
223
+ if (pkg.ok) {
224
+ const pkgScripts = pkg.value.scripts || {}
225
+ if (!pkgScripts["docs:check"]) {
226
+ warnings.push("package.json: missing docs:check npm script")
227
+ }
228
+ if (expectedVersion.startsWith("13.") && !pkgScripts["evals:v13"]) {
229
+ errors.push("package.json: V13 requires evals:v13 regression script")
230
+ }
231
+ }
232
+
233
+ return {
234
+ pass: errors.length === 0,
235
+ errors,
236
+ warnings,
237
+ version: expectedVersion,
238
+ skillCount,
239
+ commandCount,
240
+ subagentCount: agentCount,
241
+ staticScenarioCount,
242
+ routerCaseCount,
243
+ }
244
+ }
245
+
246
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
247
+ const result = checkReleaseConsistency(process.env.UES_BUNDLE_ROOT || DEFAULT_ROOT)
248
+ if (result.warnings.length) {
249
+ console.warn("Warnings:")
250
+ for (const w of result.warnings) console.warn(` WARN: ${w}`)
251
+ }
252
+ if (result.errors.length) {
253
+ console.error("Release consistency check FAILED:")
254
+ for (const e of result.errors) console.error(` - ${e}`)
255
+ process.exit(1)
256
+ }
257
+ console.log(
258
+ `Release consistency check PASS: package=${result.version}, skills=${result.skillCount}, commands=${result.commandCount}, subagents=${result.subagentCount}`,
259
+ )
260
+ }