opencode-agent-skill 13.0.0-beta.2 → 14.4.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 (102) hide show
  1. package/README.md +1571 -607
  2. package/bin/ocskill.mjs +172 -24
  3. package/docs/OPENCODE-COMPAT.md +34 -97
  4. package/docs/PI-COMPAT.md +203 -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 +359 -0
  27. package/lib/capability-registry.mjs +9 -0
  28. package/lib/code-intelligence/edit-anchor.mjs +102 -0
  29. package/lib/code-intelligence/index.mjs +95 -0
  30. package/lib/code-intelligence/lsp-provider.mjs +189 -0
  31. package/lib/completion-auditor.mjs +82 -0
  32. package/lib/context-engine-v11.mjs +65 -1
  33. package/lib/context-graph-rank.mjs +118 -0
  34. package/lib/context-manifest.mjs +97 -18
  35. package/lib/control-center.mjs +19 -1
  36. package/lib/document-ingestion.mjs +60 -0
  37. package/lib/dynamic-workflow.mjs +3 -1
  38. package/lib/evidence-store.mjs +82 -1
  39. package/lib/fast-verification-gate.mjs +69 -0
  40. package/lib/hierarchical-context.mjs +215 -0
  41. package/lib/mcp-health.mjs +144 -0
  42. package/lib/mcp-tool-policy.mjs +19 -0
  43. package/lib/memory-engine.mjs +493 -0
  44. package/lib/model-performance.mjs +33 -8
  45. package/lib/model-policy.mjs +3 -3
  46. package/lib/orchestrator-policy.mjs +5 -209
  47. package/lib/performance-fabric.mjs +229 -0
  48. package/lib/pi-rpc-pool.mjs +433 -0
  49. package/lib/process-hang-detector.mjs +83 -0
  50. package/lib/process-supervisor.mjs +193 -0
  51. package/lib/prompt-cache.mjs +27 -11
  52. package/lib/repo-graph.mjs +53 -2
  53. package/lib/reversible-context.mjs +49 -0
  54. package/lib/runtime-config.mjs +31 -0
  55. package/lib/safety.mjs +132 -0
  56. package/lib/semantic-index.mjs +52 -3
  57. package/lib/skill-compiler.mjs +128 -0
  58. package/lib/skill-quality.mjs +48 -2
  59. package/lib/task-engine.mjs +62 -5
  60. package/lib/task-policy.mjs +291 -0
  61. package/lib/verification-broker.mjs +284 -0
  62. package/lib/verification-command.mjs +111 -0
  63. package/lib/windows-shim.mjs +35 -0
  64. package/lib/workspace-fingerprint.mjs +198 -0
  65. package/package.json +52 -42
  66. package/pi/extensions/ues-child-runtime.ts +393 -0
  67. package/pi/extensions/ues.ts +3327 -0
  68. package/pi/prompts/ues-audit.md +9 -0
  69. package/pi/prompts/ues-critique.md +9 -0
  70. package/pi/prompts/ues-debug.md +9 -0
  71. package/pi/prompts/ues-feature.md +9 -0
  72. package/pi/prompts/ues-fix.md +9 -0
  73. package/pi/prompts/ues-plan.md +9 -0
  74. package/pi/prompts/ues-research.md +9 -0
  75. package/pi/prompts/ues-resume.md +9 -0
  76. package/pi/prompts/ues-review.md +7 -0
  77. package/pi/prompts/ues-run.md +17 -0
  78. package/pi/prompts/ues-verify.md +9 -0
  79. package/scripts/check-release-consistency.mjs +119 -185
  80. package/scripts/check-runtime-exports.mjs +66 -0
  81. package/scripts/check-source-integrity.mjs +246 -0
  82. package/scripts/eval-pi.mjs +492 -0
  83. package/scripts/install.mjs +16 -0
  84. package/scripts/smoke-package-closure.mjs +110 -0
  85. package/scripts/smoke-packed-install.mjs +24 -11
  86. package/scripts/smoke-pi-extension.mjs +144 -0
  87. package/scripts/uninstall.mjs +44 -0
  88. package/CHANGELOG.md +0 -415
  89. package/docs/DETERMINISTIC-TOOLS.md +0 -105
  90. package/docs/ENGINEERING-DESIGN.md +0 -194
  91. package/docs/EVALS.md +0 -158
  92. package/docs/GITHUB-RULESET.md +0 -50
  93. package/docs/NPM-PUBLISH.md +0 -116
  94. package/docs/RESEARCH-SOURCES.md +0 -37
  95. package/docs/TRACE-SCHEMA.md +0 -122
  96. package/docs/V11-PERCEPTION-ADAPTIVE-EXECUTION.md +0 -75
  97. package/docs/V11-PERCEPTION-ADAPTIVE.md +0 -220
  98. package/docs/V12-WEAK-MODEL-INTELLIGENCE.md +0 -27
  99. package/docs/V13-PARALLEL-WEAK-MODEL-RUNTIME.md +0 -86
  100. package/docs/V7-INTELLIGENCE-RUNTIME.md +0 -166
  101. package/docs/V8-INTELLIGENCE-RELIABILITY.md +0 -206
  102. package/docs/V9-SPEED-INTELLIGENCE.md +0 -102
@@ -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
@@ -0,0 +1,198 @@
1
+ import { createHash } from "node:crypto"
2
+ import { lstatSync, readFileSync, readlinkSync } from "node:fs"
3
+ import { spawnSync } from "node:child_process"
4
+ import path from "node:path"
5
+
6
+ const RUNTIME_DIRS = [
7
+ ".ues-cache",
8
+ ".ues-traces",
9
+ ".ues-work",
10
+ ".ues-learning",
11
+ ".ues-dashboard",
12
+ ".ues-sandboxes",
13
+ ".ues-memory",
14
+ ".ues-evals",
15
+ ]
16
+ const RUNTIME_PATHSPECS = [
17
+ ".",
18
+ ...RUNTIME_DIRS.map((dir) => `:(exclude)${dir}/**`),
19
+ ]
20
+
21
+ let nonGitNonce = 0
22
+
23
+ function git(root, args) {
24
+ return spawnSync("git", args, {
25
+ cwd: root,
26
+ encoding: "utf8",
27
+ maxBuffer: 16 * 1024 * 1024,
28
+ })
29
+ }
30
+
31
+ function normalizePath(value) {
32
+ return String(value || "").replaceAll("\\", "/").replace(/^\.\//, "")
33
+ }
34
+
35
+ function porcelainEntries(statusOutput) {
36
+ const records = String(statusOutput || "").split("\0").filter(Boolean)
37
+ const entries = []
38
+ let renameCode = null
39
+
40
+ for (const record of records) {
41
+ if (record.length >= 3 && record[2] === " ") {
42
+ const code = record.slice(0, 2)
43
+ const file = normalizePath(record.slice(3))
44
+ if (file) entries.push({ code, file })
45
+ renameCode = /[RC]/.test(code) ? code : null
46
+ continue
47
+ }
48
+ if (renameCode) {
49
+ const file = normalizePath(record)
50
+ if (file) entries.push({ code: renameCode, file, renameSource: true })
51
+ renameCode = null
52
+ }
53
+ }
54
+ return entries
55
+ }
56
+
57
+ function porcelainChangedFiles(statusOutput) {
58
+ return [...new Set(porcelainEntries(statusOutput).map((entry) => entry.file))].sort()
59
+ }
60
+
61
+ function untrackedContentDigest(root, statusOutput, options = {}) {
62
+ const limit = Math.max(1024 * 1024, Number(options.maxUntrackedBytes || 32 * 1024 * 1024))
63
+ const totalLimit = Math.max(limit, Number(options.maxUntrackedTotalBytes || 64 * 1024 * 1024))
64
+ const files = porcelainEntries(statusOutput)
65
+ .filter((entry) => entry.code === "??")
66
+ .map((entry) => entry.file)
67
+ .sort()
68
+ const hash = createHash("sha256")
69
+ let totalBytes = 0
70
+
71
+ for (const relative of files) {
72
+ const full = path.resolve(root, relative)
73
+ if (full !== root && !full.startsWith(root + path.sep)) {
74
+ return { cacheable: false, reason: "untracked-path-escape", files }
75
+ }
76
+ try {
77
+ const info = lstatSync(full)
78
+ hash.update(relative)
79
+ hash.update("\0")
80
+ if (info.isSymbolicLink()) {
81
+ hash.update("symlink:")
82
+ hash.update(readlinkSync(full))
83
+ hash.update("\0")
84
+ continue
85
+ }
86
+ if (!info.isFile()) {
87
+ return { cacheable: false, reason: "untracked-non-file", files }
88
+ }
89
+ if (info.size > limit) {
90
+ return {
91
+ cacheable: false,
92
+ reason: "untracked-file-too-large",
93
+ files,
94
+ file: relative,
95
+ bytes: info.size,
96
+ }
97
+ }
98
+ totalBytes += info.size
99
+ if (totalBytes > totalLimit) {
100
+ return {
101
+ cacheable: false,
102
+ reason: "untracked-total-too-large",
103
+ files,
104
+ bytes: totalBytes,
105
+ }
106
+ }
107
+ hash.update(readFileSync(full))
108
+ hash.update("\0")
109
+ } catch {
110
+ return { cacheable: false, reason: "untracked-read-failed", files, file: relative }
111
+ }
112
+ }
113
+ return { cacheable: true, digest: hash.digest("hex"), files }
114
+ }
115
+
116
+ function nonGitSnapshot(root) {
117
+ nonGitNonce += 1
118
+ return {
119
+ schemaVersion: 1,
120
+ root,
121
+ git: false,
122
+ cacheable: false,
123
+ fingerprint: createHash("sha256")
124
+ .update(
125
+ "non-git-no-reuse:" +
126
+ root +
127
+ ":" +
128
+ process.pid +
129
+ ":" +
130
+ nonGitNonce,
131
+ )
132
+ .digest("hex"),
133
+ changedFiles: [],
134
+ head: null,
135
+ }
136
+ }
137
+
138
+ export function runtimeWorkspaceSnapshot(root = process.cwd()) {
139
+ root = path.resolve(root)
140
+ const inside = git(root, ["rev-parse", "--is-inside-work-tree"])
141
+ if (inside.status !== 0 || String(inside.stdout || "").trim() !== "true") {
142
+ return nonGitSnapshot(root)
143
+ }
144
+
145
+ const head = git(root, ["rev-parse", "HEAD"])
146
+ const status = git(root, [
147
+ "status",
148
+ "--porcelain=v1",
149
+ "-z",
150
+ "--untracked-files=all",
151
+ "--",
152
+ ...RUNTIME_PATHSPECS,
153
+ ])
154
+ const diff = git(root, ["diff", "--binary", "--no-ext-diff", "--", ...RUNTIME_PATHSPECS])
155
+ const cached = git(root, ["diff", "--cached", "--binary", "--no-ext-diff", "--", ...RUNTIME_PATHSPECS])
156
+
157
+ const commands = [head, status, diff, cached]
158
+ const commandComplete = commands.every((result) => result.status === 0)
159
+ const untracked = status.status === 0
160
+ ? untrackedContentDigest(root, status.stdout)
161
+ : { cacheable: false, reason: "status-unavailable", files: [] }
162
+ const cacheable = commandComplete && untracked.cacheable === true
163
+ if (!cacheable) {
164
+ // A partial Git snapshot or unhashable untracked file must never be reused
165
+ // as if it proved repository identity. Return a nonce fingerprint so
166
+ // callers fail closed while still exposing changed-file hints.
167
+ return {
168
+ ...nonGitSnapshot(root),
169
+ git: true,
170
+ head: head.status === 0 ? String(head.stdout || "").trim() || null : null,
171
+ changedFiles: status.status === 0 ? porcelainChangedFiles(status.stdout) : [],
172
+ reason: commandComplete ? untracked.reason : "git-snapshot-incomplete",
173
+ }
174
+ }
175
+
176
+ const parts = [
177
+ String(head.stdout || ""),
178
+ String(status.stdout || ""),
179
+ String(diff.stdout || ""),
180
+ String(cached.stdout || ""),
181
+ "untracked:" + String(untracked.digest || ""),
182
+ ]
183
+ return {
184
+ schemaVersion: 1,
185
+ root,
186
+ git: true,
187
+ cacheable: true,
188
+ fingerprint: createHash("sha256")
189
+ .update(parts.join("\n---UES-RUNTIME-FP---\n"))
190
+ .digest("hex"),
191
+ changedFiles: porcelainChangedFiles(status.stdout),
192
+ head: String(head.stdout || "").trim() || null,
193
+ }
194
+ }
195
+
196
+ export function runtimeWorkspaceFingerprint(root = process.cwd()) {
197
+ return runtimeWorkspaceSnapshot(root).fingerprint
198
+ }