opencode-agent-skill 10.0.0 → 11.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 +67 -0
- package/README.md +49 -3
- package/bin/ocskill.mjs +330 -5
- package/docs/V11-PERCEPTION-ADAPTIVE-EXECUTION.md +75 -0
- package/docs/V11-PERCEPTION-ADAPTIVE.md +220 -0
- package/evals/router-triggers.json +82 -0
- package/evals/routing.json +76 -0
- package/evals/v11/tasks.json +122 -0
- package/global-config/agents/merge-arbiter.md +12 -0
- package/global-config/agents/visual-verifier.md +12 -0
- package/global-config/plugins/ues-router/index.js +272 -2
- package/global-config/plugins/ues-router/router.js +27 -3
- package/global-config/skills/browser-qa/SKILL.md +14 -0
- package/global-config/skills/browser-qa/references/workflow.md +11 -0
- package/global-config/skills/browser-security/SKILL.md +12 -0
- package/global-config/skills/component-visual-testing/SKILL.md +10 -0
- package/global-config/skills/design-source/SKILL.md +10 -0
- package/global-config/skills/design-source/references/workflow.md +12 -0
- package/global-config/skills/dynamic-workflow/SKILL.md +18 -0
- package/global-config/skills/dynamic-workflow/references/workflow.md +19 -0
- package/global-config/skills/responsive-verification/SKILL.md +10 -0
- package/global-config/skills/skill-authoring/SKILL.md +12 -0
- package/global-config/skills/skill-evaluation/SKILL.md +17 -0
- package/global-config/skills/visual-fidelity/SKILL.md +14 -0
- package/global-config/skills/visual-fidelity/references/workflow.md +14 -0
- package/lib/browser-adapter.mjs +82 -0
- package/lib/browser-runtime.mjs +193 -0
- package/lib/capability-registry.mjs +109 -0
- package/lib/context-engine-v11.mjs +146 -0
- package/lib/context-manifest.mjs +16 -3
- package/lib/control-center.mjs +12 -2
- package/lib/dynamic-workflow.mjs +179 -0
- package/lib/eval-ablation.mjs +43 -1
- package/lib/eval-report.mjs +72 -0
- package/lib/eval-telemetry.mjs +61 -0
- package/lib/evidence-budget.mjs +84 -0
- package/lib/evidence-store.mjs +178 -0
- package/lib/hermes-bridge.mjs +45 -1
- package/lib/model-config.mjs +9 -1
- package/lib/model-policy.mjs +52 -1
- package/lib/orchestrator-policy.mjs +1 -1
- package/lib/png-diff.mjs +229 -0
- package/lib/prompt-cache.mjs +60 -0
- package/lib/skill-quality.mjs +72 -0
- package/lib/task-engine.mjs +78 -4
- package/lib/ui-inspector.mjs +152 -0
- package/lib/v11-metrics.mjs +64 -0
- package/lib/visual-spec.mjs +159 -0
- package/package.json +10 -5
- package/scripts/eval-ablation.mjs +4 -1
- package/scripts/validate-v11-suite.mjs +58 -0
- package/scripts/validate.mjs +16 -4
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
const STORE_VERSION = 1
|
|
7
|
+
const REF_PREFIX = "evidence:sha256:"
|
|
8
|
+
|
|
9
|
+
function stableValue(value) {
|
|
10
|
+
if (Array.isArray(value)) return value.map(stableValue)
|
|
11
|
+
if (!value || typeof value !== "object" || Buffer.isBuffer(value)) return value
|
|
12
|
+
return Object.fromEntries(
|
|
13
|
+
Object.keys(value).sort().map((key) => [key, stableValue(value[key])]),
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toBytes(value, options = {}) {
|
|
18
|
+
if (Buffer.isBuffer(value)) return { bytes: value, encoding: "binary", mediaType: options.mediaType || "application/octet-stream" }
|
|
19
|
+
if (typeof value === "string") return { bytes: Buffer.from(value, "utf8"), encoding: "utf8", mediaType: options.mediaType || "text/plain; charset=utf-8" }
|
|
20
|
+
const text = JSON.stringify(stableValue(value), null, options.pretty === false ? 0 : 2)
|
|
21
|
+
return { bytes: Buffer.from(text, "utf8"), encoding: "utf8", mediaType: options.mediaType || "application/json" }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeHash(ref) {
|
|
25
|
+
const value = String(ref || "").trim()
|
|
26
|
+
const hash = value.startsWith(REF_PREFIX) ? value.slice(REF_PREFIX.length) : value.replace(/^sha256:/, "")
|
|
27
|
+
if (!/^[a-f0-9]{64}$/i.test(hash)) throw new Error("Invalid evidence reference")
|
|
28
|
+
return hash.toLowerCase()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function evidenceReference(hash) {
|
|
32
|
+
return REF_PREFIX + normalizeHash(hash)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function evidenceStoreRoot(root = process.cwd()) {
|
|
36
|
+
return path.join(path.resolve(root), ".ues-cache", "evidence-v1")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function evidencePaths(root, hash) {
|
|
40
|
+
const normalized = normalizeHash(hash)
|
|
41
|
+
const dir = path.join(evidenceStoreRoot(root), normalized.slice(0, 2))
|
|
42
|
+
return {
|
|
43
|
+
dir,
|
|
44
|
+
meta: path.join(dir, normalized + ".json"),
|
|
45
|
+
data: path.join(dir, normalized + ".blob"),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function putEvidence(root, value, options = {}) {
|
|
50
|
+
root = path.resolve(root)
|
|
51
|
+
const encoded = toBytes(value, options)
|
|
52
|
+
const hash = createHash("sha256").update(encoded.bytes).digest("hex")
|
|
53
|
+
const target = evidencePaths(root, hash)
|
|
54
|
+
await mkdir(target.dir, { recursive: true })
|
|
55
|
+
|
|
56
|
+
const now = new Date().toISOString()
|
|
57
|
+
const existing = existsSync(target.meta)
|
|
58
|
+
? JSON.parse(await readFile(target.meta, "utf8").catch(() => "{}"))
|
|
59
|
+
: null
|
|
60
|
+
|
|
61
|
+
if (!existsSync(target.data)) await writeFile(target.data, encoded.bytes)
|
|
62
|
+
|
|
63
|
+
const preview = encoded.encoding === "utf8"
|
|
64
|
+
? encoded.bytes.toString("utf8", 0, Math.min(encoded.bytes.length, 600))
|
|
65
|
+
: null
|
|
66
|
+
|
|
67
|
+
const metadata = {
|
|
68
|
+
schemaVersion: STORE_VERSION,
|
|
69
|
+
ref: evidenceReference(hash),
|
|
70
|
+
sha256: hash,
|
|
71
|
+
bytes: encoded.bytes.length,
|
|
72
|
+
encoding: encoded.encoding,
|
|
73
|
+
mediaType: encoded.mediaType,
|
|
74
|
+
kind: options.kind || existing?.kind || "tool-output",
|
|
75
|
+
source: options.source || existing?.source || null,
|
|
76
|
+
summary: options.summary || existing?.summary || null,
|
|
77
|
+
createdAt: existing?.createdAt || now,
|
|
78
|
+
lastSeenAt: now,
|
|
79
|
+
preview,
|
|
80
|
+
}
|
|
81
|
+
await writeFile(target.meta, JSON.stringify(metadata, null, 2) + "\n", "utf8")
|
|
82
|
+
return metadata
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getEvidence(root, ref, options = {}) {
|
|
86
|
+
const hash = normalizeHash(ref)
|
|
87
|
+
const target = evidencePaths(root, hash)
|
|
88
|
+
const metadata = JSON.parse(await readFile(target.meta, "utf8"))
|
|
89
|
+
const bytes = await readFile(target.data)
|
|
90
|
+
const start = Math.max(0, Number(options.start || 0))
|
|
91
|
+
const maxBytes = Math.max(1, Number(options.maxBytes || options.maxChars || 24_000))
|
|
92
|
+
const slice = bytes.subarray(start, Math.min(bytes.length, start + maxBytes))
|
|
93
|
+
return {
|
|
94
|
+
...metadata,
|
|
95
|
+
truncated: start + slice.length < bytes.length,
|
|
96
|
+
start,
|
|
97
|
+
returnedBytes: slice.length,
|
|
98
|
+
content: metadata.encoding === "utf8" ? slice.toString("utf8") : slice.toString("base64"),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function metadataFiles(root) {
|
|
103
|
+
const base = evidenceStoreRoot(root)
|
|
104
|
+
const prefixes = await readdir(base, { withFileTypes: true }).catch(() => [])
|
|
105
|
+
const files = []
|
|
106
|
+
for (const prefix of prefixes) {
|
|
107
|
+
if (!prefix.isDirectory()) continue
|
|
108
|
+
const dir = path.join(base, prefix.name)
|
|
109
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
110
|
+
if (entry.isFile() && entry.name.endsWith(".json")) files.push(path.join(dir, entry.name))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return files
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function evidenceStoreStatus(root = process.cwd()) {
|
|
117
|
+
const files = await metadataFiles(root)
|
|
118
|
+
let bytes = 0
|
|
119
|
+
let oldest = null
|
|
120
|
+
let newest = null
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
const meta = JSON.parse(await readFile(file, "utf8").catch(() => "{}"))
|
|
123
|
+
bytes += Number(meta.bytes || 0)
|
|
124
|
+
if (meta.createdAt && (!oldest || meta.createdAt < oldest)) oldest = meta.createdAt
|
|
125
|
+
if (meta.lastSeenAt && (!newest || meta.lastSeenAt > newest)) newest = meta.lastSeenAt
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
schemaVersion: STORE_VERSION,
|
|
129
|
+
root: evidenceStoreRoot(root),
|
|
130
|
+
entries: files.length,
|
|
131
|
+
bytes,
|
|
132
|
+
oldest,
|
|
133
|
+
newest,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function gcEvidenceStore(root = process.cwd(), options = {}) {
|
|
138
|
+
const files = await metadataFiles(root)
|
|
139
|
+
const maxEntries = Math.max(10, Number(options.maxEntries || 2_000))
|
|
140
|
+
const maxAgeMs = Math.max(0, Number(options.maxAgeDays ?? 30)) * 86_400_000
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
const rows = []
|
|
143
|
+
for (const metaFile of files) {
|
|
144
|
+
const meta = JSON.parse(await readFile(metaFile, "utf8").catch(() => "{}"))
|
|
145
|
+
const time = Date.parse(meta.lastSeenAt || meta.createdAt || 0) || 0
|
|
146
|
+
rows.push({ metaFile, meta, time })
|
|
147
|
+
}
|
|
148
|
+
rows.sort((a, b) => b.time - a.time)
|
|
149
|
+
|
|
150
|
+
const removed = []
|
|
151
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
152
|
+
const row = rows[index]
|
|
153
|
+
const tooMany = index >= maxEntries
|
|
154
|
+
const tooOld = maxAgeMs > 0 && row.time > 0 && now - row.time > maxAgeMs
|
|
155
|
+
if (!tooMany && !tooOld) continue
|
|
156
|
+
const hash = row.meta.sha256 || path.basename(row.metaFile, ".json")
|
|
157
|
+
const target = evidencePaths(root, hash)
|
|
158
|
+
await rm(target.meta, { force: true })
|
|
159
|
+
await rm(target.data, { force: true })
|
|
160
|
+
removed.push(evidenceReference(hash))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
removed,
|
|
165
|
+
removedCount: removed.length,
|
|
166
|
+
status: await evidenceStoreStatus(root),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function evidenceExists(root, ref) {
|
|
171
|
+
try {
|
|
172
|
+
const target = evidencePaths(root, normalizeHash(ref))
|
|
173
|
+
const info = await stat(target.data)
|
|
174
|
+
return info.isFile()
|
|
175
|
+
} catch {
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
}
|
package/lib/hermes-bridge.mjs
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { resolveWindowsCommand } from "./windows-shim.mjs"
|
|
3
|
+
|
|
4
|
+
function runHermesVersion() {
|
|
5
|
+
if (process.platform !== "win32") return spawnSync("hermes", ["--version"], { encoding: "utf8" })
|
|
6
|
+
const resolved = resolveWindowsCommand("hermes")
|
|
7
|
+
if (!resolved) return { status: 127, stdout: "", stderr: "Hermes CLI not found" }
|
|
8
|
+
return spawnSync(resolved.executable, [...resolved.argsPrefix, "--version"], { encoding: "utf8" })
|
|
9
|
+
}
|
|
2
10
|
|
|
3
11
|
export function hermesStatus() {
|
|
4
|
-
const result =
|
|
12
|
+
const result = runHermesVersion()
|
|
5
13
|
return {
|
|
6
14
|
available: result.status === 0,
|
|
7
15
|
version: result.status === 0 ? String(result.stdout || result.stderr || "").trim() : null,
|
|
8
16
|
error: result.status === 0 ? null : String(result.stderr || result.stdout || "Hermes CLI not found").trim(),
|
|
17
|
+
mode: "optional-sidecar",
|
|
9
18
|
}
|
|
10
19
|
}
|
|
11
20
|
|
|
@@ -15,11 +24,46 @@ export function buildHermesDelegationPrompt(contextPack) {
|
|
|
15
24
|
"Implement exactly the approved task described below.",
|
|
16
25
|
"Do not broaden scope, merge, push, publish, deploy, or alter durable UES state.",
|
|
17
26
|
"Run the declared verification and return a concise structured report.",
|
|
27
|
+
"Large evidence is referenced by evidence:sha256 pointers; fetch only the slice required for the task.",
|
|
28
|
+
"",
|
|
29
|
+
JSON.stringify(contextPack, null, 2),
|
|
30
|
+
].join("\n")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildHermesWorkflowPrompt(contextPack, schedule) {
|
|
34
|
+
return [
|
|
35
|
+
"You are the optional Hermes sidecar for a UES V11 dynamic workflow.",
|
|
36
|
+
"Follow the supplied bounded wave schedule. Deterministic tasks are not delegated to LLM children.",
|
|
37
|
+
"Never exceed declared task ownership or concurrency. Persist outputs/evidence to files instead of conversational summaries.",
|
|
38
|
+
"Do not merge, push, publish, deploy, or mutate durable UES state except through explicitly supplied UES commands.",
|
|
39
|
+
"",
|
|
40
|
+
"SCHEDULE:",
|
|
41
|
+
JSON.stringify(schedule, null, 2),
|
|
18
42
|
"",
|
|
43
|
+
"CONTEXT:",
|
|
19
44
|
JSON.stringify(contextPack, null, 2),
|
|
20
45
|
].join("\n")
|
|
21
46
|
}
|
|
22
47
|
|
|
48
|
+
export function hermesSidecarPlan(input = {}) {
|
|
49
|
+
return {
|
|
50
|
+
schemaVersion: 2,
|
|
51
|
+
adapter: "hermes",
|
|
52
|
+
optional: true,
|
|
53
|
+
mode: input.mode || "one-shot",
|
|
54
|
+
maxConcurrent: Math.max(1, Math.min(16, Number(input.maxConcurrent || 4))),
|
|
55
|
+
durableStateOwner: "ues",
|
|
56
|
+
evidenceTransport: "content-addressed-pointers",
|
|
57
|
+
allowNestedDelegation: input.allowNestedDelegation === true,
|
|
58
|
+
safety: {
|
|
59
|
+
merge: false,
|
|
60
|
+
push: false,
|
|
61
|
+
publish: false,
|
|
62
|
+
deploy: false,
|
|
63
|
+
destructiveGit: false,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
23
67
|
|
|
24
68
|
export function hermesOneShotArgs(prompt) {
|
|
25
69
|
const text = String(prompt || "").trim()
|
package/lib/model-config.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs"
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { defaultModelPolicy, resolveModel } from "./model-policy.mjs"
|
|
5
|
+
import { normalizeCapabilityProfile } from "./capability-registry.mjs"
|
|
5
6
|
|
|
6
7
|
const TIERS = new Set(["light", "standard", "heavy"])
|
|
7
8
|
|
|
@@ -23,14 +24,20 @@ function normalize(policy) {
|
|
|
23
24
|
if (TIERS.has(tier)) roleTiers[role] = tier
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
const capabilities = {}
|
|
28
|
+
for (const [model, profile] of Object.entries(input.capabilities || {})) {
|
|
29
|
+
if (validModelID(model)) capabilities[model] = normalizeCapabilityProfile(profile)
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
return {
|
|
27
|
-
schemaVersion:
|
|
33
|
+
schemaVersion: 2,
|
|
28
34
|
enabled: input.enabled === true,
|
|
29
35
|
maxEscalations: Number.isInteger(input.maxEscalations)
|
|
30
36
|
? Math.max(0, Math.min(input.maxEscalations, 2))
|
|
31
37
|
: base.maxEscalations,
|
|
32
38
|
tiers,
|
|
33
39
|
roleTiers,
|
|
40
|
+
capabilities,
|
|
34
41
|
}
|
|
35
42
|
}
|
|
36
43
|
|
|
@@ -56,6 +63,7 @@ export async function writeModelPolicy(configDir, patch = {}) {
|
|
|
56
63
|
...patch,
|
|
57
64
|
tiers: { ...current.tiers, ...(patch.tiers || {}) },
|
|
58
65
|
roleTiers: { ...current.roleTiers, ...(patch.roleTiers || {}) },
|
|
66
|
+
capabilities: { ...(current.capabilities || {}), ...(patch.capabilities || {}) },
|
|
59
67
|
})
|
|
60
68
|
const file = modelPolicyFile(configDir)
|
|
61
69
|
await mkdir(path.dirname(file), { recursive: true })
|
package/lib/model-policy.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { inferTaskCapabilities, modelCandidatesFromPolicy, selectCapabilityCandidate } from "./capability-registry.mjs"
|
|
2
|
+
|
|
1
3
|
const ROLE_TIERS = {
|
|
2
4
|
"codebase-mapper": "standard",
|
|
3
5
|
architect: "heavy",
|
|
@@ -9,6 +11,8 @@ const ROLE_TIERS = {
|
|
|
9
11
|
critic: "heavy",
|
|
10
12
|
verifier: "standard",
|
|
11
13
|
"integration-verifier": "heavy",
|
|
14
|
+
"visual-verifier": "standard",
|
|
15
|
+
"merge-arbiter": "heavy",
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
const ORDER = ["light", "standard", "heavy"]
|
|
@@ -41,11 +45,12 @@ export function resolveModel(role, attempt, config = {}) {
|
|
|
41
45
|
|
|
42
46
|
export function defaultModelPolicy() {
|
|
43
47
|
return {
|
|
44
|
-
schemaVersion:
|
|
48
|
+
schemaVersion: 2,
|
|
45
49
|
enabled: false,
|
|
46
50
|
maxEscalations: 2,
|
|
47
51
|
tiers: { light: null, standard: null, heavy: null },
|
|
48
52
|
roleTiers: { ...ROLE_TIERS },
|
|
53
|
+
capabilities: {},
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
|
|
@@ -75,3 +80,49 @@ export function resolveAdaptiveModel(role, attempt, taskPolicy = {}, config = {}
|
|
|
75
80
|
},
|
|
76
81
|
}
|
|
77
82
|
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
export function resolveCapabilityModel(role, attempt, taskText = "", taskPolicy = {}, config = {}, facts = {}) {
|
|
86
|
+
const base = resolveAdaptiveModel(role, attempt, taskPolicy, config)
|
|
87
|
+
const requirements = inferTaskCapabilities(taskText, {
|
|
88
|
+
...facts,
|
|
89
|
+
...(role === "visual-verifier" && facts.coding === undefined ? { coding: false } : {}),
|
|
90
|
+
})
|
|
91
|
+
const candidates = modelCandidatesFromPolicy(config)
|
|
92
|
+
.filter((candidate) => tierIndex(candidate.tier) >= tierIndex(base.tier))
|
|
93
|
+
const selection = selectCapabilityCandidate(requirements, candidates, {
|
|
94
|
+
role,
|
|
95
|
+
preferredTier: base.tier,
|
|
96
|
+
})
|
|
97
|
+
const capabilityEnforced = config.enabled === true && candidates.length > 0
|
|
98
|
+
if (capabilityEnforced && !selection.selected) {
|
|
99
|
+
return {
|
|
100
|
+
...base,
|
|
101
|
+
model: null,
|
|
102
|
+
capabilityRequirements: requirements,
|
|
103
|
+
capabilitySelection: selection,
|
|
104
|
+
capabilityFallback: true,
|
|
105
|
+
capabilityBlocked: true,
|
|
106
|
+
capabilityBlockReason: "no-configured-model-satisfies-required-capabilities",
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!config.enabled || !selection.selected) {
|
|
110
|
+
return {
|
|
111
|
+
...base,
|
|
112
|
+
capabilityRequirements: requirements,
|
|
113
|
+
capabilitySelection: selection,
|
|
114
|
+
capabilityFallback: selection.fallbackNeeded,
|
|
115
|
+
capabilityBlocked: false,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const selected = selection.selected
|
|
119
|
+
return {
|
|
120
|
+
...base,
|
|
121
|
+
tier: selected.tier || base.tier,
|
|
122
|
+
model: selected.id,
|
|
123
|
+
capabilityRequirements: requirements,
|
|
124
|
+
capabilitySelection: selection,
|
|
125
|
+
capabilityFallback: false,
|
|
126
|
+
capabilityBlocked: false,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const HIGH_RISK = /(
|
|
1
|
+
const HIGH_RISK = /(\bauth\b|authorization|authentication|security|permission|payment|migration|schema|database|production|deploy|public api|breaking|secret|credential|phân quyền|bảo mật|thanh toán|cơ sở dữ liệu|triển khai|migrate|migration)/i
|
|
2
2
|
const LONG = /(whole repo|whole repository|whole project|entire repo|entire project|large refactor|major refactor|long[- ](?:running|horizon)|durable state|dependency graph|integration verification|resume|migration|refactor all|toàn bộ repo|toàn bộ repository|toàn bộ dự án|toàn bộ project|refactor lớn|tác vụ dài|nhiều file|nhiều module|tiếp tục công việc|refactor toàn bộ|xác minh tích hợp|kiểm tra tích hợp|chia (?:công việc|task|tác vụ).*(?:dependency|phụ thuộc))/i
|
|
3
3
|
const DEBUG = /(fix|bug|debug|crash|regression|failure|error|broken|sửa lỗi|lỗi|điều tra lỗi|không chạy)/i
|
|
4
4
|
const CONTRACT = /(public api|api contract|openapi|response schema|request schema|breaking api|hợp đồng api|api công khai)/i
|
package/lib/png-diff.mjs
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { inflateSync, deflateSync } from "node:zlib"
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
const SIGNATURE = Buffer.from([137,80,78,71,13,10,26,10])
|
|
5
|
+
|
|
6
|
+
function paeth(a, b, c) {
|
|
7
|
+
const p = a + b - c
|
|
8
|
+
const pa = Math.abs(p - a)
|
|
9
|
+
const pb = Math.abs(p - b)
|
|
10
|
+
const pc = Math.abs(p - c)
|
|
11
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function bytesPerPixel(colorType) {
|
|
15
|
+
if (colorType === 0 || colorType === 3) return 1
|
|
16
|
+
if (colorType === 2) return 3
|
|
17
|
+
if (colorType === 4) return 2
|
|
18
|
+
if (colorType === 6) return 4
|
|
19
|
+
throw new Error("Unsupported PNG color type: " + colorType)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseChunks(buffer) {
|
|
23
|
+
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("Invalid PNG signature")
|
|
24
|
+
const chunks = []
|
|
25
|
+
let offset = 8
|
|
26
|
+
while (offset + 12 <= buffer.length) {
|
|
27
|
+
const length = buffer.readUInt32BE(offset)
|
|
28
|
+
const type = buffer.toString("ascii", offset + 4, offset + 8)
|
|
29
|
+
const dataStart = offset + 8
|
|
30
|
+
const dataEnd = dataStart + length
|
|
31
|
+
if (dataEnd + 4 > buffer.length) throw new Error("Truncated PNG chunk")
|
|
32
|
+
chunks.push({ type, data: buffer.subarray(dataStart, dataEnd) })
|
|
33
|
+
offset = dataEnd + 4
|
|
34
|
+
if (type === "IEND") break
|
|
35
|
+
}
|
|
36
|
+
return chunks
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function decodePng(buffer) {
|
|
40
|
+
const chunks = parseChunks(buffer)
|
|
41
|
+
const ihdr = chunks.find((chunk) => chunk.type === "IHDR")?.data
|
|
42
|
+
if (!ihdr || ihdr.length < 13) throw new Error("PNG missing IHDR")
|
|
43
|
+
const width = ihdr.readUInt32BE(0)
|
|
44
|
+
const height = ihdr.readUInt32BE(4)
|
|
45
|
+
const bitDepth = ihdr[8]
|
|
46
|
+
const colorType = ihdr[9]
|
|
47
|
+
const interlace = ihdr[12]
|
|
48
|
+
if (bitDepth !== 8) throw new Error("Only 8-bit PNG is supported")
|
|
49
|
+
if (interlace !== 0) throw new Error("Interlaced PNG is not supported")
|
|
50
|
+
|
|
51
|
+
const paletteChunk = chunks.find((chunk) => chunk.type === "PLTE")?.data || null
|
|
52
|
+
const transparency = chunks.find((chunk) => chunk.type === "tRNS")?.data || null
|
|
53
|
+
const idat = Buffer.concat(chunks.filter((chunk) => chunk.type === "IDAT").map((chunk) => chunk.data))
|
|
54
|
+
const bpp = bytesPerPixel(colorType)
|
|
55
|
+
const pixelsCount = width * height
|
|
56
|
+
if (!Number.isSafeInteger(pixelsCount) || pixelsCount <= 0 || pixelsCount > 50_000_000) {
|
|
57
|
+
throw new Error("PNG dimensions exceed the V11 visual safety budget")
|
|
58
|
+
}
|
|
59
|
+
const stride = width * bpp
|
|
60
|
+
const expectedRawBytes = height * (1 + stride)
|
|
61
|
+
if (!Number.isSafeInteger(expectedRawBytes) || expectedRawBytes > 256 * 1024 * 1024) {
|
|
62
|
+
throw new Error("PNG decompressed payload exceeds the V11 visual safety budget")
|
|
63
|
+
}
|
|
64
|
+
const raw = inflateSync(idat, { maxOutputLength: expectedRawBytes })
|
|
65
|
+
if (raw.length !== expectedRawBytes) throw new Error("PNG decompressed payload length does not match IHDR")
|
|
66
|
+
const pixels = Buffer.alloc(stride * height)
|
|
67
|
+
let input = 0
|
|
68
|
+
|
|
69
|
+
for (let y = 0; y < height; y += 1) {
|
|
70
|
+
const filter = raw[input++]
|
|
71
|
+
const row = y * stride
|
|
72
|
+
for (let x = 0; x < stride; x += 1) {
|
|
73
|
+
const byte = raw[input++]
|
|
74
|
+
const left = x >= bpp ? pixels[row + x - bpp] : 0
|
|
75
|
+
const up = y > 0 ? pixels[row - stride + x] : 0
|
|
76
|
+
const upLeft = y > 0 && x >= bpp ? pixels[row - stride + x - bpp] : 0
|
|
77
|
+
let value
|
|
78
|
+
if (filter === 0) value = byte
|
|
79
|
+
else if (filter === 1) value = (byte + left) & 255
|
|
80
|
+
else if (filter === 2) value = (byte + up) & 255
|
|
81
|
+
else if (filter === 3) value = (byte + Math.floor((left + up) / 2)) & 255
|
|
82
|
+
else if (filter === 4) value = (byte + paeth(left, up, upLeft)) & 255
|
|
83
|
+
else throw new Error("Unsupported PNG filter: " + filter)
|
|
84
|
+
pixels[row + x] = value
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rgba = Buffer.alloc(width * height * 4)
|
|
89
|
+
for (let i = 0, p = 0; i < pixels.length; i += bpp, p += 4) {
|
|
90
|
+
if (colorType === 6) {
|
|
91
|
+
rgba[p] = pixels[i]; rgba[p+1] = pixels[i+1]; rgba[p+2] = pixels[i+2]; rgba[p+3] = pixels[i+3]
|
|
92
|
+
} else if (colorType === 2) {
|
|
93
|
+
rgba[p] = pixels[i]; rgba[p+1] = pixels[i+1]; rgba[p+2] = pixels[i+2]; rgba[p+3] = 255
|
|
94
|
+
} else if (colorType === 0) {
|
|
95
|
+
rgba[p] = pixels[i]; rgba[p+1] = pixels[i]; rgba[p+2] = pixels[i]; rgba[p+3] = 255
|
|
96
|
+
} else if (colorType === 4) {
|
|
97
|
+
rgba[p] = pixels[i]; rgba[p+1] = pixels[i]; rgba[p+2] = pixels[i]; rgba[p+3] = pixels[i+1]
|
|
98
|
+
} else if (colorType === 3) {
|
|
99
|
+
if (!paletteChunk) throw new Error("Indexed PNG missing PLTE")
|
|
100
|
+
const index = pixels[i]
|
|
101
|
+
rgba[p] = paletteChunk[index * 3] ?? 0
|
|
102
|
+
rgba[p+1] = paletteChunk[index * 3 + 1] ?? 0
|
|
103
|
+
rgba[p+2] = paletteChunk[index * 3 + 2] ?? 0
|
|
104
|
+
rgba[p+3] = transparency?.[index] ?? 255
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { width, height, rgba }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let crcTable = null
|
|
111
|
+
function crc32(buffer) {
|
|
112
|
+
if (!crcTable) {
|
|
113
|
+
crcTable = Array.from({ length: 256 }, (_, n) => {
|
|
114
|
+
let c = n
|
|
115
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
116
|
+
return c >>> 0
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
let crc = 0xffffffff
|
|
120
|
+
for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8)
|
|
121
|
+
return (crc ^ 0xffffffff) >>> 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function chunk(type, data) {
|
|
125
|
+
const typeBytes = Buffer.from(type, "ascii")
|
|
126
|
+
const length = Buffer.alloc(4)
|
|
127
|
+
length.writeUInt32BE(data.length)
|
|
128
|
+
const crc = Buffer.alloc(4)
|
|
129
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])))
|
|
130
|
+
return Buffer.concat([length, typeBytes, data, crc])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function encodeRgbaPng({ width, height, rgba }) {
|
|
134
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw new Error("Invalid PNG dimensions")
|
|
135
|
+
if (!Buffer.isBuffer(rgba) || rgba.length !== width * height * 4) throw new Error("Invalid RGBA buffer")
|
|
136
|
+
const ihdr = Buffer.alloc(13)
|
|
137
|
+
ihdr.writeUInt32BE(width, 0)
|
|
138
|
+
ihdr.writeUInt32BE(height, 4)
|
|
139
|
+
ihdr[8] = 8
|
|
140
|
+
ihdr[9] = 6
|
|
141
|
+
const scan = Buffer.alloc(height * (1 + width * 4))
|
|
142
|
+
for (let y = 0; y < height; y += 1) {
|
|
143
|
+
const row = y * (1 + width * 4)
|
|
144
|
+
scan[row] = 0
|
|
145
|
+
rgba.copy(scan, row + 1, y * width * 4, (y + 1) * width * 4)
|
|
146
|
+
}
|
|
147
|
+
return Buffer.concat([SIGNATURE, chunk("IHDR", ihdr), chunk("IDAT", deflateSync(scan)), chunk("IEND", Buffer.alloc(0))])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function comparePngBuffers(expectedBuffer, actualBuffer, options = {}) {
|
|
151
|
+
const expected = decodePng(expectedBuffer)
|
|
152
|
+
const actual = decodePng(actualBuffer)
|
|
153
|
+
if (expected.width !== actual.width || expected.height !== actual.height) {
|
|
154
|
+
return {
|
|
155
|
+
schemaVersion: 1,
|
|
156
|
+
verdict: "FAIL",
|
|
157
|
+
reason: "dimension-mismatch",
|
|
158
|
+
expected: { width: expected.width, height: expected.height },
|
|
159
|
+
actual: { width: actual.width, height: actual.height },
|
|
160
|
+
differentPixels: expected.width * expected.height,
|
|
161
|
+
differentRatio: 1,
|
|
162
|
+
bounds: { x: 0, y: 0, width: Math.max(expected.width, actual.width), height: Math.max(expected.height, actual.height) },
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const threshold = Math.max(0, Math.min(255, Number(options.threshold ?? 16)))
|
|
167
|
+
const allowedRatio = Math.max(0, Math.min(1, Number(options.maxDiffRatio ?? 0)))
|
|
168
|
+
let differentPixels = 0
|
|
169
|
+
let minX = expected.width
|
|
170
|
+
let minY = expected.height
|
|
171
|
+
let maxX = -1
|
|
172
|
+
let maxY = -1
|
|
173
|
+
|
|
174
|
+
for (let pixel = 0; pixel < expected.width * expected.height; pixel += 1) {
|
|
175
|
+
const offset = pixel * 4
|
|
176
|
+
let delta = 0
|
|
177
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
178
|
+
delta = Math.max(delta, Math.abs(expected.rgba[offset + channel] - actual.rgba[offset + channel]))
|
|
179
|
+
}
|
|
180
|
+
if (delta <= threshold) continue
|
|
181
|
+
differentPixels += 1
|
|
182
|
+
const x = pixel % expected.width
|
|
183
|
+
const y = Math.floor(pixel / expected.width)
|
|
184
|
+
minX = Math.min(minX, x); maxX = Math.max(maxX, x)
|
|
185
|
+
minY = Math.min(minY, y); maxY = Math.max(maxY, y)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const totalPixels = expected.width * expected.height
|
|
189
|
+
const differentRatio = totalPixels ? differentPixels / totalPixels : 0
|
|
190
|
+
const bounds = differentPixels
|
|
191
|
+
? { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 }
|
|
192
|
+
: null
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
schemaVersion: 1,
|
|
196
|
+
verdict: differentRatio <= allowedRatio ? "PASS" : "FAIL",
|
|
197
|
+
width: expected.width,
|
|
198
|
+
height: expected.height,
|
|
199
|
+
differentPixels,
|
|
200
|
+
differentRatio,
|
|
201
|
+
threshold,
|
|
202
|
+
maxDiffRatio: allowedRatio,
|
|
203
|
+
bounds,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function comparePngFiles(expectedFile, actualFile, options = {}) {
|
|
208
|
+
return comparePngBuffers(await readFile(expectedFile), await readFile(actualFile), options)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function cropDecodedPng(decoded, bounds) {
|
|
212
|
+
const x = Math.max(0, Math.floor(Number(bounds?.x || 0)))
|
|
213
|
+
const y = Math.max(0, Math.floor(Number(bounds?.y || 0)))
|
|
214
|
+
const width = Math.max(1, Math.min(decoded.width - x, Math.floor(Number(bounds?.width || decoded.width))))
|
|
215
|
+
const height = Math.max(1, Math.min(decoded.height - y, Math.floor(Number(bounds?.height || decoded.height))))
|
|
216
|
+
const rgba = Buffer.alloc(width * height * 4)
|
|
217
|
+
for (let row = 0; row < height; row += 1) {
|
|
218
|
+
const sourceStart = ((y + row) * decoded.width + x) * 4
|
|
219
|
+
decoded.rgba.copy(rgba, row * width * 4, sourceStart, sourceStart + width * 4)
|
|
220
|
+
}
|
|
221
|
+
return { width, height, rgba }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function cropPngFile(inputFile, outputFile, bounds) {
|
|
225
|
+
const decoded = decodePng(await readFile(inputFile))
|
|
226
|
+
const cropped = cropDecodedPng(decoded, bounds)
|
|
227
|
+
await writeFile(outputFile, encodeRgbaPng(cropped))
|
|
228
|
+
return { file: outputFile, width: cropped.width, height: cropped.height }
|
|
229
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
function canonical(value) {
|
|
4
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
5
|
+
if (!value || typeof value !== "object") return value
|
|
6
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function stringify(value) {
|
|
10
|
+
return typeof value === "string" ? value : JSON.stringify(canonical(value))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function digest(value) {
|
|
14
|
+
return createHash("sha256").update(stringify(value)).digest("hex")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function chars(value) {
|
|
18
|
+
return stringify(value ?? "").length
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildPromptEnvelope(input = {}) {
|
|
22
|
+
const stable = {
|
|
23
|
+
invariants: input.invariants || "",
|
|
24
|
+
role: input.role || "",
|
|
25
|
+
skills: input.skills || [],
|
|
26
|
+
projectFacts: input.projectFacts || {},
|
|
27
|
+
toolPolicy: input.toolPolicy || {},
|
|
28
|
+
}
|
|
29
|
+
const dynamic = {
|
|
30
|
+
task: input.task || {},
|
|
31
|
+
evidence: input.evidence || [],
|
|
32
|
+
recentFailure: input.recentFailure || null,
|
|
33
|
+
nextAction: input.nextAction || null,
|
|
34
|
+
recentMessages: input.recentMessages || [],
|
|
35
|
+
}
|
|
36
|
+
const stableChars = chars(stable)
|
|
37
|
+
const dynamicChars = chars(dynamic)
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
stable,
|
|
41
|
+
dynamic,
|
|
42
|
+
stablePrefixHash: digest(stable),
|
|
43
|
+
dynamicHash: digest(dynamic),
|
|
44
|
+
stableChars,
|
|
45
|
+
dynamicChars,
|
|
46
|
+
cacheableRatio: stableChars + dynamicChars ? stableChars / (stableChars + dynamicChars) : 0,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function comparePromptEnvelopes(previous, current) {
|
|
51
|
+
const stableReused = Boolean(previous?.stablePrefixHash && previous.stablePrefixHash === current?.stablePrefixHash)
|
|
52
|
+
const repeatedStableChars = stableReused ? Number(current?.stableChars || 0) : 0
|
|
53
|
+
return {
|
|
54
|
+
schemaVersion: 1,
|
|
55
|
+
stableReused,
|
|
56
|
+
repeatedStableChars,
|
|
57
|
+
avoidableRepeatedChars: stableReused ? 0 : Math.min(Number(previous?.stableChars || 0), Number(current?.stableChars || 0)),
|
|
58
|
+
currentCacheableRatio: Number(current?.cacheableRatio || 0),
|
|
59
|
+
}
|
|
60
|
+
}
|