@tea-agent/loop-agent 0.20.1 → 0.22.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/CHANGELOG.md +72 -0
- package/bin/agent-worker.js +0 -0
- package/dist/adapters/loop-agent.js +52 -0
- package/dist/commands/init.js +104 -0
- package/dist/executors/dag-pi-executor.js +26 -0
- package/dist/executors/pi-executor.js +111 -36
- package/dist/executors/pi-sdk-executor.js +105 -29
- package/dist/executors/shell-executor.js +215 -29
- package/dist/shared/openspec-spec.js +49 -0
- package/dist/worker/loop-agent/loop-agent-client.js +43 -9
- package/dist/worker/observability/read-model.js +28 -2
- package/dist/worker/observe/spec-evidence.js +12 -15
- package/dist/worker/observe/static/constants.js +5 -0
- package/dist/worker/observe/static/dag-helpers.js +22 -0
- package/dist/worker/observe/static/format-pool.js +22 -3
- package/dist/worker/observe/static/styles.css +32 -3
- package/dist/worker/observe/static/views/dag-inspector.js +2 -2
- package/dist/worker/observe/static/views/dag.js +5 -0
- package/dist/worker/run-task/run-task.js +16 -6
- package/dist/workflows/dag/backend-test-markdown-workflow.js +328 -97
- package/dist/workflows/dag/backend-test-result-contract.js +10 -4
- package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
- package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
- package/dist/workflows/dag/frontend-project-capability.js +11 -8
- package/dist/workflows/dag/frontend-repair.js +6 -4
- package/dist/workflows/dag/frontend-review-context.js +67 -0
- package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
- package/dist/workflows/dag/frontend-verification-trace.js +31 -1
- package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
- package/dist/workflows/dag/init-hybrid.js +370 -79
- package/dist/workflows/dag/lifecycle.js +60 -4
- package/dist/workflows/dag/liveness-policy.js +250 -0
- package/dist/workflows/dag/node-execution.js +49 -0
- package/dist/workflows/dag/runner.js +21 -1
- package/dist/workflows/dag/types.js +67 -1
- package/docs/README.md +5 -6
- package/docs/architecture/dag-execution.md +11 -0
- package/docs/architecture/facts-and-state.md +1 -0
- package/docs/architecture/worker-and-feature.md +10 -0
- package/docs/templates/agent-dag.schema.json +15 -5
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
- package/docs/templates/backend-test-dag.json +15 -15
- package/docs/templates/frontend-implementation-contract.schema.json +4 -3
- package/docs/templates/frontend-test-case-checklist.md +6 -2
- package/docs/templates/frontend-test-dag.json +2 -2
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/frontend-design-review/SKILL.md +12 -10
- package/skills/frontend-design-review/references/review-checklist.md +4 -4
- package/skills/frontend-implementation/SKILL.md +2 -2
- package/skills/frontend-implementation/references/code-standards.md +4 -3
- package/skills/frontend-implementation/references/design-spec.md +19 -14
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/frontend-review/SKILL.md +15 -28
- package/skills/frontend-review/references/review-findings.md +16 -18
- package/skills/frontend-verification/SKILL.md +16 -13
- package/skills/frontend-verification/references/verification-checklist.md +18 -30
- package/skills/loop-agent/references/command-reference.md +2 -0
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
export const FRONTEND_LINT_BASELINE_SCHEMA_ID = "frontend-lint-baseline-v1";
|
|
6
|
+
export const FRONTEND_LINT_ASSESSMENT_SCHEMA_ID = "frontend-lint-assessment-v1";
|
|
7
|
+
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
|
|
8
|
+
const relativePathSchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1)
|
|
11
|
+
.refine((value) => !value.includes("\\") &&
|
|
12
|
+
!path.posix.isAbsolute(value) &&
|
|
13
|
+
!value.split("/").includes(".."), "expected a contained repo-relative path");
|
|
14
|
+
export const frontendLintDiagnosticSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
file: relativePathSchema,
|
|
17
|
+
line: z.number().int().positive(),
|
|
18
|
+
column: z.number().int().positive(),
|
|
19
|
+
severity: z.enum(["error", "warning"]),
|
|
20
|
+
message: z.string().min(1),
|
|
21
|
+
ruleId: z.string().min(1).nullable(),
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
24
|
+
export const frontendLintCommandIdentitySchema = z
|
|
25
|
+
.object({
|
|
26
|
+
commands: z.array(z.string().min(1)).min(1),
|
|
27
|
+
sha256: sha256Schema,
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
const rawEvidenceRefSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
path: relativePathSchema,
|
|
33
|
+
sha256: sha256Schema,
|
|
34
|
+
stream: z.enum(["stdout", "stderr"]),
|
|
35
|
+
commandIndex: z.number().int().nonnegative(),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
const lintCommandResultSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
command: z.string().min(1),
|
|
41
|
+
exitCode: z.number().int().nullable(),
|
|
42
|
+
timedOut: z.boolean(),
|
|
43
|
+
failureCategory: z.string().min(1),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
46
|
+
export const frontendLintBaselineArtifactSchema = z
|
|
47
|
+
.object({
|
|
48
|
+
schemaVersion: z.literal(1),
|
|
49
|
+
schemaId: z.literal(FRONTEND_LINT_BASELINE_SCHEMA_ID),
|
|
50
|
+
status: z.enum(["available", "unavailable"]),
|
|
51
|
+
reason: z.string().min(1).optional(),
|
|
52
|
+
commandIdentity: frontendLintCommandIdentitySchema,
|
|
53
|
+
diagnostics: z.array(frontendLintDiagnosticSchema),
|
|
54
|
+
commandResults: z.array(lintCommandResultSchema),
|
|
55
|
+
rawEvidenceRefs: z.array(rawEvidenceRefSchema),
|
|
56
|
+
})
|
|
57
|
+
.strict();
|
|
58
|
+
export const frontendLintAssessmentArtifactSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
schemaVersion: z.literal(1),
|
|
61
|
+
schemaId: z.literal(FRONTEND_LINT_ASSESSMENT_SCHEMA_ID),
|
|
62
|
+
status: z.enum(["passed", "baseline-debt", "failed", "unavailable"]),
|
|
63
|
+
commandIdentity: frontendLintCommandIdentitySchema,
|
|
64
|
+
baselineRef: z
|
|
65
|
+
.object({
|
|
66
|
+
path: relativePathSchema,
|
|
67
|
+
sha256: sha256Schema,
|
|
68
|
+
nodeId: z.string().min(1),
|
|
69
|
+
})
|
|
70
|
+
.strict()
|
|
71
|
+
.optional(),
|
|
72
|
+
currentExitCode: z.number().int().nullable(),
|
|
73
|
+
writerChangedFiles: z.array(relativePathSchema),
|
|
74
|
+
currentDiagnostics: z.array(frontendLintDiagnosticSchema),
|
|
75
|
+
toleratedDiagnosticCount: z.number().int().nonnegative(),
|
|
76
|
+
blockingDiagnostics: z.array(frontendLintDiagnosticSchema),
|
|
77
|
+
blockingReasons: z.array(z.string().min(1)),
|
|
78
|
+
rawEvidenceRefs: z.array(rawEvidenceRefSchema),
|
|
79
|
+
})
|
|
80
|
+
.strict();
|
|
81
|
+
const ANSI_PATTERN =
|
|
82
|
+
// eslint-disable-next-line no-control-regex
|
|
83
|
+
/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
|
|
84
|
+
function stripAnsi(value) {
|
|
85
|
+
return value.replace(ANSI_PATTERN, "");
|
|
86
|
+
}
|
|
87
|
+
function normalizeRepoPath(workspaceRoot, candidate) {
|
|
88
|
+
const clean = stripAnsi(candidate).trim().replace(/^file:\/\//, "");
|
|
89
|
+
const windowsAbsolute = /^[A-Za-z]:[\\/]/.test(clean);
|
|
90
|
+
const windowsRoot = /^[A-Za-z]:[\\/]/.test(workspaceRoot);
|
|
91
|
+
let relative;
|
|
92
|
+
if (windowsAbsolute || windowsRoot) {
|
|
93
|
+
if (!windowsAbsolute || !windowsRoot)
|
|
94
|
+
return null;
|
|
95
|
+
relative = path.win32.relative(workspaceRoot, clean);
|
|
96
|
+
}
|
|
97
|
+
else if (path.posix.isAbsolute(clean)) {
|
|
98
|
+
relative = path.posix.relative(workspaceRoot.replaceAll("\\", "/"), clean.replaceAll("\\", "/"));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
relative = clean;
|
|
102
|
+
}
|
|
103
|
+
const normalized = relative.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
104
|
+
if (normalized.length === 0 ||
|
|
105
|
+
path.posix.isAbsolute(normalized) ||
|
|
106
|
+
normalized === ".." ||
|
|
107
|
+
normalized.startsWith("../")) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return normalized;
|
|
111
|
+
}
|
|
112
|
+
const IGNORED_ESLINT_LINES = [
|
|
113
|
+
/^$/,
|
|
114
|
+
/^>\s/,
|
|
115
|
+
/^npm (?:warn|notice)\b/i,
|
|
116
|
+
/^eslint\b/i,
|
|
117
|
+
/^✖\s+\d+\s+problems?/i,
|
|
118
|
+
/^\d+\s+problems?\s+\(/i,
|
|
119
|
+
/^\d+\s+errors?\s+and\s+\d+\s+warnings?/i,
|
|
120
|
+
/^--$/,
|
|
121
|
+
];
|
|
122
|
+
function isIgnoredEslintLine(line) {
|
|
123
|
+
return IGNORED_ESLINT_LINES.some((pattern) => pattern.test(line.trim()));
|
|
124
|
+
}
|
|
125
|
+
export function parseEslintDiagnostics(input) {
|
|
126
|
+
const diagnostics = [];
|
|
127
|
+
const unparsedLines = [];
|
|
128
|
+
let currentFile = null;
|
|
129
|
+
for (const rawLine of stripAnsi(input.output).split(/\r?\n/)) {
|
|
130
|
+
const line = rawLine.trimEnd();
|
|
131
|
+
if (isIgnoredEslintLine(line))
|
|
132
|
+
continue;
|
|
133
|
+
const stylish = line.match(/^\s*(\d+):(\d+)\s+(error|warning)\s+(.+?)(?:\s{2,}([@a-zA-Z0-9_./-]+))?\s*$/);
|
|
134
|
+
if (stylish && currentFile) {
|
|
135
|
+
diagnostics.push({
|
|
136
|
+
file: currentFile,
|
|
137
|
+
line: Number(stylish[1]),
|
|
138
|
+
column: Number(stylish[2]),
|
|
139
|
+
severity: stylish[3],
|
|
140
|
+
message: stylish[4].trim(),
|
|
141
|
+
ruleId: stylish[5]?.trim() || null,
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const compact = line.match(/^(.+?):\s*line\s+(\d+),\s*col\s+(\d+),\s*(Error|Warning)\s*-\s*(.+?)(?:\s+\(([^)]+)\))?$/i);
|
|
146
|
+
if (compact) {
|
|
147
|
+
const file = normalizeRepoPath(input.workspaceRoot, compact[1]);
|
|
148
|
+
if (!file) {
|
|
149
|
+
unparsedLines.push(line.trim());
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
diagnostics.push({
|
|
153
|
+
file,
|
|
154
|
+
line: Number(compact[2]),
|
|
155
|
+
column: Number(compact[3]),
|
|
156
|
+
severity: compact[4].toLowerCase(),
|
|
157
|
+
message: compact[5].trim(),
|
|
158
|
+
ruleId: compact[6]?.trim() || null,
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const candidateFile = normalizeRepoPath(input.workspaceRoot, line.trim());
|
|
163
|
+
if (candidateFile &&
|
|
164
|
+
/\.[a-z0-9]+$/i.test(candidateFile) &&
|
|
165
|
+
!/\s{2,}/.test(line.trim())) {
|
|
166
|
+
currentFile = candidateFile;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
unparsedLines.push(line.trim());
|
|
170
|
+
}
|
|
171
|
+
return { diagnostics, unparsedLines };
|
|
172
|
+
}
|
|
173
|
+
export function createFrontendLintCommandIdentity(commands) {
|
|
174
|
+
const normalized = commands.map((command) => command.trim());
|
|
175
|
+
return {
|
|
176
|
+
commands: normalized,
|
|
177
|
+
sha256: createHash("sha256")
|
|
178
|
+
.update(JSON.stringify(normalized))
|
|
179
|
+
.digest("hex"),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function diagnosticKey(value) {
|
|
183
|
+
return JSON.stringify([
|
|
184
|
+
value.file,
|
|
185
|
+
value.line,
|
|
186
|
+
value.column,
|
|
187
|
+
value.severity,
|
|
188
|
+
value.message,
|
|
189
|
+
value.ruleId,
|
|
190
|
+
]);
|
|
191
|
+
}
|
|
192
|
+
export function assessFrontendLint(input) {
|
|
193
|
+
const writerChangedFiles = [
|
|
194
|
+
...new Set(input.writerChangedFiles.map((value) => value.replaceAll("\\", "/"))),
|
|
195
|
+
].sort();
|
|
196
|
+
const base = {
|
|
197
|
+
schemaVersion: 1,
|
|
198
|
+
schemaId: FRONTEND_LINT_ASSESSMENT_SCHEMA_ID,
|
|
199
|
+
commandIdentity: input.commandIdentity,
|
|
200
|
+
...(input.baselineRef ? { baselineRef: input.baselineRef } : {}),
|
|
201
|
+
currentExitCode: input.currentExitCode,
|
|
202
|
+
writerChangedFiles,
|
|
203
|
+
currentDiagnostics: input.currentDiagnostics,
|
|
204
|
+
rawEvidenceRefs: input.rawEvidenceRefs,
|
|
205
|
+
};
|
|
206
|
+
if (input.currentExitCode === 0) {
|
|
207
|
+
return {
|
|
208
|
+
...base,
|
|
209
|
+
status: "passed",
|
|
210
|
+
toleratedDiagnosticCount: 0,
|
|
211
|
+
blockingDiagnostics: [],
|
|
212
|
+
blockingReasons: [],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (!input.baseline || input.baseline.status !== "available") {
|
|
216
|
+
return {
|
|
217
|
+
...base,
|
|
218
|
+
status: "unavailable",
|
|
219
|
+
toleratedDiagnosticCount: 0,
|
|
220
|
+
blockingDiagnostics: input.currentDiagnostics,
|
|
221
|
+
blockingReasons: [
|
|
222
|
+
input.baseline?.reason ?? "lint baseline is missing or unavailable",
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (input.currentUnavailableReason) {
|
|
227
|
+
return {
|
|
228
|
+
...base,
|
|
229
|
+
status: "unavailable",
|
|
230
|
+
toleratedDiagnosticCount: 0,
|
|
231
|
+
blockingDiagnostics: input.currentDiagnostics,
|
|
232
|
+
blockingReasons: [input.currentUnavailableReason],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (input.baseline.commandIdentity.sha256 !== input.commandIdentity.sha256) {
|
|
236
|
+
return {
|
|
237
|
+
...base,
|
|
238
|
+
status: "unavailable",
|
|
239
|
+
toleratedDiagnosticCount: 0,
|
|
240
|
+
blockingDiagnostics: input.currentDiagnostics,
|
|
241
|
+
blockingReasons: ["lint command identity drifted from baseline"],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (input.currentUnparsedLines.length > 0 || input.currentDiagnostics.length === 0) {
|
|
245
|
+
return {
|
|
246
|
+
...base,
|
|
247
|
+
status: "unavailable",
|
|
248
|
+
toleratedDiagnosticCount: 0,
|
|
249
|
+
blockingDiagnostics: input.currentDiagnostics,
|
|
250
|
+
blockingReasons: ["current lint output cannot be parsed reliably"],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const changed = new Set(writerChangedFiles);
|
|
254
|
+
const changedDiagnostics = input.currentDiagnostics.filter((item) => changed.has(item.file));
|
|
255
|
+
const baselineCounts = new Map();
|
|
256
|
+
for (const item of input.baseline.diagnostics) {
|
|
257
|
+
const key = diagnosticKey(item);
|
|
258
|
+
baselineCounts.set(key, (baselineCounts.get(key) ?? 0) + 1);
|
|
259
|
+
}
|
|
260
|
+
const newDiagnostics = [];
|
|
261
|
+
let toleratedDiagnosticCount = 0;
|
|
262
|
+
for (const item of input.currentDiagnostics) {
|
|
263
|
+
if (changed.has(item.file))
|
|
264
|
+
continue;
|
|
265
|
+
const key = diagnosticKey(item);
|
|
266
|
+
const count = baselineCounts.get(key) ?? 0;
|
|
267
|
+
if (count === 0) {
|
|
268
|
+
newDiagnostics.push(item);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
baselineCounts.set(key, count - 1);
|
|
272
|
+
toleratedDiagnosticCount += 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const blockingReasons = [];
|
|
276
|
+
if (changedDiagnostics.length > 0) {
|
|
277
|
+
blockingReasons.push("writer-changed files contain lint diagnostics");
|
|
278
|
+
}
|
|
279
|
+
if (newDiagnostics.length > 0) {
|
|
280
|
+
blockingReasons.push("current lint contains diagnostics absent from baseline");
|
|
281
|
+
}
|
|
282
|
+
const blockingDiagnostics = [...changedDiagnostics, ...newDiagnostics];
|
|
283
|
+
return {
|
|
284
|
+
...base,
|
|
285
|
+
status: blockingReasons.length > 0 ? "failed" : "baseline-debt",
|
|
286
|
+
toleratedDiagnosticCount,
|
|
287
|
+
blockingDiagnostics,
|
|
288
|
+
blockingReasons,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const writerChangeManifestSchema = z
|
|
292
|
+
.object({
|
|
293
|
+
schemaVersion: z.literal(1),
|
|
294
|
+
writerNodeId: z.string().min(1),
|
|
295
|
+
changedFiles: z.array(relativePathSchema),
|
|
296
|
+
beforeStatusSha256: sha256Schema,
|
|
297
|
+
afterStatusSha256: sha256Schema,
|
|
298
|
+
})
|
|
299
|
+
.strict();
|
|
300
|
+
export async function readWriterChangedFiles(runDir, writerNodeIds) {
|
|
301
|
+
const changed = new Set();
|
|
302
|
+
for (const writerNodeId of writerNodeIds) {
|
|
303
|
+
const raw = JSON.parse(await readFile(path.join(runDir, writerNodeId, "change-manifest.json"), "utf8"));
|
|
304
|
+
const parsed = writerChangeManifestSchema.parse(raw);
|
|
305
|
+
if (parsed.writerNodeId !== writerNodeId) {
|
|
306
|
+
throw new Error(`lint assessment writer manifest ownership mismatch for ${writerNodeId}`);
|
|
307
|
+
}
|
|
308
|
+
for (const file of parsed.changedFiles)
|
|
309
|
+
changed.add(file);
|
|
310
|
+
}
|
|
311
|
+
return [...changed].sort();
|
|
312
|
+
}
|
|
313
|
+
async function writeContract(runDir, name, value) {
|
|
314
|
+
const relative = `contracts/${name}`;
|
|
315
|
+
const raw = `${JSON.stringify(value, null, 2)}\n`;
|
|
316
|
+
await mkdir(path.join(runDir, "contracts"), { recursive: true });
|
|
317
|
+
await writeFile(path.join(runDir, relative), raw, "utf8");
|
|
318
|
+
return {
|
|
319
|
+
path: relative,
|
|
320
|
+
sha256: createHash("sha256").update(raw).digest("hex"),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
async function materializeRawEvidence(input) {
|
|
324
|
+
const refs = [];
|
|
325
|
+
const evidenceDir = path.join(input.runDir, "contracts", "lint-evidence");
|
|
326
|
+
await mkdir(evidenceDir, { recursive: true });
|
|
327
|
+
for (const [commandIndex, result] of input.results.entries()) {
|
|
328
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
329
|
+
const raw = await readResultStream(input.runDir, result, stream);
|
|
330
|
+
const relative = `contracts/lint-evidence/${input.phase}-${commandIndex}.${stream}.txt`;
|
|
331
|
+
await writeFile(path.join(input.runDir, relative), raw, "utf8");
|
|
332
|
+
refs.push({
|
|
333
|
+
path: relative,
|
|
334
|
+
sha256: createHash("sha256").update(raw).digest("hex"),
|
|
335
|
+
stream,
|
|
336
|
+
commandIndex,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return refs;
|
|
341
|
+
}
|
|
342
|
+
async function readResultStream(runDir, result, stream) {
|
|
343
|
+
const artifactPath = stream === "stdout"
|
|
344
|
+
? result.stdoutArtifactPath
|
|
345
|
+
: result.stderrArtifactPath;
|
|
346
|
+
if (artifactPath) {
|
|
347
|
+
const resolved = path.resolve(artifactPath);
|
|
348
|
+
const relative = path.relative(path.resolve(runDir), resolved);
|
|
349
|
+
if (relative !== "" &&
|
|
350
|
+
!relative.startsWith("..") &&
|
|
351
|
+
!path.isAbsolute(relative)) {
|
|
352
|
+
try {
|
|
353
|
+
return await readFile(resolved, "utf8");
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Fall through to the bounded executor value.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return result[stream];
|
|
361
|
+
}
|
|
362
|
+
async function parseCommandResults(input) {
|
|
363
|
+
const diagnostics = [];
|
|
364
|
+
const unparsedLines = [];
|
|
365
|
+
for (const result of input.results) {
|
|
366
|
+
const parsed = parseEslintDiagnostics({
|
|
367
|
+
workspaceRoot: input.workspaceRoot,
|
|
368
|
+
output: `${await readResultStream(input.runDir, result, "stdout")}\n${await readResultStream(input.runDir, result, "stderr")}`,
|
|
369
|
+
});
|
|
370
|
+
diagnostics.push(...parsed.diagnostics);
|
|
371
|
+
unparsedLines.push(...parsed.unparsedLines);
|
|
372
|
+
}
|
|
373
|
+
return { diagnostics, unparsedLines };
|
|
374
|
+
}
|
|
375
|
+
export async function materializeFrontendLintBaseline(input) {
|
|
376
|
+
const commandIdentity = createFrontendLintCommandIdentity(input.commands);
|
|
377
|
+
const parsed = await parseCommandResults(input);
|
|
378
|
+
const unusableResult = input.results.find((result) => result.timedOut ||
|
|
379
|
+
result.exitCode === null ||
|
|
380
|
+
result.failureCategory === "spawn-error");
|
|
381
|
+
let reason;
|
|
382
|
+
if (input.worktreeChanged)
|
|
383
|
+
reason = "lint baseline command modified the worktree";
|
|
384
|
+
else if (unusableResult)
|
|
385
|
+
reason = "lint baseline command timed out or could not execute";
|
|
386
|
+
else if (input.results.some((result) => result.exitCode !== 0) &&
|
|
387
|
+
(parsed.unparsedLines.length > 0 || parsed.diagnostics.length === 0)) {
|
|
388
|
+
reason = "lint baseline output cannot be parsed reliably";
|
|
389
|
+
}
|
|
390
|
+
const rawEvidenceRefs = await materializeRawEvidence({
|
|
391
|
+
runDir: input.runDir,
|
|
392
|
+
phase: "baseline",
|
|
393
|
+
results: input.results,
|
|
394
|
+
});
|
|
395
|
+
const artifact = frontendLintBaselineArtifactSchema.parse({
|
|
396
|
+
schemaVersion: 1,
|
|
397
|
+
schemaId: FRONTEND_LINT_BASELINE_SCHEMA_ID,
|
|
398
|
+
status: reason ? "unavailable" : "available",
|
|
399
|
+
...(reason ? { reason } : {}),
|
|
400
|
+
commandIdentity,
|
|
401
|
+
diagnostics: parsed.diagnostics,
|
|
402
|
+
commandResults: input.results.map((result) => ({
|
|
403
|
+
command: result.command,
|
|
404
|
+
exitCode: result.exitCode,
|
|
405
|
+
timedOut: result.timedOut,
|
|
406
|
+
failureCategory: result.failureCategory,
|
|
407
|
+
})),
|
|
408
|
+
rawEvidenceRefs,
|
|
409
|
+
});
|
|
410
|
+
const ref = await writeContract(input.runDir, "frontend-lint-baseline.json", artifact);
|
|
411
|
+
return { artifact, ref };
|
|
412
|
+
}
|
|
413
|
+
export async function materializeFrontendLintAssessment(input) {
|
|
414
|
+
const baselinePath = path.join(input.runDir, "contracts", "frontend-lint-baseline.json");
|
|
415
|
+
let baseline;
|
|
416
|
+
let baselineRef;
|
|
417
|
+
try {
|
|
418
|
+
const raw = await readFile(baselinePath, "utf8");
|
|
419
|
+
baseline = frontendLintBaselineArtifactSchema.parse(JSON.parse(raw));
|
|
420
|
+
baselineRef = {
|
|
421
|
+
path: "contracts/frontend-lint-baseline.json",
|
|
422
|
+
sha256: createHash("sha256").update(raw).digest("hex"),
|
|
423
|
+
nodeId: input.baselineNodeId,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
baseline = undefined;
|
|
428
|
+
}
|
|
429
|
+
let writerChangedFiles = [];
|
|
430
|
+
let manifestError;
|
|
431
|
+
try {
|
|
432
|
+
writerChangedFiles = await readWriterChangedFiles(input.runDir, input.writerNodeIds);
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
manifestError = error instanceof Error ? error.message : String(error);
|
|
436
|
+
}
|
|
437
|
+
const parsed = await parseCommandResults(input);
|
|
438
|
+
const rawEvidenceRefs = await materializeRawEvidence({
|
|
439
|
+
runDir: input.runDir,
|
|
440
|
+
phase: "current",
|
|
441
|
+
results: input.results,
|
|
442
|
+
});
|
|
443
|
+
const commandIdentity = createFrontendLintCommandIdentity(input.commands);
|
|
444
|
+
const nonzero = input.results.find((result) => result.exitCode !== 0);
|
|
445
|
+
const unusableResult = input.results.find((result) => result.timedOut ||
|
|
446
|
+
result.exitCode === null ||
|
|
447
|
+
result.failureCategory === "spawn-error");
|
|
448
|
+
let assessment = assessFrontendLint({
|
|
449
|
+
baseline,
|
|
450
|
+
baselineRef,
|
|
451
|
+
commandIdentity,
|
|
452
|
+
currentExitCode: unusableResult ? null : (nonzero?.exitCode ?? 0),
|
|
453
|
+
currentDiagnostics: parsed.diagnostics,
|
|
454
|
+
currentUnparsedLines: parsed.unparsedLines,
|
|
455
|
+
currentUnavailableReason: unusableResult
|
|
456
|
+
? "current lint command timed out or could not execute"
|
|
457
|
+
: undefined,
|
|
458
|
+
writerChangedFiles,
|
|
459
|
+
rawEvidenceRefs,
|
|
460
|
+
});
|
|
461
|
+
if (manifestError && assessment.status !== "passed") {
|
|
462
|
+
assessment = {
|
|
463
|
+
...assessment,
|
|
464
|
+
status: "unavailable",
|
|
465
|
+
blockingReasons: [manifestError],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
assessment = frontendLintAssessmentArtifactSchema.parse(assessment);
|
|
469
|
+
await writeContract(input.runDir, "frontend-lint-assessment.json", assessment);
|
|
470
|
+
return assessment;
|
|
471
|
+
}
|
|
@@ -1,34 +1,52 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
|
|
4
|
+
import { frontendImplementationContractSchema, materializeFrontendImplementationContract, assertFrontendSourceBindingFresh, } from "./frontend-implementation-contract.js";
|
|
5
|
+
import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
|
|
6
|
+
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
4
7
|
async function selectNode(runDir, primary, fallbacks) {
|
|
5
|
-
|
|
8
|
+
const candidates = [primary, ...fallbacks.filter((id) => id !== primary)];
|
|
9
|
+
const rejections = [];
|
|
10
|
+
for (const nodeId of candidates) {
|
|
6
11
|
try {
|
|
7
|
-
await
|
|
8
|
-
|
|
12
|
+
const raw = await readFile(path.join(runDir, `${nodeId}.json`), "utf8");
|
|
13
|
+
const record = JSON.parse(raw);
|
|
14
|
+
if (record.status === "FINISHED")
|
|
15
|
+
return nodeId;
|
|
16
|
+
rejections.push(`${nodeId}: status=${String(record.status)}`);
|
|
9
17
|
}
|
|
10
18
|
catch (error) {
|
|
11
|
-
if (error.code === "ENOENT")
|
|
19
|
+
if (error.code === "ENOENT") {
|
|
20
|
+
rejections.push(`${nodeId}: file missing`);
|
|
12
21
|
continue;
|
|
22
|
+
}
|
|
13
23
|
throw error;
|
|
14
24
|
}
|
|
15
25
|
}
|
|
16
|
-
throw new Error(`frontend prewrite gate
|
|
26
|
+
throw new Error(`frontend prewrite gate no FINISHED candidate: ${rejections.join("; ")}`);
|
|
17
27
|
}
|
|
18
28
|
async function readNodeText(runDir, nodeId) {
|
|
19
29
|
const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
|
|
30
|
+
if (record.status !== "FINISHED") {
|
|
31
|
+
throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
|
|
32
|
+
}
|
|
20
33
|
const text = record.assistantText?.trim() || record.stdout?.trim() || "";
|
|
21
34
|
if (!text)
|
|
22
35
|
throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
|
|
23
36
|
return text;
|
|
24
37
|
}
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
38
|
+
function firstNonEmptyVerdictLine(text) {
|
|
39
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
40
|
+
const normalize = (line) => {
|
|
41
|
+
const emphasized = line.match(/^(?:`{1,3}|\*{1,3})\s*(VERDICT:[^`*]+?)\s*(?:`{1,3}|\*{1,3})$/);
|
|
42
|
+
return (emphasized?.[1] ?? line).trim();
|
|
43
|
+
};
|
|
44
|
+
const normalizedLines = lines.map(normalize);
|
|
45
|
+
const first = normalizedLines[0] ?? "";
|
|
46
|
+
// The prompt requires VERDICT to be the first non-empty line, but model
|
|
47
|
+
// output can still prepend a summary. Keep the protocol strict in the
|
|
48
|
+
// prompt while making the deterministic gate resilient to that drift.
|
|
49
|
+
return normalizedLines.find((line) => /^VERDICT:/.test(line)) ?? first;
|
|
32
50
|
}
|
|
33
51
|
function eventArgs(event) {
|
|
34
52
|
return event.args ?? event.toolInput ?? event.input ?? {};
|
|
@@ -55,7 +73,7 @@ async function checkOpenspecReadEvidence(input) {
|
|
|
55
73
|
const matched = new Set();
|
|
56
74
|
const normalizedCandidates = new Set(candidatePaths
|
|
57
75
|
.map((candidate) => toRepoRelativePath(candidate, repoRoot))
|
|
58
|
-
.filter((candidate) => Boolean(candidate
|
|
76
|
+
.filter((candidate) => Boolean(candidate && isOpenspecSpecFilePath(candidate))));
|
|
59
77
|
for (const nodeId of [planNodeId, reviewNodeId]) {
|
|
60
78
|
const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
|
|
61
79
|
try {
|
|
@@ -105,11 +123,31 @@ async function checkOpenspecReadEvidence(input) {
|
|
|
105
123
|
return [...matched];
|
|
106
124
|
}
|
|
107
125
|
export async function runFrontendPrewriteGate(input) {
|
|
126
|
+
const workspaceRoot = input.workspaceRoot ?? input.repoRoot;
|
|
127
|
+
if (workspaceRoot) {
|
|
128
|
+
if (input.config.requireSourceFreshness) {
|
|
129
|
+
if (!input.sourceBinding)
|
|
130
|
+
throw new Error("frontend prewrite gate requires sourceBinding for freshness check");
|
|
131
|
+
await assertFrontendSourceBindingFresh({ workspaceRoot, binding: input.sourceBinding });
|
|
132
|
+
}
|
|
133
|
+
let hasGitMetadata = true;
|
|
134
|
+
try {
|
|
135
|
+
await stat(path.join(workspaceRoot, ".git"));
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error instanceof Error && error.message.includes("ENOENT"))
|
|
139
|
+
hasGitMetadata = false;
|
|
140
|
+
else
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
if (hasGitMetadata)
|
|
144
|
+
await captureFrontendWorktreeBaseline({ runDir: input.runDir, workspaceRoot });
|
|
145
|
+
}
|
|
108
146
|
const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
|
|
109
147
|
const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
|
|
110
148
|
const planText = await readNodeText(input.runDir, planNodeId);
|
|
111
149
|
const reviewText = await readNodeText(input.runDir, reviewNodeId);
|
|
112
|
-
const verdict =
|
|
150
|
+
const verdict = firstNonEmptyVerdictLine(reviewText);
|
|
113
151
|
if (verdict !== "VERDICT: pass") {
|
|
114
152
|
throw new Error(`frontend prewrite gate blocked by ${reviewNodeId}: ${verdict || "missing VERDICT"}`);
|
|
115
153
|
}
|
|
@@ -129,13 +167,38 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
129
167
|
if (!input.config.allowedMockStrategies.includes(contract.mockApi.strategy)) {
|
|
130
168
|
throw new Error(`frontend prewrite gate blocked mock strategy ${contract.mockApi.strategy}; allowed=${input.config.allowedMockStrategies.join(",")}`);
|
|
131
169
|
}
|
|
170
|
+
if (input.config.implementationWriteSet) {
|
|
171
|
+
const writeSet = new Set(input.config.implementationWriteSet);
|
|
172
|
+
const contractTargets = new Set(contract.targets.files);
|
|
173
|
+
const uncoveredTargets = [...contractTargets].filter((target) => ![...writeSet].some((pattern) => pathMatchesPattern(target, pattern) || target === pattern));
|
|
174
|
+
if (uncoveredTargets.length > 0) {
|
|
175
|
+
throw new Error(`frontend prewrite gate contract target is outside implementation writeSet: ${uncoveredTargets.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (contract.mockApi.strategy !== "not-needed") {
|
|
179
|
+
for (const endpoint of contract.mockApi.endpoints) {
|
|
180
|
+
for (const relative of [endpoint.fixture, endpoint.consumer]) {
|
|
181
|
+
if (!relative)
|
|
182
|
+
continue;
|
|
183
|
+
const absolute = path.resolve(workspaceRoot ?? ".", relative);
|
|
184
|
+
try {
|
|
185
|
+
const info = await stat(absolute);
|
|
186
|
+
if (!info.isFile())
|
|
187
|
+
throw new Error("not a regular file");
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
throw new Error(`frontend prewrite gate missing Mock file ${relative}: ${error instanceof Error ? error.message : String(error)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
132
195
|
const candidatePaths = input.config.openspecCandidatePaths ?? [];
|
|
133
196
|
const openspecReadPaths = await checkOpenspecReadEvidence({
|
|
134
197
|
runDir: input.runDir,
|
|
135
198
|
candidatePaths,
|
|
136
199
|
planNodeId,
|
|
137
200
|
reviewNodeId,
|
|
138
|
-
repoRoot:
|
|
201
|
+
repoRoot: workspaceRoot ?? process.cwd(),
|
|
139
202
|
});
|
|
140
203
|
if (candidatePaths.length > 0 && openspecReadPaths.length === 0) {
|
|
141
204
|
const checkedNodes = [planNodeId, reviewNodeId].join(", ");
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
|
+
import { OPENSPEC_SPEC_DIRS, OPENSPEC_SPEC_EXT_RE } from "../../shared/openspec-spec.js";
|
|
8
9
|
async function exists(filePath) {
|
|
9
10
|
try {
|
|
10
11
|
await stat(filePath);
|
|
@@ -33,10 +34,14 @@ function hasDep(deps, name) {
|
|
|
33
34
|
return Object.hasOwn(deps, name);
|
|
34
35
|
}
|
|
35
36
|
async function listOpenspec(repoRoot) {
|
|
36
|
-
const root = path.join(repoRoot, "openspec");
|
|
37
|
-
if (!(await exists(root)))
|
|
38
|
-
return [];
|
|
39
37
|
const out = [];
|
|
38
|
+
for (const specDir of OPENSPEC_SPEC_DIRS) {
|
|
39
|
+
const root = path.join(repoRoot, specDir);
|
|
40
|
+
if (!(await exists(root)))
|
|
41
|
+
continue;
|
|
42
|
+
await walk(root, 0);
|
|
43
|
+
}
|
|
44
|
+
return out.slice(0, 40);
|
|
40
45
|
async function walk(dir, depth) {
|
|
41
46
|
if (depth > 4)
|
|
42
47
|
return;
|
|
@@ -53,7 +58,7 @@ async function listOpenspec(repoRoot) {
|
|
|
53
58
|
const info = await stat(full);
|
|
54
59
|
if (info.isDirectory())
|
|
55
60
|
await walk(full, depth + 1);
|
|
56
|
-
else if (
|
|
61
|
+
else if (OPENSPEC_SPEC_EXT_RE.test(name)) {
|
|
57
62
|
out.push(path.relative(repoRoot, full).replace(/\\/g, "/"));
|
|
58
63
|
}
|
|
59
64
|
}
|
|
@@ -62,15 +67,13 @@ async function listOpenspec(repoRoot) {
|
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
}
|
|
65
|
-
await walk(root, 0);
|
|
66
|
-
return out.slice(0, 40);
|
|
67
70
|
}
|
|
68
71
|
export function buildAdapterGuidance(capability) {
|
|
69
72
|
const lines = [
|
|
70
73
|
"## Frontend project capability (generation-time)",
|
|
71
74
|
`Framework: ${capability.framework}${capability.frameworkVersion ? `@${capability.frameworkVersion}` : ""}`,
|
|
72
75
|
`Evidence: ${capability.evidencePaths.join(", ") || "(none)"}`,
|
|
73
|
-
"Rules: openspec
|
|
76
|
+
"Rules: openspec specs and task sources outrank adapter tips; do not invent APIs for unknown versions; lockfile-only is not enough.",
|
|
74
77
|
];
|
|
75
78
|
if (capability.framework === "react") {
|
|
76
79
|
lines.push("React adapter: prefer function components + hooks; reuse existing Testing Library / Vitest patterns; do not introduce new state libs without authorization.");
|
|
@@ -125,7 +128,7 @@ export async function discoverFrontendProjectCapability(repoRoot) {
|
|
|
125
128
|
},
|
|
126
129
|
};
|
|
127
130
|
if (openspec.length) {
|
|
128
|
-
base.reasons.push(`openspec
|
|
131
|
+
base.reasons.push(`openspec spec files discovered: ${openspec.slice(0, 5).join(", ")}`);
|
|
129
132
|
}
|
|
130
133
|
return { ...base, adapterGuidance: buildAdapterGuidance(base) };
|
|
131
134
|
}
|