opencode-agent-skill 14.2.0-beta.1 → 15.0.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 (55) hide show
  1. package/README.md +51 -18
  2. package/docs/PI-COMPAT.md +167 -3
  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 +1 -1
  13. package/lib/capability-fabric.mjs +40 -0
  14. package/lib/code-intelligence/edit-anchor.mjs +102 -0
  15. package/lib/code-intelligence/index.mjs +95 -0
  16. package/lib/code-intelligence/lsp-provider.mjs +189 -0
  17. package/lib/completion-auditor.mjs +102 -0
  18. package/lib/document-ingestion.mjs +60 -0
  19. package/lib/evidence-store.mjs +92 -8
  20. package/lib/execution-contract.mjs +579 -0
  21. package/lib/fast-verification-gate.mjs +69 -0
  22. package/lib/leaf-runtime-optimizer.mjs +76 -0
  23. package/lib/mcp-health.mjs +144 -0
  24. package/lib/mcp-tool-policy.mjs +19 -0
  25. package/lib/memory-engine.mjs +28 -0
  26. package/lib/pi-child-invocation.mjs +65 -0
  27. package/lib/pi-rpc-pool.mjs +61 -9
  28. package/lib/plan-salvage.mjs +124 -0
  29. package/lib/planning-speed-policy.mjs +131 -0
  30. package/lib/prompt-cache.mjs +26 -12
  31. package/lib/repo-graph.mjs +1 -1
  32. package/lib/repo-inspect.mjs +1 -0
  33. package/lib/reversible-context.mjs +49 -0
  34. package/lib/runtime-artifacts.mjs +42 -0
  35. package/lib/runtime-events.mjs +33 -1
  36. package/lib/safety.mjs +1 -0
  37. package/lib/semantic-index.mjs +1 -1
  38. package/lib/service-manager.mjs +526 -0
  39. package/lib/session-display.mjs +49 -0
  40. package/lib/task-engine.mjs +6 -10
  41. package/lib/task-graph.mjs +87 -0
  42. package/lib/task-policy.mjs +210 -8
  43. package/lib/trajectory.mjs +111 -1
  44. package/lib/turbo-fast-path.mjs +44 -0
  45. package/lib/workspace-fingerprint.mjs +2 -11
  46. package/lib/workspace-root.mjs +49 -0
  47. package/lib/worktree-sandbox.mjs +234 -11
  48. package/package.json +9 -6
  49. package/pi/extensions/ues-child-runtime.ts +300 -2
  50. package/pi/extensions/ues.ts +1887 -160
  51. package/scripts/check-release-consistency.mjs +25 -2
  52. package/scripts/check-source-integrity.mjs +532 -3
  53. package/scripts/eval-pi.mjs +76 -36
  54. package/scripts/smoke-pi-extension.mjs +1 -1
  55. package/pi/prompts/ues-run.md +0 -17
@@ -13,3 +13,18 @@ Define the invariant and the smallest atomic boundary. Consider retries, unique
13
13
 
14
14
  ## Verification
15
15
  Validate migration up/down strategy when supported, run affected data tests, and inspect generated SQL/schema diff. For risky changes use representative existing data or a dry run. Confirm old/new application compatibility when rollout can overlap.
16
+
17
+
18
+ ## Fixture/demo cleanup safety
19
+
20
+ Treat cleanup of test/demo records as a data migration, not a UI concern.
21
+
22
+ - refuse production targets and never silently fall back from a test database to a development database;
23
+ - dry-run before deletion and report the exact candidate IDs/keys plus pre-cleanup counts;
24
+ - match deterministic markers or audited source provenance, never a broad substring that could include legitimate records;
25
+ - preserve canonical seed/demo records and real user history;
26
+ - delete in dependency-safe FK order and use a transaction/rollback boundary when the stack supports it;
27
+ - report post-cleanup counts and run the cleanup a second time to prove idempotency;
28
+ - verify public/API data after cleanup. Frontend filtering is not cleanup evidence.
29
+
30
+ Only claim `DB_CLEAN_PASS` when fresh data-layer evidence proves the cleanup candidates, counts, canonical preservation, public-data result, and zero-change second pass.
@@ -13,6 +13,11 @@ When the user explicitly chooses the long-horizon workflow (for example with `/u
13
13
  STATE.json
14
14
  EVIDENCE.json
15
15
  EVENTS.jsonl
16
+ EXECUTION_CONTRACT.json
17
+ FINAL_VERDICTS.json
18
+ phases/
19
+ MANIFEST.json
20
+ phase-XX-<slug>.json
16
21
  tasks/
17
22
  reports/
18
23
  ```
@@ -21,11 +26,15 @@ The directory is git-ignored execution state, not hidden reasoning. Store requir
21
26
 
22
27
  ## Machine-enforced gates
23
28
 
24
- The V6 engine enforces three boundaries:
29
+ The runtime enforces seven boundaries:
25
30
 
26
- 1. **Plan gate** — `work plan` leaves the item in `awaiting-plan-approval`. Long/high-risk plans require a structured `plan-verification` receipt bound to the current plan hash before `work approve-plan` succeeds.
27
- 2. **Concurrent state gate** — all mutable `STATE.json` and `EVIDENCE.json` operations use a per-work-item lock plus atomic file replacement, preventing safe-wave executors from losing each other's state.
28
- 3. **Integration gate** — long/high-risk PASS requires a structured `integration-verification` receipt bound to the current workspace fingerprint. Finalization rejects any later workspace change.
31
+ 1. **Inherited-work gate** — snapshot source-facing dirty paths before planning. UES children may not discard them with restore/checkout/stash/clean/reset, and planned writers must declare every touched file.
32
+ 2. **Local-env gate** — local `.env*` runtime inputs are no-write by default; only explicit user authorization permits local env mutation. Repository templates remain writable.
33
+ 3. **Plan gate** — `work plan` leaves the item in `awaiting-plan-approval`. Long/high-risk plans require a structured `plan-verification` receipt bound to the current plan hash before `work approve-plan` succeeds.
34
+ 4. **Phase gate** — explicit execution phases become deterministic previous-phase barriers. Constraint-only phases stay global invariants.
35
+ 5. **Concurrent state gate** — all mutable `STATE.json` and `EVIDENCE.json` operations use a per-work-item lock plus atomic file replacement, preventing safe-wave executors from losing each other's state.
36
+ 6. **Integration gate** — long/high-risk source completion requires a structured `integration-verification` receipt bound to the current workspace fingerprint. Finalization rejects any later workspace change.
37
+ 7. **Verdict matrix gate** — source, runtime, database cleanup and real-device proof remain independent. Missing required evidence is reported as not verified rather than promoted to PASS.
29
38
 
30
39
  ## Pipeline
31
40
 
@@ -33,18 +42,19 @@ The V6 engine enforces three boundaries:
33
42
  2. Write observable acceptance criteria into `SPEC.md`.
34
43
  3. Initialize state with `ocskill work init`.
35
44
  4. Create `PLAN.json` following the plan schema and import it with `ocskill work plan`.
36
- 5. Run `ues-plan-checker`. If it returns PASS, create `work gate-receipt <slug> plan` and persist approval with `work approve-plan --receipt-file ...`.
37
- 6. Use `ocskill task-graph` to compute dependency-safe waves.
38
- 7. For each ready task:
45
+ 5. If the user supplied explicit `PHASE N` sections, preserve every execution phase in the plan and let UES add phase barriers; constraint-only phases remain invariants.
46
+ 6. Run `ues-plan-checker`. If it returns PASS, create `work gate-receipt <slug> plan` and persist approval with `work approve-plan --receipt-file ...`.
47
+ 7. Persist the execution contract/phase artifacts and use `ocskill task-graph` to compute dependency-safe waves.
48
+ 8. For each ready task:
39
49
  - on OpenCode V2, if two or more approved tasks are independent, prefer `ues.dispatch_parallel` so one shared model can execute them concurrently with isolated worktrees, leases, independent verifier sessions and serialized integration;
40
50
  - otherwise prefer `ues.dispatch_task`, which performs `work start`, creates a fresh `ues-executor` session, selects the configured attempt-based model tier, prompts it with a bounded context pack and waits for completion;
41
51
  - inspect the child diff and verification;
42
52
  - record at least one successful `work verify-command` receipt for long/high-risk work;
43
53
  - persist `work complete --evidence ...` or `work fail --reason ...`.
44
- 8. On failure, re-diagnose rather than stacking patches. A later `ues.dispatch_task` attempt can escalate from standard to heavy when configured.
45
- 9. After all tasks complete, run `ues-integration-verifier`.
46
- 10. Persist the verifier's actual result with a structured integration receipt plus `ocskill work verify-integration --receipt-file ...`.
47
- 11. Only a recorded PASS with an unchanged workspace can be finalized.
54
+ 9. On failure, re-diagnose rather than stacking patches. A later `ues.dispatch_task` attempt can escalate from standard to heavy when configured.
55
+ 10. After all tasks complete, run `ues-integration-verifier`.
56
+ 11. Persist the verifier's actual result with a structured integration receipt plus `ocskill work verify-integration --receipt-file ...`.
57
+ 12. Emit the independent final verdict matrix and persist `FINAL_VERDICTS.json`. Only a recorded PASS with an unchanged workspace can be finalized.
48
58
 
49
59
  ## Parallelism
50
60
 
@@ -0,0 +1,70 @@
1
+ function finite(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
2
+ const parsed = Number(value)
3
+ if (!Number.isFinite(parsed)) return fallback
4
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
5
+ }
6
+
7
+ export function createAdaptiveDeadline(options = {}, startedAt = Date.now()) {
8
+ const initialMs = finite(options.hardTimeoutMs, 60_000, 1_000)
9
+ const absoluteMs = Math.max(
10
+ initialMs,
11
+ finite(options.absoluteHardTimeoutMs, initialMs, initialMs),
12
+ )
13
+ const extensionMs = finite(options.activityExtensionMs, 0, 0, absoluteMs)
14
+ const activityWindowMs = finite(
15
+ options.activityWindowMs,
16
+ Math.max(5_000, Math.min(extensionMs || initialMs, 30_000)),
17
+ 1_000,
18
+ absoluteMs,
19
+ )
20
+
21
+ let deadlineAt = startedAt + initialMs
22
+ const absoluteAt = startedAt + absoluteMs
23
+ let extensions = 0
24
+
25
+ function maybeExtend(now = Date.now(), lastActivityAt = 0) {
26
+ if (extensionMs <= 0 || deadlineAt >= absoluteAt) {
27
+ return { extended: false, deadlineAt, absoluteAt, extensions }
28
+ }
29
+ if (now - Number(lastActivityAt || 0) > activityWindowMs) {
30
+ return { extended: false, deadlineAt, absoluteAt, extensions }
31
+ }
32
+ const next = Math.min(absoluteAt, Math.max(deadlineAt, now + extensionMs))
33
+ if (next <= deadlineAt) {
34
+ return { extended: false, deadlineAt, absoluteAt, extensions }
35
+ }
36
+ deadlineAt = next
37
+ extensions += 1
38
+ return { extended: true, deadlineAt, absoluteAt, extensions }
39
+ }
40
+
41
+ function shouldAbort(now = Date.now(), lastActivityAt = 0) {
42
+ if (now < deadlineAt) {
43
+ return { abort: false, deadlineAt, absoluteAt, extensions }
44
+ }
45
+ const extension = maybeExtend(now, lastActivityAt)
46
+ if (extension.extended) {
47
+ return { abort: false, ...extension }
48
+ }
49
+ return {
50
+ abort: true,
51
+ reason: now >= absoluteAt ? "absolute-hard-timeout" : "hard-timeout",
52
+ deadlineAt,
53
+ absoluteAt,
54
+ extensions,
55
+ }
56
+ }
57
+
58
+ return {
59
+ startedAt,
60
+ initialMs,
61
+ absoluteMs,
62
+ extensionMs,
63
+ activityWindowMs,
64
+ get deadlineAt() { return deadlineAt },
65
+ get absoluteAt() { return absoluteAt },
66
+ get extensions() { return extensions },
67
+ maybeExtend,
68
+ shouldAbort,
69
+ }
70
+ }
@@ -59,20 +59,35 @@ export function adaptiveContextBudget(taskPolicy = {}, role = "executor", attemp
59
59
  const profile = String(taskPolicy.executionProfile || taskPolicy.profile?.name || "standard")
60
60
  const highRisk = taskPolicy.risk === "high" || taskPolicy.risk === "critical"
61
61
 
62
- // High-risk work keeps the original evidence budget. Turbo mode must not trade
63
- // away security/payment/schema evidence for latency.
64
- if (highRisk || options.disabled === true) {
62
+ if (options.disabled === true) {
65
63
  return {
66
64
  schemaVersion: 1,
67
65
  budget: base,
68
66
  baseBudget: base,
69
67
  adaptive: false,
70
- reason: highRisk ? "high-risk-preserves-base-budget" : "disabled",
68
+ reason: "disabled",
71
69
  }
72
70
  }
73
71
 
74
72
  const table = ROLE_TARGETS[profile] || ROLE_TARGETS.standard
75
73
  const target = clamp(table?.[role], base, 4_000, base)
74
+ const plannerRole = role === "architect" || role === "plan-checker"
75
+
76
+ // High-risk execution/verification keeps the full evidence ceiling. Read-only
77
+ // planning roles are allowed a bounded context because they can use targeted
78
+ // repository tools and are independently plan-gated before any write begins.
79
+ if (highRisk && !plannerRole) {
80
+ return {
81
+ schemaVersion: 1,
82
+ budget: base,
83
+ baseBudget: base,
84
+ adaptive: false,
85
+ reason: "high-risk-execution-preserves-base-budget",
86
+ profile,
87
+ role,
88
+ attempt: Math.max(1, Math.trunc(Number(attempt || 1))),
89
+ }
90
+ }
76
91
  const normalizedAttempt = Math.max(1, Math.trunc(Number(attempt || 1)))
77
92
 
78
93
  // Failed attempts expand deterministically back toward the policy ceiling.
@@ -92,6 +107,11 @@ export function adaptiveContextBudget(taskPolicy = {}, role = "executor", attemp
92
107
  profile,
93
108
  role,
94
109
  attempt: normalizedAttempt,
95
- reason: budget < base ? "role-bounded-expand-on-failure" : "policy-ceiling",
110
+ reason:
111
+ highRisk && plannerRole
112
+ ? "high-risk-planner-role-bounded"
113
+ : budget < base
114
+ ? "role-bounded-expand-on-failure"
115
+ : "policy-ceiling",
96
116
  }
97
117
  }
@@ -6,7 +6,7 @@ import { runtimeWorkspaceFingerprint } from "./workspace-fingerprint.mjs"
6
6
 
7
7
  const TEST_RE = /(?:^|[/.\\])(?:__tests__|tests?|spec)(?:[/.\\]|$)|\.(?:test|spec|e2e-spec)\.[cm]?[jt]sx?$/i
8
8
  const SOURCE_EXT = new Set([".js",".jsx",".mjs",".cjs",".ts",".tsx",".py",".java",".kt",".kts",".cs",".go",".rs",".dart",".swift"])
9
- const SKIP = new Set([".git","node_modules",".next","dist","build","coverage",".venv","venv","target","Pods","DerivedData",".gradle",".ues-cache",".ues-work",".ues-sandboxes",".ues-traces",".ues-learning",".ues-dashboard",".ues-memory",".ues-evals"])
9
+ const SKIP = new Set([".git","node_modules",".next","dist","build","coverage",".venv","venv","target","Pods","DerivedData",".gradle",".ues-cache",".ues-work",".ues-sandboxes",".ues-traces",".ues-learning",".ues-dashboard",".ues-memory",".ues-evals",".ues-services"])
10
10
  const AFFECTED_TEST_CACHE = new Map()
11
11
  const AFFECTED_TEST_INFLIGHT = new Map()
12
12
 
@@ -106,6 +106,46 @@ export function defaultCapabilityRegistry(root = process.cwd()) {
106
106
  "code.search": [
107
107
  { id: "ues-semantic-index", kind: "builtin", priority: 100, quality: 0.9, costClass: "low", latencyClass: "fast" },
108
108
  ],
109
+ "code.edit.anchored": [
110
+ { id: "ues-hashline", kind: "builtin", priority: 120, quality: 0.98, costClass: "low", latencyClass: "fast", metadata: { failClosed: true, staleAnchorReject: true } },
111
+ ],
112
+ "code.structure": [
113
+ { id: "ast-grep", kind: "command", command: "ast-grep", priority: 120, quality: 0.96, costClass: "low", latencyClass: "fast" },
114
+ { id: "sg", kind: "command", command: "sg", priority: 115, quality: 0.96, costClass: "low", latencyClass: "fast" },
115
+ { id: "ues-semantic-index", kind: "builtin", priority: 80, quality: 0.8, costClass: "low", latencyClass: "fast" },
116
+ ],
117
+ "code.diagnostics": [
118
+ { id: "typescript-language-server", kind: "command", command: "typescript-language-server", priority: 110, quality: 0.95, costClass: "low", latencyClass: "medium" },
119
+ { id: "pyright-langserver", kind: "command", command: "pyright-langserver", priority: 105, quality: 0.95, costClass: "low", latencyClass: "medium" },
120
+ { id: "pylsp", kind: "command", command: "pylsp", priority: 95, quality: 0.85, costClass: "low", latencyClass: "medium" },
121
+ { id: "gopls", kind: "command", command: "gopls", priority: 105, quality: 0.95, costClass: "low", latencyClass: "medium" },
122
+ { id: "rust-analyzer", kind: "command", command: "rust-analyzer", priority: 105, quality: 0.95, costClass: "low", latencyClass: "medium" },
123
+ { id: "clangd", kind: "command", command: "clangd", priority: 100, quality: 0.9, costClass: "low", latencyClass: "medium" },
124
+ ],
125
+ "document.ingestion": [
126
+ { id: "ues-builtin-text", kind: "builtin", priority: 100, quality: 0.95, costClass: "low", latencyClass: "fast", metadata: { formats: ["text", "markdown", "json", "csv", "html", "xml"] } },
127
+ { id: "markitdown-cli", kind: "command", command: "markitdown", priority: 95, quality: 0.95, costClass: "low", latencyClass: "medium", metadata: { optional: true, formats: ["pdf", "docx", "pptx", "xlsx", "epub", "zip"] } },
128
+ ],
129
+ "context.reversible": [
130
+ { id: "ues-evidence-context", kind: "builtin", priority: 120, quality: 0.98, costClass: "low", latencyClass: "fast", metadata: { reversible: true, searchable: true, levels: ["T1", "T2", "T3", "raw"] } },
131
+ ],
132
+ "runtime.service": [
133
+ {
134
+ id: "ues-managed-service",
135
+ kind: "builtin",
136
+ priority: 125,
137
+ quality: 0.98,
138
+ costClass: "low",
139
+ latencyClass: "fast",
140
+ metadata: {
141
+ shellFree: true,
142
+ readiness: ["port", "log", "process"],
143
+ actions: ["start", "wait-ready", "status", "logs", "stop", "restart"],
144
+ boundedLifetime: true,
145
+ evidenceLogs: true,
146
+ },
147
+ },
148
+ ],
109
149
  memory: [
110
150
  { id: "ues-memory", kind: "builtin", priority: 100, quality: 0.9, costClass: "low", latencyClass: "fast" },
111
151
  ],
@@ -0,0 +1,102 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ const DEFAULT_HASH_CHARS = 8
4
+
5
+ function normalizeNewlines(value) {
6
+ return String(value ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n")
7
+ }
8
+
9
+ function hashText(value, chars = DEFAULT_HASH_CHARS) {
10
+ return createHash("sha256").update(String(value ?? "")).digest("hex").slice(0, Math.max(6, Math.min(16, Number(chars || DEFAULT_HASH_CHARS))))
11
+ }
12
+
13
+ export function lineAnchor(lineNumber, lineText, options = {}) {
14
+ const line = Math.max(1, Math.trunc(Number(lineNumber || 1)))
15
+ return `L${line}:${hashText(lineText, options.hashChars)}`
16
+ }
17
+
18
+ export function parseLineAnchor(anchor) {
19
+ const match = String(anchor || "").trim().match(/^L([1-9]\d*):([a-f0-9]{6,16})$/i)
20
+ if (!match) return null
21
+ return { line: Number(match[1]), hash: match[2].toLowerCase() }
22
+ }
23
+
24
+ export function anchoredLines(source, options = {}) {
25
+ const text = normalizeNewlines(source)
26
+ const lines = text.split("\n")
27
+ const startLine = Math.max(1, Math.trunc(Number(options.startLine || 1)))
28
+ const requestedEnd = options.endLine == null ? lines.length : Math.trunc(Number(options.endLine))
29
+ const endLine = Math.max(startLine, Math.min(lines.length, requestedEnd || lines.length))
30
+ const rows = []
31
+ for (let line = startLine; line <= endLine; line += 1) {
32
+ const value = lines[line - 1] ?? ""
33
+ rows.push({ line, anchor: lineAnchor(line, value, options), text: value })
34
+ }
35
+ return {
36
+ schemaVersion: 1,
37
+ startLine,
38
+ endLine,
39
+ lineCount: lines.length,
40
+ sourceHash: hashText(text, 16),
41
+ rows,
42
+ text: rows.map((row) => `${row.anchor}|${row.text}`).join("\n"),
43
+ }
44
+ }
45
+
46
+ function validateAnchor(lines, rawAnchor, label, options = {}) {
47
+ const parsed = parseLineAnchor(rawAnchor)
48
+ if (!parsed) throw new Error(`${label} must use L<line>:<hash> format`)
49
+ if (parsed.line > lines.length) throw new Error(`${label} points beyond end of file`)
50
+ const actual = lineAnchor(parsed.line, lines[parsed.line - 1] ?? "", options)
51
+ if (actual.toLowerCase() !== String(rawAnchor).toLowerCase()) {
52
+ const error = new Error(`${label} is stale or ambiguous; re-read anchored source before editing`)
53
+ error.code = "UES_STALE_ANCHOR"
54
+ error.expected = actual
55
+ error.received = String(rawAnchor)
56
+ throw error
57
+ }
58
+ return parsed
59
+ }
60
+
61
+ export function applyAnchoredEdits(source, edits = [], options = {}) {
62
+ const text = normalizeNewlines(source)
63
+ const lines = text.split("\n")
64
+ if (!Array.isArray(edits) || edits.length === 0) throw new Error("at least one anchored edit is required")
65
+ const maxEdits = Math.max(1, Math.min(200, Number(options.maxEdits || 50)))
66
+ if (edits.length > maxEdits) throw new Error(`too many anchored edits; maximum is ${maxEdits}`)
67
+
68
+ const prepared = edits.map((edit, index) => {
69
+ const start = validateAnchor(lines, edit?.anchor || edit?.startAnchor, `edit[${index}].anchor`, options)
70
+ const end = edit?.endAnchor
71
+ ? validateAnchor(lines, edit.endAnchor, `edit[${index}].endAnchor`, options)
72
+ : start
73
+ if (end.line < start.line) throw new Error(`edit[${index}] endAnchor precedes anchor`)
74
+ return {
75
+ index,
76
+ startLine: start.line,
77
+ endLine: end.line,
78
+ replacement: normalizeNewlines(edit?.replacement ?? ""),
79
+ }
80
+ })
81
+
82
+ prepared.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine)
83
+ for (let index = 1; index < prepared.length; index += 1) {
84
+ if (prepared[index].startLine <= prepared[index - 1].endLine) {
85
+ throw new Error(`anchored edits overlap at edit[${prepared[index].index}]`)
86
+ }
87
+ }
88
+
89
+ const next = [...lines]
90
+ for (const edit of [...prepared].sort((a, b) => b.startLine - a.startLine)) {
91
+ const replacementLines = edit.replacement.split("\n")
92
+ next.splice(edit.startLine - 1, edit.endLine - edit.startLine + 1, ...replacementLines)
93
+ }
94
+ const output = next.join("\n")
95
+ return {
96
+ schemaVersion: 1,
97
+ sourceHash: hashText(text, 16),
98
+ outputHash: hashText(output, 16),
99
+ applied: prepared.length,
100
+ text: output,
101
+ }
102
+ }
@@ -0,0 +1,95 @@
1
+ import { lstat, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"
2
+ import { spawnSync } from "node:child_process"
3
+ import path from "node:path"
4
+ import { querySemanticIndex } from "../semantic-index.mjs"
5
+ import { anchoredLines, applyAnchoredEdits } from "./edit-anchor.mjs"
6
+ import { diagnoseCode, lspProviderStatus } from "./lsp-provider.mjs"
7
+
8
+ const AST_COMMANDS = ["ast-grep", "sg"]
9
+ const commandCache = new Map()
10
+ const COMMAND_TTL_MS = 60_000
11
+
12
+ function commandExists(command) {
13
+ const now = Date.now()
14
+ const cached = commandCache.get(command)
15
+ if (cached && now - cached.at < COMMAND_TTL_MS) return cached.available
16
+ const finder = process.platform === "win32" ? "where" : "which"
17
+ const available = spawnSync(finder, [command], { stdio: "ignore", windowsHide: true }).status === 0
18
+ commandCache.set(command, { available, at: now })
19
+ return available
20
+ }
21
+
22
+ function inside(root, target) { return target === root || target.startsWith(root + path.sep) }
23
+
24
+ async function safeFile(root, relative) {
25
+ const base = await realpath(path.resolve(root)).catch(() => path.resolve(root))
26
+ const requested = path.resolve(base, String(relative || ""))
27
+ if (!inside(base, requested)) throw new Error("code path escapes workspace root")
28
+ const info = await lstat(requested)
29
+ if (!info.isFile()) throw new Error("code path is not a file")
30
+ const actual = await realpath(requested)
31
+ if (!inside(base, actual)) throw new Error("code symlink escapes workspace root")
32
+ return { base, file: actual, info }
33
+ }
34
+
35
+ export function probeCodeIntelligence(file = "") {
36
+ const astProvider = AST_COMMANDS.find(commandExists) || null
37
+ return {
38
+ schemaVersion: 1,
39
+ anchoredEditing: true,
40
+ semanticIndex: true,
41
+ astProvider,
42
+ lsp: lspProviderStatus(file),
43
+ }
44
+ }
45
+
46
+ export async function readAnchoredCode(root, relative, options = {}) {
47
+ const target = await safeFile(root, relative)
48
+ const maxBytes = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options.maxBytes || 512 * 1024)))
49
+ if (target.info.size > maxBytes) throw new Error("code file exceeds anchored-read limit (" + target.info.size + " > " + maxBytes + " bytes)")
50
+ const source = await readFile(target.file, "utf8")
51
+ return { file: path.relative(target.base, target.file).replaceAll("\\", "/"), ...anchoredLines(source, options) }
52
+ }
53
+
54
+ export async function applyAnchoredFileEdits(root, relative, edits, options = {}) {
55
+ const target = await safeFile(root, relative)
56
+ const maxBytes = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options.maxBytes || 512 * 1024)))
57
+ if (target.info.size > maxBytes) throw new Error("code file exceeds anchored-edit limit (" + target.info.size + " > " + maxBytes + " bytes)")
58
+ const source = await readFile(target.file, "utf8")
59
+ const result = applyAnchoredEdits(source, edits, options)
60
+ const relativeFile = path.relative(target.base, target.file).replaceAll("\\", "/")
61
+ if (options.dryRun === true) return { ...result, file: relativeFile, dryRun: true }
62
+ const temp = target.file + "." + process.pid + "." + Date.now() + ".ues.tmp"
63
+ await writeFile(temp, result.text, "utf8")
64
+ try { await rename(temp, target.file) } catch (error) { await rm(temp, { force: true }).catch(() => {}); throw error }
65
+ return { ...result, file: relativeFile, dryRun: false }
66
+ }
67
+
68
+ function runAstGrep(root, pattern, language, limit) {
69
+ const command = AST_COMMANDS.find(commandExists) || null
70
+ if (!command || !String(pattern || "").trim()) return { provider: null, results: [], error: null }
71
+ const args = ["run", "--pattern", String(pattern), "--json=stream"]
72
+ if (language) args.push("--lang", String(language))
73
+ args.push(".")
74
+ const result = spawnSync(command, args, { cwd: path.resolve(root), encoding: "utf8", timeout: 20_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true })
75
+ if (result.status !== 0) return { provider: command, results: [], error: String(result.stderr || ("ast-grep exit " + result.status)).trim().slice(0, 1000) }
76
+ const rows = String(result.stdout || "").split(/\r?\n/).filter(Boolean).slice(0, limit).map((line) => {
77
+ try { return JSON.parse(line) } catch { return { raw: line } }
78
+ })
79
+ return { provider: command, results: rows, error: null }
80
+ }
81
+
82
+ export async function searchCodeIntelligence(root, query, options = {}) {
83
+ const maxResults = Math.max(1, Math.min(50, Number(options.maxResults || 12)))
84
+ const semantic = await querySemanticIndex(root, query, { maxFiles: options.maxFiles || 6000, limit: maxResults })
85
+ const structural = options.structuralPattern ? runAstGrep(root, options.structuralPattern, options.language, maxResults) : { provider: null, results: [], error: null }
86
+ return {
87
+ schemaVersion: 1,
88
+ query: String(query || ""),
89
+ providers: probeCodeIntelligence(options.file || ""),
90
+ semantic: { ...semantic, results: (semantic.results || []).slice(0, maxResults) },
91
+ structural,
92
+ }
93
+ }
94
+
95
+ export { diagnoseCode, lspProviderStatus } from "./lsp-provider.mjs"
@@ -0,0 +1,189 @@
1
+ import { spawn, spawnSync } from "node:child_process"
2
+ import { lstat, readFile, realpath } from "node:fs/promises"
3
+ import path from "node:path"
4
+ import { pathToFileURL } from "node:url"
5
+
6
+ const PROVIDERS = [
7
+ { 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
+ { id: "pyright-langserver", extensions: new Set([".py"]), commands: [["pyright-langserver", ["--stdio"]], ["pylsp", []]], languageId: () => "python" },
9
+ { id: "gopls", extensions: new Set([".go"]), commands: [["gopls", []]], languageId: () => "go" },
10
+ { id: "rust-analyzer", extensions: new Set([".rs"]), commands: [["rust-analyzer", []]], languageId: () => "rust" },
11
+ { id: "clangd", extensions: new Set([".c", ".cc", ".cpp", ".cxx", ".h", ".hpp"]), commands: [["clangd", []]], languageId: (ext) => [".c", ".h"].includes(ext) ? "c" : "cpp" },
12
+ ]
13
+
14
+ const commandCache = new Map()
15
+ const COMMAND_TTL_MS = 60_000
16
+
17
+ function commandExists(command) {
18
+ const now = Date.now()
19
+ const cached = commandCache.get(command)
20
+ if (cached && now - cached.at < COMMAND_TTL_MS) return cached.available
21
+ const finder = process.platform === "win32" ? "where" : "which"
22
+ const available = spawnSync(finder, [command], { stdio: "ignore", windowsHide: true }).status === 0
23
+ commandCache.set(command, { available, at: now })
24
+ return available
25
+ }
26
+
27
+ function inside(root, target) {
28
+ return target === root || target.startsWith(root + path.sep)
29
+ }
30
+
31
+ async function safeFile(root, relative) {
32
+ const base = await realpath(path.resolve(root)).catch(() => path.resolve(root))
33
+ const requested = path.resolve(base, String(relative || ""))
34
+ if (!inside(base, requested)) throw new Error("diagnostic path escapes workspace root")
35
+ const info = await lstat(requested)
36
+ if (!info.isFile()) throw new Error("diagnostic path is not a file")
37
+ const actual = await realpath(requested)
38
+ if (!inside(base, actual)) throw new Error("diagnostic symlink escapes workspace root")
39
+ return { base, file: actual, info }
40
+ }
41
+
42
+ function providerFor(file) {
43
+ const ext = path.extname(file).toLowerCase()
44
+ for (const provider of PROVIDERS) {
45
+ if (!provider.extensions.has(ext)) continue
46
+ for (const [command, args] of provider.commands) {
47
+ if (commandExists(command)) return { ...provider, command, args, languageId: provider.languageId(ext) }
48
+ }
49
+ return { ...provider, command: null, args: [], languageId: provider.languageId(ext) }
50
+ }
51
+ return null
52
+ }
53
+
54
+ export function lspProviderStatus(file = "") {
55
+ const ext = path.extname(String(file || "")).toLowerCase()
56
+ const providers = PROVIDERS
57
+ .filter((provider) => !ext || provider.extensions.has(ext))
58
+ .map((provider) => {
59
+ const selected = provider.commands.find(([command]) => commandExists(command)) || null
60
+ return { id: provider.id, available: Boolean(selected), command: selected?.[0] || null, extensions: [...provider.extensions] }
61
+ })
62
+ return { schemaVersion: 1, extension: ext, available: providers.some((row) => row.available), providers }
63
+ }
64
+
65
+ function encodeMessage(payload) {
66
+ const body = Buffer.from(JSON.stringify(payload), "utf8")
67
+ return Buffer.concat([Buffer.from("Content-Length: " + body.length + "\r\n\r\n", "ascii"), body])
68
+ }
69
+
70
+ function sanitizeDiagnostic(value = {}) {
71
+ return {
72
+ range: value.range || null,
73
+ severity: Number.isFinite(Number(value.severity)) ? Number(value.severity) : null,
74
+ code: value.code == null ? null : String(value.code),
75
+ source: value.source == null ? null : String(value.source),
76
+ message: String(value.message || "").slice(0, 4000),
77
+ }
78
+ }
79
+
80
+ export async function diagnoseCode(root, relative, options = {}) {
81
+ const target = await safeFile(root, relative)
82
+ const provider = providerFor(target.file)
83
+ const relativeFile = path.relative(target.base, target.file).replaceAll("\\", "/")
84
+ if (!provider?.command) {
85
+ return { schemaVersion: 1, file: relativeFile, available: false, provider: provider?.id || null, diagnostics: [], reason: provider ? "lsp-command-unavailable" : "unsupported-extension" }
86
+ }
87
+
88
+ const maxBytes = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options.maxBytes || 1024 * 1024)))
89
+ if (target.info.size > maxBytes) throw new Error("diagnostic file exceeds limit (" + target.info.size + " > " + maxBytes + " bytes)")
90
+ const source = await readFile(target.file, "utf8")
91
+ const uri = pathToFileURL(target.file).href
92
+ const rootUri = pathToFileURL(target.base + path.sep).href
93
+ const timeoutMs = Math.max(500, Math.min(10_000, Number(options.timeoutMs || 3000)))
94
+ const maxDiagnostics = Math.max(1, Math.min(500, Number(options.maxDiagnostics || 100)))
95
+
96
+ return await new Promise((resolve) => {
97
+ const child = spawn(provider.command, provider.args, { cwd: target.base, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, shell: false })
98
+ let buffer = Buffer.alloc(0)
99
+ let stderr = ""
100
+ let settled = false
101
+ let initialized = false
102
+ let diagnostics = []
103
+ let timer = null
104
+
105
+ const finish = (reason, error = null) => {
106
+ if (settled) return
107
+ settled = true
108
+ if (timer) clearTimeout(timer)
109
+ try {
110
+ if (initialized && child.stdin.writable) {
111
+ child.stdin.write(encodeMessage({ jsonrpc: "2.0", id: 2, method: "shutdown", params: null }))
112
+ child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
113
+ }
114
+ } catch {}
115
+ setTimeout(() => { try { child.kill() } catch {} }, 50).unref?.()
116
+ resolve({
117
+ schemaVersion: 1,
118
+ file: relativeFile,
119
+ available: true,
120
+ provider: provider.id,
121
+ command: provider.command,
122
+ diagnostics: diagnostics.slice(0, maxDiagnostics),
123
+ reason,
124
+ stderr: stderr.trim().slice(0, 2000) || null,
125
+ error: error ? String(error instanceof Error ? error.message : error).slice(0, 1000) : null,
126
+ })
127
+ }
128
+
129
+ const handle = (message) => {
130
+ if (message?.id === 1 && message?.result) {
131
+ initialized = true
132
+ try {
133
+ child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "initialized", params: {} }))
134
+ child.stdin.write(encodeMessage({
135
+ jsonrpc: "2.0",
136
+ method: "textDocument/didOpen",
137
+ params: { textDocument: { uri, languageId: provider.languageId, version: 1, text: source } },
138
+ }))
139
+ } catch (error) {
140
+ finish("write-failed", error)
141
+ }
142
+ return
143
+ }
144
+ if (message?.method === "textDocument/publishDiagnostics" && message?.params?.uri === uri) {
145
+ diagnostics = Array.isArray(message.params.diagnostics) ? message.params.diagnostics.map(sanitizeDiagnostic).slice(0, maxDiagnostics) : []
146
+ finish("publishDiagnostics")
147
+ }
148
+ }
149
+
150
+ const parse = () => {
151
+ while (!settled) {
152
+ const headerEnd = buffer.indexOf("\r\n\r\n")
153
+ if (headerEnd < 0) return
154
+ const header = buffer.subarray(0, headerEnd).toString("ascii")
155
+ const match = header.match(/Content-Length:\s*(\d+)/i)
156
+ if (!match) { buffer = buffer.subarray(headerEnd + 4); continue }
157
+ const length = Number(match[1])
158
+ const bodyStart = headerEnd + 4
159
+ if (buffer.length < bodyStart + length) return
160
+ const body = buffer.subarray(bodyStart, bodyStart + length).toString("utf8")
161
+ buffer = buffer.subarray(bodyStart + length)
162
+ try { handle(JSON.parse(body)) } catch {}
163
+ }
164
+ }
165
+
166
+ child.stdout.on("data", (chunk) => { buffer = Buffer.concat([buffer, Buffer.from(chunk)]); parse() })
167
+ child.stderr.on("data", (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk.toString() })
168
+ child.on("error", (error) => finish("spawn-error", error))
169
+ child.on("close", (code) => { if (!settled) finish("server-exit:" + (code ?? "unknown")) })
170
+
171
+ try {
172
+ child.stdin.write(encodeMessage({
173
+ jsonrpc: "2.0",
174
+ id: 1,
175
+ method: "initialize",
176
+ params: {
177
+ processId: process.pid,
178
+ rootUri,
179
+ capabilities: { textDocument: { publishDiagnostics: { relatedInformation: true } } },
180
+ workspaceFolders: [{ uri: rootUri, name: path.basename(target.base) || "workspace" }],
181
+ },
182
+ }))
183
+ } catch (error) {
184
+ finish("initialize-write-failed", error)
185
+ }
186
+ timer = setTimeout(() => finish("timeout"), timeoutMs)
187
+ timer.unref?.()
188
+ })
189
+ }