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.
Files changed (53) hide show
  1. package/README.md +34 -16
  2. package/docs/PI-COMPAT.md +151 -2
  3. package/docs/V15-MANAGED-RUNTIME.md +128 -0
  4. package/global-config/agents/architect.md +63 -1
  5. package/global-config/agents/executor.md +7 -0
  6. package/global-config/agents/plan-checker.md +8 -1
  7. package/global-config/agents/verifier.md +3 -3
  8. package/global-config/skills/database-engineering/references/workflow.md +15 -0
  9. package/global-config/skills/engineering-orchestrator/references/long-horizon.md +21 -11
  10. package/lib/activity-deadline.mjs +70 -0
  11. package/lib/adaptive-context-budget.mjs +25 -5
  12. package/lib/affected-tests.mjs +38 -5
  13. package/lib/capability-fabric.mjs +17 -0
  14. package/lib/code-intelligence/index.mjs +3 -2
  15. package/lib/code-intelligence/lsp-provider.mjs +326 -52
  16. package/lib/compaction-resume-guard.mjs +255 -0
  17. package/lib/completion-auditor.mjs +25 -5
  18. package/lib/context-engine-v11.mjs +20 -7
  19. package/lib/evidence-store.mjs +92 -8
  20. package/lib/execution-contract.mjs +592 -0
  21. package/lib/leaf-runtime-optimizer.mjs +76 -0
  22. package/lib/memory-engine.mjs +189 -16
  23. package/lib/performance-fabric.mjs +262 -107
  24. package/lib/pi-child-invocation.mjs +65 -0
  25. package/lib/pi-rpc-pool.mjs +61 -9
  26. package/lib/plan-salvage.mjs +124 -0
  27. package/lib/planning-speed-policy.mjs +131 -0
  28. package/lib/repo-graph.mjs +1 -1
  29. package/lib/repo-inspect.mjs +1 -0
  30. package/lib/runtime-artifacts.mjs +42 -0
  31. package/lib/runtime-events.mjs +33 -1
  32. package/lib/safety.mjs +1 -0
  33. package/lib/semantic-index.mjs +1 -1
  34. package/lib/service-manager.mjs +526 -0
  35. package/lib/session-display.mjs +49 -0
  36. package/lib/subagent-artifacts.mjs +182 -0
  37. package/lib/task-graph.mjs +87 -0
  38. package/lib/task-policy.mjs +150 -4
  39. package/lib/trajectory.mjs +111 -1
  40. package/lib/turbo-fast-path.mjs +44 -0
  41. package/lib/verification-broker.mjs +32 -27
  42. package/lib/workspace-fingerprint.mjs +45 -19
  43. package/lib/workspace-hygiene.mjs +481 -0
  44. package/lib/workspace-root.mjs +49 -0
  45. package/lib/worktree-sandbox.mjs +234 -11
  46. package/package.json +8 -5
  47. package/pi/extensions/ues-child-runtime.ts +170 -4
  48. package/pi/extensions/ues.ts +2063 -188
  49. package/scripts/check-release-consistency.mjs +25 -2
  50. package/scripts/check-source-integrity.mjs +471 -4
  51. package/scripts/eval-pi.mjs +76 -36
  52. package/scripts/smoke-pi-extension.mjs +1 -1
  53. 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 = 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
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 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
- })
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
- 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({
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
- ...RUNTIME_DIRS.map((dir) => `:(exclude)${dir}/**`),
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 porcelainEntries(statusOutput) {
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(porcelainEntries(statusOutput).map((entry) => entry.file))].sort()
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 = porcelainEntries(statusOutput)
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: 1,
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 runtimeWorkspaceSnapshot(root = process.cwd()) {
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: 1,
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
+ }