opencode-agent-skill 13.0.0-beta.2 → 14.2.0-beta.1

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 (93) hide show
  1. package/README.md +1556 -607
  2. package/bin/ocskill.mjs +172 -24
  3. package/docs/OPENCODE-COMPAT.md +34 -97
  4. package/docs/PI-COMPAT.md +188 -0
  5. package/docs/V14-CONTEXT-MEMORY-FABRIC.md +70 -0
  6. package/docs/V14.1-QUALITY-PERFORMANCE-FABRIC.md +114 -0
  7. package/docs/V14.2-TURBO-WEAK-MODEL-RUNTIME.md +448 -0
  8. package/evals/v14/tasks.json +46 -0
  9. package/global-config/agents/executor.md +7 -0
  10. package/global-config/agents/visual-verifier.md +22 -3
  11. package/global-config/plugins/ues-router/index.js +13 -8
  12. package/global-config/plugins/ues-router/policy-runtime.js +7 -0
  13. package/global-config/plugins/ues-router/router.js +12 -2
  14. package/global-config/skills/ecommerce-engineering/SKILL.md +1 -1
  15. package/global-config/skills/file-upload-engineering/SKILL.md +1 -1
  16. package/global-config/skills/git-safety/SKILL.md +1 -1
  17. package/global-config/skills/nestjs-engineering/SKILL.md +1 -1
  18. package/global-config/skills/performance-engineering/SKILL.md +1 -1
  19. package/global-config/skills/react-native-engineering/SKILL.md +1 -1
  20. package/global-config/skills/rest-api-design/SKILL.md +1 -1
  21. package/global-config/skills/ui-ux-engineering/SKILL.md +1 -1
  22. package/lib/adaptive-context-budget.mjs +97 -0
  23. package/lib/affected-tests.mjs +260 -0
  24. package/lib/benchmark-confidence.mjs +41 -2
  25. package/lib/browser-mcp-routing.mjs +166 -0
  26. package/lib/capability-fabric.mjs +336 -0
  27. package/lib/capability-registry.mjs +9 -0
  28. package/lib/context-engine-v11.mjs +65 -1
  29. package/lib/context-graph-rank.mjs +118 -0
  30. package/lib/context-manifest.mjs +97 -18
  31. package/lib/control-center.mjs +19 -1
  32. package/lib/dynamic-workflow.mjs +3 -1
  33. package/lib/evidence-store.mjs +82 -1
  34. package/lib/hierarchical-context.mjs +215 -0
  35. package/lib/memory-engine.mjs +465 -0
  36. package/lib/model-performance.mjs +33 -8
  37. package/lib/model-policy.mjs +3 -3
  38. package/lib/orchestrator-policy.mjs +5 -209
  39. package/lib/performance-fabric.mjs +229 -0
  40. package/lib/pi-rpc-pool.mjs +433 -0
  41. package/lib/process-hang-detector.mjs +83 -0
  42. package/lib/process-supervisor.mjs +193 -0
  43. package/lib/prompt-cache.mjs +2 -0
  44. package/lib/repo-graph.mjs +53 -2
  45. package/lib/runtime-config.mjs +31 -0
  46. package/lib/safety.mjs +132 -0
  47. package/lib/semantic-index.mjs +52 -3
  48. package/lib/skill-compiler.mjs +128 -0
  49. package/lib/skill-quality.mjs +48 -2
  50. package/lib/task-engine.mjs +66 -5
  51. package/lib/task-policy.mjs +235 -0
  52. package/lib/verification-broker.mjs +284 -0
  53. package/lib/verification-command.mjs +111 -0
  54. package/lib/windows-shim.mjs +35 -0
  55. package/lib/workspace-fingerprint.mjs +198 -0
  56. package/package.json +52 -42
  57. package/pi/extensions/ues-child-runtime.ts +238 -0
  58. package/pi/extensions/ues.ts +3200 -0
  59. package/pi/prompts/ues-audit.md +9 -0
  60. package/pi/prompts/ues-critique.md +9 -0
  61. package/pi/prompts/ues-debug.md +9 -0
  62. package/pi/prompts/ues-feature.md +9 -0
  63. package/pi/prompts/ues-fix.md +9 -0
  64. package/pi/prompts/ues-plan.md +9 -0
  65. package/pi/prompts/ues-research.md +9 -0
  66. package/pi/prompts/ues-resume.md +9 -0
  67. package/pi/prompts/ues-review.md +7 -0
  68. package/pi/prompts/ues-run.md +17 -0
  69. package/pi/prompts/ues-verify.md +9 -0
  70. package/scripts/check-release-consistency.mjs +119 -185
  71. package/scripts/check-runtime-exports.mjs +66 -0
  72. package/scripts/check-source-integrity.mjs +184 -0
  73. package/scripts/eval-pi.mjs +492 -0
  74. package/scripts/install.mjs +16 -0
  75. package/scripts/smoke-package-closure.mjs +110 -0
  76. package/scripts/smoke-packed-install.mjs +24 -11
  77. package/scripts/smoke-pi-extension.mjs +144 -0
  78. package/scripts/uninstall.mjs +44 -0
  79. package/CHANGELOG.md +0 -415
  80. package/docs/DETERMINISTIC-TOOLS.md +0 -105
  81. package/docs/ENGINEERING-DESIGN.md +0 -194
  82. package/docs/EVALS.md +0 -158
  83. package/docs/GITHUB-RULESET.md +0 -50
  84. package/docs/NPM-PUBLISH.md +0 -116
  85. package/docs/RESEARCH-SOURCES.md +0 -37
  86. package/docs/TRACE-SCHEMA.md +0 -122
  87. package/docs/V11-PERCEPTION-ADAPTIVE-EXECUTION.md +0 -75
  88. package/docs/V11-PERCEPTION-ADAPTIVE.md +0 -220
  89. package/docs/V12-WEAK-MODEL-INTELLIGENCE.md +0 -27
  90. package/docs/V13-PARALLEL-WEAK-MODEL-RUNTIME.md +0 -86
  91. package/docs/V7-INTELLIGENCE-RUNTIME.md +0 -166
  92. package/docs/V8-INTELLIGENCE-RELIABILITY.md +0 -206
  93. package/docs/V9-SPEED-INTELLIGENCE.md +0 -102
@@ -0,0 +1,235 @@
1
+ const SENSITIVE_DOMAIN = /(\bauth\b|authorization|authentication|security|permission|payment|schema|database|production|deploy|public api|secret|credential|phân quyền|bảo mật|thanh toán|cơ sở dữ liệu|triển khai|api công khai|bí mật|thông tin xác thực)/i
2
+ const HIGH_RISK_MUTATION = /((?:fix|change|modify|update|alter|migrate|drop|truncate|delete|remove|rotate|deploy|publish|push|sửa|thay đổi|cập nhật|xóa|xoá|di trú|chuyển đổi|triển khai).{0,64}(?:\bauth\b|authorization|authentication|security|permission|payment(?: handling| flow)?|schema|database|production|public api|secret|credential|phân quyền|bảo mật|thanh toán|cơ sở dữ liệu|api công khai|bí mật|thông tin xác thực)|(?:\bauth\b|authorization|authentication|security|permission|payment(?: handling| flow)?|schema|database|production|public api|secret|credential|phân quyền|bảo mật|thanh toán|cơ sở dữ liệu|api công khai|bí mật|thông tin xác thực).{0,64}(?:fix|change|modify|update|alter|migrate|drop|truncate|delete|remove|rotate|deploy|publish|push|sửa|thay đổi|cập nhật|xóa|xoá|di trú|chuyển đổi|triển khai)|database migration|schema migration|migrate database|migrate schema|drop table|truncate table|deploy(?:ment)?\s+(?:to\s+)?production|production\s+deploy(?:ment)?|rotate\s+(?:secret|credential)|breaking\s+(?:change\s+to\s+)?(?:public\s+)?api|npm publish|git push|force push|reset --hard|git clean)/i
3
+ 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|refactor all|multi[- ]step migration|migration across|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
4
+ 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
5
+ const CONTRACT = /(public api|api contract|openapi|response schema|request schema|breaking api|hợp đồng api|api công khai)/i
6
+ const DATA = /(database|sql|migration|schema|transaction|index|cơ sở dữ liệu|dữ liệu|migrate)/i
7
+
8
+ function riskTextFor(value) {
9
+ return String(value || "")
10
+ .replace(/\b(?:do not|don't|without)\s+(?:edit|modify|change|write|delete|remove)[^.\n]*/gi, "")
11
+ .replace(/\b(?:no|read[- ]only)\s+(?:edits?|changes?|writes?)[^.\n]*/gi, "")
12
+ .replace(/không\s+(?:sửa|chỉnh sửa|thay đổi|ghi|xóa|xoá)[^.\n]*/gi, "")
13
+ .replace(/chỉ\s+đọc[^.\n]*/gi, "")
14
+ }
15
+
16
+ function signal(name, matched, weight) {
17
+ return matched ? { name, weight } : null
18
+ }
19
+
20
+ function profileFor(mode, risk) {
21
+ if (mode === "inline" && risk === "low") {
22
+ return {
23
+ name: "fast",
24
+ maxSkills: 2,
25
+ contextBudget: 8_000,
26
+ contextStrategy: "incremental-semantic",
27
+ skillLoading: "direct-only",
28
+ durableState: false,
29
+ worktree: "off",
30
+ critic: "off",
31
+ verification: "targeted",
32
+ fullCI: false,
33
+ containerVerification: "off",
34
+ }
35
+ }
36
+ if (mode === "long-horizon" || risk === "high") {
37
+ return {
38
+ name: "deep",
39
+ maxSkills: 5,
40
+ contextBudget: 48_000,
41
+ contextStrategy: "semantic+graph+git",
42
+ skillLoading: "orchestrated",
43
+ durableState: true,
44
+ worktree: "auto-writers",
45
+ critic: "required-for-high-risk-or-final",
46
+ verification: "targeted+integration",
47
+ fullCI: true,
48
+ containerVerification: risk === "high" ? "preferred-if-capable" : "optional",
49
+ }
50
+ }
51
+ return {
52
+ name: "standard",
53
+ maxSkills: 4,
54
+ contextBudget: 20_000,
55
+ contextStrategy: "incremental-semantic+git",
56
+ skillLoading: "selective",
57
+ durableState: false,
58
+ worktree: "auto-on-conflict",
59
+ critic: "on-failure-or-elevated-risk",
60
+ verification: "targeted+affected",
61
+ fullCI: false,
62
+ containerVerification: "off",
63
+ }
64
+ }
65
+
66
+ function boundedInt(value, fallback, min, max) {
67
+ const parsed = Number(value)
68
+ if (!Number.isFinite(parsed)) return fallback
69
+ return Math.min(max, Math.max(min, Math.round(parsed)))
70
+ }
71
+
72
+ export function recoveryPolicyForAttempt(taskPolicy = {}, attempt = 1) {
73
+ const normalizedAttempt = boundedInt(attempt, 1, 1, 99)
74
+ const baseBudget = boundedInt(
75
+ taskPolicy.contextBudget ?? taskPolicy.profile?.contextBudget,
76
+ 20_000,
77
+ 4_000,
78
+ 48_000,
79
+ )
80
+ const baseSkills = boundedInt(
81
+ taskPolicy.maxSkills ?? taskPolicy.profile?.maxSkills,
82
+ 4,
83
+ 1,
84
+ 5,
85
+ )
86
+ const baseStrategy = taskPolicy.profile?.contextStrategy || "incremental-semantic+git"
87
+
88
+ if (normalizedAttempt <= 1) {
89
+ return {
90
+ schemaVersion: 1,
91
+ stage: "initial",
92
+ attempt: normalizedAttempt,
93
+ contextBudget: baseBudget,
94
+ maxSkills: baseSkills,
95
+ contextStrategy: baseStrategy,
96
+ requireDiagnosis: false,
97
+ requireCritic: false,
98
+ modelEscalation: false,
99
+ directives: [],
100
+ }
101
+ }
102
+
103
+ if (normalizedAttempt === 2) {
104
+ return {
105
+ schemaVersion: 1,
106
+ stage: "diagnose",
107
+ attempt: normalizedAttempt,
108
+ contextBudget: Math.min(48_000, Math.max(baseBudget, Math.round(baseBudget * 1.35))),
109
+ maxSkills: Math.min(5, baseSkills + 1),
110
+ contextStrategy:
111
+ taskPolicy.executionProfile === "fast"
112
+ ? "incremental-semantic+git"
113
+ : baseStrategy,
114
+ requireDiagnosis: true,
115
+ requireCritic: false,
116
+ modelEscalation: true,
117
+ directives: [
118
+ "reproduce or capture the exact previous failure before editing",
119
+ "inspect the direct caller, nearest test and failure-adjacent evidence",
120
+ "do not stack another speculative patch on top of the failed attempt",
121
+ ],
122
+ }
123
+ }
124
+
125
+ return {
126
+ schemaVersion: 1,
127
+ stage: "deep-recovery",
128
+ attempt: normalizedAttempt,
129
+ contextBudget: Math.min(48_000, Math.max(20_000, Math.round(baseBudget * 1.75))),
130
+ maxSkills: Math.min(5, baseSkills + 2),
131
+ contextStrategy: "semantic+graph+git",
132
+ requireDiagnosis: true,
133
+ requireCritic: true,
134
+ modelEscalation: true,
135
+ directives: [
136
+ "re-investigate from fresh evidence and explicitly reject the failed hypothesis",
137
+ "expand to callers, dependencies, tests and boundary contracts before editing",
138
+ "challenge the architecture or coupling if repeated fixes expose a wider problem",
139
+ "run an independent critic or review pass before accepting the recovery",
140
+ ],
141
+ }
142
+ }
143
+
144
+ export function shouldRunDedicatedDiagnosis(taskPolicy = {}, attempt = 1) {
145
+ const hasDebugSignal = Array.isArray(taskPolicy.signals) &&
146
+ taskPolicy.signals.some((item) => item?.name === "debugging")
147
+ if (!hasDebugSignal) return false
148
+
149
+ const normalizedAttempt = boundedInt(attempt, 1, 1, 99)
150
+ if (normalizedAttempt > 1) {
151
+ return recoveryPolicyForAttempt(taskPolicy, normalizedAttempt).requireDiagnosis === true
152
+ }
153
+
154
+ return taskPolicy.executionProfile !== "fast" || taskPolicy.risk !== "low"
155
+ }
156
+
157
+ export function classifyEngineeringTask(text, facts = {}) {
158
+ const value = String(text || "")
159
+ const riskText = riskTextFor(value)
160
+ const sensitiveDomain = SENSITIVE_DOMAIN.test(value)
161
+ const sensitiveMutation = HIGH_RISK_MUTATION.test(riskText)
162
+ const declaredHighRisk =
163
+ ["high", "critical"].includes(String(facts.risk || "").toLowerCase()) ||
164
+ /\b(?:risk|rủi ro)\s*[:=\/-]?\s*(?:high|critical|cao|nghiêm trọng)\b/i.test(value) ||
165
+ /\b(?:high|critical)[-\s]?(?:risk|rủi ro)\b/i.test(value)
166
+ const declaredLongHorizon =
167
+ facts.longHorizon === true ||
168
+ ["long", "long-horizon", "deep"].includes(String(facts.mode || "").toLowerCase())
169
+ const compoundLongRisk =
170
+ /\blong\s*[/|,+]\s*(?:high|critical)[-\s]?risk\b/i.test(value) ||
171
+ /\b(?:high|critical)[-\s]?risk\s*[/|,+]\s*long\b/i.test(value)
172
+ const explicitLongHorizon = declaredLongHorizon || compoundLongRisk || LONG.test(value)
173
+ const signals = [
174
+ signal("long-request-text", value.length > 700, 1),
175
+ signal("medium-request-text", value.length > 250, 1),
176
+ signal("high-risk-domain", sensitiveDomain, 1),
177
+ signal("high-risk-operation", sensitiveMutation, 2),
178
+ signal("declared-high-risk", declaredHighRisk, 2),
179
+ signal("explicit-long-horizon", explicitLongHorizon, 2),
180
+ signal("debugging", DEBUG.test(value), 1),
181
+ signal("public-contract", CONTRACT.test(value) || facts.hasPublicContract, 2),
182
+ signal("data-migration", (DATA.test(riskText) && /migration|schema|migrate|di trú|chuyển đổi/i.test(riskText)) || facts.hasMigration, 2),
183
+ signal("many-changed-files", Number(facts.changedFiles || 0) > 5, 1),
184
+ signal("very-many-changed-files", Number(facts.changedFiles || 0) > 12, 1),
185
+ signal("large-repository", Number(facts.repoFiles || 0) > 1500, 1),
186
+ signal("monorepo", facts.monorepo === true, 1),
187
+ ].filter(Boolean)
188
+
189
+ const score = signals.reduce((sum, item) => sum + item.weight, 0)
190
+ const highRisk = declaredHighRisk || sensitiveMutation || Boolean(facts.hasMigration) || Boolean(facts.hasPublicContract)
191
+ const risk = highRisk ? "high" : score >= 3 ? "medium" : "low"
192
+ const mode = explicitLongHorizon || score >= 4 ? "long-horizon" : score >= 2 ? "standard" : "inline"
193
+ const modelTier = risk === "high" || mode === "long-horizon" ? "heavy" : score >= 2 ? "standard" : "light"
194
+ const maxAttempts = risk === "high" ? 2 : 3
195
+ const profile = profileFor(mode, risk)
196
+
197
+ const domains = []
198
+ if (/(auth|permission|tenant|token|session|phân quyền|xác thực)/i.test(value)) domains.push("auth-security")
199
+ if (/(payment|checkout|refund|webhook|thanh toán|hoàn tiền)/i.test(value)) domains.push("payment")
200
+ if (DATA.test(value)) domains.push("database")
201
+ if (CONTRACT.test(value)) domains.push("api-contract")
202
+ if (/(react native|expo|android|ios|gradle|xcode)/i.test(value)) domains.push("react-native")
203
+ else if (/(next\.js|nextjs|app router|server component)/i.test(value)) domains.push("nextjs")
204
+ else if (/\breact\b|useeffect|usestate|component/i.test(value)) domains.push("react")
205
+ if (/(docker|kubernetes|terraform|github actions|ci\/cd|deploy|triển khai)/i.test(value)) domains.push("devops")
206
+
207
+ return {
208
+ schemaVersion: 4,
209
+ score,
210
+ signals,
211
+ risk,
212
+ mode,
213
+ executionProfile: profile.name,
214
+ modelTier,
215
+ maxAttempts,
216
+ contextBudget: profile.contextBudget,
217
+ maxSkills: profile.maxSkills,
218
+ requirePlanCheck: profile.durableState || risk === "high",
219
+ requireIntegrationVerification: mode !== "inline" || risk === "high",
220
+ requireFreshEvidence: true,
221
+ profile,
222
+ domains: [...new Set(domains)],
223
+ recovery: {
224
+ escalateAfterFailure: true,
225
+ diagnosisBeforePatch: true,
226
+ deepRecoveryFromAttempt: 3,
227
+ },
228
+ antiHallucination: {
229
+ evidenceFirst: true,
230
+ noCompletionWithoutVerification: mode !== "inline" || risk === "high",
231
+ noUnsupportedSemanticClaims: true,
232
+ failClosedOnMissingCapability: risk === "high",
233
+ },
234
+ }
235
+ }
@@ -0,0 +1,284 @@
1
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { createHash, randomUUID } from "node:crypto"
4
+ import { createVerificationReceipt, validateVerificationReceipt } from "./evidence-receipt.mjs"
5
+ import { getEvidence, putEvidence } from "./evidence-store.mjs"
6
+ import { runtimeWorkspaceFingerprint } from "./workspace-fingerprint.mjs"
7
+
8
+ const CACHE_VERSION = 1
9
+ const CACHE_FILE = "verification-broker-v1.json"
10
+
11
+ const CACHE_WRITE_TAILS = new Map()
12
+ const CACHE_LOCK_SUFFIX = ".lock"
13
+ const CACHE_LOCK_STALE_MS = 5_000
14
+ const CACHE_LOCK_WAIT_MS = 8_000
15
+
16
+ function sleep(ms) {
17
+ return new Promise((resolve) => setTimeout(resolve, ms))
18
+ }
19
+
20
+ function cacheLockPath(root) {
21
+ return cachePath(root) + CACHE_LOCK_SUFFIX
22
+ }
23
+
24
+ async function withCrossProcessCacheLock(root, fn) {
25
+ const lockDir = cacheLockPath(root)
26
+ await mkdir(path.dirname(lockDir), { recursive: true })
27
+ const deadline = Date.now() + CACHE_LOCK_WAIT_MS
28
+ let delayMs = 8
29
+
30
+ while (true) {
31
+ try {
32
+ await mkdir(lockDir)
33
+ break
34
+ } catch (error) {
35
+ if (error?.code !== "EEXIST") throw error
36
+
37
+ const info = await stat(lockDir).catch(() => null)
38
+ if (info && Date.now() - info.mtimeMs > CACHE_LOCK_STALE_MS) {
39
+ const confirmed = await stat(lockDir).catch(() => null)
40
+ if (
41
+ confirmed &&
42
+ confirmed.ino === info.ino &&
43
+ confirmed.mtimeMs === info.mtimeMs
44
+ ) {
45
+ await rm(lockDir, { recursive: true, force: true }).catch(() => {})
46
+ continue
47
+ }
48
+ }
49
+ if (Date.now() >= deadline) {
50
+ throw new Error("Timed out waiting for verification broker cache lock")
51
+ }
52
+ await sleep(delayMs)
53
+ delayMs = Math.min(80, delayMs * 2)
54
+ }
55
+ }
56
+
57
+ try {
58
+ return await fn()
59
+ } finally {
60
+ await rm(lockDir, { recursive: true, force: true }).catch(() => {})
61
+ }
62
+ }
63
+
64
+ async function withCacheWriteLock(root, fn) {
65
+ const key = path.resolve(root)
66
+ const previous = CACHE_WRITE_TAILS.get(key) || Promise.resolve()
67
+ let release
68
+ const barrier = new Promise((resolve) => { release = resolve })
69
+ const tail = previous.catch(() => {}).then(() => barrier)
70
+ CACHE_WRITE_TAILS.set(key, tail)
71
+
72
+ await previous.catch(() => {})
73
+ try {
74
+ return await withCrossProcessCacheLock(root, fn)
75
+ } finally {
76
+ release()
77
+ if (CACHE_WRITE_TAILS.get(key) === tail) CACHE_WRITE_TAILS.delete(key)
78
+ }
79
+ }
80
+
81
+ function cachePath(root) {
82
+ return path.join(path.resolve(root), ".ues-cache", CACHE_FILE)
83
+ }
84
+
85
+ function keyFor(command, args = []) {
86
+ return createHash("sha256")
87
+ .update(JSON.stringify([String(command || ""), (args || []).map(String)]))
88
+ .digest("hex")
89
+ }
90
+
91
+ function receiptReusableAtFingerprint(receipt, fingerprint) {
92
+ if (!validateVerificationReceipt(receipt).valid) return false
93
+ if (receipt.exitCode !== 0 || receipt.passed !== true) return false
94
+ if (!Number.isFinite(Date.parse(receipt.startedAt || ""))) return false
95
+ if (!Number.isFinite(Date.parse(receipt.finishedAt || ""))) return false
96
+ return Boolean(
97
+ receipt.workspaceBefore &&
98
+ receipt.workspaceAfter &&
99
+ receipt.workspaceBefore === receipt.workspaceAfter &&
100
+ receipt.workspaceAfter === fingerprint
101
+ )
102
+ }
103
+
104
+ function entryMatchesKey(key, entry) {
105
+ const receipt = entry?.receipt
106
+ if (!receipt || !String(receipt.command || "").trim()) return false
107
+ if (!Array.isArray(receipt.args)) return false
108
+ return keyFor(receipt.command, receipt.args) === key
109
+ }
110
+
111
+ function finishedAtMs(entry) {
112
+ const value = Date.parse(entry?.finishedAt || entry?.receipt?.finishedAt || "")
113
+ return Number.isFinite(value) ? value : null
114
+ }
115
+
116
+ function freshEnough(entry, maxAgeMs, now = Date.now()) {
117
+ const finished = finishedAtMs(entry)
118
+ if (finished == null) return false
119
+ if (finished > now + 60_000) return false
120
+ if (!maxAgeMs) return true
121
+ return now - finished <= maxAgeMs
122
+ }
123
+
124
+ async function readCache(root) {
125
+ try {
126
+ const parsed = JSON.parse(await readFile(cachePath(root), "utf8"))
127
+ if (parsed?.schemaVersion !== CACHE_VERSION || typeof parsed.entries !== "object") return { schemaVersion: CACHE_VERSION, entries: {} }
128
+ return parsed
129
+ } catch {
130
+ return { schemaVersion: CACHE_VERSION, entries: {} }
131
+ }
132
+ }
133
+
134
+ async function writeCache(root, value) {
135
+ const file = cachePath(root)
136
+ await mkdir(path.dirname(file), { recursive: true })
137
+ const temp = file + "." + process.pid + "." + Date.now() + "." + randomUUID() + ".tmp"
138
+ await writeFile(temp, JSON.stringify(value, null, 2) + "\n", "utf8")
139
+ try {
140
+ await rename(temp, file)
141
+ } catch (error) {
142
+ await rm(temp, { force: true }).catch(() => {})
143
+ throw error
144
+ }
145
+ }
146
+
147
+ export async function findReusableVerification(root, command, args = [], options = {}) {
148
+ root = path.resolve(root)
149
+ const maxAgeMs = Math.max(0, Number(options.maxAgeMs ?? 20 * 60_000))
150
+ const cache = await readCache(root)
151
+ const key = keyFor(command, args)
152
+ const entry = cache.entries[key]
153
+ if (!entry || !entryMatchesKey(key, entry)) return null
154
+ if (!freshEnough(entry, maxAgeMs)) return null
155
+
156
+ const currentFingerprint = String(options.workspaceFingerprint || runtimeWorkspaceFingerprint(root))
157
+ if (!receiptReusableAtFingerprint(entry.receipt, currentFingerprint)) return null
158
+
159
+ const stdout = entry.stdoutRef
160
+ ? await getEvidence(root, entry.stdoutRef, { maxBytes: options.maxBytes || 16_000 }).catch(() => null)
161
+ : null
162
+ const stderr = entry.stderrRef
163
+ ? await getEvidence(root, entry.stderrRef, { maxBytes: options.maxBytes || 8_000 }).catch(() => null)
164
+ : null
165
+
166
+ return {
167
+ schemaVersion: 1,
168
+ reused: true,
169
+ key,
170
+ receipt: entry.receipt,
171
+ stdoutRef: entry.stdoutRef || null,
172
+ stderrRef: entry.stderrRef || null,
173
+ stdout: stdout?.content || "",
174
+ stderr: stderr?.content || "",
175
+ ageMs: Math.max(0, Date.now() - finishedAtMs(entry)),
176
+ }
177
+ }
178
+
179
+ export async function recordVerification(root, input = {}) {
180
+ root = path.resolve(root)
181
+ const stdout = String(input.stdout || "")
182
+ const stderr = String(input.stderr || "")
183
+ const stdoutEvidence = await putEvidence(root, stdout, {
184
+ kind: "verification-stdout",
185
+ source: input.command || "verification-broker",
186
+ summary: "Captured stdout preserved for reusable verification receipt",
187
+ })
188
+ const stderrEvidence = await putEvidence(root, stderr, {
189
+ kind: "verification-stderr",
190
+ source: input.command || "verification-broker",
191
+ summary: "Captured stderr preserved for reusable verification receipt",
192
+ })
193
+
194
+ const receipt = createVerificationReceipt({
195
+ task: input.task || null,
196
+ runId: input.runId || null,
197
+ command: input.command,
198
+ args: input.args || [],
199
+ cwd: root,
200
+ exitCode: input.exitCode,
201
+ startedAt: input.startedAt,
202
+ finishedAt: input.finishedAt,
203
+ durationMs: input.durationMs,
204
+ stdout,
205
+ stderr,
206
+ workspaceBefore: input.workspaceBefore || null,
207
+ workspaceAfter: input.workspaceAfter || runtimeWorkspaceFingerprint(root),
208
+ })
209
+
210
+ const key = keyFor(input.command, input.args || [])
211
+ await withCacheWriteLock(root, async () => {
212
+ const cache = await readCache(root)
213
+ cache.entries[key] = {
214
+ receipt,
215
+ stdoutRef: stdoutEvidence.ref,
216
+ stderrRef: stderrEvidence.ref,
217
+ finishedAt: receipt.finishedAt,
218
+ }
219
+
220
+ // Keep cache small and deterministic.
221
+ const rows = Object.entries(cache.entries)
222
+ .sort((a, b) => (finishedAtMs(b[1]) || 0) - (finishedAtMs(a[1]) || 0))
223
+ .slice(0, 200)
224
+ cache.entries = Object.fromEntries(rows)
225
+ await writeCache(root, cache)
226
+ })
227
+
228
+ return {
229
+ schemaVersion: 1,
230
+ reused: false,
231
+ key,
232
+ receipt,
233
+ stdoutRef: stdoutEvidence.ref,
234
+ stderrRef: stderrEvidence.ref,
235
+ }
236
+ }
237
+
238
+ export async function listReusableVerification(root, options = {}) {
239
+ root = path.resolve(root)
240
+ const currentFingerprint = String(options.workspaceFingerprint || runtimeWorkspaceFingerprint(root))
241
+ const maxAgeMs = Math.max(0, Number(options.maxAgeMs ?? 30 * 60_000))
242
+ const limit = Math.max(1, Math.min(50, Number(options.limit || 12)))
243
+ const now = Date.now()
244
+ const cache = await readCache(root)
245
+ const rows = []
246
+
247
+ for (const [key, entry] of Object.entries(cache.entries || {})) {
248
+ const receipt = entry?.receipt
249
+ if (!entryMatchesKey(key, entry)) continue
250
+ if (!receiptReusableAtFingerprint(receipt, currentFingerprint)) continue
251
+ const finished = finishedAtMs(entry)
252
+ if (!freshEnough(entry, maxAgeMs, now)) continue
253
+ rows.push({
254
+ key,
255
+ receipt,
256
+ stdoutRef: entry.stdoutRef || null,
257
+ stderrRef: entry.stderrRef || null,
258
+ finishedAt: entry.finishedAt || receipt.finishedAt || null,
259
+ })
260
+ }
261
+
262
+ rows.sort((a, b) => (Date.parse(b.finishedAt || "") || 0) - (Date.parse(a.finishedAt || "") || 0))
263
+ const selected = rows.slice(0, limit)
264
+ const output = []
265
+ for (const row of selected) {
266
+ const stdout = row.stdoutRef
267
+ ? await getEvidence(root, row.stdoutRef, { maxBytes: options.previewBytes || 2400 }).catch(() => null)
268
+ : null
269
+ const stderr = row.stderrRef
270
+ ? await getEvidence(root, row.stderrRef, { maxBytes: options.previewBytes || 1200 }).catch(() => null)
271
+ : null
272
+ output.push({
273
+ ...row,
274
+ stdoutPreview: stdout?.content || "",
275
+ stderrPreview: stderr?.content || "",
276
+ })
277
+ }
278
+ return {
279
+ schemaVersion: 1,
280
+ workspaceFingerprint: currentFingerprint,
281
+ count: output.length,
282
+ results: output,
283
+ }
284
+ }
@@ -0,0 +1,111 @@
1
+ export const VERIFICATION_COMMAND_RE =
2
+ /(?:^|\s|&&|;|\|)(?:pnpm|npm|yarn|bun|npx|node|python|pytest|go|cargo|dotnet|mvn|gradle|\.\/gradlew|gradlew\.bat)[^\n]*(?:test|jest|vitest|pytest|typecheck|tsc|lint|eslint|ruff|mypy|check|build|compile)/i
3
+
4
+ export function looksLikeVerificationCommand(command = "") {
5
+ return VERIFICATION_COMMAND_RE.test(String(command || ""))
6
+ }
7
+
8
+ export function hasMaskedShellExitRisk(command = "") {
9
+ const value = String(command || "")
10
+ // Receipt reuse is an optimization, so ambiguity fails closed. Quoted shell
11
+ // metacharacters may cause a false negative here, which only means rerunning
12
+ // the check; it can never create a false PASS receipt.
13
+ if (value.includes("||") || value.includes(";") || value.includes("|") || /\r|\n/.test(value)) {
14
+ return true
15
+ }
16
+ // Reject a single/background '&' while allowing '&&'.
17
+ const withoutAndAnd = value.replaceAll("&&", "")
18
+ if (withoutAndAnd.includes("&")) return true
19
+ return false
20
+ }
21
+
22
+ export function canRecordReusableVerification(command = "") {
23
+ const value = String(command || "").trim()
24
+ return Boolean(value) &&
25
+ looksLikeVerificationCommand(value) &&
26
+ !hasMaskedShellExitRisk(value)
27
+ }
28
+
29
+ function tokenizeSimpleShell(command) {
30
+ const value = String(command || "").trim()
31
+ if (!value || hasMaskedShellExitRisk(value)) return null
32
+ // Exact executable+args reuse is stricter than generic receipt eligibility:
33
+ // a compound chain has one aggregate exit status but no single argv identity.
34
+ if (/&&|\|\||;|\||\r|\n/.test(value)) return null
35
+ // Fail closed on expansion, redirection, globs and backslash-sensitive
36
+ // shell syntax. Receipt reuse is only an optimization, so false negatives
37
+ // here cost latency rather than correctness.
38
+ if (/[<>\\]/.test(value) || value.includes("\u0060") || /\$\(|\$\{|\$[A-Za-z_]|%[A-Za-z_][A-Za-z0-9_]*%|\*|\?|\[|\]/.test(value)) return null
39
+
40
+ const tokens = []
41
+ let current = ""
42
+ let quote = null
43
+ let escaped = false
44
+
45
+ const push = () => {
46
+ if (!current) return
47
+ tokens.push(current)
48
+ current = ""
49
+ }
50
+
51
+ for (let index = 0; index < value.length; index += 1) {
52
+ const char = value[index]
53
+ if (escaped) {
54
+ current += char
55
+ escaped = false
56
+ continue
57
+ }
58
+ if (char === "\\" && quote !== "'") {
59
+ escaped = true
60
+ continue
61
+ }
62
+ if (quote) {
63
+ if (char === quote) quote = null
64
+ else current += char
65
+ continue
66
+ }
67
+ if (char === "'" || char === '"') {
68
+ quote = char
69
+ continue
70
+ }
71
+ if (/\s/.test(char)) {
72
+ push()
73
+ continue
74
+ }
75
+ current += char
76
+ }
77
+
78
+ if (escaped || quote) return null
79
+ push()
80
+ return tokens.length ? tokens : null
81
+ }
82
+
83
+ export function canonicalVerificationCommand(command = "") {
84
+ const value = String(command || "").trim()
85
+ if (!canRecordReusableVerification(value)) return null
86
+
87
+ const tokens = tokenizeSimpleShell(value)
88
+ if (!tokens?.length) return null
89
+
90
+ const firstRaw = String(tokens[0] || "")
91
+ const first = firstRaw.toLowerCase()
92
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(firstRaw) || firstRaw.startsWith("~")) {
93
+ return null
94
+ }
95
+ if ((first === "cmd" || first === "cmd.exe") && /^\/c$/i.test(tokens[1] || "")) {
96
+ if (tokens.length < 3 || /\s/.test(tokens[2])) return null
97
+ return { command: tokens[2], args: tokens.slice(3), raw: value }
98
+ }
99
+
100
+ // PowerShell -Command is intentionally not canonicalized: quoting and command
101
+ // parsing semantics are too rich for a safe exact-reuse key.
102
+ if (first === "powershell" || first === "powershell.exe" || first === "pwsh" || first === "pwsh.exe") {
103
+ return null
104
+ }
105
+
106
+ return {
107
+ command: tokens[0],
108
+ args: tokens.slice(1),
109
+ raw: value,
110
+ }
111
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs"
2
2
  import path from "node:path"
3
+ import os from "node:os"
3
4
  import { spawnSync } from "node:child_process"
4
5
 
5
6
  function within(base, candidate) {
@@ -219,6 +220,40 @@ export function resolveWindowsCommand(name) {
219
220
  return resolveWindowsCommandCandidates(candidates)
220
221
  }
221
222
 
223
+ export function resolveManagedPiCommand(homeDir = os.homedir()) {
224
+ const releasesDir = path.join(homeDir, ".pi", "agent", "install", "releases")
225
+ let releases = []
226
+ try {
227
+ releases = readdirSync(releasesDir, { withFileTypes: true })
228
+ .filter((entry) => entry.isDirectory())
229
+ .map((entry) => entry.name)
230
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: "base" }))
231
+ } catch {
232
+ return null
233
+ }
234
+
235
+ for (const release of releases) {
236
+ const packageDir = path.join(
237
+ releasesDir,
238
+ release,
239
+ "node_modules",
240
+ "@earendil-works",
241
+ "pi-coding-agent",
242
+ )
243
+ const target = packageBinTarget(packageDir, "pi")
244
+ if (!target) continue
245
+ const resolved = executionForTarget(target)
246
+ if (resolved) {
247
+ return {
248
+ ...resolved,
249
+ source: target,
250
+ managedRelease: release,
251
+ }
252
+ }
253
+ }
254
+ return null
255
+ }
256
+
222
257
  function spawnWhere(name) {
223
258
  // Lazy import avoidance is unnecessary here; child_process is builtin and this
224
259
  // module is Node-only. Kept as one helper so tests can cover candidate selection