opencode-agent-skill 15.1.0 → 15.1.2

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 (43) hide show
  1. package/README.md +5 -5
  2. package/docs/V15-MANAGED-RUNTIME.md +18 -0
  3. package/global-config/agents/verifier.md +6 -2
  4. package/lib/affected-tests.mjs +28 -7
  5. package/lib/capability-fabric.mjs +107 -48
  6. package/lib/code-intelligence/index.mjs +50 -24
  7. package/lib/code-intelligence/lsp-pool.mjs +831 -0
  8. package/lib/code-intelligence/lsp-provider.mjs +197 -20
  9. package/lib/compaction-resume-guard.mjs +76 -4
  10. package/lib/completion-auditor.mjs +20 -0
  11. package/lib/context-manifest.mjs +88 -36
  12. package/lib/decision-policy.mjs +40 -2
  13. package/lib/evidence-store.mjs +121 -31
  14. package/lib/executable-probe.mjs +69 -0
  15. package/lib/execution-contract.mjs +2 -2
  16. package/lib/hierarchical-context.mjs +31 -3
  17. package/lib/learning-engine.mjs +164 -65
  18. package/lib/memory-engine.mjs +177 -95
  19. package/lib/model-config.mjs +86 -7
  20. package/lib/performance-fabric.mjs +92 -0
  21. package/lib/permission-policy.mjs +232 -0
  22. package/lib/pi-rpc-pool.mjs +220 -24
  23. package/lib/provider-recovery.mjs +51 -0
  24. package/lib/repo-graph.mjs +40 -7
  25. package/lib/repo-inspect.mjs +3 -2
  26. package/lib/runtime-events.mjs +31 -10
  27. package/lib/semantic-index.mjs +69 -21
  28. package/lib/skill-compiler.mjs +84 -18
  29. package/lib/task-engine.mjs +28 -9
  30. package/lib/task-policy.mjs +80 -7
  31. package/lib/trajectory.mjs +25 -1
  32. package/lib/untrusted-output.mjs +106 -0
  33. package/lib/workspace-fingerprint.mjs +17 -9
  34. package/lib/workspace-hygiene.mjs +2 -2
  35. package/lib/worktree-sandbox.mjs +14 -8
  36. package/package.json +8 -6
  37. package/pi/extensions/ues-child-runtime.ts +116 -5
  38. package/pi/extensions/ues.ts +780 -81
  39. package/scripts/benchmark-lsp-pool.mjs +128 -0
  40. package/scripts/check-release-consistency.mjs +16 -4
  41. package/scripts/check-source-integrity.mjs +118 -6
  42. package/scripts/run-test-suite.mjs +143 -0
  43. package/scripts/smoke-package-closure.mjs +4 -0
@@ -1,8 +1,15 @@
1
- import { spawn, spawnSync } from "node:child_process"
1
+ import { spawn } from "node:child_process"
2
+ import { existsSync } from "node:fs"
2
3
  import { lstat, readFile, realpath } from "node:fs/promises"
3
4
  import path from "node:path"
5
+ import { commandExists } from "../executable-probe.mjs"
6
+ import { resolveWindowsCommand } from "../windows-shim.mjs"
7
+ import { lspPoolStatus as managedLspPoolStatus, shutdownLspPool as shutdownManagedLspPool, withManagedLspSession } from "./lsp-pool.mjs"
4
8
  import { fileURLToPath, pathToFileURL } from "node:url"
5
9
 
10
+ const TYPESCRIPT_FALLBACK_CACHE_TTL_MS = 60_000
11
+ let typescriptFallbackCache = null
12
+
6
13
  const PROVIDERS = [
7
14
  { id: "typescript-language-server", extensions: new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]), commands: [["typescript-language-server", ["--stdio"]]], languageId: (ext) => [".ts", ".tsx"].includes(ext) ? "typescript" : "javascript" },
8
15
  { id: "pyright-langserver", extensions: new Set([".py"]), commands: [["pyright-langserver", ["--stdio"]], ["pylsp", []]], languageId: () => "python" },
@@ -22,19 +29,6 @@ export const LSP_OPERATIONS = Object.freeze([
22
29
  "outgoing-calls",
23
30
  ])
24
31
 
25
- const commandCache = new Map()
26
- const COMMAND_TTL_MS = 60_000
27
-
28
- function commandExists(command) {
29
- const now = Date.now()
30
- const cached = commandCache.get(command)
31
- if (cached && now - cached.at < COMMAND_TTL_MS) return cached.available
32
- const finder = process.platform === "win32" ? "where" : "which"
33
- const available = spawnSync(finder, [command], { stdio: "ignore", windowsHide: true }).status === 0
34
- commandCache.set(command, { available, at: now })
35
- return available
36
- }
37
-
38
32
  function inside(root, target) {
39
33
  return target === root || target.startsWith(root + path.sep)
40
34
  }
@@ -50,18 +44,107 @@ async function safeFile(root, relative) {
50
44
  return { base, file: actual, info }
51
45
  }
52
46
 
47
+ export function clearTypeScriptTsserverFallbackCache() {
48
+ typescriptFallbackCache = null
49
+ }
50
+
51
+ function deriveTypeScriptTsserverFallback(execution) {
52
+ const candidates = [
53
+ execution?.entry,
54
+ ...(Array.isArray(execution?.argsPrefix) ? execution.argsPrefix : []),
55
+ ].filter(Boolean)
56
+
57
+ for (const value of candidates) {
58
+ const entry = path.resolve(String(value))
59
+ const binDir = path.dirname(entry)
60
+ const packageRoot = path.basename(binDir).toLowerCase() === "bin"
61
+ ? path.dirname(binDir)
62
+ : null
63
+ const preferred = packageRoot ? path.join(packageRoot, "lib", "tsserver.js") : null
64
+ if (preferred && existsSync(preferred)) return preferred
65
+ if (existsSync(entry)) return entry
66
+ }
67
+ return null
68
+ }
69
+
70
+ export function resolveTypeScriptTsserverFallback(options = {}) {
71
+ const explicit = String(options.explicitPath ?? process.env.UES_TYPESCRIPT_TSSERVER_PATH ?? "").trim()
72
+ if (explicit && existsSync(path.resolve(explicit))) return path.resolve(explicit)
73
+
74
+ const platform = String(options.platform || process.platform)
75
+ if (platform !== "win32") return null
76
+
77
+ // Explicit injected executions are deterministic probes used by tests/callers;
78
+ // do not mix them into the host PATH cache.
79
+ if (options.execution) return deriveTypeScriptTsserverFallback(options.execution)
80
+
81
+ const cacheKey = [
82
+ explicit,
83
+ String(process.env.PATH || ""),
84
+ String(process.env.PATHEXT || ""),
85
+ ].join("\u0000")
86
+ const ttlMs = Math.max(0, Math.min(
87
+ 5 * 60_000,
88
+ Number(options.cacheTtlMs ?? process.env.UES_LSP_PROVIDER_PROBE_TTL_MS ?? TYPESCRIPT_FALLBACK_CACHE_TTL_MS),
89
+ ))
90
+ const now = Date.now()
91
+ if (
92
+ typescriptFallbackCache &&
93
+ typescriptFallbackCache.key === cacheKey &&
94
+ now - typescriptFallbackCache.checkedAt < ttlMs
95
+ ) {
96
+ return typescriptFallbackCache.value
97
+ }
98
+
99
+ const resolver = typeof options.resolveCommand === "function"
100
+ ? options.resolveCommand
101
+ : resolveWindowsCommand
102
+ const value = deriveTypeScriptTsserverFallback(resolver("tsserver"))
103
+ typescriptFallbackCache = { key: cacheKey, checkedAt: now, value }
104
+ return value
105
+ }
106
+
107
+ function providerRuntimeOptions(provider) {
108
+ if (provider?.id !== "typescript-language-server") return {}
109
+ const fallbackPath = resolveTypeScriptTsserverFallback()
110
+ return fallbackPath
111
+ ? { initializationOptions: { tsserver: { fallbackPath } } }
112
+ : {}
113
+ }
114
+
53
115
  function providerFor(file) {
54
116
  const ext = path.extname(file).toLowerCase()
55
117
  for (const provider of PROVIDERS) {
56
118
  if (!provider.extensions.has(ext)) continue
57
119
  for (const [command, args] of provider.commands) {
58
- if (commandExists(command)) return { ...provider, command, args, languageId: provider.languageId(ext) }
120
+ if (commandExists(command)) {
121
+ return {
122
+ ...provider,
123
+ ...providerRuntimeOptions(provider),
124
+ command,
125
+ args,
126
+ languageId: provider.languageId(ext),
127
+ }
128
+ }
59
129
  }
60
130
  return { ...provider, command: null, args: [], languageId: provider.languageId(ext) }
61
131
  }
62
132
  return null
63
133
  }
64
134
 
135
+ export function lspPersistencePolicy(options = {}) {
136
+ if (typeof options.persistent === "boolean") {
137
+ return { enabled: options.persistent, source: "explicit-option" }
138
+ }
139
+ const env = String(process.env.UES_LSP_PERSISTENT || "").trim()
140
+ if (env === "0") return { enabled: false, source: "env-disabled" }
141
+ if (env === "1") return { enabled: true, source: "env-enabled" }
142
+ if (process.env.UES_CHILD_PROCESS === "1") {
143
+ return { enabled: true, source: "pi-child-runtime" }
144
+ }
145
+ return { enabled: false, source: "short-lived-default" }
146
+ }
147
+
65
148
  export function lspProviderStatus(file = "") {
66
149
  const ext = path.extname(String(file || "")).toLowerCase()
67
150
  const providers = PROVIDERS
@@ -75,6 +158,13 @@ export function lspProviderStatus(file = "") {
75
158
  extension: ext,
76
159
  available: providers.some((row) => row.available),
77
160
  operations: [...LSP_OPERATIONS],
161
+ persistentPool: {
162
+ ...managedLspPoolStatus({
163
+ includeSessions: false,
164
+ persistent: lspPersistencePolicy().enabled,
165
+ }),
166
+ policy: lspPersistencePolicy(),
167
+ },
78
168
  providers,
79
169
  }
80
170
  }
@@ -199,7 +289,7 @@ function sanitizeHierarchyCalls(base, values, direction, limit = 120) {
199
289
  })
200
290
  }
201
291
 
202
- async function withLspSession(root, relative, options, operation) {
292
+ async function withEphemeralLspSession(root, relative, options, operation) {
203
293
  const target = await safeFile(root, relative)
204
294
  const provider = providerFor(target.file)
205
295
  const relativeFile = path.relative(target.base, target.file).replaceAll("\\", "/")
@@ -222,8 +312,26 @@ async function withLspSession(root, relative, options, operation) {
222
312
  const rootUri = pathToFileURL(target.base + path.sep).href
223
313
  const timeoutMs = Math.max(500, Math.min(15_000, Number(options.timeoutMs || 5000)))
224
314
 
315
+ const execution = process.platform === "win32"
316
+ ? resolveWindowsCommand(provider.command)
317
+ : { executable: provider.command, argsPrefix: [] }
318
+ if (!execution?.executable) {
319
+ return {
320
+ schemaVersion: 2,
321
+ file: relativeFile,
322
+ available: true,
323
+ provider: provider.id,
324
+ command: provider.command,
325
+ operation: options.operation || null,
326
+ reason: "spawn-error",
327
+ result: null,
328
+ stderr: null,
329
+ error: "unable to resolve LSP executable: " + provider.command,
330
+ }
331
+ }
332
+
225
333
  return await new Promise((resolve) => {
226
- const child = spawn(provider.command, provider.args, {
334
+ const child = spawn(execution.executable, [...(execution.argsPrefix || []), ...provider.args], {
227
335
  cwd: target.base,
228
336
  stdio: ["pipe", "pipe", "pipe"],
229
337
  windowsHide: true,
@@ -364,6 +472,7 @@ async function withLspSession(root, relative, options, operation) {
364
472
  workspace: { workspaceFolders: true, applyEdit: false },
365
473
  },
366
474
  workspaceFolders: [{ uri: rootUri, name: path.basename(target.base) || "workspace" }],
475
+ initializationOptions: provider.initializationOptions || undefined,
367
476
  })
368
477
  notify("initialized", {})
369
478
  notify("textDocument/didOpen", {
@@ -388,13 +497,35 @@ async function withLspSession(root, relative, options, operation) {
388
497
  export async function lspOperation(root, relative, operation, options = {}) {
389
498
  const op = String(operation || "")
390
499
  if (!LSP_OPERATIONS.includes(op)) throw new Error("unsupported LSP operation: " + op)
500
+
501
+ const target = await safeFile(root, relative)
502
+ const provider = providerFor(target.file)
503
+ const relativeFile = path.relative(target.base, target.file).replaceAll("\\", "/")
504
+ if (!provider?.command) {
505
+ return {
506
+ schemaVersion: 2,
507
+ file: relativeFile,
508
+ available: false,
509
+ provider: provider?.id || null,
510
+ operation: op,
511
+ reason: provider ? "lsp-command-unavailable" : "unsupported-extension",
512
+ result: null,
513
+ persistent: false,
514
+ }
515
+ }
516
+
391
517
  const maxResults = Math.max(1, Math.min(500, Number(options.maxResults || 120)))
392
- return withLspSession(root, relative, { ...options, operation: op }, async (session) => {
518
+ const execute = async (session) => {
393
519
  const pos = position(options)
394
520
  const textDocument = { uri: session.uri }
395
521
 
396
522
  if (op === "diagnostics") {
397
- const params = await session.waitForNotification("textDocument/publishDiagnostics")
523
+ const params = await session.waitForNotification("textDocument/publishDiagnostics", {
524
+ predicate: (value) =>
525
+ (!value?.uri || value.uri === session.uri) &&
526
+ (value?.version == null || Number(value.version) >= Number(session.documentSync?.version || 0)),
527
+ afterAt: session.documentSync?.changed ? session.documentSync.syncedAt : 0,
528
+ })
398
529
  const values = Array.isArray(params?.diagnostics) ? params.diagnostics : []
399
530
  return { diagnostics: values.slice(0, maxResults).map(sanitizeDiagnostic) }
400
531
  }
@@ -450,7 +581,53 @@ export async function lspOperation(root, relative, operation, options = {}) {
450
581
  }
451
582
  }
452
583
  return null
453
- })
584
+ }
585
+
586
+ const persistence = lspPersistencePolicy(options)
587
+ const managed = await withManagedLspSession(
588
+ target,
589
+ provider,
590
+ { ...options, persistent: persistence.enabled, operation: op },
591
+ execute,
592
+ ).catch((error) => ({
593
+ ok: false,
594
+ reason: "managed-lsp-exception",
595
+ error: String(error instanceof Error ? error.message : error).slice(0, 1200),
596
+ }))
597
+
598
+ if (managed?.ok) {
599
+ return {
600
+ schemaVersion: 2,
601
+ file: relativeFile,
602
+ available: true,
603
+ provider: provider.id,
604
+ command: provider.command,
605
+ operation: op,
606
+ reason: "ok",
607
+ result: managed.result,
608
+ stderr: managed.stderr || null,
609
+ error: null,
610
+ persistent: true,
611
+ pool: { ...managed.meta, policy: persistence },
612
+ }
613
+ }
614
+
615
+ const fallback = await withEphemeralLspSession(root, relative, { ...options, operation: op }, execute)
616
+ return {
617
+ ...fallback,
618
+ persistent: false,
619
+ fallbackFrom: managed?.reason || "managed-lsp-unavailable",
620
+ managedError: managed?.error || null,
621
+ persistencePolicy: persistence,
622
+ }
623
+ }
624
+
625
+ export function lspPoolStatus(options = {}) {
626
+ return managedLspPoolStatus(options)
627
+ }
628
+
629
+ export async function shutdownLspPool(root = null) {
630
+ return shutdownManagedLspPool(root)
454
631
  }
455
632
 
456
633
  export async function diagnoseCode(root, relative, options = {}) {
@@ -153,6 +153,72 @@ function stateSummary(state) {
153
153
  }
154
154
  }
155
155
 
156
+ function buildInstructionEpoch(state, tasks, receipts, gateReceipts) {
157
+ const rows = Array.isArray(tasks) ? tasks : []
158
+ const passedTaskReceipts = new Set(
159
+ (Array.isArray(receipts) ? receipts : [])
160
+ .filter((item) => item?.passed === true && item?.task)
161
+ .map((item) => String(item.task)),
162
+ )
163
+ const completedTaskIds = rows.filter((row) => row.status === "completed").map((row) => row.id)
164
+ const activeTaskIds = rows.filter((row) => row.status === "running").map((row) => row.id)
165
+ const failedTaskIds = rows.filter((row) => row.status === "failed").map((row) => row.id)
166
+ const pendingTaskIds = rows
167
+ .filter((row) => !["completed", "failed"].includes(String(row.status || "").toLowerCase()))
168
+ .map((row) => row.id)
169
+ const tasksWithoutPassingReceipt = completedTaskIds.filter((id) => !passedTaskReceipts.has(String(id)))
170
+ const decisions = (Array.isArray(state?.decisions) ? state.decisions : [])
171
+ .slice(-8)
172
+ .map((item) => ({
173
+ at: item?.at || null,
174
+ text: item?.text ? String(item.text).slice(0, 600) : null,
175
+ source: item?.source || null,
176
+ risk: item?.policy?.risk || null,
177
+ autoResolvable: item?.policy?.autoResolvable === true,
178
+ confidence: Number.isFinite(Number(item?.policy?.decision?.confidence))
179
+ ? Number(item.policy.decision.confidence)
180
+ : null,
181
+ }))
182
+
183
+ const body = {
184
+ schemaVersion: 1,
185
+ source: "durable-state-fold",
186
+ objective: state?.goal ? String(state.goal).slice(0, 1200) : null,
187
+ status: state?.status || null,
188
+ planHash: state?.planHash || null,
189
+ planApproved: state?.planApproved === true,
190
+ activeTaskIds,
191
+ pendingTaskIds,
192
+ completedTaskIds,
193
+ failedTaskIds,
194
+ blockers: (Array.isArray(state?.blockers) ? state.blockers : []).slice(-12).map((item) => String(item).slice(0, 500)),
195
+ recentDecisions: decisions,
196
+ checkpoint: state?.checkpoint ? {
197
+ id: state.checkpoint.id || null,
198
+ currentTaskId: state.checkpoint.currentTaskId || null,
199
+ runId: state.checkpoint.runId || null,
200
+ workspaceFingerprint: state.checkpoint.workspaceFingerprint || null,
201
+ nextAction: state.checkpoint.nextAction || null,
202
+ } : null,
203
+ nextAction: state?.nextAction || null,
204
+ verification: {
205
+ tasksWithoutPassingReceipt,
206
+ passingReceiptIds: (Array.isArray(receipts) ? receipts : [])
207
+ .filter((item) => item?.passed === true && item?.id)
208
+ .slice(-20)
209
+ .map((item) => item.id),
210
+ passingGateReceiptIds: (Array.isArray(gateReceipts) ? gateReceipts : [])
211
+ .filter((item) => String(item?.verdict || "").toUpperCase() === "PASS" && item?.id)
212
+ .slice(-12)
213
+ .map((item) => item.id),
214
+ },
215
+ }
216
+ return {
217
+ ...body,
218
+ epochHash: digest(JSON.stringify(body)),
219
+ }
220
+ }
221
+
156
222
  export async function checkpointDurableWorkBeforeCompaction(root, options = {}) {
157
223
  const reason = String(options.reason || "unknown")
158
224
  const rows = await activeWorkspaces(root, options.maxWorkspaces || MAX_WORKSPACES)
@@ -197,6 +263,10 @@ export async function buildCompactionResumeGuard(root, options = {}) {
197
263
  ? path.relative(root, path.join(row.dir, "phases", "MANIFEST.json")).replaceAll("\\", "/")
198
264
  : null,
199
265
  }
266
+ const tasks = taskRows(plan, row.state)
267
+ const receipts = receiptRows(evidence)
268
+ const gateReceipts = gateRows(evidence)
269
+ const instructionEpoch = buildInstructionEpoch(row.state, tasks, receipts, gateReceipts)
200
270
  workspaces.push({
201
271
  slug: row.slug,
202
272
  sources,
@@ -208,9 +278,10 @@ export async function buildCompactionResumeGuard(root, options = {}) {
208
278
  phaseManifest: manifest ? digest(JSON.stringify(manifest)) : null,
209
279
  },
210
280
  state: stateSummary(row.state),
211
- tasks: taskRows(plan, row.state),
212
- receipts: receiptRows(evidence),
213
- gateReceipts: gateRows(evidence),
281
+ instructionEpoch,
282
+ tasks,
283
+ receipts,
284
+ gateReceipts,
214
285
  executionContract: contract ? {
215
286
  schemaVersion: contract.schemaVersion || null,
216
287
  taskHash: contract.taskHash || null,
@@ -243,7 +314,8 @@ export function renderCompactionResumeGuard(packet, options = {}) {
243
314
  "## UES Durable Compaction Resume Guard",
244
315
  "Authoritative durable state was rebuilt from filesystem artifacts after Pi compaction.",
245
316
  "The model-generated compaction summary is advisory only for durable execution state.",
246
- "If the summary conflicts with STATE.json, PLAN.json, EXECUTION_CONTRACT.json, phase artifacts, or evidence receipts below, the deterministic artifacts win.",
317
+ "If the summary conflicts with STATE.json, PLAN.json, EXECUTION_CONTRACT.json, phase artifacts, instructionEpoch, or evidence receipts below, the deterministic artifacts win.",
318
+ "Treat instructionEpoch as the compact deterministic control-plane fold: objective, active work, blockers, decisions, receipt gaps and next action.",
247
319
  "Do not mark a task/phase complete unless the rebuilt state and fresh receipts support it.",
248
320
  "Follow checkpoint.nextAction/state.nextAction and re-read the referenced artifact when more detail is required.",
249
321
  "",
@@ -42,6 +42,17 @@ function receiptPassed(row = {}) {
42
42
  return Boolean(receipt?.passed === true && Number(receipt?.exitCode) === 0)
43
43
  }
44
44
 
45
+ function claimEvidenceStatus(value) {
46
+ const lines = text(value).split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
47
+ const counts = { verified: 0, inferred: 0, unknown: 0 }
48
+ for (const line of lines) {
49
+ if (/^(?:[-*]\s*)?VERIFIED\s*:/i.test(line)) counts.verified += 1
50
+ else if (/^(?:[-*]\s*)?INFERRED\s*:/i.test(line)) counts.inferred += 1
51
+ else if (/^(?:[-*]\s*)?UNKNOWN\s*:/i.test(line)) counts.unknown += 1
52
+ }
53
+ return counts
54
+ }
55
+
45
56
  export function auditCompletion(input = {}) {
46
57
  const failures = []
47
58
  const warnings = []
@@ -67,6 +78,14 @@ export function auditCompletion(input = {}) {
67
78
  }
68
79
  if (Object.hasOwn(sections, "checks-not-run") && !benignEmptySection(sections["checks-not-run"])) warnings.push("verification-reports-checks-not-run")
69
80
 
81
+ const claimStatus = claimEvidenceStatus(sections["acceptance-criteria-proven"])
82
+ const claimStatusCount = claimStatus.verified + claimStatus.inferred + claimStatus.unknown
83
+ if (input.requireClaimEvidenceStatus === true && claimStatusCount === 0) {
84
+ failures.push("acceptance-criteria-evidence-status-missing")
85
+ }
86
+ if (claimStatus.inferred > 0) failures.push("acceptance-criteria-inferred-not-proven")
87
+ if (claimStatus.unknown > 0) failures.push("acceptance-criteria-unknown-not-proven")
88
+
70
89
  const snapshot = input.workspaceSnapshot || null
71
90
  if (snapshot) {
72
91
  if (snapshot.cacheable === true && !text(snapshot.fingerprint)) failures.push("workspace-fingerprint-missing")
@@ -95,6 +114,7 @@ export function auditCompletion(input = {}) {
95
114
  visual: requireVisual ? passRun(visual) : null,
96
115
  reportSections: Object.keys(sections).sort(),
97
116
  passingReceiptCount: passingReceipts.length,
117
+ claimEvidenceStatus: claimStatus,
98
118
  workspaceFingerprint: snapshot?.fingerprint || null,
99
119
  changedFiles: Array.isArray(snapshot?.changedFiles) ? snapshot.changedFiles.slice(0, 200) : [],
100
120
  },
@@ -7,6 +7,8 @@ import { taskFiles } from "./task-graph.mjs"
7
7
  import { buildSemanticIndexCached, querySemanticIndex } from "./semantic-index.mjs"
8
8
  import { queryContextHierarchy } from "./hierarchical-context.mjs"
9
9
  import { rankContextGraph } from "./context-graph-rank.mjs"
10
+ import { runtimeWorkspaceSnapshot, workspaceStatusEntries } from "./workspace-fingerprint.mjs"
11
+ import { sourceFacingPaths, sourceGitPathspecs } from "./runtime-artifacts.mjs"
10
12
 
11
13
  const ROOT_INSTRUCTIONS = [
12
14
  "AGENTS.md",
@@ -28,6 +30,14 @@ function unique(values) {
28
30
  return [...new Set(values.filter(Boolean))]
29
31
  }
30
32
 
33
+ function boundedFailure(component, error, impact = "evidence") {
34
+ return {
35
+ component,
36
+ impact,
37
+ message: String(error instanceof Error ? error.message : error || "unknown failure").slice(0, 500),
38
+ }
39
+ }
40
+
31
41
  function safeFull(root, relative) {
32
42
  const base = path.resolve(root)
33
43
  const full = path.resolve(base, relative)
@@ -148,18 +158,22 @@ function symbolHits(source, terms, limit = 12) {
148
158
  function gitChangedFiles(root) {
149
159
  const result = spawnSync(
150
160
  "git",
151
- ["status", "--porcelain=v1", "--untracked-files=all"],
161
+ [
162
+ "status",
163
+ "--porcelain=v1",
164
+ "-z",
165
+ "--untracked-files=all",
166
+ "--",
167
+ ...sourceGitPathspecs(),
168
+ ],
152
169
  { cwd: root, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 },
153
170
  )
154
171
  if (result.status !== 0) return []
155
- return unique(
156
- result.stdout
157
- .split(/\r?\n/)
158
- .filter(Boolean)
159
- .map((line) => line.slice(3).trim())
160
- .map((value) => value.includes(" -> ") ? value.split(" -> ").at(-1) : value)
161
- .map((value) => value.replaceAll("\\", "/")),
162
- )
172
+ return unique(sourceFacingPaths(
173
+ workspaceStatusEntries(result.stdout)
174
+ .filter((entry) => entry.renameSource !== true)
175
+ .map((entry) => entry.file),
176
+ ))
163
177
  }
164
178
 
165
179
  async function nearbyInstructions(root, declared) {
@@ -249,9 +263,20 @@ export async function buildContextManifest(root, task, options = {}) {
249
263
  const evidenceBudget = options.evidenceBudget || null
250
264
  const declared = taskFiles(task)
251
265
  const terms = taskTerms(task)
266
+ let workspaceSnapshot = null
267
+ const suppliedFingerprint = String(options.workspaceFingerprint || "").trim()
268
+ if (!Array.isArray(options.changedFiles) || !suppliedFingerprint) {
269
+ try {
270
+ workspaceSnapshot = runtimeWorkspaceSnapshot(root, { workspaceState: options.workspaceState })
271
+ } catch {}
272
+ }
273
+ const reusableWorkspaceFingerprint = suppliedFingerprint ||
274
+ (workspaceSnapshot?.cacheable ? String(workspaceSnapshot.fingerprint || "") : "")
252
275
  const changed = Array.isArray(options.changedFiles)
253
276
  ? options.changedFiles.map((file) => String(file || "").replaceAll("\\", "/")).filter(Boolean)
254
- : gitChangedFiles(root)
277
+ : Array.isArray(workspaceSnapshot?.changedFiles)
278
+ ? workspaceSnapshot.changedFiles
279
+ : gitChangedFiles(root)
255
280
  const strategy = String(options.strategy || "semantic+graph+git")
256
281
  const queryText = [
257
282
  task?.title,
@@ -260,25 +285,51 @@ export async function buildContextManifest(root, task, options = {}) {
260
285
  ...(task?.verification || []),
261
286
  ].filter(Boolean).join(" ")
262
287
 
288
+ const contextDiagnostics = []
263
289
  const semanticSnapshot = await buildSemanticIndexCached(root, {
264
290
  maxFiles: options.semanticMaxFiles ?? 6000,
265
291
  maxDepth: options.semanticMaxDepth ?? 14,
266
- workspaceFingerprint: options.workspaceFingerprint,
267
- }).catch(() => null)
292
+ workspaceFingerprint: reusableWorkspaceFingerprint,
293
+ }).catch((error) => {
294
+ contextDiagnostics.push(boundedFailure("semantic-snapshot", error, "performance"))
295
+ return null
296
+ })
268
297
 
269
- const hierarchy = await queryContextHierarchy(root, queryText, {
270
- declaredFiles: declared,
271
- maxScopes: options.hierarchyMaxScopes ?? 6,
272
- maxFiles: options.semanticMaxFiles ?? 6000,
273
- builtIndex: semanticSnapshot || undefined,
274
- }).catch(() => null)
298
+ const useGraph = strategy.includes("graph")
299
+ const [hierarchy, semantic, graph, instructions] = await Promise.all([
300
+ queryContextHierarchy(root, queryText, {
301
+ declaredFiles: declared,
302
+ maxScopes: options.hierarchyMaxScopes ?? 6,
303
+ maxFiles: options.semanticMaxFiles ?? 6000,
304
+ builtIndex: semanticSnapshot || undefined,
305
+ }).catch((error) => {
306
+ contextDiagnostics.push(boundedFailure("hierarchy", error))
307
+ return null
308
+ }),
309
+ querySemanticIndex(root, queryText, {
310
+ limit: 32,
311
+ maxFiles: options.semanticMaxFiles ?? 6000,
312
+ builtIndex: semanticSnapshot || undefined,
313
+ }).catch((error) => {
314
+ contextDiagnostics.push(boundedFailure("semantic-query", error))
315
+ return null
316
+ }),
317
+ useGraph
318
+ ? buildRepoGraphCached(root, {
319
+ maxFiles: options.maxFiles ?? 2500,
320
+ workspaceFingerprint: reusableWorkspaceFingerprint,
321
+ }).catch((error) => {
322
+ contextDiagnostics.push(boundedFailure("repo-graph", error))
323
+ return { nodes: [], edges: [], hotspots: [], scannedFiles: 0, truncated: false }
324
+ })
325
+ : Promise.resolve({ nodes: [], edges: [], hotspots: [], scannedFiles: 0, truncated: false }),
326
+ nearbyInstructions(root, declared).catch((error) => {
327
+ contextDiagnostics.push(boundedFailure("instructions", error))
328
+ return []
329
+ }),
330
+ ])
275
331
  const hierarchyScopes = hierarchy?.scopes?.map((item) => item.path) || []
276
332
 
277
- const semantic = await querySemanticIndex(root, queryText, {
278
- limit: 32,
279
- maxFiles: options.semanticMaxFiles ?? 6000,
280
- builtIndex: semanticSnapshot || undefined,
281
- }).catch(() => null)
282
333
  const scopedSemanticResults = (semantic?.results || []).map((item) => {
283
334
  const scopeRank = hierarchyScopes.findIndex((scope) =>
284
335
  scope === "." || item.path === scope || item.path.startsWith(scope + "/"),
@@ -293,13 +344,6 @@ export async function buildContextManifest(root, task, options = {}) {
293
344
  }).sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
294
345
  const semanticPaths = scopedSemanticResults.map((item) => item.path)
295
346
 
296
- const useGraph = strategy.includes("graph")
297
- const graph = useGraph
298
- ? await buildRepoGraphCached(root, {
299
- maxFiles: options.maxFiles ?? 2500,
300
- workspaceFingerprint: options.workspaceFingerprint,
301
- })
302
- : { nodes: [], edges: [], hotspots: [], scannedFiles: 0, truncated: false }
303
347
  const nodeMap = new Map(graph.nodes.map((node) => [node.path, node]))
304
348
  const incoming = new Map()
305
349
 
@@ -330,7 +374,6 @@ export async function buildContextManifest(root, task, options = {}) {
330
374
  .slice(0, 24)
331
375
  .map((item) => item.file)
332
376
 
333
- const instructions = await nearbyInstructions(root, declared)
334
377
  const graphRanked = useGraph
335
378
  ? rankContextGraph(graph, {
336
379
  semanticResults: scopedSemanticResults,
@@ -388,11 +431,8 @@ export async function buildContextManifest(root, task, options = {}) {
388
431
  ...instructions,
389
432
  ])
390
433
 
391
- const files = []
392
- for (const file of priority) {
393
- const info = await fileInfo(root, file)
394
- if (info) files.push(info)
395
- }
434
+ const files = (await Promise.all(priority.map((file) => fileInfo(root, file))))
435
+ .filter(Boolean)
396
436
 
397
437
  let remaining = budget
398
438
  const categoryRemaining = {
@@ -422,9 +462,18 @@ export async function buildContextManifest(root, task, options = {}) {
422
462
  })
423
463
  }
424
464
 
465
+ contextDiagnostics.sort((a, b) =>
466
+ a.component.localeCompare(b.component) ||
467
+ a.impact.localeCompare(b.impact) ||
468
+ a.message.localeCompare(b.message)
469
+ )
470
+ const evidenceDegraded = contextDiagnostics.some((item) => item.impact === "evidence")
471
+
425
472
  return {
426
473
  schemaVersion: 4,
427
474
  task: task?.id || null,
475
+ contextQuality: evidenceDegraded ? "degraded" : "full",
476
+ degradedComponents: contextDiagnostics,
428
477
  strategy,
429
478
  queryTerms: terms,
430
479
  declared,
@@ -437,6 +486,7 @@ export async function buildContextManifest(root, task, options = {}) {
437
486
  excerpts,
438
487
  hierarchy: hierarchy ? {
439
488
  schemaVersion: hierarchy.schemaVersion,
489
+ runtimeCacheHit: hierarchy.runtimeCacheHit === true,
440
490
  levels: hierarchy.levels,
441
491
  scopes: hierarchy.scopes.slice(0, 6).map((item) => ({
442
492
  path: item.path,
@@ -450,6 +500,8 @@ export async function buildContextManifest(root, task, options = {}) {
450
500
  semantic: semantic ? {
451
501
  evidenceLevel: semantic.evidenceLevel,
452
502
  resultCount: semantic.results.length,
503
+ runtimeCacheHit: semanticSnapshot?.runtimeCacheHit === true,
504
+ runtimeCacheCoalesced: semanticSnapshot?.runtimeCacheCoalesced === true,
453
505
  stats: semantic.stats,
454
506
  } : null,
455
507
  graph: {