opencode-agent-skill 14.4.0 → 15.1.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 +34 -16
- package/docs/PI-COMPAT.md +151 -2
- package/docs/V15-MANAGED-RUNTIME.md +128 -0
- package/global-config/agents/architect.md +63 -1
- package/global-config/agents/executor.md +7 -0
- package/global-config/agents/plan-checker.md +8 -1
- package/global-config/agents/verifier.md +3 -3
- package/global-config/skills/database-engineering/references/workflow.md +15 -0
- package/global-config/skills/engineering-orchestrator/references/long-horizon.md +21 -11
- package/lib/activity-deadline.mjs +70 -0
- package/lib/adaptive-context-budget.mjs +25 -5
- package/lib/affected-tests.mjs +38 -5
- package/lib/capability-fabric.mjs +17 -0
- package/lib/code-intelligence/index.mjs +3 -2
- package/lib/code-intelligence/lsp-provider.mjs +326 -52
- package/lib/compaction-resume-guard.mjs +255 -0
- package/lib/completion-auditor.mjs +25 -5
- package/lib/context-engine-v11.mjs +20 -7
- package/lib/evidence-store.mjs +92 -8
- package/lib/execution-contract.mjs +592 -0
- package/lib/leaf-runtime-optimizer.mjs +76 -0
- package/lib/memory-engine.mjs +189 -16
- package/lib/performance-fabric.mjs +262 -107
- package/lib/pi-child-invocation.mjs +65 -0
- package/lib/pi-rpc-pool.mjs +61 -9
- package/lib/plan-salvage.mjs +124 -0
- package/lib/planning-speed-policy.mjs +131 -0
- package/lib/repo-graph.mjs +1 -1
- package/lib/repo-inspect.mjs +1 -0
- package/lib/runtime-artifacts.mjs +42 -0
- package/lib/runtime-events.mjs +33 -1
- package/lib/safety.mjs +1 -0
- package/lib/semantic-index.mjs +1 -1
- package/lib/service-manager.mjs +526 -0
- package/lib/session-display.mjs +49 -0
- package/lib/subagent-artifacts.mjs +182 -0
- package/lib/task-graph.mjs +87 -0
- package/lib/task-policy.mjs +150 -4
- package/lib/trajectory.mjs +111 -1
- package/lib/turbo-fast-path.mjs +44 -0
- package/lib/verification-broker.mjs +32 -27
- package/lib/workspace-fingerprint.mjs +45 -19
- package/lib/workspace-hygiene.mjs +481 -0
- package/lib/workspace-root.mjs +49 -0
- package/lib/worktree-sandbox.mjs +234 -11
- package/package.json +8 -5
- package/pi/extensions/ues-child-runtime.ts +170 -4
- package/pi/extensions/ues.ts +2063 -188
- package/scripts/check-release-consistency.mjs +25 -2
- package/scripts/check-source-integrity.mjs +471 -4
- package/scripts/eval-pi.mjs +76 -36
- package/scripts/smoke-pi-extension.mjs +1 -1
- package/pi/prompts/ues-run.md +0 -17
|
@@ -156,12 +156,14 @@ export async function findReusableVerification(root, command, args = [], options
|
|
|
156
156
|
const currentFingerprint = String(options.workspaceFingerprint || runtimeWorkspaceFingerprint(root))
|
|
157
157
|
if (!receiptReusableAtFingerprint(entry.receipt, currentFingerprint)) return null
|
|
158
158
|
|
|
159
|
-
const stdout =
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
const [stdout, stderr] = await Promise.all([
|
|
160
|
+
entry.stdoutRef
|
|
161
|
+
? getEvidence(root, entry.stdoutRef, { maxBytes: options.maxBytes || 16_000 }).catch(() => null)
|
|
162
|
+
: Promise.resolve(null),
|
|
163
|
+
entry.stderrRef
|
|
164
|
+
? getEvidence(root, entry.stderrRef, { maxBytes: options.maxBytes || 8_000 }).catch(() => null)
|
|
165
|
+
: Promise.resolve(null),
|
|
166
|
+
])
|
|
165
167
|
|
|
166
168
|
return {
|
|
167
169
|
schemaVersion: 1,
|
|
@@ -180,16 +182,18 @@ export async function recordVerification(root, input = {}) {
|
|
|
180
182
|
root = path.resolve(root)
|
|
181
183
|
const stdout = String(input.stdout || "")
|
|
182
184
|
const stderr = String(input.stderr || "")
|
|
183
|
-
const stdoutEvidence = await
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
185
|
+
const [stdoutEvidence, stderrEvidence] = await Promise.all([
|
|
186
|
+
putEvidence(root, stdout, {
|
|
187
|
+
kind: "verification-stdout",
|
|
188
|
+
source: input.command || "verification-broker",
|
|
189
|
+
summary: "Captured stdout preserved for reusable verification receipt",
|
|
190
|
+
}),
|
|
191
|
+
putEvidence(root, stderr, {
|
|
192
|
+
kind: "verification-stderr",
|
|
193
|
+
source: input.command || "verification-broker",
|
|
194
|
+
summary: "Captured stderr preserved for reusable verification receipt",
|
|
195
|
+
}),
|
|
196
|
+
])
|
|
193
197
|
|
|
194
198
|
const receipt = createVerificationReceipt({
|
|
195
199
|
task: input.task || null,
|
|
@@ -261,20 +265,21 @@ export async function listReusableVerification(root, options = {}) {
|
|
|
261
265
|
|
|
262
266
|
rows.sort((a, b) => (Date.parse(b.finishedAt || "") || 0) - (Date.parse(a.finishedAt || "") || 0))
|
|
263
267
|
const selected = rows.slice(0, limit)
|
|
264
|
-
const output =
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
268
|
+
const output = await Promise.all(selected.map(async (row) => {
|
|
269
|
+
const [stdout, stderr] = await Promise.all([
|
|
270
|
+
row.stdoutRef
|
|
271
|
+
? getEvidence(root, row.stdoutRef, { maxBytes: options.previewBytes || 2400 }).catch(() => null)
|
|
272
|
+
: Promise.resolve(null),
|
|
273
|
+
row.stderrRef
|
|
274
|
+
? getEvidence(root, row.stderrRef, { maxBytes: options.previewBytes || 1200 }).catch(() => null)
|
|
275
|
+
: Promise.resolve(null),
|
|
276
|
+
])
|
|
277
|
+
return {
|
|
273
278
|
...row,
|
|
274
279
|
stdoutPreview: stdout?.content || "",
|
|
275
280
|
stderrPreview: stderr?.content || "",
|
|
276
|
-
}
|
|
277
|
-
}
|
|
281
|
+
}
|
|
282
|
+
}))
|
|
278
283
|
return {
|
|
279
284
|
schemaVersion: 1,
|
|
280
285
|
workspaceFingerprint: currentFingerprint,
|
|
@@ -2,20 +2,11 @@ import { createHash } from "node:crypto"
|
|
|
2
2
|
import { lstatSync, readFileSync, readlinkSync } from "node:fs"
|
|
3
3
|
import { spawnSync } from "node:child_process"
|
|
4
4
|
import path from "node:path"
|
|
5
|
+
import { UES_RUNTIME_DIRS } from "./runtime-artifacts.mjs"
|
|
5
6
|
|
|
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
7
|
const RUNTIME_PATHSPECS = [
|
|
17
8
|
".",
|
|
18
|
-
...
|
|
9
|
+
...UES_RUNTIME_DIRS.map((dir) => `:(exclude)${dir}/**`),
|
|
19
10
|
]
|
|
20
11
|
|
|
21
12
|
let nonGitNonce = 0
|
|
@@ -32,7 +23,7 @@ function normalizePath(value) {
|
|
|
32
23
|
return String(value || "").replaceAll("\\", "/").replace(/^\.\//, "")
|
|
33
24
|
}
|
|
34
25
|
|
|
35
|
-
function
|
|
26
|
+
export function workspaceStatusEntries(statusOutput) {
|
|
36
27
|
const records = String(statusOutput || "").split("\0").filter(Boolean)
|
|
37
28
|
const entries = []
|
|
38
29
|
let renameCode = null
|
|
@@ -55,13 +46,13 @@ function porcelainEntries(statusOutput) {
|
|
|
55
46
|
}
|
|
56
47
|
|
|
57
48
|
function porcelainChangedFiles(statusOutput) {
|
|
58
|
-
return [...new Set(
|
|
49
|
+
return [...new Set(workspaceStatusEntries(statusOutput).map((entry) => entry.file))].sort()
|
|
59
50
|
}
|
|
60
51
|
|
|
61
52
|
function untrackedContentDigest(root, statusOutput, options = {}) {
|
|
62
53
|
const limit = Math.max(1024 * 1024, Number(options.maxUntrackedBytes || 32 * 1024 * 1024))
|
|
63
54
|
const totalLimit = Math.max(limit, Number(options.maxUntrackedTotalBytes || 64 * 1024 * 1024))
|
|
64
|
-
const files =
|
|
55
|
+
const files = workspaceStatusEntries(statusOutput)
|
|
65
56
|
.filter((entry) => entry.code === "??")
|
|
66
57
|
.map((entry) => entry.file)
|
|
67
58
|
.sort()
|
|
@@ -116,7 +107,7 @@ function untrackedContentDigest(root, statusOutput, options = {}) {
|
|
|
116
107
|
function nonGitSnapshot(root) {
|
|
117
108
|
nonGitNonce += 1
|
|
118
109
|
return {
|
|
119
|
-
schemaVersion:
|
|
110
|
+
schemaVersion: 2,
|
|
120
111
|
root,
|
|
121
112
|
git: false,
|
|
122
113
|
cacheable: false,
|
|
@@ -132,10 +123,14 @@ function nonGitSnapshot(root) {
|
|
|
132
123
|
.digest("hex"),
|
|
133
124
|
changedFiles: [],
|
|
134
125
|
head: null,
|
|
126
|
+
statusOutput: "",
|
|
127
|
+
statusEntries: [],
|
|
128
|
+
diffOutput: "",
|
|
129
|
+
cachedDiffOutput: "",
|
|
135
130
|
}
|
|
136
131
|
}
|
|
137
132
|
|
|
138
|
-
export function
|
|
133
|
+
export function captureWorkspaceStateV2(root = process.cwd()) {
|
|
139
134
|
root = path.resolve(root)
|
|
140
135
|
const inside = git(root, ["rev-parse", "--is-inside-work-tree"])
|
|
141
136
|
if (inside.status !== 0 || String(inside.stdout || "").trim() !== "true") {
|
|
@@ -169,6 +164,10 @@ export function runtimeWorkspaceSnapshot(root = process.cwd()) {
|
|
|
169
164
|
git: true,
|
|
170
165
|
head: head.status === 0 ? String(head.stdout || "").trim() || null : null,
|
|
171
166
|
changedFiles: status.status === 0 ? porcelainChangedFiles(status.stdout) : [],
|
|
167
|
+
statusOutput: status.status === 0 ? String(status.stdout || "") : "",
|
|
168
|
+
statusEntries: status.status === 0 ? workspaceStatusEntries(status.stdout) : [],
|
|
169
|
+
diffOutput: diff.status === 0 ? String(diff.stdout || "") : "",
|
|
170
|
+
cachedDiffOutput: cached.status === 0 ? String(cached.stdout || "") : "",
|
|
172
171
|
reason: commandComplete ? untracked.reason : "git-snapshot-incomplete",
|
|
173
172
|
}
|
|
174
173
|
}
|
|
@@ -181,7 +180,7 @@ export function runtimeWorkspaceSnapshot(root = process.cwd()) {
|
|
|
181
180
|
"untracked:" + String(untracked.digest || ""),
|
|
182
181
|
]
|
|
183
182
|
return {
|
|
184
|
-
schemaVersion:
|
|
183
|
+
schemaVersion: 2,
|
|
185
184
|
root,
|
|
186
185
|
git: true,
|
|
187
186
|
cacheable: true,
|
|
@@ -190,9 +189,36 @@ export function runtimeWorkspaceSnapshot(root = process.cwd()) {
|
|
|
190
189
|
.digest("hex"),
|
|
191
190
|
changedFiles: porcelainChangedFiles(status.stdout),
|
|
192
191
|
head: String(head.stdout || "").trim() || null,
|
|
192
|
+
statusOutput: String(status.stdout || ""),
|
|
193
|
+
statusEntries: workspaceStatusEntries(status.stdout),
|
|
194
|
+
diffOutput: String(diff.stdout || ""),
|
|
195
|
+
cachedDiffOutput: String(cached.stdout || ""),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function reusableWorkspaceState(root, state) {
|
|
200
|
+
if (!state || typeof state !== "object") return null
|
|
201
|
+
const resolved = path.resolve(root)
|
|
202
|
+
if (!state.root || path.resolve(state.root) !== resolved) return null
|
|
203
|
+
return state
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function runtimeWorkspaceSnapshot(root = process.cwd(), options = {}) {
|
|
207
|
+
root = path.resolve(root)
|
|
208
|
+
const state = reusableWorkspaceState(root, options.workspaceState) || captureWorkspaceStateV2(root)
|
|
209
|
+
return {
|
|
210
|
+
schemaVersion: 1,
|
|
211
|
+
root,
|
|
212
|
+
git: state.git === true,
|
|
213
|
+
cacheable: state.cacheable === true,
|
|
214
|
+
fingerprint: state.fingerprint,
|
|
215
|
+
changedFiles: Array.isArray(state.changedFiles) ? [...state.changedFiles] : [],
|
|
216
|
+
head: state.head || null,
|
|
217
|
+
...(state.reason ? { reason: state.reason } : {}),
|
|
218
|
+
workspaceStateVersion: Number(state.schemaVersion || 1),
|
|
193
219
|
}
|
|
194
220
|
}
|
|
195
221
|
|
|
196
|
-
export function runtimeWorkspaceFingerprint(root = process.cwd()) {
|
|
197
|
-
return runtimeWorkspaceSnapshot(root).fingerprint
|
|
222
|
+
export function runtimeWorkspaceFingerprint(root = process.cwd(), options = {}) {
|
|
223
|
+
return runtimeWorkspaceSnapshot(root, options).fingerprint
|
|
198
224
|
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs"
|
|
3
|
+
import { rm } from "node:fs/promises"
|
|
4
|
+
import { spawnSync } from "node:child_process"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { decodeTextBuffer } from "./text-encoding.mjs"
|
|
7
|
+
import { isUesRuntimeArtifactPath, sourceFacingPaths } from "./runtime-artifacts.mjs"
|
|
8
|
+
|
|
9
|
+
const CODE_EXTENSIONS = new Set([
|
|
10
|
+
".js", ".cjs", ".mjs", ".jsx", ".ts", ".cts", ".mts", ".tsx",
|
|
11
|
+
".py", ".pyi", ".java", ".kt", ".kts", ".go", ".rs", ".c", ".h",
|
|
12
|
+
".cc", ".cpp", ".cxx", ".hpp", ".cs", ".php", ".rb", ".swift",
|
|
13
|
+
".scala", ".vue", ".svelte", ".html", ".htm", ".css", ".scss", ".sass",
|
|
14
|
+
".less", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml", ".sql",
|
|
15
|
+
".graphql", ".gql", ".sh", ".bash", ".zsh", ".ps1", ".cmd", ".bat",
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
const CODE_BASENAMES = new Set([
|
|
19
|
+
"dockerfile", "makefile", "rakefile", "gemfile", "procfile",
|
|
20
|
+
".eslintrc", ".prettierrc", ".babelrc",
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
const KNOWN_SCRATCH = Object.freeze([
|
|
24
|
+
".ues-cache/fast-acceptance.test.mjs",
|
|
25
|
+
".ues-cache/tmp",
|
|
26
|
+
".ues-cache/scratch",
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
function normalizePath(value) {
|
|
30
|
+
return String(value || "").replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/^\/+/, "")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withinRoot(root, relative) {
|
|
34
|
+
const resolvedRoot = path.resolve(root)
|
|
35
|
+
const file = path.resolve(resolvedRoot, relative)
|
|
36
|
+
if (file !== resolvedRoot && !file.startsWith(resolvedRoot + path.sep)) {
|
|
37
|
+
throw new Error("workspace hygiene path escapes root: " + relative)
|
|
38
|
+
}
|
|
39
|
+
return file
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fileState(root, relative) {
|
|
43
|
+
const normalized = normalizePath(relative)
|
|
44
|
+
if (!normalized) return { exists: false, kind: "missing", size: 0, hash: null }
|
|
45
|
+
let file
|
|
46
|
+
try {
|
|
47
|
+
file = withinRoot(root, normalized)
|
|
48
|
+
} catch {
|
|
49
|
+
return { exists: false, kind: "escape", size: 0, hash: null }
|
|
50
|
+
}
|
|
51
|
+
if (!existsSync(file)) return { exists: false, kind: "missing", size: 0, hash: null }
|
|
52
|
+
try {
|
|
53
|
+
const info = lstatSync(file)
|
|
54
|
+
if (info.isSymbolicLink()) {
|
|
55
|
+
const target = readlinkSync(file)
|
|
56
|
+
return {
|
|
57
|
+
exists: true,
|
|
58
|
+
kind: "symlink",
|
|
59
|
+
size: Buffer.byteLength(target),
|
|
60
|
+
hash: createHash("sha256").update(target).digest("hex"),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (info.isFile()) {
|
|
64
|
+
const bytes = readFileSync(file)
|
|
65
|
+
return {
|
|
66
|
+
exists: true,
|
|
67
|
+
kind: "file",
|
|
68
|
+
size: bytes.length,
|
|
69
|
+
hash: createHash("sha256").update(bytes).digest("hex"),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (info.isDirectory()) return { exists: true, kind: "directory", size: 0, hash: null }
|
|
73
|
+
return { exists: true, kind: "other", size: Number(info.size || 0), hash: null }
|
|
74
|
+
} catch {
|
|
75
|
+
return { exists: false, kind: "unreadable", size: 0, hash: null }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sameState(left, right) {
|
|
80
|
+
return Boolean(left) && Boolean(right) &&
|
|
81
|
+
left.exists === right.exists &&
|
|
82
|
+
left.kind === right.kind &&
|
|
83
|
+
Number(left.size || 0) === Number(right.size || 0) &&
|
|
84
|
+
String(left.hash || "") === String(right.hash || "")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseGitStatusEntries(statusOutput) {
|
|
88
|
+
const tokens = String(statusOutput || "").split("\0").filter(Boolean)
|
|
89
|
+
const rows = []
|
|
90
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
91
|
+
const token = tokens[index]
|
|
92
|
+
if (token.length < 3 || token[2] !== " ") continue
|
|
93
|
+
const status = token.slice(0, 2)
|
|
94
|
+
const current = normalizePath(token.slice(3))
|
|
95
|
+
let original = null
|
|
96
|
+
if (/[RC]/.test(status) && index + 1 < tokens.length) {
|
|
97
|
+
original = normalizePath(tokens[index + 1])
|
|
98
|
+
index += 1
|
|
99
|
+
}
|
|
100
|
+
if (current && !isUesRuntimeArtifactPath(current)) rows.push({ status, path: current, originalPath: original })
|
|
101
|
+
}
|
|
102
|
+
return rows
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function gitStatusEntries(root, workspaceState = null) {
|
|
106
|
+
const resolved = path.resolve(root)
|
|
107
|
+
if (
|
|
108
|
+
workspaceState &&
|
|
109
|
+
typeof workspaceState === "object" &&
|
|
110
|
+
workspaceState.root &&
|
|
111
|
+
path.resolve(workspaceState.root) === resolved &&
|
|
112
|
+
typeof workspaceState.statusOutput === "string"
|
|
113
|
+
) {
|
|
114
|
+
return parseGitStatusEntries(workspaceState.statusOutput)
|
|
115
|
+
}
|
|
116
|
+
const result = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
117
|
+
cwd: resolved,
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
121
|
+
})
|
|
122
|
+
if (result.status !== 0) {
|
|
123
|
+
throw new Error(String(result.stderr || result.stdout || "git status failed").trim())
|
|
124
|
+
}
|
|
125
|
+
return parseGitStatusEntries(result.stdout)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function snapshotDirty(root, workspaceState = null) {
|
|
129
|
+
const rows = gitStatusEntries(root, workspaceState)
|
|
130
|
+
const paths = sourceFacingPaths(rows.flatMap((row) => [row.path, row.originalPath].filter(Boolean))).sort()
|
|
131
|
+
const entries = {}
|
|
132
|
+
for (const relative of paths) {
|
|
133
|
+
const row = rows.find((item) => item.path === relative || item.originalPath === relative)
|
|
134
|
+
entries[relative] = {
|
|
135
|
+
status: row?.status || "??",
|
|
136
|
+
state: fileState(root, relative),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { paths, entries }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function captureWorkspaceHygieneBaseline(root = process.cwd(), options = {}) {
|
|
143
|
+
const resolved = path.resolve(root)
|
|
144
|
+
const dirty = snapshotDirty(resolved, options.workspaceState || null)
|
|
145
|
+
const scratch = {}
|
|
146
|
+
for (const relative of KNOWN_SCRATCH) {
|
|
147
|
+
scratch[relative] = fileState(resolved, relative)
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
schemaVersion: 1,
|
|
151
|
+
root: resolved,
|
|
152
|
+
capturedAt: new Date().toISOString(),
|
|
153
|
+
paths: dirty.paths,
|
|
154
|
+
entries: dirty.entries,
|
|
155
|
+
scratch,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isAuditableSourcePath(relative) {
|
|
160
|
+
const normalized = normalizePath(relative)
|
|
161
|
+
if (!normalized || isUesRuntimeArtifactPath(normalized)) return false
|
|
162
|
+
const base = path.posix.basename(normalized).toLowerCase()
|
|
163
|
+
const ext = path.posix.extname(base).toLowerCase()
|
|
164
|
+
return CODE_EXTENSIONS.has(ext) || CODE_BASENAMES.has(base)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function locationAt(text, utf16Index) {
|
|
168
|
+
const prefix = String(text || "").slice(0, Math.max(0, utf16Index))
|
|
169
|
+
const rows = prefix.split("\n")
|
|
170
|
+
return {
|
|
171
|
+
line: rows.length,
|
|
172
|
+
column: [...(rows.at(-1) || "")].length + 1,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function codePointLabel(cp) {
|
|
177
|
+
return "U+" + cp.toString(16).toUpperCase().padStart(4, "0")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function finding(file, kind, message, index, text, codePoint = null) {
|
|
181
|
+
const loc = locationAt(text, index)
|
|
182
|
+
return {
|
|
183
|
+
severity: "error",
|
|
184
|
+
blocking: true,
|
|
185
|
+
file: normalizePath(file),
|
|
186
|
+
kind,
|
|
187
|
+
line: loc.line,
|
|
188
|
+
column: loc.column,
|
|
189
|
+
codePoint: codePoint == null ? null : codePointLabel(codePoint),
|
|
190
|
+
message,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function auditUnicodeSource(text, options = {}) {
|
|
195
|
+
const source = String(text ?? "")
|
|
196
|
+
const file = normalizePath(options.file || "<memory>")
|
|
197
|
+
const findings = []
|
|
198
|
+
let utf16Index = 0
|
|
199
|
+
|
|
200
|
+
for (const char of source) {
|
|
201
|
+
const cp = char.codePointAt(0)
|
|
202
|
+
const width = char.length
|
|
203
|
+
const isAllowedWhitespace = cp === 0x09 || cp === 0x0a || cp === 0x0d
|
|
204
|
+
if ((cp < 0x20 && !isAllowedWhitespace) || (cp >= 0x7f && cp <= 0x9f)) {
|
|
205
|
+
findings.push(finding(file, "control-character", "Unexpected control character in source", utf16Index, source, cp))
|
|
206
|
+
} else if (cp === 0xfffd) {
|
|
207
|
+
findings.push(finding(file, "replacement-character", "Unicode replacement character indicates corrupted or lossy text", utf16Index, source, cp))
|
|
208
|
+
} else if (cp === 0xfeff && utf16Index !== 0) {
|
|
209
|
+
findings.push(finding(file, "embedded-bom", "BOM/zero-width no-break space is only allowed at the start of a file", utf16Index, source, cp))
|
|
210
|
+
} else if (
|
|
211
|
+
cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0x2060 ||
|
|
212
|
+
cp === 0x180e
|
|
213
|
+
) {
|
|
214
|
+
findings.push(finding(file, "zero-width-character", "Invisible zero-width/joining character is not allowed in audited source", utf16Index, source, cp))
|
|
215
|
+
} else if (
|
|
216
|
+
(cp >= 0x202a && cp <= 0x202e) ||
|
|
217
|
+
(cp >= 0x2066 && cp <= 0x2069)
|
|
218
|
+
) {
|
|
219
|
+
findings.push(finding(file, "bidi-control", "Bidirectional override/isolate control is blocked to prevent visually misleading source", utf16Index, source, cp))
|
|
220
|
+
}
|
|
221
|
+
utf16Index += width
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const identifierLike = /[\p{L}_$][\p{L}\p{N}\p{Mn}\p{Mc}_$]*/gu
|
|
225
|
+
for (const match of source.matchAll(identifierLike)) {
|
|
226
|
+
const token = match[0]
|
|
227
|
+
const latin = /\p{Script=Latin}/u.test(token)
|
|
228
|
+
const cyrillic = /\p{Script=Cyrillic}/u.test(token)
|
|
229
|
+
const greek = /\p{Script=Greek}/u.test(token)
|
|
230
|
+
if (latin && (cyrillic || greek)) {
|
|
231
|
+
findings.push(finding(
|
|
232
|
+
file,
|
|
233
|
+
"mixed-script-token",
|
|
234
|
+
"Token mixes Latin with Cyrillic/Greek characters and may contain a homoglyph",
|
|
235
|
+
Number(match.index || 0),
|
|
236
|
+
source,
|
|
237
|
+
null,
|
|
238
|
+
))
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
schemaVersion: 1,
|
|
244
|
+
file,
|
|
245
|
+
safe: findings.length === 0,
|
|
246
|
+
findings,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function auditUnicodeFiles(root, files, options = {}) {
|
|
251
|
+
const reports = []
|
|
252
|
+
const maxBytes = Math.max(64 * 1024, Math.min(Number(options.maxBytes || 4 * 1024 * 1024), 16 * 1024 * 1024))
|
|
253
|
+
for (const relative of sourceFacingPaths(files).filter(isAuditableSourcePath)) {
|
|
254
|
+
const absolute = withinRoot(root, relative)
|
|
255
|
+
if (!existsSync(absolute)) continue
|
|
256
|
+
const info = lstatSync(absolute)
|
|
257
|
+
if (!info.isFile()) continue
|
|
258
|
+
if (info.size > maxBytes) {
|
|
259
|
+
reports.push({
|
|
260
|
+
schemaVersion: 1,
|
|
261
|
+
file: relative,
|
|
262
|
+
safe: false,
|
|
263
|
+
findings: [{
|
|
264
|
+
severity: "error",
|
|
265
|
+
blocking: true,
|
|
266
|
+
file: relative,
|
|
267
|
+
kind: "source-too-large",
|
|
268
|
+
line: null,
|
|
269
|
+
column: null,
|
|
270
|
+
codePoint: null,
|
|
271
|
+
message: `Changed source exceeds hygiene audit byte limit (${info.size} > ${maxBytes})`,
|
|
272
|
+
}],
|
|
273
|
+
})
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const text = decodeTextBuffer(readFileSync(absolute))
|
|
278
|
+
reports.push(auditUnicodeSource(text, { file: relative }))
|
|
279
|
+
} catch (error) {
|
|
280
|
+
reports.push({
|
|
281
|
+
schemaVersion: 1,
|
|
282
|
+
file: relative,
|
|
283
|
+
safe: false,
|
|
284
|
+
findings: [{
|
|
285
|
+
severity: "error",
|
|
286
|
+
blocking: true,
|
|
287
|
+
file: relative,
|
|
288
|
+
kind: "text-decode-failed",
|
|
289
|
+
line: null,
|
|
290
|
+
column: null,
|
|
291
|
+
codePoint: null,
|
|
292
|
+
message: error instanceof Error ? error.message : String(error),
|
|
293
|
+
}],
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
safe: reports.every((report) => report.safe),
|
|
299
|
+
reports,
|
|
300
|
+
findings: reports.flatMap((report) => report.findings || []),
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function classifyTransientArtifact(relative) {
|
|
305
|
+
const normalized = normalizePath(relative)
|
|
306
|
+
const lower = normalized.toLowerCase()
|
|
307
|
+
const base = path.posix.basename(lower)
|
|
308
|
+
|
|
309
|
+
if (
|
|
310
|
+
/\.(?:tmp|temp|swp|swo|orig|rej|bak)$/.test(base) ||
|
|
311
|
+
/~$/.test(base) ||
|
|
312
|
+
[".ds_store", "thumbs.db", "npm-debug.log", "yarn-debug.log", "yarn-error.log", "pnpm-debug.log"].includes(base) ||
|
|
313
|
+
/(?:^|\/)(?:__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|coverage|test-results|playwright-report)(?:\/|$)/.test(lower)
|
|
314
|
+
) {
|
|
315
|
+
return { transient: true, confidence: "high", reason: "known-generated-or-temporary-artifact" }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (
|
|
319
|
+
/^(?:debug|scratch|tmp|temp|trial|experiment|playground|test-try|try)[-_.]/.test(base)
|
|
320
|
+
) {
|
|
321
|
+
return { transient: true, confidence: "suspicious", reason: "debug-scratch-or-generated-output-name" }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { transient: false, confidence: "none", reason: null }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function taskMentionsPath(taskText, relative) {
|
|
328
|
+
const task = String(taskText || "").toLowerCase().replaceAll("\\", "/")
|
|
329
|
+
const normalized = normalizePath(relative).toLowerCase()
|
|
330
|
+
const base = path.posix.basename(normalized)
|
|
331
|
+
return Boolean(
|
|
332
|
+
normalized && task.includes(normalized) ||
|
|
333
|
+
base.length >= 4 && task.includes(base)
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function cleanupScratchCreatedAfterBaseline(root, baseline) {
|
|
338
|
+
const removed = []
|
|
339
|
+
for (const relative of KNOWN_SCRATCH) {
|
|
340
|
+
const before = baseline?.scratch?.[relative]
|
|
341
|
+
const after = fileState(root, relative)
|
|
342
|
+
if (after.exists && !before?.exists) {
|
|
343
|
+
const target = withinRoot(root, relative)
|
|
344
|
+
await rm(target, { recursive: true, force: true })
|
|
345
|
+
if (!existsSync(target)) removed.push(relative)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return removed
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function removeHighConfidenceCreatedArtifacts(root, createdPaths, taskText) {
|
|
352
|
+
const removed = []
|
|
353
|
+
for (const relative of createdPaths) {
|
|
354
|
+
const classification = classifyTransientArtifact(relative)
|
|
355
|
+
if (!classification.transient || classification.confidence !== "high") continue
|
|
356
|
+
if (taskMentionsPath(taskText, relative)) continue
|
|
357
|
+
const target = withinRoot(root, relative)
|
|
358
|
+
await rm(target, { recursive: true, force: true }).catch(() => {})
|
|
359
|
+
if (!existsSync(target)) removed.push(relative)
|
|
360
|
+
}
|
|
361
|
+
return removed
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function deltaFromBaseline(root, baseline) {
|
|
365
|
+
const current = snapshotDirty(root)
|
|
366
|
+
const beforeEntries = baseline?.entries || {}
|
|
367
|
+
const all = new Set([...(baseline?.paths || []), ...current.paths])
|
|
368
|
+
const changed = []
|
|
369
|
+
const created = []
|
|
370
|
+
|
|
371
|
+
for (const relative of all) {
|
|
372
|
+
const before = beforeEntries[relative]?.state || null
|
|
373
|
+
const nowEntry = current.entries[relative] || null
|
|
374
|
+
const after = nowEntry?.state || fileState(root, relative)
|
|
375
|
+
if (before && sameState(before, after)) continue
|
|
376
|
+
if (!before && !nowEntry) continue
|
|
377
|
+
changed.push(relative)
|
|
378
|
+
const status = String(nowEntry?.status || "")
|
|
379
|
+
if (!before && (status === "??" || status.startsWith("A"))) created.push(relative)
|
|
380
|
+
}
|
|
381
|
+
return { current, changed: sourceFacingPaths(changed), created: sourceFacingPaths(created) }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function renderFinding(item) {
|
|
385
|
+
const where = item.line ? `:${item.line}:${item.column || 1}` : ""
|
|
386
|
+
const codePoint = item.codePoint ? ` ${item.codePoint}` : ""
|
|
387
|
+
return `${item.file}${where} [${item.kind}]${codePoint} ${item.message}`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function auditWorkspaceDelta(root, options = {}) {
|
|
391
|
+
const baseline = options.baseline || captureWorkspaceHygieneBaseline(root)
|
|
392
|
+
let delta = deltaFromBaseline(root, baseline)
|
|
393
|
+
const removedScratch = options.autoClean === false
|
|
394
|
+
? []
|
|
395
|
+
: await cleanupScratchCreatedAfterBaseline(root, baseline)
|
|
396
|
+
const removedTransient = options.autoClean === false
|
|
397
|
+
? []
|
|
398
|
+
: await removeHighConfidenceCreatedArtifacts(root, delta.created, options.taskText)
|
|
399
|
+
if (removedScratch.length || removedTransient.length) delta = deltaFromBaseline(root, baseline)
|
|
400
|
+
|
|
401
|
+
const unicode = await auditUnicodeFiles(root, delta.changed, options)
|
|
402
|
+
const artifactFindings = []
|
|
403
|
+
for (const relative of delta.created) {
|
|
404
|
+
const classification = classifyTransientArtifact(relative)
|
|
405
|
+
if (!classification.transient) continue
|
|
406
|
+
if (classification.confidence === "high" && removedTransient.includes(relative)) continue
|
|
407
|
+
if (taskMentionsPath(options.taskText, relative)) continue
|
|
408
|
+
artifactFindings.push({
|
|
409
|
+
severity: "error",
|
|
410
|
+
blocking: true,
|
|
411
|
+
file: relative,
|
|
412
|
+
kind: "unexplained-transient-artifact",
|
|
413
|
+
line: null,
|
|
414
|
+
column: null,
|
|
415
|
+
codePoint: null,
|
|
416
|
+
message: `New file looks like a debug/scratch/generated artifact (${classification.reason})`,
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const allowed = new Set(sourceFacingPaths(options.allowedPaths || []))
|
|
421
|
+
const scopeFindings = options.strictScope === true
|
|
422
|
+
? delta.changed
|
|
423
|
+
.filter((relative) => !allowed.has(relative))
|
|
424
|
+
.map((relative) => ({
|
|
425
|
+
severity: "error",
|
|
426
|
+
blocking: true,
|
|
427
|
+
file: relative,
|
|
428
|
+
kind: "undeclared-source-change",
|
|
429
|
+
line: null,
|
|
430
|
+
column: null,
|
|
431
|
+
codePoint: null,
|
|
432
|
+
message: "Source changed outside the declared final write scope",
|
|
433
|
+
}))
|
|
434
|
+
: []
|
|
435
|
+
|
|
436
|
+
const readOnlyFindings = options.allowSourceMutations === false && delta.changed.length
|
|
437
|
+
? delta.changed.map((relative) => ({
|
|
438
|
+
severity: "error",
|
|
439
|
+
blocking: true,
|
|
440
|
+
file: relative,
|
|
441
|
+
kind: "read-only-agent-mutation",
|
|
442
|
+
line: null,
|
|
443
|
+
column: null,
|
|
444
|
+
codePoint: null,
|
|
445
|
+
message: "Read-only specialist mutated source/runtime-visible project files",
|
|
446
|
+
}))
|
|
447
|
+
: []
|
|
448
|
+
|
|
449
|
+
const findings = [
|
|
450
|
+
...unicode.findings,
|
|
451
|
+
...artifactFindings,
|
|
452
|
+
...scopeFindings,
|
|
453
|
+
...readOnlyFindings,
|
|
454
|
+
]
|
|
455
|
+
return {
|
|
456
|
+
schemaVersion: 1,
|
|
457
|
+
safe: findings.length === 0,
|
|
458
|
+
changed: delta.changed,
|
|
459
|
+
created: delta.created,
|
|
460
|
+
removed: [...removedScratch, ...removedTransient],
|
|
461
|
+
unicode,
|
|
462
|
+
findings,
|
|
463
|
+
summary: findings.length
|
|
464
|
+
? findings.slice(0, 20).map(renderFinding).join("\n")
|
|
465
|
+
: `workspace hygiene PASS; changed=${delta.changed.length}; cleaned=${removedScratch.length + removedTransient.length}`,
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function postRunFileHygiene(root, options = {}) {
|
|
470
|
+
return auditWorkspaceDelta(path.resolve(root), {
|
|
471
|
+
...options,
|
|
472
|
+
autoClean: options.autoClean !== false,
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export async function preFinalWorkspaceAudit(root, options = {}) {
|
|
477
|
+
return auditWorkspaceDelta(path.resolve(root), {
|
|
478
|
+
...options,
|
|
479
|
+
autoClean: false,
|
|
480
|
+
})
|
|
481
|
+
}
|