@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/bin/agent-worker.js +0 -0
  3. package/dist/adapters/loop-agent.js +52 -0
  4. package/dist/commands/init.js +104 -0
  5. package/dist/executors/dag-pi-executor.js +26 -0
  6. package/dist/executors/pi-executor.js +111 -36
  7. package/dist/executors/pi-sdk-executor.js +105 -29
  8. package/dist/executors/shell-executor.js +215 -29
  9. package/dist/shared/openspec-spec.js +49 -0
  10. package/dist/worker/loop-agent/loop-agent-client.js +43 -9
  11. package/dist/worker/observability/read-model.js +28 -2
  12. package/dist/worker/observe/spec-evidence.js +12 -15
  13. package/dist/worker/observe/static/constants.js +5 -0
  14. package/dist/worker/observe/static/dag-helpers.js +22 -0
  15. package/dist/worker/observe/static/format-pool.js +22 -3
  16. package/dist/worker/observe/static/styles.css +32 -3
  17. package/dist/worker/observe/static/views/dag-inspector.js +2 -2
  18. package/dist/worker/observe/static/views/dag.js +5 -0
  19. package/dist/worker/run-task/run-task.js +16 -6
  20. package/dist/workflows/dag/backend-test-markdown-workflow.js +328 -97
  21. package/dist/workflows/dag/backend-test-result-contract.js +10 -4
  22. package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
  23. package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
  24. package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
  25. package/dist/workflows/dag/frontend-project-capability.js +11 -8
  26. package/dist/workflows/dag/frontend-repair.js +6 -4
  27. package/dist/workflows/dag/frontend-review-context.js +67 -0
  28. package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
  29. package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
  30. package/dist/workflows/dag/frontend-verification-trace.js +31 -1
  31. package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
  32. package/dist/workflows/dag/init-hybrid.js +370 -79
  33. package/dist/workflows/dag/lifecycle.js +60 -4
  34. package/dist/workflows/dag/liveness-policy.js +250 -0
  35. package/dist/workflows/dag/node-execution.js +49 -0
  36. package/dist/workflows/dag/runner.js +21 -1
  37. package/dist/workflows/dag/types.js +67 -1
  38. package/docs/README.md +5 -6
  39. package/docs/architecture/dag-execution.md +11 -0
  40. package/docs/architecture/facts-and-state.md +1 -0
  41. package/docs/architecture/worker-and-feature.md +10 -0
  42. package/docs/templates/agent-dag.schema.json +15 -5
  43. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
  44. package/docs/templates/backend-test-dag.json +15 -15
  45. package/docs/templates/frontend-implementation-contract.schema.json +4 -3
  46. package/docs/templates/frontend-test-case-checklist.md +6 -2
  47. package/docs/templates/frontend-test-dag.json +2 -2
  48. package/harness.json +1 -1
  49. package/package.json +1 -1
  50. package/skills/frontend-design-review/SKILL.md +12 -10
  51. package/skills/frontend-design-review/references/review-checklist.md +4 -4
  52. package/skills/frontend-implementation/SKILL.md +2 -2
  53. package/skills/frontend-implementation/references/code-standards.md +4 -3
  54. package/skills/frontend-implementation/references/design-spec.md +19 -14
  55. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  56. package/skills/frontend-review/SKILL.md +15 -28
  57. package/skills/frontend-review/references/review-findings.md +16 -18
  58. package/skills/frontend-verification/SKILL.md +16 -13
  59. package/skills/frontend-verification/references/verification-checklist.md +18 -30
  60. package/skills/loop-agent/references/command-reference.md +2 -0
  61. 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 { frontendImplementationContractSchema, materializeFrontendImplementationContract, } from "./frontend-implementation-contract.js";
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
- for (const nodeId of [primary, ...fallbacks.filter((id) => id !== primary)]) {
8
+ const candidates = [primary, ...fallbacks.filter((id) => id !== primary)];
9
+ const rejections = [];
10
+ for (const nodeId of candidates) {
6
11
  try {
7
- await stat(path.join(runDir, `${nodeId}.json`));
8
- return nodeId;
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 missing node output: ${[primary, ...fallbacks].join(", ")}`);
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 firstVerdictLine(text) {
26
- for (const raw of text.split(/\r?\n/)) {
27
- const line = raw.trim().replace(/^\*{1,3}\s*(VERDICT:[^*]+?)\s*\*{1,3}$/, "$1").trim();
28
- if (line.startsWith("VERDICT:"))
29
- return line;
30
- }
31
- return "";
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?.startsWith("openspec/"))));
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 = firstVerdictLine(reviewText);
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: input.repoRoot ?? process.cwd(),
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 (/\.(md|mdx|json)$/i.test(name)) {
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/** and task sources outrank adapter tips; do not invent APIs for unknown versions; lockfile-only is not enough.",
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/ files discovered: ${openspec.slice(0, 5).join(", ")}`);
131
+ base.reasons.push(`openspec spec files discovered: ${openspec.slice(0, 5).join(", ")}`);
129
132
  }
130
133
  return { ...base, adapterGuidance: buildAdapterGuidance(base) };
131
134
  }