opencode-agent-skill 14.4.0 → 15.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -16
- package/docs/PI-COMPAT.md +151 -2
- package/docs/V15-MANAGED-RUNTIME.md +128 -0
- package/global-config/agents/architect.md +63 -1
- package/global-config/agents/executor.md +7 -0
- package/global-config/agents/plan-checker.md +8 -1
- package/global-config/agents/verifier.md +3 -3
- package/global-config/skills/database-engineering/references/workflow.md +15 -0
- package/global-config/skills/engineering-orchestrator/references/long-horizon.md +21 -11
- package/lib/activity-deadline.mjs +70 -0
- package/lib/adaptive-context-budget.mjs +25 -5
- package/lib/affected-tests.mjs +38 -5
- package/lib/capability-fabric.mjs +17 -0
- package/lib/code-intelligence/index.mjs +3 -2
- package/lib/code-intelligence/lsp-provider.mjs +326 -52
- package/lib/compaction-resume-guard.mjs +255 -0
- package/lib/completion-auditor.mjs +25 -5
- package/lib/context-engine-v11.mjs +20 -7
- package/lib/evidence-store.mjs +92 -8
- package/lib/execution-contract.mjs +592 -0
- package/lib/leaf-runtime-optimizer.mjs +76 -0
- package/lib/memory-engine.mjs +189 -16
- package/lib/performance-fabric.mjs +262 -107
- package/lib/pi-child-invocation.mjs +65 -0
- package/lib/pi-rpc-pool.mjs +61 -9
- package/lib/plan-salvage.mjs +124 -0
- package/lib/planning-speed-policy.mjs +131 -0
- package/lib/repo-graph.mjs +1 -1
- package/lib/repo-inspect.mjs +1 -0
- package/lib/runtime-artifacts.mjs +42 -0
- package/lib/runtime-events.mjs +33 -1
- package/lib/safety.mjs +1 -0
- package/lib/semantic-index.mjs +1 -1
- package/lib/service-manager.mjs +526 -0
- package/lib/session-display.mjs +49 -0
- package/lib/subagent-artifacts.mjs +182 -0
- package/lib/task-graph.mjs +87 -0
- package/lib/task-policy.mjs +150 -4
- package/lib/trajectory.mjs +111 -1
- package/lib/turbo-fast-path.mjs +44 -0
- package/lib/verification-broker.mjs +32 -27
- package/lib/workspace-fingerprint.mjs +45 -19
- package/lib/workspace-hygiene.mjs +481 -0
- package/lib/workspace-root.mjs +49 -0
- package/lib/worktree-sandbox.mjs +234 -11
- package/package.json +8 -5
- package/pi/extensions/ues-child-runtime.ts +170 -4
- package/pi/extensions/ues.ts +2063 -188
- package/scripts/check-release-consistency.mjs +25 -2
- package/scripts/check-source-integrity.mjs +471 -4
- package/scripts/eval-pi.mjs +76 -36
- package/scripts/smoke-pi-extension.mjs +1 -1
- package/pi/prompts/ues-run.md +0 -17
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { lstatSync, readFileSync, readlinkSync } from "node:fs"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { sourceFacingPaths } from "./runtime-artifacts.mjs"
|
|
6
|
+
|
|
7
|
+
const PHASE_HEADING = /^\s*(?:=+\s*)?PHASE\s+(\d+)\s*(?:[—–:-]+)\s*(.+?)\s*$/i
|
|
8
|
+
const LOCAL_ENV_BASENAME = /^\.env(?:\.[^/\\]+)?$/i
|
|
9
|
+
const ENV_TEMPLATE_BASENAME = /^\.env\.(?:example|sample|template)$/i
|
|
10
|
+
|
|
11
|
+
function normalizePath(value) {
|
|
12
|
+
return String(value || "").trim().replaceAll("\\", "/").replace(/^\.\//, "")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function slugify(value) {
|
|
16
|
+
return String(value || "phase")
|
|
17
|
+
.normalize("NFKD")
|
|
18
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
21
|
+
.replace(/^-+|-+$/g, "")
|
|
22
|
+
.slice(0, 48) || "phase"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sha256(value) {
|
|
26
|
+
return createHash("sha256").update(String(value || "")).digest("hex")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isLocalEnvPath(value) {
|
|
30
|
+
const normalized = normalizePath(value)
|
|
31
|
+
const base = normalized.split("/").pop() || ""
|
|
32
|
+
if (ENV_TEMPLATE_BASENAME.test(base)) return false
|
|
33
|
+
return LOCAL_ENV_BASENAME.test(base)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function crossToolTempPathRisk(value, platform = process.platform) {
|
|
37
|
+
const raw = String(value || "").trim().replaceAll("\\", "/")
|
|
38
|
+
if (!raw || String(platform || "").toLowerCase() !== "win32") {
|
|
39
|
+
return { risky: false, id: null, path: raw || null }
|
|
40
|
+
}
|
|
41
|
+
const risky = /^\/(?:tmp|var\/tmp)(?:\/|$)/i.test(raw)
|
|
42
|
+
return {
|
|
43
|
+
risky,
|
|
44
|
+
id: risky ? "cross-tool-posix-temp-path" : null,
|
|
45
|
+
path: raw || null,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function taskExplicitlyAllowsLocalEnvWrite(task) {
|
|
50
|
+
const value = String(task || "")
|
|
51
|
+
const mentionsEnv = /(?:^|[\s"'\x60/])\.env(?:\.[\w.-]+)?(?=$|[\s"'\x60,;:.)/])/i.test(value)
|
|
52
|
+
if (!mentionsEnv) return false
|
|
53
|
+
const denied =
|
|
54
|
+
/(?:do\s+not|don't|never|must\s+not|không|khong|cấm|cam).{0,80}(?:modify|edit|update|write|create|set|change|sửa|chỉnh sửa|cập nhật|ghi|tạo|thay đổi)?[^\n.]*\.env|\.env[^\n.]*.{0,40}(?:do\s+not|must\s+not|không|khong|cấm|cam)/i.test(value)
|
|
55
|
+
if (denied) return false
|
|
56
|
+
return /(?:modify|edit|update|write|create|set|change|sửa|chỉnh sửa|cập nhật|ghi|tạo|thay đổi).{0,80}\.env|\.env.{0,80}(?:modify|edit|update|write|create|set|change|sửa|chỉnh sửa|cập nhật|ghi|tạo|thay đổi)/i.test(value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function localEnvWriteRisk(command) {
|
|
60
|
+
const value = String(command || "")
|
|
61
|
+
if (!value.trim()) return { risky: false, id: null }
|
|
62
|
+
const localEnv = String.raw`\.env(?!\.(?:example|sample|template)\b)(?:\.[\w.-]+)?\b`
|
|
63
|
+
const patterns = [
|
|
64
|
+
new RegExp(String.raw`(?:>|>>)[^\n;&|]*${localEnv}`, "i"),
|
|
65
|
+
new RegExp(String.raw`\b(?:sed|perl)\b[^\n;&|]*\s-i\b[^\n;&|]*${localEnv}`, "i"),
|
|
66
|
+
new RegExp(String.raw`\b(?:set-content|add-content|out-file|copy-item|move-item|rename-item)\b[^\n;&|]*${localEnv}`, "i"),
|
|
67
|
+
new RegExp(String.raw`\b(?:cp|mv|install)\b[^\n;&|]*\s${localEnv}`, "i"),
|
|
68
|
+
new RegExp(String.raw`\b(?:writeFileSync|writeFile|appendFileSync|appendFile)\s*\([^\n)]*["']${localEnv}["']`, "i"),
|
|
69
|
+
new RegExp(String.raw`\bopen\s*\([^\n)]*["']${localEnv}["'][^\n)]*["'](?:w|a|x)[+b]?["']`, "i"),
|
|
70
|
+
]
|
|
71
|
+
return patterns.some((pattern) => pattern.test(value))
|
|
72
|
+
? { risky: true, id: "local-env-write" }
|
|
73
|
+
: { risky: false, id: null }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function inheritedPathState(root, relative) {
|
|
77
|
+
const normalized = normalizePath(relative)
|
|
78
|
+
const full = path.resolve(root, normalized)
|
|
79
|
+
const rel = normalizePath(path.relative(path.resolve(root), full))
|
|
80
|
+
if (!normalized || rel === ".." || rel.startsWith("../") || path.isAbsolute(rel)) {
|
|
81
|
+
return { exists: false, kind: "outside", hash: null, size: 0 }
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const info = lstatSync(full)
|
|
85
|
+
if (info.isSymbolicLink()) {
|
|
86
|
+
const target = readlinkSync(full)
|
|
87
|
+
return {
|
|
88
|
+
exists: true,
|
|
89
|
+
kind: "symlink",
|
|
90
|
+
hash: sha256("symlink\0" + target),
|
|
91
|
+
size: Buffer.byteLength(target),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!info.isFile()) {
|
|
95
|
+
return {
|
|
96
|
+
exists: true,
|
|
97
|
+
kind: info.isDirectory() ? "directory" : "other",
|
|
98
|
+
hash: sha256(info.isDirectory() ? "directory" : "other"),
|
|
99
|
+
size: Number(info.size || 0),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const bytes = readFileSync(full)
|
|
103
|
+
return {
|
|
104
|
+
exists: true,
|
|
105
|
+
kind: "file",
|
|
106
|
+
hash: createHash("sha256").update(bytes).digest("hex"),
|
|
107
|
+
size: bytes.length,
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error?.code === "ENOENT") return { exists: false, kind: "missing", hash: null, size: 0 }
|
|
111
|
+
return {
|
|
112
|
+
exists: false,
|
|
113
|
+
kind: "unreadable",
|
|
114
|
+
hash: sha256(String(error?.code || error?.message || error)),
|
|
115
|
+
size: 0,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function pathMentionedWithMutationIntent(task, relative) {
|
|
121
|
+
const text = String(task || "").replaceAll("\\", "/")
|
|
122
|
+
const needle = normalizePath(relative)
|
|
123
|
+
if (!needle) return false
|
|
124
|
+
const lower = text.toLowerCase()
|
|
125
|
+
const target = needle.toLowerCase()
|
|
126
|
+
let index = lower.indexOf(target)
|
|
127
|
+
while (index >= 0) {
|
|
128
|
+
const start = Math.max(0, index - 140)
|
|
129
|
+
const end = Math.min(text.length, index + target.length + 140)
|
|
130
|
+
const window = text.slice(start, end)
|
|
131
|
+
const denied =
|
|
132
|
+
/(?:do\s+not|don't|never|must\s+not|without|không|khong|cấm|cam).{0,100}(?:edit|modify|change|write|create|delete|remove|update|fix|sửa|chỉnh sửa|thay đổi|ghi|tạo|xóa|xoá|cập nhật)[^\n.]{0,100}/i.test(window) ||
|
|
133
|
+
/(?:edit|modify|change|write|create|delete|remove|update|fix|sửa|chỉnh sửa|thay đổi|ghi|tạo|xóa|xoá|cập nhật)[^\n.]{0,100}(?:do\s+not|don't|never|must\s+not|without|không|khong|cấm|cam)/i.test(window)
|
|
134
|
+
const mutation =
|
|
135
|
+
/(?:fix|repair|implement|edit|modify|change|write|create|delete|remove|update|refactor|migrate|rename|sửa|chỉnh sửa|thay đổi|ghi|tạo|xóa|xoá|cập nhật|đổi tên)/i.test(window)
|
|
136
|
+
if (mutation && !denied) return true
|
|
137
|
+
index = lower.indexOf(target, index + target.length)
|
|
138
|
+
}
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function explicitlyAuthorizedInheritedDirtyPaths(task, dirtyPaths = []) {
|
|
143
|
+
return [...new Set((dirtyPaths || [])
|
|
144
|
+
.map(normalizePath)
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.filter((item) => pathMentionedWithMutationIntent(task, item)))]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function detectInheritedDirtyViolations(root, snapshot, approvedPaths = []) {
|
|
150
|
+
if (!snapshot?.available || !Array.isArray(snapshot.entries)) {
|
|
151
|
+
return { safe: false, violations: [], reason: "inherited-dirty-snapshot-unavailable" }
|
|
152
|
+
}
|
|
153
|
+
const approved = new Set((approvedPaths || []).map(normalizePath).filter(Boolean))
|
|
154
|
+
const violations = []
|
|
155
|
+
for (const entry of snapshot.entries) {
|
|
156
|
+
const relative = normalizePath(entry.path)
|
|
157
|
+
if (!relative || approved.has(relative)) continue
|
|
158
|
+
const before = entry.state || null
|
|
159
|
+
const after = inheritedPathState(root, relative)
|
|
160
|
+
if (
|
|
161
|
+
!before ||
|
|
162
|
+
before.exists !== after.exists ||
|
|
163
|
+
before.kind !== after.kind ||
|
|
164
|
+
before.hash !== after.hash ||
|
|
165
|
+
Number(before.size || 0) !== Number(after.size || 0)
|
|
166
|
+
) {
|
|
167
|
+
violations.push({ path: relative, before, after })
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
safe: violations.length === 0,
|
|
172
|
+
violations,
|
|
173
|
+
reason: violations.length ? "inherited-dirty-work-modified" : null,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function captureInheritedDirtyState(root, options = {}) {
|
|
178
|
+
const resolved = path.resolve(root)
|
|
179
|
+
let statusOutput = ""
|
|
180
|
+
const workspaceState = options.workspaceState || null
|
|
181
|
+
if (
|
|
182
|
+
workspaceState &&
|
|
183
|
+
typeof workspaceState === "object" &&
|
|
184
|
+
workspaceState.root &&
|
|
185
|
+
path.resolve(workspaceState.root) === resolved &&
|
|
186
|
+
typeof workspaceState.statusOutput === "string"
|
|
187
|
+
) {
|
|
188
|
+
statusOutput = workspaceState.statusOutput
|
|
189
|
+
} else {
|
|
190
|
+
const probe = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
191
|
+
cwd: resolved,
|
|
192
|
+
encoding: "utf8",
|
|
193
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
194
|
+
})
|
|
195
|
+
if (probe.status !== 0) {
|
|
196
|
+
return {
|
|
197
|
+
schemaVersion: 1,
|
|
198
|
+
available: false,
|
|
199
|
+
clean: false,
|
|
200
|
+
root: resolved,
|
|
201
|
+
entries: [],
|
|
202
|
+
paths: [],
|
|
203
|
+
error: String(probe.stderr || probe.stdout || "git status failed").trim(),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
statusOutput = String(probe.stdout || "")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const tokens = statusOutput.split("\0").filter(Boolean)
|
|
210
|
+
const entries = []
|
|
211
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
212
|
+
const token = tokens[index]
|
|
213
|
+
if (token.length < 3) continue
|
|
214
|
+
const status = token.slice(0, 2)
|
|
215
|
+
const separator = token[2]
|
|
216
|
+
if (separator !== " ") continue
|
|
217
|
+
const currentPath = normalizePath(token.slice(3))
|
|
218
|
+
let originalPath = null
|
|
219
|
+
if (/[RC]/.test(status) && index + 1 < tokens.length) {
|
|
220
|
+
originalPath = normalizePath(tokens[index + 1])
|
|
221
|
+
index += 1
|
|
222
|
+
}
|
|
223
|
+
if (!currentPath) continue
|
|
224
|
+
entries.push({ status, path: currentPath, originalPath })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const generatedArtifact = /(?:^|\/)(?:node_modules|\.next(?:-desktop)?|dist|build|coverage|\.turbo|\.cache)(?:\/|$)/i
|
|
228
|
+
const paths = sourceFacingPaths(
|
|
229
|
+
entries.flatMap((entry) => [entry.path, entry.originalPath].filter(Boolean)),
|
|
230
|
+
).filter((item) => !generatedArtifact.test(item)).sort()
|
|
231
|
+
const pathSet = new Set(paths)
|
|
232
|
+
const sourceEntries = entries
|
|
233
|
+
.filter((entry) =>
|
|
234
|
+
pathSet.has(entry.path) || (entry.originalPath && pathSet.has(entry.originalPath))
|
|
235
|
+
)
|
|
236
|
+
.map((entry) => ({
|
|
237
|
+
...entry,
|
|
238
|
+
state: inheritedPathState(resolved, entry.path),
|
|
239
|
+
...(entry.originalPath ? { originalState: inheritedPathState(resolved, entry.originalPath) } : {}),
|
|
240
|
+
}))
|
|
241
|
+
return {
|
|
242
|
+
schemaVersion: 1,
|
|
243
|
+
available: true,
|
|
244
|
+
clean: paths.length === 0,
|
|
245
|
+
root: resolved,
|
|
246
|
+
entries: sourceEntries,
|
|
247
|
+
paths,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function extractExplicitPhases(task) {
|
|
252
|
+
const lines = String(task || "").split(/\r?\n/)
|
|
253
|
+
const phases = []
|
|
254
|
+
let current = null
|
|
255
|
+
|
|
256
|
+
const flush = () => {
|
|
257
|
+
if (!current) return
|
|
258
|
+
const body = current.lines.join("\n").trim()
|
|
259
|
+
const number = Number(current.number)
|
|
260
|
+
const title = current.title.trim()
|
|
261
|
+
const slug = slugify(title)
|
|
262
|
+
const constraintOnly =
|
|
263
|
+
/(?:do not|không được làm|tuyệt đối không|guardrail|constraint|prohibited)/i.test(title) ||
|
|
264
|
+
/(?:TUYỆT ĐỐI KHÔNG|MUST NOT|DO NOT)\s*:/i.test(body)
|
|
265
|
+
phases.push({
|
|
266
|
+
number,
|
|
267
|
+
title,
|
|
268
|
+
slug,
|
|
269
|
+
kind: constraintOnly ? "constraint" : "execution",
|
|
270
|
+
body,
|
|
271
|
+
bodyHash: sha256(body),
|
|
272
|
+
artifact: `phases/phase-${String(number).padStart(2, "0")}-${slug}.json`,
|
|
273
|
+
})
|
|
274
|
+
current = null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const line of lines) {
|
|
278
|
+
const match = line.match(PHASE_HEADING)
|
|
279
|
+
if (match) {
|
|
280
|
+
flush()
|
|
281
|
+
current = { number: match[1], title: match[2], lines: [] }
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
if (current) current.lines.push(line)
|
|
285
|
+
}
|
|
286
|
+
flush()
|
|
287
|
+
|
|
288
|
+
phases.sort((a, b) => a.number - b.number)
|
|
289
|
+
return phases
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function taskPhaseNumber(task) {
|
|
293
|
+
const explicit = Number(task?.phase)
|
|
294
|
+
if (Number.isInteger(explicit) && explicit >= 0) return explicit
|
|
295
|
+
const text = [task?.id, task?.title, task?.summary].filter(Boolean).join(" ")
|
|
296
|
+
const match = text.match(/\bphase\s*[-_:]?\s*(\d+)\b/i)
|
|
297
|
+
return match ? Number(match[1]) : null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function enforcePhaseGates(plan, contract) {
|
|
301
|
+
if (!contract?.phases?.length) {
|
|
302
|
+
return { valid: true, plan, errors: [], assignments: {} }
|
|
303
|
+
}
|
|
304
|
+
if (!plan || !Array.isArray(plan.tasks)) {
|
|
305
|
+
return { valid: false, plan, errors: ["phase-gated plan must contain tasks"], assignments: {} }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const executionPhases = contract.phases.filter((phase) => phase.kind !== "constraint")
|
|
309
|
+
const allowed = new Set(executionPhases.map((phase) => Number(phase.number)))
|
|
310
|
+
const assignments = {}
|
|
311
|
+
const errors = []
|
|
312
|
+
const byPhase = new Map()
|
|
313
|
+
const taskById = new Map(plan.tasks.map((task) => [String(task.id), task]))
|
|
314
|
+
|
|
315
|
+
for (const task of plan.tasks) {
|
|
316
|
+
const phase = taskPhaseNumber(task)
|
|
317
|
+
if (phase == null) {
|
|
318
|
+
errors.push(`task ${task.id} is missing an explicit phase number`)
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (!allowed.has(phase)) {
|
|
322
|
+
errors.push(`task ${task.id} declares unknown phase ${phase}`)
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
assignments[String(task.id)] = phase
|
|
326
|
+
if (!byPhase.has(phase)) byPhase.set(phase, [])
|
|
327
|
+
byPhase.get(phase).push(String(task.id))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const phase of executionPhases) {
|
|
331
|
+
if (!(byPhase.get(Number(phase.number)) || []).length) {
|
|
332
|
+
errors.push(`explicit PHASE ${phase.number} has no planned task`)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const task of plan.tasks) {
|
|
337
|
+
const taskPhase = assignments[String(task.id)]
|
|
338
|
+
if (taskPhase == null) continue
|
|
339
|
+
for (const dep of Array.isArray(task.dependsOn) ? task.dependsOn : []) {
|
|
340
|
+
const depPhase = assignments[String(dep)]
|
|
341
|
+
if (depPhase != null && depPhase > taskPhase) {
|
|
342
|
+
errors.push(`task ${task.id} in phase ${taskPhase} depends on later phase ${depPhase} task ${dep}`)
|
|
343
|
+
}
|
|
344
|
+
if (!taskById.has(String(dep))) {
|
|
345
|
+
errors.push(`task ${task.id} depends on missing task ${dep}`)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (errors.length) return { valid: false, plan, errors, assignments }
|
|
351
|
+
|
|
352
|
+
const orderedPhases = executionPhases.map((phase) => Number(phase.number)).sort((a, b) => a - b)
|
|
353
|
+
const tasks = plan.tasks.map((task) => ({
|
|
354
|
+
...task,
|
|
355
|
+
phase: assignments[String(task.id)],
|
|
356
|
+
dependsOn: [...new Set(Array.isArray(task.dependsOn) ? task.dependsOn.map(String) : [])],
|
|
357
|
+
}))
|
|
358
|
+
|
|
359
|
+
for (let index = 1; index < orderedPhases.length; index += 1) {
|
|
360
|
+
const previous = byPhase.get(orderedPhases[index - 1]) || []
|
|
361
|
+
const current = new Set(byPhase.get(orderedPhases[index]) || [])
|
|
362
|
+
for (const task of tasks) {
|
|
363
|
+
if (!current.has(String(task.id))) continue
|
|
364
|
+
task.dependsOn = [...new Set([...task.dependsOn, ...previous])].filter((id) => id !== String(task.id))
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
valid: true,
|
|
370
|
+
plan: {
|
|
371
|
+
...plan,
|
|
372
|
+
tasks,
|
|
373
|
+
phaseGate: {
|
|
374
|
+
schemaVersion: 1,
|
|
375
|
+
orderedPhases,
|
|
376
|
+
barrierStrategy: "previous-phase-all-tasks",
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
errors: [],
|
|
380
|
+
assignments,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function dataCleanupSafetyRequired(task) {
|
|
385
|
+
const value = String(task || "")
|
|
386
|
+
return /(?:cleanup|clean up|dọn|don dep|dọn dẹp|xóa fixture|xoá fixture)/i.test(value) &&
|
|
387
|
+
/(?:database|\bdb\b|fixture|seed|catalog|dữ liệu|du lieu|prisma|sql)/i.test(value)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function deviceVerificationRequired(task) {
|
|
391
|
+
return /(?:expo go|real device|physical device|manual mobile acceptance|test thiết bị|thiết bị thật|device verification)/i.test(String(task || ""))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function runtimeVerificationRequired(task) {
|
|
395
|
+
return /(?:runtime|smoke|e2e|integration|api thật|api real|real api|endpoint|build|typecheck|lint|tests?|jest|vitest|pytest|playwright|gradle|maven|cargo test|go test)/i.test(String(task || ""))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function buildExecutionContract(task, inheritedDirty = null) {
|
|
399
|
+
const phases = extractExplicitPhases(task)
|
|
400
|
+
const dirty = inheritedDirty || {
|
|
401
|
+
schemaVersion: 1,
|
|
402
|
+
available: false,
|
|
403
|
+
clean: false,
|
|
404
|
+
entries: [],
|
|
405
|
+
paths: [],
|
|
406
|
+
}
|
|
407
|
+
const dbCleanup = dataCleanupSafetyRequired(task)
|
|
408
|
+
const device = deviceVerificationRequired(task)
|
|
409
|
+
const runtime = runtimeVerificationRequired(task)
|
|
410
|
+
|
|
411
|
+
const approvedInheritedDirtyPaths = explicitlyAuthorizedInheritedDirtyPaths(
|
|
412
|
+
task,
|
|
413
|
+
dirty.paths || [],
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
schemaVersion: 1,
|
|
418
|
+
taskHash: sha256(task),
|
|
419
|
+
inheritedDirty: dirty,
|
|
420
|
+
approvedInheritedDirtyPaths,
|
|
421
|
+
localEnvWriteExplicitlyAllowed: taskExplicitlyAllowsLocalEnvWrite(task),
|
|
422
|
+
phases,
|
|
423
|
+
gates: {
|
|
424
|
+
source: true,
|
|
425
|
+
runtime,
|
|
426
|
+
dbClean: dbCleanup,
|
|
427
|
+
device,
|
|
428
|
+
},
|
|
429
|
+
invariants: [
|
|
430
|
+
"Preserve pre-existing dirty work. Never restore, checkout-away, clean, stash, or overwrite inherited changes unless the file is explicitly in the approved task write scope.",
|
|
431
|
+
"Treat .env and .env.* as local runtime inputs. Do not modify them unless the user explicitly requested that exact local env mutation. Prefer .env.example/sample/template and docs for repository changes.",
|
|
432
|
+
"Never claim cleanup success from frontend filtering or hidden presentation logic; prove cleanup at the data/source layer.",
|
|
433
|
+
"Do not cross an explicit PHASE boundary without verified evidence for prerequisite work represented in the approved plan.",
|
|
434
|
+
],
|
|
435
|
+
databaseCleanupGate: dbCleanup
|
|
436
|
+
? {
|
|
437
|
+
requireProductionRefusal: true,
|
|
438
|
+
requireDryRun: true,
|
|
439
|
+
requireExactIds: true,
|
|
440
|
+
requirePrePostCounts: true,
|
|
441
|
+
requireDeterministicMarkers: true,
|
|
442
|
+
requireCanonicalPreservation: true,
|
|
443
|
+
requireIdempotencyProof: true,
|
|
444
|
+
preferTransactionRollback: true,
|
|
445
|
+
}
|
|
446
|
+
: null,
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function executionContractPrompt(contract) {
|
|
451
|
+
if (!contract) return ""
|
|
452
|
+
const dirtyPaths = Array.isArray(contract.inheritedDirty?.paths)
|
|
453
|
+
? contract.inheritedDirty.paths.slice(0, 120)
|
|
454
|
+
: []
|
|
455
|
+
const phaseSummary = Array.isArray(contract.phases)
|
|
456
|
+
? contract.phases.map((phase) => `PHASE ${phase.number} [${phase.kind}]: ${phase.title} -> ${phase.artifact}`)
|
|
457
|
+
: []
|
|
458
|
+
const approvedDirty = Array.isArray(contract.approvedInheritedDirtyPaths)
|
|
459
|
+
? contract.approvedInheritedDirtyPaths.slice(0, 120)
|
|
460
|
+
: []
|
|
461
|
+
const lines = [
|
|
462
|
+
"## UES deterministic execution contract",
|
|
463
|
+
"These constraints are controller-owned and must not be weakened by the model.",
|
|
464
|
+
...contract.invariants.map((item) => "- " + item),
|
|
465
|
+
dirtyPaths.length
|
|
466
|
+
? "\nInherited dirty paths present before this UES run (preserve unless explicitly approved in task write scope):\n" +
|
|
467
|
+
dirtyPaths.map((item) => "- " + item).join("\n") +
|
|
468
|
+
(approvedDirty.length
|
|
469
|
+
? "\nExplicitly approved inherited dirty mutation scope:\n" + approvedDirty.map((item) => "- " + item).join("\n")
|
|
470
|
+
: "\nExplicitly approved inherited dirty mutation scope: none.")
|
|
471
|
+
: "\nInherited dirty paths: none detected.",
|
|
472
|
+
contract.localEnvWriteExplicitlyAllowed
|
|
473
|
+
? "\nLocal .env mutation: explicitly requested by the user for this task."
|
|
474
|
+
: "\nLocal .env mutation: NOT authorized. Do not edit .env/.env.local/.env.development/etc.; use templates/docs or report NEEDS_USER_ENV.",
|
|
475
|
+
phaseSummary.length
|
|
476
|
+
? "\nExplicit phase contract:\n" + phaseSummary.map((item) => "- " + item).join("\n")
|
|
477
|
+
: "",
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
if (contract.databaseCleanupGate) {
|
|
481
|
+
lines.push(
|
|
482
|
+
"",
|
|
483
|
+
"Database cleanup gate:",
|
|
484
|
+
"- refuse production targets;",
|
|
485
|
+
"- perform a dry-run before deletion;",
|
|
486
|
+
"- record exact candidate IDs and pre/post entity counts;",
|
|
487
|
+
"- delete only fixtures linked to deterministic audited markers/source;",
|
|
488
|
+
"- preserve canonical seed and user history;",
|
|
489
|
+
"- prove idempotency with a second cleanup pass;",
|
|
490
|
+
"- use transaction/rollback protection when the stack supports it.",
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
lines.push(
|
|
495
|
+
"",
|
|
496
|
+
"Final verdict markers:",
|
|
497
|
+
"- SOURCE_PASS only after source/task verification and integration gates pass.",
|
|
498
|
+
contract.gates.runtime
|
|
499
|
+
? "- RUNTIME_PASS only with fresh executable/runtime evidence; otherwise RUNTIME_NOT_VERIFIED."
|
|
500
|
+
: "- RUNTIME_NOT_REQUIRED.",
|
|
501
|
+
contract.gates.dbClean
|
|
502
|
+
? "- DB_CLEAN_PASS only with data-layer cleanup/public-data evidence; otherwise DB_CLEAN_NOT_VERIFIED."
|
|
503
|
+
: "- DB_CLEAN_NOT_REQUIRED.",
|
|
504
|
+
contract.gates.device
|
|
505
|
+
? "- DEVICE_PASS only after a real requested device check; otherwise DEVICE_NOT_VERIFIED."
|
|
506
|
+
: "- DEVICE_NOT_REQUIRED.",
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
return lines.filter(Boolean).join("\n")
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function buildFinalVerdictMatrix(task, evidence = {}) {
|
|
513
|
+
const contract = evidence.contract || buildExecutionContract(task)
|
|
514
|
+
const text = [
|
|
515
|
+
evidence.primaryOutput,
|
|
516
|
+
evidence.integrationOutput,
|
|
517
|
+
evidence.visualOutput,
|
|
518
|
+
].filter(Boolean).join("\n")
|
|
519
|
+
|
|
520
|
+
const sourcePass = evidence.primaryPass === true && evidence.integrationPass !== false
|
|
521
|
+
const runtimeCheckText = [
|
|
522
|
+
evidence.primaryChecks,
|
|
523
|
+
evidence.integrationChecks,
|
|
524
|
+
evidence.visualChecks,
|
|
525
|
+
].filter(Boolean).join("\n")
|
|
526
|
+
const executableRuntimeEvidence =
|
|
527
|
+
evidence.primaryPass === true &&
|
|
528
|
+
evidence.integrationPass !== false &&
|
|
529
|
+
/(?:\b(?:npm|pnpm|yarn|node|pytest|jest|vitest|gradle|mvn|dotnet|cargo|go)\b|runtime:smoke|e2e|typecheck|lint|curl\s|http:\/\/|https:\/\/)/i.test(runtimeCheckText)
|
|
530
|
+
const runtime = !contract.gates.runtime
|
|
531
|
+
? "RUNTIME_NOT_REQUIRED"
|
|
532
|
+
: executableRuntimeEvidence
|
|
533
|
+
? "RUNTIME_PASS"
|
|
534
|
+
: "RUNTIME_NOT_VERIFIED"
|
|
535
|
+
const dbCleanupEvidence =
|
|
536
|
+
evidence.primaryPass === true &&
|
|
537
|
+
evidence.integrationPass !== false &&
|
|
538
|
+
/\bDB_CLEAN_PASS\b/i.test(text) &&
|
|
539
|
+
/(?:dry[- ]?run|cleanup|fixture|seed|catalog|prisma|sql|dữ liệu|du lieu)/i.test(text + "\n" + runtimeCheckText) &&
|
|
540
|
+
/(?:idempotent|second\s+(?:cleanup|run|pass)|lần\s+(?:2|hai)|repeat(?:ed)?\s+run)/i.test(text) &&
|
|
541
|
+
/(?:count|removed|deleted|records?|xóa|xoá|đã dọn|pre[- ]?count|post[- ]?count|before|after|trước|sau)/i.test(text)
|
|
542
|
+
const dbClean = !contract.gates.dbClean
|
|
543
|
+
? "DB_CLEAN_NOT_REQUIRED"
|
|
544
|
+
: dbCleanupEvidence
|
|
545
|
+
? "DB_CLEAN_PASS"
|
|
546
|
+
: "DB_CLEAN_NOT_VERIFIED"
|
|
547
|
+
const deviceEvidence =
|
|
548
|
+
/\bDEVICE_PASS\b/i.test(text) &&
|
|
549
|
+
/(?:adb\s+(?:devices|shell)|expo\s+go|physical\s+device|thiết\s+bị\s+thật)/i.test(runtimeCheckText)
|
|
550
|
+
const device = !contract.gates.device
|
|
551
|
+
? "DEVICE_NOT_REQUIRED"
|
|
552
|
+
: deviceEvidence
|
|
553
|
+
? "DEVICE_PASS"
|
|
554
|
+
: "DEVICE_NOT_VERIFIED"
|
|
555
|
+
|
|
556
|
+
const source = sourcePass ? "SOURCE_PASS" : "SOURCE_NOT_VERIFIED"
|
|
557
|
+
const sourceRuntimeDataPass =
|
|
558
|
+
sourcePass &&
|
|
559
|
+
(!contract.gates.runtime || runtime === "RUNTIME_PASS") &&
|
|
560
|
+
(!contract.gates.dbClean || dbClean === "DB_CLEAN_PASS")
|
|
561
|
+
const requiredPass =
|
|
562
|
+
sourceRuntimeDataPass &&
|
|
563
|
+
(!contract.gates.device || device === "DEVICE_PASS")
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
schemaVersion: 1,
|
|
567
|
+
source,
|
|
568
|
+
runtime,
|
|
569
|
+
dbClean,
|
|
570
|
+
device,
|
|
571
|
+
final: requiredPass
|
|
572
|
+
? "PASS"
|
|
573
|
+
: sourceRuntimeDataPass && device === "DEVICE_NOT_VERIFIED"
|
|
574
|
+
? "SOURCE_RUNTIME_PASS_DEVICE_NOT_VERIFIED"
|
|
575
|
+
: "PARTIAL_OR_NOT_VERIFIED",
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function phaseArtifactPayloads(contract) {
|
|
580
|
+
return (contract?.phases || []).map((phase) => ({
|
|
581
|
+
file: phase.artifact,
|
|
582
|
+
value: {
|
|
583
|
+
schemaVersion: 1,
|
|
584
|
+
phase: phase.number,
|
|
585
|
+
title: phase.title,
|
|
586
|
+
sourceBodyHash: phase.bodyHash,
|
|
587
|
+
sourceBody: phase.body,
|
|
588
|
+
status: "PENDING",
|
|
589
|
+
evidence: [],
|
|
590
|
+
},
|
|
591
|
+
}))
|
|
592
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { classifyEngineeringTask } from "./task-policy.mjs"
|
|
2
|
+
|
|
3
|
+
function strings(value) {
|
|
4
|
+
if (!value) return []
|
|
5
|
+
return (Array.isArray(value) ? value : [value]).map(String).map((item) => item.trim()).filter(Boolean)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function declaredWriteFiles(task = {}) {
|
|
9
|
+
const files = task?.files || {}
|
|
10
|
+
return [...new Set([
|
|
11
|
+
...strings(files.create),
|
|
12
|
+
...strings(files.modify),
|
|
13
|
+
...strings(files.test),
|
|
14
|
+
...strings(files.delete),
|
|
15
|
+
].map((item) => item.replaceAll("\\", "/")))]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function leafTaskPolicy(task = {}, rootPolicy = {}) {
|
|
19
|
+
const writeFiles = declaredWriteFiles(task)
|
|
20
|
+
const readFiles = strings(task?.files?.read)
|
|
21
|
+
const text = [
|
|
22
|
+
task?.title,
|
|
23
|
+
task?.summary,
|
|
24
|
+
...strings(task?.acceptance),
|
|
25
|
+
...strings(task?.verification),
|
|
26
|
+
task?.riskNotes,
|
|
27
|
+
writeFiles.length ? "Declared write files: " + writeFiles.join(", ") : "",
|
|
28
|
+
readFiles.length ? "Declared read files: " + readFiles.join(", ") : "",
|
|
29
|
+
].filter(Boolean).join("\n")
|
|
30
|
+
|
|
31
|
+
const policy = classifyEngineeringTask(text, {
|
|
32
|
+
declaredFiles: writeFiles,
|
|
33
|
+
changedFiles: writeFiles.length,
|
|
34
|
+
risk: task?.risk,
|
|
35
|
+
longHorizon: false,
|
|
36
|
+
failureEvidence: false,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// The root may be DEEP because the overall project is large; do not force an
|
|
40
|
+
// independent low-risk leaf to pay that cost. Root safety is preserved by the
|
|
41
|
+
// final integration/completion gates and by leaf-declared risk.
|
|
42
|
+
return {
|
|
43
|
+
...policy,
|
|
44
|
+
requireFreshEvidence: true,
|
|
45
|
+
rootExecutionProfile: rootPolicy?.executionProfile || null,
|
|
46
|
+
rootRisk: rootPolicy?.risk || null,
|
|
47
|
+
localEnvWriteExplicitlyAllowed: rootPolicy?.localEnvWriteExplicitlyAllowed === true,
|
|
48
|
+
leafOptimized: true,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const FAILURE_SIGNAL = /(?:\bFAIL(?:ED|URE)?\b|\bERROR\b|AssertionError|TypeError|ReferenceError|SQLSTATE|ECONN\w*|EADDRINUSE|expected|actual|exit(?:Code| code)?\s*[:=]?\s*[1-9]|timed?\s*out|timeout|not found|cannot|unable|mismatch|regression|✖|×)/i
|
|
53
|
+
const FILE_SIGNAL = /(?:^|\s)(?:[A-Za-z]:)?[^\s:]+\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|cs|sql|json|yaml|yml)(?::\d+(?::\d+)?)?/i
|
|
54
|
+
|
|
55
|
+
export function failureDelta(output, options = {}) {
|
|
56
|
+
const maxChars = Math.max(1200, Math.min(8000, Number(options.maxChars || 4200)))
|
|
57
|
+
const raw = String(output || "").trim()
|
|
58
|
+
if (!raw) return ""
|
|
59
|
+
|
|
60
|
+
const lines = raw.split(/\r?\n/)
|
|
61
|
+
const selected = []
|
|
62
|
+
for (let i = 0; i < lines.length; i++) {
|
|
63
|
+
const line = lines[i]
|
|
64
|
+
if (!FAILURE_SIGNAL.test(line) && !FILE_SIGNAL.test(line)) continue
|
|
65
|
+
if (i > 0 && lines[i - 1].trim()) selected.push(lines[i - 1])
|
|
66
|
+
selected.push(line)
|
|
67
|
+
if (i + 1 < lines.length && lines[i + 1].trim()) selected.push(lines[i + 1])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const compact = [...new Set(selected.map((line) => line.trim()).filter(Boolean))].join("\n")
|
|
71
|
+
if (compact) return compact.slice(0, maxChars)
|
|
72
|
+
|
|
73
|
+
// Fail closed on unknown output shape: keep a bounded tail, which usually
|
|
74
|
+
// contains the final verdict/error without replaying the entire previous turn.
|
|
75
|
+
return raw.slice(Math.max(0, raw.length - maxChars))
|
|
76
|
+
}
|