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.
- package/README.md +51 -18
- package/docs/PI-COMPAT.md +167 -3
- package/docs/V15-MANAGED-RUNTIME.md +128 -0
- package/global-config/agents/architect.md +63 -1
- package/global-config/agents/executor.md +7 -0
- package/global-config/agents/plan-checker.md +8 -1
- package/global-config/agents/verifier.md +3 -3
- package/global-config/skills/database-engineering/references/workflow.md +15 -0
- package/global-config/skills/engineering-orchestrator/references/long-horizon.md +21 -11
- package/lib/activity-deadline.mjs +70 -0
- package/lib/adaptive-context-budget.mjs +25 -5
- package/lib/affected-tests.mjs +1 -1
- package/lib/capability-fabric.mjs +40 -0
- package/lib/code-intelligence/edit-anchor.mjs +102 -0
- package/lib/code-intelligence/index.mjs +95 -0
- package/lib/code-intelligence/lsp-provider.mjs +189 -0
- package/lib/completion-auditor.mjs +102 -0
- package/lib/document-ingestion.mjs +60 -0
- package/lib/evidence-store.mjs +92 -8
- package/lib/execution-contract.mjs +579 -0
- package/lib/fast-verification-gate.mjs +69 -0
- package/lib/leaf-runtime-optimizer.mjs +76 -0
- package/lib/mcp-health.mjs +144 -0
- package/lib/mcp-tool-policy.mjs +19 -0
- package/lib/memory-engine.mjs +28 -0
- package/lib/pi-child-invocation.mjs +65 -0
- package/lib/pi-rpc-pool.mjs +61 -9
- package/lib/plan-salvage.mjs +124 -0
- package/lib/planning-speed-policy.mjs +131 -0
- package/lib/prompt-cache.mjs +26 -12
- package/lib/repo-graph.mjs +1 -1
- package/lib/repo-inspect.mjs +1 -0
- package/lib/reversible-context.mjs +49 -0
- package/lib/runtime-artifacts.mjs +42 -0
- package/lib/runtime-events.mjs +33 -1
- package/lib/safety.mjs +1 -0
- package/lib/semantic-index.mjs +1 -1
- package/lib/service-manager.mjs +526 -0
- package/lib/session-display.mjs +49 -0
- package/lib/task-engine.mjs +6 -10
- package/lib/task-graph.mjs +87 -0
- package/lib/task-policy.mjs +210 -8
- package/lib/trajectory.mjs +111 -1
- package/lib/turbo-fast-path.mjs +44 -0
- package/lib/workspace-fingerprint.mjs +2 -11
- package/lib/workspace-root.mjs +49 -0
- package/lib/worktree-sandbox.mjs +234 -11
- package/package.json +9 -6
- package/pi/extensions/ues-child-runtime.ts +300 -2
- package/pi/extensions/ues.ts +1887 -160
- package/scripts/check-release-consistency.mjs +25 -2
- package/scripts/check-source-integrity.mjs +532 -3
- package/scripts/eval-pi.mjs +76 -36
- package/scripts/smoke-pi-extension.mjs +1 -1
- 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
|
|
29
|
+
The runtime enforces seven boundaries:
|
|
25
30
|
|
|
26
|
-
1. **
|
|
27
|
-
2. **
|
|
28
|
-
3. **
|
|
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.
|
|
37
|
-
6.
|
|
38
|
-
7.
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
}
|
package/lib/affected-tests.mjs
CHANGED
|
@@ -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
|
+
}
|