@tea-agent/loop-agent 0.21.0 → 0.23.1

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 (108) hide show
  1. package/AGENTS.md +42 -108
  2. package/CHANGELOG.md +114 -0
  3. package/README.md +7 -4
  4. package/bin/agent-worker.js +0 -0
  5. package/dist/adapters/loop-agent.js +52 -0
  6. package/dist/application/context-usage/skill-resolution-stats.js +263 -0
  7. package/dist/application/dag/generate-task-dag.js +17 -3
  8. package/dist/cli/command-definitions.js +8 -7
  9. package/dist/cli/program.js +17 -15
  10. package/dist/commands/doctor.js +269 -18
  11. package/dist/commands/init.js +198 -86
  12. package/dist/commands/stats.js +40 -11
  13. package/dist/executors/dag-pi-executor.js +2 -0
  14. package/dist/executors/shell-executor.js +162 -19
  15. package/dist/shared/openspec-spec.js +49 -0
  16. package/dist/shared/operator/capabilities.js +11 -1
  17. package/dist/worker/console/app-data.js +4 -0
  18. package/dist/worker/console/chat/instruction-skills.js +217 -0
  19. package/dist/worker/console/chat/model-resolver.js +106 -0
  20. package/dist/worker/console/chat/pi-runtime.js +605 -0
  21. package/dist/worker/console/chat/resource-loader.js +66 -0
  22. package/dist/worker/console/chat/routes.js +357 -0
  23. package/dist/worker/console/chat/session-store.js +238 -0
  24. package/dist/worker/console/chat/tool-adapter.js +238 -0
  25. package/dist/worker/console/chat/tools.js +171 -0
  26. package/dist/worker/console/server.js +55 -0
  27. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +25 -0
  28. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +1 -0
  29. package/dist/worker/console/static/index.html +2 -2
  30. package/dist/worker/feature/profile-schema.js +1 -1
  31. package/dist/worker/observability/read-model.js +21 -1
  32. package/dist/worker/observe/spec-evidence.js +12 -15
  33. package/dist/worker/observe/static/dag-helpers.js +22 -0
  34. package/dist/worker/observe/static/views/dag.js +5 -0
  35. package/dist/workflows/dag/backend-test-markdown-workflow.js +37 -0
  36. package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
  37. package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
  38. package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
  39. package/dist/workflows/dag/frontend-project-capability.js +11 -8
  40. package/dist/workflows/dag/frontend-repair.js +6 -4
  41. package/dist/workflows/dag/frontend-review-context.js +67 -0
  42. package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
  43. package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
  44. package/dist/workflows/dag/frontend-verification-trace.js +31 -1
  45. package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
  46. package/dist/workflows/dag/init-hybrid.js +412 -84
  47. package/dist/workflows/dag/node-execution.js +38 -1
  48. package/dist/workflows/dag/output-protocol.js +89 -0
  49. package/dist/workflows/dag/prompt.js +35 -1
  50. package/dist/workflows/dag/recovery-recommendation.js +45 -0
  51. package/dist/workflows/dag/report.js +28 -1
  52. package/dist/workflows/dag/rerun-task.js +1 -1
  53. package/dist/workflows/dag/scheduler.js +9 -0
  54. package/dist/workflows/dag/types.js +74 -1
  55. package/dist/workflows/dag/validate.js +55 -0
  56. package/docs/README.md +73 -156
  57. package/docs/architecture/README.md +3 -2
  58. package/docs/architecture/dag-execution.md +2 -2
  59. package/docs/architecture/evolution.md +14 -12
  60. package/docs/architecture/system-overview.md +1 -1
  61. package/docs/architecture/worker-and-feature.md +3 -3
  62. package/docs/governance/README.md +15 -0
  63. package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
  64. package/docs/init-surface.manifest.json +22 -4
  65. package/docs/operations/README.md +12 -0
  66. package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
  67. package/docs/skills/vetted-skill-registry.md +23 -3
  68. package/docs/templates/README.md +55 -0
  69. package/docs/templates/agent-dag.schema.json +15 -5
  70. package/docs/templates/backend-test-dag.json +1 -1
  71. package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
  72. package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
  73. package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
  74. package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
  75. package/docs/templates/frontend-implementation-contract.schema.json +4 -3
  76. package/docs/templates/frontend-test-case-checklist.md +6 -2
  77. package/docs/templates/frontend-test-dag.json +2 -2
  78. package/docs/templates/hybrid-dag.json +1 -1
  79. package/docs/templates/progress-log.md +9 -2
  80. package/harness.json +5 -5
  81. package/package.json +5 -5
  82. package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
  83. package/skills/agent-worker/SKILL.md +1 -1
  84. package/skills/frontend-design-review/SKILL.md +12 -10
  85. package/skills/frontend-design-review/references/review-checklist.md +4 -4
  86. package/skills/frontend-implementation/SKILL.md +2 -2
  87. package/skills/frontend-implementation/references/code-standards.md +4 -3
  88. package/skills/frontend-implementation/references/design-spec.md +19 -14
  89. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  90. package/skills/frontend-review/SKILL.md +15 -28
  91. package/skills/frontend-review/references/review-findings.md +16 -18
  92. package/skills/frontend-verification/SKILL.md +16 -13
  93. package/skills/frontend-verification/references/verification-checklist.md +18 -30
  94. package/skills/grill-with-docs/SKILL.md +44 -52
  95. package/skills/grill-with-docs/adr-format.md +37 -26
  96. package/skills/grill-with-docs/context-format.md +18 -26
  97. package/skills/loop-agent/SKILL.md +28 -112
  98. package/skills/loop-agent/references/command-reference.md +9 -3
  99. package/skills/loop-agent/references/harness-policy.md +3 -3
  100. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  101. package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
  102. package/skills/loop-agent/references/task-workflow.md +2 -0
  103. package/skills/systematic-debugging/SKILL.md +20 -4
  104. package/skills/test-driven-development/SKILL.md +10 -3
  105. package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
  106. package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
  107. /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
  108. /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
@@ -9,7 +9,7 @@ import { buildDagNodePromptEnvelope } from "./prompt.js";
9
9
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
10
10
  import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
11
11
  import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
12
- import { buildProtocolRetryInstruction, validateOutputProtocol, } from "./output-protocol.js";
12
+ import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, validateOutputProtocol, } from "./output-protocol.js";
13
13
  import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
14
14
  import { buildProjectGovernanceContext, readCompletedWriterChangeManifests, writeProjectGovernanceContext, } from "./project-governance-context.js";
15
15
  import { assertSkillSnapshotCoversSpec, buildNodePromptFromSnapshot, isDagSkillSnapshotIntegrityError, readSkillSnapshot, } from "./skill-snapshot.js";
@@ -483,6 +483,43 @@ export async function executeDagNode(input) {
483
483
  await input.persistState();
484
484
  }
485
485
  }
486
+ // AC2: after retries are exhausted on a canonical review verdict protocol
487
+ // node, attempt one deterministic normalization of the full terminal output.
488
+ // This ONLY re-wraps output so the first non-empty line is the single
489
+ // supported verdict; it never changes the verdict value, and missing /
490
+ // duplicated / conflicting / illegal verdicts remain protocol-invalid.
491
+ // Shell, write-guard, timeout, and model failures are never normalized.
492
+ if (terminalResult &&
493
+ !terminalResult.ok &&
494
+ terminalResult.failureCategory === "protocol-invalid" &&
495
+ task.outputProtocol) {
496
+ const fullText = terminalResult.assistantText && terminalResult.assistantText.trim().length > 0
497
+ ? terminalResult.assistantText
498
+ : terminalResult.stdout ?? "";
499
+ const normalized = normalizeReviewVerdictAfterRetries(task.outputProtocol, fullText);
500
+ if (normalized.ok) {
501
+ const normalizedText = normalized.normalizedOutput;
502
+ terminalResult = {
503
+ ...terminalResult,
504
+ ok: true,
505
+ failureCategory: undefined,
506
+ assistantText: normalizedText,
507
+ stdout: normalizedText,
508
+ stderr: [
509
+ terminalResult.stderr,
510
+ `verdict normalized after retries exhausted: ${normalized.verdictLine}`,
511
+ ]
512
+ .filter(Boolean)
513
+ .join("\n"),
514
+ };
515
+ // Sync the observable node fields so downstream gates and reports see the
516
+ // normalized first-line verdict while the original evidence is preserved.
517
+ node.assistantText = normalizedText;
518
+ node.stdout = normalizedText;
519
+ node.failureCategory = undefined;
520
+ node.stderr = terminalResult.stderr;
521
+ }
522
+ }
486
523
  const result = terminalResult;
487
524
  const started = Date.now();
488
525
  try {
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { normalizeVerdictCandidateLine } from "./dynamic-runtime/shared.js";
2
3
  /**
3
4
  * Machine-readable output protocol for safe read-only Pi nodes.
4
5
  *
@@ -74,3 +75,91 @@ export function buildProtocolRetryInstruction(protocol, reason) {
74
75
  "</retry_instruction>",
75
76
  ].join("\n");
76
77
  }
78
+ /**
79
+ * AC2: deterministic review-verdict normalization applied ONLY after retries
80
+ * are exhausted on a review verdict protocol node.
81
+ *
82
+ * The canonical review verdict protocol accepts exactly one of
83
+ * {@link REVIEW_VERDICT_OUTPUT_PROTOCOL}.validLines. This helper scans the
84
+ * FULL output (assistantText + stdout style text) for verdict lines using the
85
+ * same whole-line Markdown-emphasis normalization the verdict gate uses.
86
+ *
87
+ * Normalization succeeds if and only if the full output contains exactly one
88
+ * supported, unambiguous verdict line. It produces a normalized output whose
89
+ * first non-empty line is that verdict, preserving the original evidence text
90
+ * afterwards. It never changes the verdict value.
91
+ *
92
+ * It fails (returns ok:false) for:
93
+ * - no verdict line anywhere;
94
+ * - multiple verdict lines, even if identical (duplicated protocol ambiguity);
95
+ * - conflicting verdict lines;
96
+ * - any unsupported/illegal verdict value (e.g. `VERDICT: approve`);
97
+ * - malformed lines that are not a supported verdict after normalization;
98
+ * - natural-language approval prose without a real verdict token.
99
+ *
100
+ * This helper only operates on the canonical review verdict protocol; any
101
+ * other protocol is left untouched (ok:false) so decision envelopes, shell
102
+ * output, and structured protocols are never loosely tolerated.
103
+ */
104
+ export function normalizeReviewVerdictAfterRetries(protocol, text) {
105
+ const isReviewVerdictProtocol = protocol.type === REVIEW_VERDICT_OUTPUT_PROTOCOL.type &&
106
+ Array.from(protocol.validLines).every((line, index) => line === REVIEW_VERDICT_OUTPUT_PROTOCOL.validLines[index]) &&
107
+ protocol.validLines.length ===
108
+ REVIEW_VERDICT_OUTPUT_PROTOCOL.validLines.length;
109
+ if (!isReviewVerdictProtocol) {
110
+ return {
111
+ ok: false,
112
+ reason: "deterministic verdict normalization only applies to the canonical review verdict protocol",
113
+ };
114
+ }
115
+ const supported = new Set(REVIEW_VERDICT_OUTPUT_PROTOCOL.validLines);
116
+ const candidates = [];
117
+ let seenIllegalVerdictPrefixedLine = false;
118
+ for (const rawLine of String(text).split(/\r?\n/)) {
119
+ const normalized = normalizeVerdictCandidateLine(rawLine);
120
+ if (!normalized)
121
+ continue;
122
+ // Detect any line that looks like a verdict token (VERDICT: ...) but is
123
+ // not a supported value, so a single valid + single illegal line cannot
124
+ // be silently normalized to the valid one.
125
+ if (/^VERDICT:/.test(normalized) && !supported.has(normalized)) {
126
+ seenIllegalVerdictPrefixedLine = true;
127
+ continue;
128
+ }
129
+ if (supported.has(normalized)) {
130
+ candidates.push(normalized);
131
+ }
132
+ }
133
+ if (seenIllegalVerdictPrefixedLine) {
134
+ return {
135
+ ok: false,
136
+ reason: "output contains an illegal or unsupported VERDICT value; cannot normalize",
137
+ };
138
+ }
139
+ if (candidates.length === 0) {
140
+ return {
141
+ ok: false,
142
+ reason: "no supported verdict line found in full output; cannot normalize",
143
+ };
144
+ }
145
+ const unique = new Set(candidates);
146
+ if (unique.size !== 1 || candidates.length !== 1) {
147
+ return {
148
+ ok: false,
149
+ reason: "output contains multiple or conflicting verdict lines; normalization requires exactly one supported verdict",
150
+ };
151
+ }
152
+ const verdictLine = candidates[0];
153
+ const preservedEvidence = String(text)
154
+ .split(/\r?\n/)
155
+ .filter((line) => {
156
+ const normalized = normalizeVerdictCandidateLine(line);
157
+ return !normalized || !supported.has(normalized);
158
+ })
159
+ .join("\n")
160
+ .trim();
161
+ const normalizedOutput = preservedEvidence
162
+ ? `${verdictLine}\n${preservedEvidence}`
163
+ : verdictLine;
164
+ return { ok: true, verdictLine, normalizedOutput };
165
+ }
@@ -58,13 +58,47 @@ export function formatOutputLanguageBlock(language = DEFAULT_DAG_OUTPUT_LANGUAGE
58
58
  }
59
59
  export function buildUpstreamContext(task, upstream, maxChars = MAX_UPSTREAM_CHARS) {
60
60
  const sections = [];
61
+ const toleratedErrors = new Set(task.failureAwareDependsOn ?? []);
61
62
  for (const depId of task.depends_on) {
62
63
  const record = upstream[depId];
63
64
  const stdout = record?.stdout?.trim() ? record.stdout : "";
64
65
  const assistantText = record?.assistantText?.trim()
65
66
  ? record.assistantText
66
67
  : "";
67
- const upstreamText = stdout || assistantText;
68
+ const stderr = record?.stderr?.trim() ? record.stderr : "";
69
+ const upstreamText = stdout || assistantText || stderr;
70
+ // AC1: a failure-aware recovery/diagnosis/closeout node may consume an
71
+ // upstream ERROR as settled failure evidence. Surface that evidence
72
+ // explicitly so the node can read the failure without the upstream being
73
+ // rewritten to FINISHED.
74
+ if (record?.status === "ERROR" &&
75
+ toleratedErrors.has(depId) &&
76
+ upstreamText) {
77
+ const { preview, truncated } = formatStdoutPreview(upstreamText, maxChars);
78
+ let section = `## Upstream failure evidence: ${depId} (status=ERROR${record.failureCategory ? `, failureCategory=${record.failureCategory}` : ""})\n${preview}`;
79
+ const artifactKind = stdout
80
+ ? "stdout"
81
+ : assistantText
82
+ ? "assistant"
83
+ : "stderr";
84
+ const artifactPath = artifactKind === "stdout"
85
+ ? record.stdoutArtifactPath
86
+ : artifactKind === "assistant"
87
+ ? record.assistantArtifactPath
88
+ : undefined;
89
+ if (truncated && artifactPath) {
90
+ section += formatUpstreamArtifactPointerMap(record, artifactKind === "stdout" ? "stdout" : "assistant");
91
+ }
92
+ else if (truncated && record.nodeRecordPath) {
93
+ section += [
94
+ "",
95
+ "Full failure record (read-only runner evidence — use read tool to fetch; do not edit):",
96
+ `- node record: ${record.nodeRecordPath}`,
97
+ ].join("\n");
98
+ }
99
+ sections.push(section);
100
+ continue;
101
+ }
68
102
  if (!record || record.status !== "FINISHED" || !upstreamText)
69
103
  continue;
70
104
  const artifactKind = stdout ? "stdout" : "assistant";
@@ -8,6 +8,51 @@ export const DAG_RECOVERY_ACTIONS = [
8
8
  "inspect-upstream",
9
9
  "unknown",
10
10
  ];
11
+ const FOLLOW_UP_BY_RAW_CATEGORY = {
12
+ "nonzero-exit": {
13
+ nextStepCategory: "repair",
14
+ reason: "Deterministic command exited nonzero; inspect stdout/stderr and exit code, then repair code, command, or environment before rerun.",
15
+ },
16
+ "decision-envelope-invalid": {
17
+ nextStepCategory: "revision",
18
+ reason: "Decision gate output violated the envelope protocol; revise the decision node prompt/output and do not infer approval.",
19
+ },
20
+ "protocol-invalid": {
21
+ nextStepCategory: "revision",
22
+ reason: "Model output violated the required protocol; if deterministic verdict normalization cannot prove exactly one supported verdict, revise/retry the protocol-producing node rather than inferring approval.",
23
+ },
24
+ "write-guard": {
25
+ nextStepCategory: "repair",
26
+ reason: "Write boundary was violated; narrow writeSet or revert out-of-bound changes before rerun. Write-guard failures must not be converted to success.",
27
+ },
28
+ timeout: {
29
+ nextStepCategory: "retry",
30
+ reason: "Execution exceeded its activity/wall-clock budget; inspect readiness/resource diagnostics and scoped timeout budget, then retry once the readiness issue is fixed.",
31
+ },
32
+ "controller-interrupted": {
33
+ nextStepCategory: "restart",
34
+ reason: "Controller was interrupted; reconcile run state from persisted DAG facts, then restart or resume the run.",
35
+ },
36
+ };
37
+ const UNKNOWN_FOLLOW_UP = {
38
+ nextStepCategory: "diagnosis",
39
+ reason: "Failure category is unavailable or unrecognized; run diagnosis/report before selecting repair/retry/restart.",
40
+ };
41
+ /**
42
+ * Deterministic mapping from a raw failure category to a stable next-step
43
+ * category and reason. Unknown/missing categories route to diagnosis.
44
+ *
45
+ * This is a routing hint only; callers must not use it to change a DAG run's
46
+ * terminal status or to convert a genuine failure into success.
47
+ */
48
+ export function recommendFollowUpForFailureCategory(rawFailureCategory) {
49
+ if (!rawFailureCategory)
50
+ return { ...UNKNOWN_FOLLOW_UP };
51
+ const direct = FOLLOW_UP_BY_RAW_CATEGORY[rawFailureCategory];
52
+ if (direct)
53
+ return { ...direct };
54
+ return { ...UNKNOWN_FOLLOW_UP };
55
+ }
11
56
  const REPORT_DERIVED_ONLY = "Derived planning hint only; dag report does not execute retry or resume.";
12
57
  const TRANSIENT_EXECUTOR_RAW = new Set([
13
58
  "network",
@@ -8,7 +8,7 @@ import { dagRunDirExists, getDagRunDir, locateDagRun, readDagRunSpec, readDagRun
8
8
  export const DAG_CLOSEOUT_DRAFT_DISCLAIMER = "> **Advisory only.** Derived from completed run facts. Canonical source remains `dag report --json` and `.harness/dag-runs/completed/<run-id>/`. Do not treat this draft as authoritative.";
9
9
  import { dagNormalizedFailureCategorySchema, normalizeDagFailureCategory, } from "./failure-category.js";
10
10
  import { dagProductLineFailureCategoryValues, routeDagFailure, } from "./failure-routing.js";
11
- import { DAG_RECOVERY_ACTIONS, planDagRecovery, } from "./recovery-recommendation.js";
11
+ import { DAG_RECOVERY_ACTIONS, planDagRecovery, recommendFollowUpForFailureCategory, } from "./recovery-recommendation.js";
12
12
  import { dagNodeExecutorSchema, dagNodeStatusSchema, LEGACY_TOP_LEVEL_MODELS_ERROR, parseDagSpec, resolveModelForTask, } from "./types.js";
13
13
  import { checkRuntimeContractCompatible, DAG_CONTROLLER_CAPABILITIES, } from "./runtime-contract.js";
14
14
  export const DAG_REPORT_SCHEMA_VERSION = 1;
@@ -141,6 +141,10 @@ const dagNodeReportRowSchema = z
141
141
  normalizedFailureCategory: dagNormalizedFailureCategorySchema.optional(),
142
142
  productLineFailureCategory: dagProductLineFailureCategorySchema.optional(),
143
143
  recommendedFollowUp: z.string().optional(),
144
+ recommendedFollowUpCategory: z
145
+ .enum(["repair", "revision", "retry", "restart", "diagnosis"])
146
+ .optional(),
147
+ recommendedFollowUpReason: z.string().optional(),
144
148
  recoveryRecommendation: dagRecoveryRecommendationSchema.optional(),
145
149
  backend: z.enum(["sdk", "cli"]).optional(),
146
150
  sdkAttempted: z.boolean().optional(),
@@ -166,6 +170,10 @@ const dagRunReportEntrySchema = z
166
170
  normalizedFailureCategory: dagNormalizedFailureCategorySchema.optional(),
167
171
  productLineFailureCategory: dagProductLineFailureCategorySchema.optional(),
168
172
  recommendedFollowUp: z.string().optional(),
173
+ recommendedFollowUpCategory: z
174
+ .enum(["repair", "revision", "retry", "restart", "diagnosis"])
175
+ .optional(),
176
+ recommendedFollowUpReason: z.string().optional(),
169
177
  recoveryRecommendation: dagRecoveryRecommendationSchema.optional(),
170
178
  primaryFailure: dagReportPrimaryFailureSchema,
171
179
  primaryRecovery: dagRecoveryRecommendationSchema,
@@ -434,6 +442,9 @@ export async function buildDagRunReportEntry(input) {
434
442
  nodeId,
435
443
  executor: node.executor,
436
444
  });
445
+ const followUp = node.status === "ERROR" || node.status === "SKIPPED"
446
+ ? recommendFollowUpForFailureCategory(node.failureCategory)
447
+ : undefined;
437
448
  nodes.push({
438
449
  nodeId,
439
450
  rank,
@@ -448,6 +459,12 @@ export async function buildDagRunReportEntry(input) {
448
459
  failureCategory: node.failureCategory,
449
460
  normalizedFailureCategory,
450
461
  ...failureRouting,
462
+ ...(followUp
463
+ ? {
464
+ recommendedFollowUpCategory: followUp.nextStepCategory,
465
+ recommendedFollowUpReason: followUp.reason,
466
+ }
467
+ : {}),
451
468
  recoveryRecommendation: planDagRecovery({
452
469
  status: node.status,
453
470
  normalizedFailureCategory,
@@ -513,6 +530,10 @@ export async function buildDagRunReportEntry(input) {
513
530
  normalizedFailureCategory: runRecoverySource.normalizedFailureCategory,
514
531
  nodeId: "nodeId" in runRecoverySource ? runRecoverySource.nodeId : undefined,
515
532
  });
533
+ const runFollowUp = input.state.status === "failed" ||
534
+ input.state.status === "partial_failed"
535
+ ? recommendFollowUpForFailureCategory(input.state.failureCategory)
536
+ : undefined;
516
537
  const partialEntry = {
517
538
  runId: input.state.runId,
518
539
  title: input.state.title,
@@ -524,6 +545,12 @@ export async function buildDagRunReportEntry(input) {
524
545
  failureCategory: input.state.failureCategory,
525
546
  normalizedFailureCategory,
526
547
  ...runFailureRouting,
548
+ ...(runFollowUp
549
+ ? {
550
+ recommendedFollowUpCategory: runFollowUp.nextStepCategory,
551
+ recommendedFollowUpReason: runFollowUp.reason,
552
+ }
553
+ : {}),
527
554
  recoveryRecommendation,
528
555
  pausedByNodeId: input.state.pausedByNodeId,
529
556
  pauseReason: input.state.pauseReason,
@@ -193,7 +193,7 @@ function blockedResult(input) {
193
193
  };
194
194
  }
195
195
  function extractRunSummary(generateResult) {
196
- if (generateResult.mode === "generate+validate")
196
+ if (generateResult.mode === "generate+basic-validate")
197
197
  return undefined;
198
198
  return generateResult.run;
199
199
  }
@@ -17,6 +17,7 @@ function dependencyReadiness(task, nodes) {
17
17
  if (task.depends_on.length === 0)
18
18
  return "run";
19
19
  const softConditionJoin = task.dependsPolicy === "all-or-condition-skip";
20
+ const toleratedErrors = new Set(task.failureAwareDependsOn ?? []);
20
21
  let hasFinished = false;
21
22
  let hasPendingUpstream = false;
22
23
  let hasHardBlock = false;
@@ -35,6 +36,14 @@ function dependencyReadiness(task, nodes) {
35
36
  continue;
36
37
  }
37
38
  if (dep.status === "ERROR") {
39
+ // AC1: an explicit read-only recovery/diagnosis/closeout node may
40
+ // consume a declared upstream ERROR as settled failure evidence. This
41
+ // does not rewrite the upstream status; it only allows the recovery
42
+ // node to become runnable so it can read the failure evidence.
43
+ if (toleratedErrors.has(depId)) {
44
+ hasFinished = true;
45
+ continue;
46
+ }
38
47
  hasHardBlock = true;
39
48
  continue;
40
49
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
2
3
  import { campaignBudgetSchema, } from "../../application/evaluation/budget.js";
3
4
  import { assertDagPromptSourceRule } from "./prompt-source.js";
4
5
  import { dagRetryPolicySchema } from "./retry-policy.js";
@@ -25,6 +26,11 @@ export const dagShellVerifyEvidenceSchema = z.object({
25
26
  commandSource: z.enum(["adapter", "inline"]),
26
27
  commandCount: z.number().int().nonnegative(),
27
28
  commandLabels: z.array(z.string()).default([]),
29
+ commandTexts: z.array(z.string()).default([]),
30
+ /** Effective timeout applied independently to each shell command. */
31
+ commandTimeoutMs: z.number().int().positive().optional(),
32
+ /** Worst-case serial budget: commandCount * commandTimeoutMs. */
33
+ totalTimeoutBudgetMs: z.number().int().nonnegative().optional(),
28
34
  finalFullRequired: z.boolean().optional(),
29
35
  });
30
36
  export const dagRepairArtifactGateSchema = z.object({
@@ -128,22 +134,80 @@ export const dagFrontendPrewriteGateSchema = z.object({
128
134
  allowedMockStrategies: z.array(z.enum(["native", "browser-intercept", "request-adapter", "not-needed"])).min(1),
129
135
  artifactName: z.string().regex(/^[a-z0-9][a-z0-9._-]*\.json$/),
130
136
  outputDir: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/),
137
+ requireSourceFreshness: z.literal(true),
138
+ implementationWriteSet: z.array(z.string().min(1)).min(1).optional(),
131
139
  openspecCandidatePaths: z
132
- .array(z.string().regex(/^openspec\/.+/, "openspec candidate must be repo-relative"))
140
+ .array(z.string().refine((candidate) => !candidate.includes("\\") &&
141
+ isOpenspecSpecFilePath(candidate), "openspec candidate must be a repo-relative supported file under openspec/schemas/ or openspec/project-specs/"))
133
142
  .default([]),
134
143
  });
144
+ export const dagFrontendLintBaselineSchema = z
145
+ .object({
146
+ schemaVersion: z.literal(1),
147
+ lintCommands: z.array(z.string().min(1)).min(1),
148
+ lintEvidence: dagShellVerifyEvidenceSchema,
149
+ })
150
+ .strict();
135
151
  export const dagFrontendVerificationBundleSchema = z.object({
136
152
  schemaVersion: z.literal(1),
137
153
  mockCommands: z.array(z.string()).default([]),
154
+ lintCommands: z.array(z.string().min(1)).optional(),
138
155
  staticCommands: z.array(z.string()).min(1),
139
156
  behaviorCommands: z.array(z.string()).min(1),
140
157
  mockEvidence: dagShellVerifyEvidenceSchema.optional(),
158
+ lintEvidence: dagShellVerifyEvidenceSchema.optional(),
141
159
  staticEvidence: dagShellVerifyEvidenceSchema,
142
160
  behaviorEvidence: dagShellVerifyEvidenceSchema,
161
+ lintBaselineNodeId: dagFrontendNodeIdSchema.optional(),
162
+ writerNodeIds: z.array(dagFrontendNodeIdSchema).optional(),
143
163
  mode: z.enum(["initial", "repair"]),
164
+ }).superRefine((bundle, context) => {
165
+ const groups = [
166
+ ["mock", bundle.mockCommands, bundle.mockEvidence],
167
+ ["lint", bundle.lintCommands ?? [], bundle.lintEvidence],
168
+ ["static", bundle.staticCommands, bundle.staticEvidence],
169
+ ["behavior", bundle.behaviorCommands, bundle.behaviorEvidence],
170
+ ];
171
+ for (const [name, commands, evidence] of groups) {
172
+ if ((name === "mock" || name === "lint") && commands.length === 0 && !evidence)
173
+ continue;
174
+ if (!evidence) {
175
+ context.addIssue({ code: z.ZodIssueCode.custom, path: [`${name}Evidence`], message: `${name} evidence is required when commands are configured` });
176
+ continue;
177
+ }
178
+ if (evidence.commandCount !== commands.length) {
179
+ context.addIssue({ code: z.ZodIssueCode.custom, path: [`${name}Evidence`, "commandCount"], message: `${name} commandCount must match commands` });
180
+ }
181
+ if (evidence.commandLabels.length !== commands.length) {
182
+ context.addIssue({ code: z.ZodIssueCode.custom, path: [`${name}Evidence`, "commandLabels"], message: `${name} commandLabels must match commands` });
183
+ }
184
+ if (evidence.commandTexts.length !== commands.length || evidence.commandTexts.some((command, index) => command !== commands[index])) {
185
+ context.addIssue({ code: z.ZodIssueCode.custom, path: [`${name}Evidence`, "commandTexts"], message: `${name} commandTexts must exactly match commands` });
186
+ }
187
+ if (new Set(evidence.commandLabels).size !== evidence.commandLabels.length || evidence.commandLabels.some((label) => !label.trim())) {
188
+ context.addIssue({ code: z.ZodIssueCode.custom, path: [`${name}Evidence`, "commandLabels"], message: `${name} commandLabels must be non-empty and unique` });
189
+ }
190
+ }
191
+ if ((bundle.lintCommands?.length ?? 0) > 0) {
192
+ if (!bundle.lintBaselineNodeId) {
193
+ context.addIssue({
194
+ code: z.ZodIssueCode.custom,
195
+ path: ["lintBaselineNodeId"],
196
+ message: "lintBaselineNodeId is required when lintCommands are present",
197
+ });
198
+ }
199
+ if (!bundle.writerNodeIds || bundle.writerNodeIds.length === 0) {
200
+ context.addIssue({
201
+ code: z.ZodIssueCode.custom,
202
+ path: ["writerNodeIds"],
203
+ message: "writerNodeIds are required when lintCommands are present",
204
+ });
205
+ }
206
+ }
144
207
  });
145
208
  export const dagFrontendReviewContextSchema = z.object({
146
209
  schemaVersion: z.literal(1),
210
+ requireBaseline: z.literal(true),
147
211
  });
148
212
  export const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
149
213
  export const dagVersionSchema = z
@@ -226,6 +290,7 @@ export const dagShellConfigSchema = z.object({
226
290
  requirementCoverageGate: dagRequirementCoverageGateSchema.optional(),
227
291
  jsonArtifactGate: dagJsonArtifactGateSchema.optional(),
228
292
  frontendPrewriteGate: dagFrontendPrewriteGateSchema.optional(),
293
+ frontendLintBaseline: dagFrontendLintBaselineSchema.optional(),
229
294
  frontendVerificationBundle: dagFrontendVerificationBundleSchema.optional(),
230
295
  frontendReviewContext: dagFrontendReviewContextSchema.optional(),
231
296
  backendTestPipeline: dagBackendTestPipelineSchema.optional(),
@@ -363,6 +428,14 @@ export const dagTaskSchema = z.object({
363
428
  * every dep is FINISHED or soft condition-skip and at least one is FINISHED (OR-join)
364
429
  */
365
430
  dependsPolicy: z.enum(["all", "all-or-condition-skip"]).optional(),
431
+ /**
432
+ * Explicit opt-in: upstream node ids whose ERROR terminal state this
433
+ * read-only node may consume as settled failure evidence. Validation
434
+ * restricts this to read-only Pi recovery/diagnosis/closeout nodes; writers,
435
+ * shell verifiers, hard gates, and dynamic/decision nodes remain fail-closed.
436
+ * Every id must also appear in depends_on.
437
+ */
438
+ failureAwareDependsOn: z.array(z.string()).optional(),
366
439
  /** Execute only when this fail-closed condition resolves true after dependencies settle. */
367
440
  runIf: z.string().min(1).optional(),
368
441
  complexity: dagComplexitySchema,
@@ -614,6 +614,60 @@ function validateDecisionGateTaskConfig(task, issues) {
614
614
  });
615
615
  }
616
616
  }
617
+ /**
618
+ * AC1: failure-aware dependencies are an explicit, narrow opt-in. Only
619
+ * read-only Pi nodes acting as recovery/diagnosis/supervision/closeout
620
+ * (reviewer, verifier, supervisor, closeout roles) may tolerate an upstream
621
+ * ERROR. Writers, shell verifiers, hard/decision gates, dynamic nodes, and any
622
+ * node with write capability must remain fail-closed.
623
+ */
624
+ const FAILURE_AWARE_SAFE_ROLES = new Set([
625
+ "reviewer",
626
+ "verifier",
627
+ "supervisor",
628
+ "closeout",
629
+ ]);
630
+ function validateFailureAwareDependsOn(task, spec, issues) {
631
+ const tolerated = task.failureAwareDependsOn;
632
+ if (!tolerated || tolerated.length === 0)
633
+ return;
634
+ const isReadOnlyPiRecoveryNode = task.executor === "pi" &&
635
+ FAILURE_AWARE_SAFE_ROLES.has(task.role ?? "") &&
636
+ task.toolProfile !== "write" &&
637
+ (task.writePolicy === undefined ||
638
+ task.writePolicy === "read-only" ||
639
+ task.writePolicy === "none") &&
640
+ (task.writeSet ?? []).length === 0 &&
641
+ !task.decisionGate?.enabled &&
642
+ !task.dynamicExpansion &&
643
+ !task.dynamicReduction &&
644
+ !task.dynamicCondition &&
645
+ !task.dynamicLoopUntil;
646
+ if (!isReadOnlyPiRecoveryNode) {
647
+ issues.push({
648
+ type: "invalid-failure-aware-dependency",
649
+ message: `task ${task.id} declares failureAwareDependsOn but failure-aware dependencies are allowed only for read-only recovery/diagnosis/supervision/closeout-equivalent Pi nodes (reviewer, verifier, supervisor, closeout); writer and shell verification nodes may not tolerate upstream ERROR`,
650
+ });
651
+ return;
652
+ }
653
+ const dependsOn = new Set(task.depends_on);
654
+ const knownIds = new Set(spec.tasks.map((entry) => entry.id));
655
+ for (const upstreamId of tolerated) {
656
+ if (!knownIds.has(upstreamId)) {
657
+ issues.push({
658
+ type: "invalid-failure-aware-dependency",
659
+ message: `task ${task.id} failureAwareDependsOn references unknown task id ${upstreamId}`,
660
+ });
661
+ continue;
662
+ }
663
+ if (!dependsOn.has(upstreamId)) {
664
+ issues.push({
665
+ type: "invalid-failure-aware-dependency",
666
+ message: `task ${task.id} failure-aware dependency ${upstreamId} must be listed in depends_on`,
667
+ });
668
+ }
669
+ }
670
+ }
617
671
  export function collectExecutorModelWarnings(spec, expectedMatrices = DEFAULT_DAG_EXECUTOR_MODELS) {
618
672
  const warnings = [];
619
673
  const executors = Object.keys(DEFAULT_DAG_EXECUTOR_MODELS);
@@ -704,6 +758,7 @@ export function validateDagSpec(spec) {
704
758
  validateRetryPolicyTaskConfig(task, issues);
705
759
  validateOutputProtocolTaskConfig(task, issues);
706
760
  validateProjectGovernanceTaskConfig(task, spec, issues);
761
+ validateFailureAwareDependsOn(task, spec, issues);
707
762
  }
708
763
  validateSameRankWriteSetConflicts(spec, ranks, issues);
709
764
  validateSameRankAgentAttributionRisks(spec, ranks, issues);