@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
@@ -1,6 +1,6 @@
1
1
  import { appendFile, mkdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
3
+ import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
4
4
  import { serializeSessionEvent } from './pi-event-serializer.js';
5
5
  let sdkSessionFactoryOverride;
6
6
  let sdkImportOverrideForTests;
@@ -169,6 +169,21 @@ export function shouldPersistSessionEvent(event) {
169
169
  }
170
170
  return true;
171
171
  }
172
+ function classifySdkActivityKind(event) {
173
+ if (!event || typeof event !== 'object')
174
+ return 'provider';
175
+ const type = typeof event.type === 'string'
176
+ ? String(event.type)
177
+ : '';
178
+ if (type === 'tool_start' || type === 'tool_end'
179
+ || type === 'tool_execution_start' || type === 'tool_execution_end') {
180
+ return 'tool';
181
+ }
182
+ if (type === 'thinking_delta' || type === 'message_update') {
183
+ return null;
184
+ }
185
+ return 'provider';
186
+ }
172
187
  function createSessionEventAppender(filePath, onSessionEvent) {
173
188
  let chain = Promise.resolve();
174
189
  let dirEnsured = false;
@@ -181,6 +196,9 @@ function createSessionEventAppender(filePath, onSessionEvent) {
181
196
  dirEnsured = true;
182
197
  }
183
198
  await appendFile(filePath, `${line}\n`, 'utf-8');
199
+ // Only after successful persistence: surface the persisted session event.
200
+ // Transport activity is reported synchronously by the subscription so
201
+ // filtered deltas and slow disk writes cannot trip the stall watchdog.
184
202
  onSessionEvent?.(line, event);
185
203
  }
186
204
  catch {
@@ -271,6 +289,8 @@ export async function executeSingleSdkAttempt(options) {
271
289
  ? `${modelConfig.provider}/${modelConfig.model}`
272
290
  : modelConfig.model ?? 'default';
273
291
  const timeoutMs = options.timeoutMs ?? modelConfig.timeoutMs ?? DEFAULT_TIMEOUT_MS;
292
+ const stallTimeoutMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
293
+ const abortGraceMs = options.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS;
274
294
  const piSdkArgs = [
275
295
  '--provider', modelConfig.provider ?? '(default)',
276
296
  '--model', modelConfig.model ?? '(default)',
@@ -282,15 +302,46 @@ export async function executeSingleSdkAttempt(options) {
282
302
  ];
283
303
  const startedAt = Date.now();
284
304
  let timedOut = false;
305
+ let terminationConfirmed = true;
285
306
  let stderr = '';
286
307
  const stdoutPreview = new BoundedTextPreview('stdout');
287
308
  const stdoutCollector = createPiJsonlStreamCollector();
288
309
  const usageSamples = [];
289
310
  let session;
311
+ let unsubscribe;
290
312
  let timeoutHandle;
313
+ let stallHandle;
314
+ let disposeAttempted = false;
291
315
  const sessionEventAppender = options.sessionEventsPath
292
316
  ? createSessionEventAppender(options.sessionEventsPath, options.onSessionEvent)
293
317
  : undefined;
318
+ const appendStderr = (message) => {
319
+ stderr = stderr ? `${stderr}\n${message}` : message;
320
+ };
321
+ const runSessionActionWithGrace = async (label, action) => {
322
+ let graceHandle;
323
+ const outcome = await Promise.race([
324
+ Promise.resolve()
325
+ .then(action)
326
+ .then(() => ({ ok: true }))
327
+ .catch((error) => ({ ok: false, error })),
328
+ new Promise((resolve) => {
329
+ graceHandle = setTimeout(() => resolve({ ok: false, timedOut: true }), abortGraceMs);
330
+ }),
331
+ ]);
332
+ if (graceHandle)
333
+ clearTimeout(graceHandle);
334
+ if (outcome.ok)
335
+ return true;
336
+ if ('timedOut' in outcome) {
337
+ appendStderr(`pi SDK ${label} was not confirmed within ${abortGraceMs}ms`);
338
+ }
339
+ else {
340
+ const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
341
+ appendStderr(`pi SDK ${label} failed: ${message}`);
342
+ }
343
+ return false;
344
+ };
294
345
  try {
295
346
  const createSession = await resolveSdkSessionFactory(options.reuseScope);
296
347
  session = await createSession({
@@ -301,7 +352,32 @@ export async function executeSingleSdkAttempt(options) {
301
352
  model: modelConfig.model,
302
353
  thinking: modelConfig.thinking,
303
354
  });
304
- const unsubscribe = session.subscribe((event) => {
355
+ let resolveStall;
356
+ const stallPromise = stallTimeoutMs > 0
357
+ ? new Promise((resolve) => {
358
+ resolveStall = resolve;
359
+ })
360
+ : null;
361
+ const armStallWatchdog = () => {
362
+ if (!stallPromise || !resolveStall)
363
+ return;
364
+ if (stallHandle)
365
+ clearTimeout(stallHandle);
366
+ stallHandle = setTimeout(() => resolveStall?.('stall'), stallTimeoutMs);
367
+ };
368
+ unsubscribe = session.subscribe((event) => {
369
+ // Every real SDK event proves transport activity, including noisy deltas
370
+ // intentionally excluded from persisted JSONL and meaningful DAG progress.
371
+ armStallWatchdog();
372
+ const activityKind = classifySdkActivityKind(event);
373
+ if (activityKind) {
374
+ try {
375
+ options.onActivity?.({ kind: activityKind, at: new Date().toISOString() });
376
+ }
377
+ catch {
378
+ // best-effort: activity must never change Pi result
379
+ }
380
+ }
305
381
  const usageSample = extractSdkUsageSample(event);
306
382
  if (usageSample)
307
383
  usageSamples.push(usageSample);
@@ -317,44 +393,44 @@ export async function executeSingleSdkAttempt(options) {
317
393
  ? `${filePrefix}\n${options.userMessage}`
318
394
  : options.userMessage;
319
395
  const promptPromise = session.prompt(promptMessage);
396
+ armStallWatchdog();
320
397
  const timeoutPromise = timeoutMs > 0
321
398
  ? new Promise((resolve) => {
322
399
  timeoutHandle = setTimeout(() => {
323
- timedOut = true;
324
- void session?.abort();
325
- resolve('timeout');
400
+ resolve('absolute-timeout');
326
401
  }, timeoutMs);
327
402
  })
328
403
  : null;
329
- if (timeoutPromise) {
330
- const raced = await Promise.race([
331
- promptPromise.then(() => 'done'),
332
- timeoutPromise,
333
- ]);
334
- if (raced === 'timeout') {
335
- stderr = `pi SDK step timed out after ${timeoutMs}ms`;
336
- }
404
+ const raced = await Promise.race([
405
+ promptPromise.then(() => 'done'),
406
+ ...(timeoutPromise ? [timeoutPromise] : []),
407
+ ...(stallPromise ? [stallPromise] : []),
408
+ ]);
409
+ if (raced !== 'done') {
410
+ timedOut = true;
411
+ appendStderr(raced === 'stall'
412
+ ? `pi SDK step stalled after ${stallTimeoutMs}ms with no provider activity`
413
+ : `pi SDK step timed out after ${timeoutMs}ms`);
414
+ const abortConfirmed = await runSessionActionWithGrace('abort', () => session.abort());
415
+ disposeAttempted = true;
416
+ const disposeConfirmed = await runSessionActionWithGrace('dispose', () => session.dispose());
417
+ terminationConfirmed = abortConfirmed && disposeConfirmed;
337
418
  }
338
- else {
339
- await promptPromise;
340
- }
341
- unsubscribe();
342
419
  }
343
420
  catch (error) {
344
421
  const message = error instanceof Error ? error.message : String(error);
345
- stderr = stderr ? `${stderr}\n${message}` : message;
422
+ appendStderr(message);
346
423
  }
347
424
  finally {
348
425
  if (timeoutHandle)
349
426
  clearTimeout(timeoutHandle);
350
- if (session) {
351
- try {
352
- await session.dispose();
353
- }
354
- catch (disposeError) {
355
- const message = disposeError instanceof Error ? disposeError.message : String(disposeError);
356
- stderr = stderr ? `${stderr}\n${message}` : message;
357
- }
427
+ if (stallHandle)
428
+ clearTimeout(stallHandle);
429
+ unsubscribe?.();
430
+ if (session && !disposeAttempted) {
431
+ disposeAttempted = true;
432
+ const disposeConfirmed = await runSessionActionWithGrace('dispose', () => session.dispose());
433
+ terminationConfirmed = terminationConfirmed && disposeConfirmed;
358
434
  }
359
435
  if (sessionEventAppender) {
360
436
  try {
@@ -378,14 +454,14 @@ export async function executeSingleSdkAttempt(options) {
378
454
  ? collected.parsedEvents
379
455
  : fallbackParsed?.parsedEvents ?? 0;
380
456
  const tokensUsed = aggregateSdkTokenUsage(usageSamples);
381
- const failureCategory = classifyPiFailure({
457
+ const failureCategory = terminationConfirmed ? classifyPiFailure({
382
458
  assistantText,
383
459
  exitCode: timedOut ? 1 : stderr ? 1 : 0,
384
460
  outputTooLarge: collected.outputTooLarge,
385
461
  stderr,
386
462
  stdout,
387
463
  timedOut,
388
- });
464
+ }) : 'termination-unconfirmed';
389
465
  return {
390
466
  assistantText,
391
467
  backend: 'sdk',
@@ -394,7 +470,7 @@ export async function executeSingleSdkAttempt(options) {
394
470
  exitCode: timedOut || stderr ? 1 : 0,
395
471
  failureCategory,
396
472
  modelDisplay,
397
- ok: !timedOut && !stderr && assistantText.length > 0 && !collected.outputTooLarge,
473
+ ok: terminationConfirmed && !timedOut && !stderr && assistantText.length > 0 && !collected.outputTooLarge,
398
474
  parsedEvents,
399
475
  stderr,
400
476
  stdout,
@@ -14,10 +14,11 @@ import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "
14
14
  import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
15
15
  import { formatFrontendPrewriteGateStdout, runFrontendPrewriteGate, } from "../workflows/dag/frontend-prewrite-gate.js";
16
16
  import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from "../workflows/dag/frontend-review-context.js";
17
+ import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
17
18
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
18
19
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
19
20
  import { materializeBackendTestResultFromRunDir, parseJunitXml } from "../workflows/dag/backend-test-result-contract.js";
20
- import { collectBackendTestHumanCaseCatalog, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
21
+ import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
21
22
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
22
23
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
23
24
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
@@ -304,6 +305,14 @@ async function runShellWriteGuard(input) {
304
305
  forbiddenPaths: input.task.forbiddenPaths,
305
306
  });
306
307
  }
308
+ async function readRequiredRunReport(reportsDir, filename) {
309
+ try {
310
+ return await readFile(path.join(reportsDir, filename), "utf8");
311
+ }
312
+ catch {
313
+ throw new Error(`missing required upstream report: reports/${filename}`);
314
+ }
315
+ }
307
316
  async function executeBackendTestPipeline(input, meta) {
308
317
  const pipeline = input.task.shell?.backendTestPipeline;
309
318
  const started = Date.now();
@@ -332,22 +341,56 @@ async function executeBackendTestPipeline(input, meta) {
332
341
  meta.spec.objective ?? "",
333
342
  ...(meta.spec.successCriteria ?? []),
334
343
  ]);
335
- const report = await validateBackendMarkdownCases({
336
- workspaceRoot: input.cwd,
337
- environmentMarkdown,
338
- requiredRequirementIds,
339
- sourceBinding: meta.spec.sourceBinding,
340
- });
344
+ let report;
345
+ try {
346
+ report = await validateBackendMarkdownCases({
347
+ workspaceRoot: input.cwd,
348
+ environmentMarkdown,
349
+ requiredRequirementIds,
350
+ sourceBinding: meta.spec.sourceBinding,
351
+ });
352
+ }
353
+ catch (error) {
354
+ report = `# Backend Markdown Case Validation\n\n## Status\n\nFAIL\n\n## Findings\n\n- Validator could not complete: ${error instanceof Error ? error.message : String(error)}\n`;
355
+ }
341
356
  const reportPath = await writeRunReport(meta.runDir, "backend-md-case-validation.md", report);
342
357
  outputs.push(`caseValidation=${reportPath}`, report);
358
+ if (hasBlockingBackendMarkdownSafetyFindings(report)) {
359
+ return {
360
+ ok: false,
361
+ stdout: outputs.join("\n"),
362
+ stderr: "backend Markdown safety gate blocked: secret-shaped values detected",
363
+ failureCategory: "invalid-output",
364
+ durationMs: Date.now() - started,
365
+ };
366
+ }
343
367
  }
344
368
  else if (pipeline === "markdown-traceability") {
345
- const report = await validateBackendMarkdownTraceability(input.cwd);
369
+ let report;
370
+ try {
371
+ report = await validateBackendMarkdownTraceability(input.cwd);
372
+ }
373
+ catch (error) {
374
+ report = `# Backend Test Traceability\n\n## Status\n\nFAIL\n\n## Findings\n\n- Traceability validation could not complete: ${error instanceof Error ? error.message : String(error)}\n`;
375
+ }
346
376
  const reportPath = await writeRunReport(meta.runDir, "backend-test-traceability.md", report);
347
377
  outputs.push(`traceability=${reportPath}`, report);
348
378
  }
349
379
  else if (pipeline === "markdown-execute-html") {
350
- const results = await executePipelineCommands(input, meta);
380
+ const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
381
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
382
+ const pytestTargets = mappedScripts.map(shellQuote).join(" ");
383
+ const pytestCommand = [
384
+ 'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
385
+ 'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
386
+ 'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" >&2; exit 127; fi',
387
+ `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 "\${PYTHON_BIN}" -m pytest ${pytestTargets} -v -p no:cacheprovider -o junit_logging=all -o junit_log_passing_tests=true --junitxml="\${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml"`,
388
+ "STATUS=$?",
389
+ 'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
390
+ 'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml" ]; then exit 0; fi',
391
+ 'exit "${STATUS}"',
392
+ ].join("; ");
393
+ const results = await executePipelineCommands(input, meta, [pytestCommand]);
351
394
  if (!results.every((result) => result.ok)) {
352
395
  const failure = results.find((result) => !result.ok);
353
396
  return { ok: false, stdout: results.map((result) => result.stdout).join("\n"), stderr: failure.stderr, failureCategory: failure.failureCategory, durationMs: Date.now() - started };
@@ -359,18 +402,22 @@ async function executeBackendTestPipeline(input, meta) {
359
402
  throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
360
403
  const parsed = parseJunitXml(junitContent);
361
404
  const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
405
+ const caseValidationSummary = await readRequiredRunReport(reportsDir, "backend-md-case-validation.md");
406
+ const traceabilitySummary = await readRequiredRunReport(reportsDir, "backend-test-traceability.md");
362
407
  const htmlContent = renderBackendTestHtml({
363
408
  title: meta.spec.title,
364
409
  parsed,
365
410
  cases,
366
411
  environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8"),
367
- traceabilitySummary: await readFile(path.join(reportsDir, "backend-test-traceability.md"), "utf8"),
412
+ caseValidationSummary,
413
+ traceabilitySummary,
368
414
  });
369
415
  const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
370
- const facts = renderBackendTestFacts({ parsed, cases, pytestExitCode, junitRelativePath: "reports/backend-test.junit.xml", htmlRelativePath: "reports/backend-test.html", junitContent, htmlContent });
416
+ const facts = renderBackendTestFacts({ parsed, cases, pytestExitCode, junitRelativePath: "reports/backend-test.junit.xml", htmlRelativePath: "reports/backend-test.html", junitContent, htmlContent, caseValidationSummary, traceabilitySummary });
417
+ const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
371
418
  const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
372
419
  const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
373
- outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `facts=${factsPath}`, facts);
420
+ outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, facts);
374
421
  }
375
422
  else if (pipeline === "contracts") {
376
423
  const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
@@ -618,13 +665,46 @@ async function executeFrontendVerificationBundle(input, meta) {
618
665
  catch {
619
666
  beforeStatus = undefined;
620
667
  }
668
+ const lintResults = [];
669
+ for (const command of bundle.lintCommands ?? []) {
670
+ const commandNumber = results.length + 1;
671
+ const result = await executeShellCommand({
672
+ command,
673
+ cwd,
674
+ timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
675
+ envAllowlist: shell.envAllowlist,
676
+ dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
677
+ outputArtifacts: {
678
+ stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
679
+ stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
680
+ },
681
+ });
682
+ lintResults.push(result);
683
+ results.push(result);
684
+ }
685
+ let lintAssessment;
686
+ if ((bundle.lintCommands?.length ?? 0) > 0 &&
687
+ bundle.lintBaselineNodeId &&
688
+ (bundle.writerNodeIds?.length ?? 0) > 0) {
689
+ lintAssessment = await materializeFrontendLintAssessment({
690
+ runDir: meta.runDir,
691
+ workspaceRoot: input.cwd,
692
+ commands: bundle.lintCommands,
693
+ results: lintResults,
694
+ baselineNodeId: bundle.lintBaselineNodeId,
695
+ writerNodeIds: bundle.writerNodeIds,
696
+ });
697
+ }
698
+ const lintBlocked = lintAssessment?.status === "failed" ||
699
+ lintAssessment?.status === "unavailable";
621
700
  const groups = [
622
- { name: "mock", commands: bundle.mockCommands },
623
- { name: "static", commands: bundle.staticCommands },
624
- { name: "behavior", commands: bundle.behaviorCommands },
701
+ { name: "mock", commands: bundle.mockCommands, labels: bundle.mockEvidence?.commandLabels ?? [] },
702
+ { name: "static", commands: bundle.staticCommands, labels: bundle.staticEvidence.commandLabels },
703
+ { name: "behavior", commands: bundle.behaviorCommands, labels: bundle.behaviorEvidence.commandLabels },
625
704
  ];
626
- for (const group of groups) {
627
- for (const command of group.commands) {
705
+ const successfulLabels = new Map();
706
+ for (const group of lintBlocked ? [] : groups) {
707
+ for (const [index, command] of group.commands.entries()) {
628
708
  const commandNumber = results.length + 1;
629
709
  const result = await executeShellCommand({
630
710
  command,
@@ -638,6 +718,11 @@ async function executeFrontendVerificationBundle(input, meta) {
638
718
  },
639
719
  });
640
720
  results.push(result);
721
+ if (result.ok && group.labels[index]) {
722
+ const labels = successfulLabels.get(group.name) ?? [];
723
+ labels.push(group.labels[index]);
724
+ successfulLabels.set(group.name, labels);
725
+ }
641
726
  if (!result.ok)
642
727
  break;
643
728
  }
@@ -670,20 +755,36 @@ async function executeFrontendVerificationBundle(input, meta) {
670
755
  failureCategory: result.failureCategory,
671
756
  command: result.command,
672
757
  }));
673
- const firstFailure = results.find((result) => !result.ok);
758
+ const firstFailure = lintBlocked
759
+ ? lintResults.find((result) => !result.ok)
760
+ : results
761
+ .filter((result) => !lintResults.includes(result))
762
+ .find((result) => !result.ok);
763
+ const lintSyntheticFailure = lintBlocked && !firstFailure
764
+ ? {
765
+ failureCategory: "invalid-output",
766
+ stderr: lintAssessment?.blockingReasons.join("; ") ??
767
+ "frontend lint assessment failed",
768
+ }
769
+ : undefined;
674
770
  let traceError;
675
771
  try {
676
772
  await runFrontendVerificationTraceGate({
677
773
  runDir: meta.runDir,
678
774
  workspaceRoot: input.cwd,
679
775
  evidence: {
776
+ mock: {
777
+ nodeId: input.task.id,
778
+ commandLabels: successfulLabels.get("mock") ?? [],
779
+ commandTexts: bundle.mockCommands.slice(0, successfulLabels.get("mock")?.length ?? 0),
780
+ },
680
781
  static: {
681
782
  nodeId: input.task.id,
682
- commandLabels: bundle.staticEvidence.commandLabels,
783
+ commandLabels: successfulLabels.get("static") ?? [],
683
784
  },
684
785
  behavior: {
685
786
  nodeId: input.task.id,
686
- commandLabels: bundle.behaviorEvidence.commandLabels,
787
+ commandLabels: successfulLabels.get("behavior") ?? [],
687
788
  },
688
789
  },
689
790
  });
@@ -692,19 +793,26 @@ async function executeFrontendVerificationBundle(input, meta) {
692
793
  traceError = error instanceof Error ? error : new Error(String(error));
693
794
  }
694
795
  if (bundle.mode === "repair") {
695
- if (firstFailure || traceError) {
796
+ if (firstFailure || lintSyntheticFailure || traceError) {
696
797
  return {
697
798
  ok: false,
698
799
  stdout: summarizeCommandResults(results).stdout,
699
- stderr: firstFailure?.stderr || traceError?.message || "frontend reverify failed",
700
- failureCategory: firstFailure?.failureCategory ?? "invalid-output",
800
+ stderr: firstFailure?.stderr ||
801
+ lintSyntheticFailure?.stderr ||
802
+ traceError?.message ||
803
+ "frontend reverify failed",
804
+ failureCategory: firstFailure?.failureCategory ??
805
+ lintSyntheticFailure?.failureCategory ??
806
+ "invalid-output",
701
807
  durationMs: Date.now() - started,
702
808
  ...{ commandResults },
703
809
  };
704
810
  }
705
811
  return {
706
812
  ok: true,
707
- stdout: "Frontend reverify bundle: pass",
813
+ stdout: lintAssessment?.status === "baseline-debt"
814
+ ? "Frontend reverify bundle: pass with lint baseline-debt"
815
+ : "Frontend reverify bundle: pass",
708
816
  stderr: "",
709
817
  failureCategory: "success",
710
818
  durationMs: Date.now() - started,
@@ -712,14 +820,22 @@ async function executeFrontendVerificationBundle(input, meta) {
712
820
  };
713
821
  }
714
822
  const failureFacts = [];
715
- if (firstFailure) {
823
+ if (firstFailure || lintSyntheticFailure) {
824
+ const failureStdout = firstFailure
825
+ ? [firstFailure.command, firstFailure.stdout]
826
+ .filter(Boolean)
827
+ .join("\n")
828
+ : "";
716
829
  failureFacts.push({
717
830
  nodeId: input.task.id,
718
831
  record: {
719
832
  status: "FINISHED",
720
- failureCategory: firstFailure.failureCategory,
721
- stdout: summarizeCommandResults(results).stdout,
722
- stderr: firstFailure.stderr,
833
+ failureCategory: firstFailure?.failureCategory ??
834
+ lintSyntheticFailure?.failureCategory,
835
+ // Keep classification scoped to the failed command. Aggregate
836
+ // successful output may contain unrelated writeSet-like JSON.
837
+ stdout: failureStdout,
838
+ stderr: firstFailure?.stderr ?? lintSyntheticFailure?.stderr,
723
839
  commandResults,
724
840
  },
725
841
  });
@@ -760,6 +876,68 @@ async function executeFrontendVerificationBundle(input, meta) {
760
876
  };
761
877
  }
762
878
  }
879
+ async function executeFrontendLintBaseline(input, meta) {
880
+ const started = Date.now();
881
+ const shell = input.task.shell;
882
+ const baseline = shell.frontendLintBaseline;
883
+ const cwd = resolveShellCwd(input.cwd, shell.cwd);
884
+ let beforeStatus;
885
+ try {
886
+ beforeStatus = await readGitStatusPorcelain(input.cwd);
887
+ }
888
+ catch {
889
+ beforeStatus = undefined;
890
+ }
891
+ const results = [];
892
+ for (const command of baseline.lintCommands) {
893
+ const commandNumber = results.length + 1;
894
+ results.push(await executeShellCommand({
895
+ command,
896
+ cwd,
897
+ timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
898
+ envAllowlist: shell.envAllowlist,
899
+ dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
900
+ outputArtifacts: {
901
+ stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
902
+ stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
903
+ },
904
+ }));
905
+ }
906
+ let afterStatus;
907
+ try {
908
+ afterStatus = await readGitStatusPorcelain(input.cwd);
909
+ }
910
+ catch {
911
+ afterStatus = undefined;
912
+ }
913
+ try {
914
+ const materialized = await materializeFrontendLintBaseline({
915
+ runDir: meta.runDir,
916
+ workspaceRoot: input.cwd,
917
+ commands: baseline.lintCommands,
918
+ results,
919
+ worktreeChanged: beforeStatus === undefined ||
920
+ afterStatus === undefined ||
921
+ beforeStatus !== afterStatus,
922
+ });
923
+ return {
924
+ ok: true,
925
+ stdout: `Frontend lint baseline: ${materialized.artifact.status}\nArtifact: ${materialized.ref.path}\nDiagnostics: ${materialized.artifact.diagnostics.length}`,
926
+ stderr: materialized.artifact.reason ?? "",
927
+ failureCategory: "success",
928
+ durationMs: Date.now() - started,
929
+ };
930
+ }
931
+ catch (error) {
932
+ return {
933
+ ok: false,
934
+ stdout: "",
935
+ stderr: error instanceof Error ? error.message : String(error),
936
+ failureCategory: "invalid-output",
937
+ durationMs: Date.now() - started,
938
+ };
939
+ }
940
+ }
763
941
  export async function executeDagShellNode(input, meta) {
764
942
  const shell = input.task.shell;
765
943
  if (shell?.frontendPrewriteGate) {
@@ -769,6 +947,7 @@ export async function executeDagShellNode(input, meta) {
769
947
  runDir: meta.runDir,
770
948
  config: shell.frontendPrewriteGate,
771
949
  sourceBinding: meta.spec.sourceBinding,
950
+ workspaceRoot: input.cwd,
772
951
  repoRoot: input.cwd,
773
952
  });
774
953
  return { ok: true, stdout: formatFrontendPrewriteGateStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
@@ -777,13 +956,20 @@ export async function executeDagShellNode(input, meta) {
777
956
  return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
778
957
  }
779
958
  }
959
+ if (shell?.frontendLintBaseline) {
960
+ return executeFrontendLintBaseline(input, meta);
961
+ }
780
962
  if (shell?.frontendVerificationBundle) {
781
963
  return executeFrontendVerificationBundle(input, meta);
782
964
  }
783
965
  if (shell?.frontendReviewContext) {
784
966
  const started = Date.now();
785
967
  try {
786
- const result = await runFrontendReviewContextGate({ runDir: meta.runDir, workspaceRoot: input.cwd });
968
+ const result = await runFrontendReviewContextGate({
969
+ runDir: meta.runDir,
970
+ workspaceRoot: input.cwd,
971
+ requireBaseline: shell.frontendReviewContext.requireBaseline,
972
+ });
787
973
  return { ok: true, stdout: formatFrontendReviewContextStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
788
974
  }
789
975
  catch (error) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared openspec specification path predicates.
3
+ *
4
+ * Canonical frontend specification directories (case-sensitive) and extension
5
+ * allowlist.
6
+ * All consumers in candidate discovery, DAG schema, prewrite gate, and observe
7
+ * must use these predicates to ensure a single consistent scope definition.
8
+ */
9
+ /** Canonical spec directories (case-sensitive, POSIX form). */
10
+ export const OPENSPEC_SPEC_DIRS = [
11
+ "openspec/schemas",
12
+ "openspec/project-specs",
13
+ "ai_workspace",
14
+ ];
15
+ /** Allowed spec file extensions (case-insensitive). */
16
+ export const OPENSPEC_SPEC_EXT_RE = /\.(md|mdx|json|yaml|yml)$/i;
17
+ /**
18
+ * Returns `true` when `filePath` is inside one of the canonical frontend spec
19
+ * directories. `filePath` must be a repo-relative POSIX path (backslashes are
20
+ * normalised internally). Directory names are case-sensitive.
21
+ */
22
+ export function isOpenspecSpecPath(filePath) {
23
+ const normalized = filePath.replaceAll("\\", "/");
24
+ for (const dir of OPENSPEC_SPEC_DIRS) {
25
+ if (normalized === dir || normalized.startsWith(dir + "/"))
26
+ return true;
27
+ }
28
+ return false;
29
+ }
30
+ /**
31
+ * Returns `true` when `filename` (the basename only, not a full path) has an
32
+ * allowed frontend specification extension.
33
+ */
34
+ export function isValidOpenspecExtension(filename) {
35
+ return OPENSPEC_SPEC_EXT_RE.test(filename);
36
+ }
37
+ /** Returns `true` for a supported file inside a canonical openspec spec dir. */
38
+ export function isOpenspecSpecFilePath(filePath) {
39
+ return (isOpenspecSpecPath(filePath) &&
40
+ isValidOpenspecExtension(filePath.replaceAll("\\", "/")));
41
+ }
42
+ /**
43
+ * Returns `true` when free-form search input references a canonical frontend
44
+ * specification directory with exact lowercase directory names.
45
+ */
46
+ export function isOpenspecSpecSearchTarget(value) {
47
+ const normalized = value.replaceAll("\\", "/");
48
+ return /(?:^|[^A-Za-z0-9_.-])(?:openspec\/(?:schemas|project-specs)|ai_workspace)(?:\/|$)/.test(normalized);
49
+ }