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.
- package/README.md +1571 -607
- package/bin/ocskill.mjs +172 -24
- package/docs/OPENCODE-COMPAT.md +34 -97
- package/docs/PI-COMPAT.md +203 -0
- package/docs/V14-CONTEXT-MEMORY-FABRIC.md +70 -0
- package/docs/V14.1-QUALITY-PERFORMANCE-FABRIC.md +114 -0
- package/docs/V14.2-TURBO-WEAK-MODEL-RUNTIME.md +448 -0
- package/evals/v14/tasks.json +46 -0
- package/global-config/agents/executor.md +7 -0
- package/global-config/agents/visual-verifier.md +22 -3
- package/global-config/plugins/ues-router/index.js +13 -8
- package/global-config/plugins/ues-router/policy-runtime.js +7 -0
- package/global-config/plugins/ues-router/router.js +12 -2
- package/global-config/skills/ecommerce-engineering/SKILL.md +1 -1
- package/global-config/skills/file-upload-engineering/SKILL.md +1 -1
- package/global-config/skills/git-safety/SKILL.md +1 -1
- package/global-config/skills/nestjs-engineering/SKILL.md +1 -1
- package/global-config/skills/performance-engineering/SKILL.md +1 -1
- package/global-config/skills/react-native-engineering/SKILL.md +1 -1
- package/global-config/skills/rest-api-design/SKILL.md +1 -1
- package/global-config/skills/ui-ux-engineering/SKILL.md +1 -1
- package/lib/adaptive-context-budget.mjs +97 -0
- package/lib/affected-tests.mjs +260 -0
- package/lib/benchmark-confidence.mjs +41 -2
- package/lib/browser-mcp-routing.mjs +166 -0
- package/lib/capability-fabric.mjs +359 -0
- package/lib/capability-registry.mjs +9 -0
- package/lib/code-intelligence/edit-anchor.mjs +102 -0
- package/lib/code-intelligence/index.mjs +95 -0
- package/lib/code-intelligence/lsp-provider.mjs +189 -0
- package/lib/completion-auditor.mjs +82 -0
- package/lib/context-engine-v11.mjs +65 -1
- package/lib/context-graph-rank.mjs +118 -0
- package/lib/context-manifest.mjs +97 -18
- package/lib/control-center.mjs +19 -1
- package/lib/document-ingestion.mjs +60 -0
- package/lib/dynamic-workflow.mjs +3 -1
- package/lib/evidence-store.mjs +82 -1
- package/lib/fast-verification-gate.mjs +69 -0
- package/lib/hierarchical-context.mjs +215 -0
- package/lib/mcp-health.mjs +144 -0
- package/lib/mcp-tool-policy.mjs +19 -0
- package/lib/memory-engine.mjs +493 -0
- package/lib/model-performance.mjs +33 -8
- package/lib/model-policy.mjs +3 -3
- package/lib/orchestrator-policy.mjs +5 -209
- package/lib/performance-fabric.mjs +229 -0
- package/lib/pi-rpc-pool.mjs +433 -0
- package/lib/process-hang-detector.mjs +83 -0
- package/lib/process-supervisor.mjs +193 -0
- package/lib/prompt-cache.mjs +27 -11
- package/lib/repo-graph.mjs +53 -2
- package/lib/reversible-context.mjs +49 -0
- package/lib/runtime-config.mjs +31 -0
- package/lib/safety.mjs +132 -0
- package/lib/semantic-index.mjs +52 -3
- package/lib/skill-compiler.mjs +128 -0
- package/lib/skill-quality.mjs +48 -2
- package/lib/task-engine.mjs +62 -5
- package/lib/task-policy.mjs +291 -0
- package/lib/verification-broker.mjs +284 -0
- package/lib/verification-command.mjs +111 -0
- package/lib/windows-shim.mjs +35 -0
- package/lib/workspace-fingerprint.mjs +198 -0
- package/package.json +52 -42
- package/pi/extensions/ues-child-runtime.ts +393 -0
- package/pi/extensions/ues.ts +3327 -0
- package/pi/prompts/ues-audit.md +9 -0
- package/pi/prompts/ues-critique.md +9 -0
- package/pi/prompts/ues-debug.md +9 -0
- package/pi/prompts/ues-feature.md +9 -0
- package/pi/prompts/ues-fix.md +9 -0
- package/pi/prompts/ues-plan.md +9 -0
- package/pi/prompts/ues-research.md +9 -0
- package/pi/prompts/ues-resume.md +9 -0
- package/pi/prompts/ues-review.md +7 -0
- package/pi/prompts/ues-run.md +17 -0
- package/pi/prompts/ues-verify.md +9 -0
- package/scripts/check-release-consistency.mjs +119 -185
- package/scripts/check-runtime-exports.mjs +66 -0
- package/scripts/check-source-integrity.mjs +246 -0
- package/scripts/eval-pi.mjs +492 -0
- package/scripts/install.mjs +16 -0
- package/scripts/smoke-package-closure.mjs +110 -0
- package/scripts/smoke-packed-install.mjs +24 -11
- package/scripts/smoke-pi-extension.mjs +144 -0
- package/scripts/uninstall.mjs +44 -0
- package/CHANGELOG.md +0 -415
- package/docs/DETERMINISTIC-TOOLS.md +0 -105
- package/docs/ENGINEERING-DESIGN.md +0 -194
- package/docs/EVALS.md +0 -158
- package/docs/GITHUB-RULESET.md +0 -50
- package/docs/NPM-PUBLISH.md +0 -116
- package/docs/RESEARCH-SOURCES.md +0 -37
- package/docs/TRACE-SCHEMA.md +0 -122
- package/docs/V11-PERCEPTION-ADAPTIVE-EXECUTION.md +0 -75
- package/docs/V11-PERCEPTION-ADAPTIVE.md +0 -220
- package/docs/V12-WEAK-MODEL-INTELLIGENCE.md +0 -27
- package/docs/V13-PARALLEL-WEAK-MODEL-RUNTIME.md +0 -86
- package/docs/V7-INTELLIGENCE-RUNTIME.md +0 -166
- package/docs/V8-INTELLIGENCE-RELIABILITY.md +0 -206
- 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
|
+
}
|
package/lib/windows-shim.mjs
CHANGED
|
@@ -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
|
+
}
|