@tea-agent/loop-agent 0.26.2 → 0.26.4

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.
@@ -37,6 +37,9 @@ function symbolEvidenceCandidates(symbol) {
37
37
  return [symbol, describeTitle, "describe("];
38
38
  return [symbol];
39
39
  }
40
+ function isConfigurationVerificationFile(file) {
41
+ return /(?:^|\/)(?:package\.json|tsconfig(?:\.[^/]+)?\.json|(?:vite|webpack|rollup|docusaurus)\.config\.[cm]?[jt]s)$/.test(file.replace(/\\/g, "/"));
42
+ }
40
43
  async function assertFileAndSymbol(input) {
41
44
  const issues = [];
42
45
  const absolute = path.resolve(input.workspaceRoot, input.file);
@@ -94,6 +97,7 @@ export async function runFrontendVerificationTraceGate(input) {
94
97
  .join("; ")}`);
95
98
  }
96
99
  const contract = parsed.data;
100
+ const requiresBehaviorVerification = contract.verificationTargets.some((target) => target.type !== "static");
97
101
  // Prefer post-repair reverify nodes when present (M3); else initial verify nodes (M2).
98
102
  const staticCandidates = [
99
103
  "frontend-static-reverify-shell",
@@ -146,7 +150,8 @@ export async function runFrontendVerificationTraceGate(input) {
146
150
  if (input.evidence.static.commandLabels.length === 0) {
147
151
  throw new Error("trace: no successful static verification commands in current run");
148
152
  }
149
- if (input.evidence.behavior.commandLabels.length === 0) {
153
+ if (requiresBehaviorVerification &&
154
+ input.evidence.behavior.commandLabels.length === 0) {
150
155
  throw new Error("trace: no successful behavior verification commands in current run");
151
156
  }
152
157
  }
@@ -181,7 +186,12 @@ export async function runFrontendVerificationTraceGate(input) {
181
186
  const fileIssues = await assertFileAndSymbol({
182
187
  workspaceRoot: input.workspaceRoot,
183
188
  file: target.file,
184
- symbol: target.symbol,
189
+ // Configuration files prove that the command's entrypoint exists;
190
+ // they do not expose source symbols. LLMs sometimes derive a
191
+ // filename fragment such as "build" from tsconfig.build.json.
192
+ symbol: isConfigurationVerificationFile(target.file)
193
+ ? undefined
194
+ : target.symbol,
185
195
  });
186
196
  issues.push(...fileIssues);
187
197
  const status = issues.length ? "failed" : "ok";
@@ -106,7 +106,7 @@ function writeSetMatchesAnyPattern(writeSet, patterns) {
106
106
  function shellCommandLooksDeterministic(command) {
107
107
  const normalized = command.replace(/["']/g, "");
108
108
  return (/\bnpx vitest run\b/.test(normalized) ||
109
- /\bnpm (?:run )?(?:lint|typecheck|test)\b/.test(normalized) ||
109
+ /\bnpm(?:\s+--prefix\s+\S+)*\s+(?:run\s+)?(?:lint|typecheck|test|build)\b/.test(normalized) ||
110
110
  /check-repo\.sh/.test(normalized) ||
111
111
  /\bshell\.preset\b/.test(normalized) ||
112
112
  /loop-agent-standard-verify/.test(normalized));
@@ -13,6 +13,7 @@ import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, STRU
13
13
  import { REVIEW_JSON_VERDICT_OUTPUT_PROTOCOL, REVIEW_VERDICT_OUTPUT_PROTOCOL, } from "./output-protocol.js";
14
14
  import { resolveAdapter } from "../../adapters/index.js";
15
15
  import { loadHarnessManifest } from "../../governance/harness.js";
16
+ import { mergeDocumentIndexCompanions } from "../../governance/document-index-closure.js";
16
17
  import { buildAuthoritySurfaceAuditNode, buildAuthoritySurfaceGateNode, resolveAuthoritySurfaceAudit, } from "./authority-surface.js";
17
18
  import { applySddEmbeddedEnhancements, probeRepoLocalSddSkills, } from "./sdd-embedded.js";
18
19
  import { discoverProjectGovernancePresence } from "./project-governance-context.js";
@@ -721,9 +722,28 @@ function mergeForbiddenPaths(taskConfig) {
721
722
  ]);
722
723
  return [...merged];
723
724
  }
724
- function resolveImplementPaths(taskConfig) {
725
+ function resolveImplementPaths(taskConfig, options = {}) {
725
726
  if (taskConfig.allowedPaths.length > 0) {
726
727
  const allowed = [...taskConfig.allowedPaths];
728
+ const forbidden = mergeForbiddenPaths(taskConfig);
729
+ const repoRoot = options.repoRoot;
730
+ // Only consult the catalog when writer paths can touch docs/* so non-docs
731
+ // temp fixtures without a catalog keep working; docs writers still fail closed.
732
+ const mayNeedDocIndex = allowed.some((entry) => {
733
+ const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
734
+ return normalized === "docs" || normalized.startsWith("docs/");
735
+ });
736
+ if (repoRoot && mayNeedDocIndex) {
737
+ const merged = mergeDocumentIndexCompanions({
738
+ repoRoot,
739
+ paths: allowed,
740
+ forbiddenPaths: forbidden,
741
+ });
742
+ return {
743
+ allowedPaths: merged.paths,
744
+ writeSet: [...merged.paths],
745
+ };
746
+ }
727
747
  return {
728
748
  allowedPaths: allowed,
729
749
  writeSet: [...allowed],
@@ -786,7 +806,9 @@ function applyMavenVerificationPlanning(input) {
786
806
  if (!input.repoRoot || !input.commands || input.commands.length === 0) {
787
807
  return { commands: input.commands };
788
808
  }
789
- const implement = resolveImplementPaths(input.taskConfig);
809
+ const implement = resolveImplementPaths(input.taskConfig, {
810
+ repoRoot: input.repoRoot,
811
+ });
790
812
  const planned = planMavenVerification({
791
813
  repoRoot: input.repoRoot,
792
814
  commands: input.commands,
@@ -890,7 +912,9 @@ function chooseFrontendVerifyCommands(input) {
890
912
  if (input.parsedCommands.length > 0) {
891
913
  return { commands: input.parsedCommands, commandSource: "inline" };
892
914
  }
893
- if (input.adapterCommands && input.adapterCommands.length > 0) {
915
+ if (input.allowAdapter !== false &&
916
+ input.adapterCommands &&
917
+ input.adapterCommands.length > 0) {
894
918
  return { commands: input.adapterCommands, commandSource: "adapter" };
895
919
  }
896
920
  return { commandSource: "inline" };
@@ -1513,7 +1537,9 @@ function mergeFinalVerifyCommands(repoRoot, taskConfig, adapterCommands) {
1513
1537
  export function buildStandardHybridDagFromTask(sources) {
1514
1538
  const { taskConfig } = sources;
1515
1539
  const forbiddenPaths = mergeForbiddenPaths(taskConfig);
1516
- const implementPaths = resolveImplementPaths(taskConfig);
1540
+ const implementPaths = resolveImplementPaths(taskConfig, {
1541
+ repoRoot: sources.repoRoot,
1542
+ });
1517
1543
  const scoutPaths = deriveParallelScoutPaths(taskConfig);
1518
1544
  const scoutComplexity = mapTaskComplexity(taskConfig.complexity);
1519
1545
  const implementComplexity = resolveWriterComplexity(taskConfig);
@@ -2074,7 +2100,9 @@ async function buildFrontendHybridDagFromTask(sources) {
2074
2100
  frontendMockMode: mockMode,
2075
2101
  };
2076
2102
  const forbiddenPaths = mergeForbiddenPaths(taskConfig);
2077
- const implementPaths = resolveImplementPaths(taskConfig);
2103
+ const implementPaths = resolveImplementPaths(taskConfig, {
2104
+ repoRoot: sources.repoRoot,
2105
+ });
2078
2106
  const implementId = frontendImplementationNodeId();
2079
2107
  const mockContextBlock = resolveFrontendMockContextBlock(frontendSources);
2080
2108
  const capabilityContextBlock = resolveFrontendCapabilityContextBlock(frontendSources);
@@ -2158,7 +2186,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2158
2186
  "Frontend planning must consume the read-only Mock assessment strategy produced after scouting; MOCK_STRATEGY: blocked must not pass the deterministic Mock contract gate.",
2159
2187
  "Mock implementations must preserve the real request path as the default, require explicit test/dev activation, and never rely on commenting out the real request.",
2160
2188
  "Mock-backed behavior evidence proves only the documented frontend contract, never real API integration.",
2161
- "frontend-implementation DAGs must complete deterministic static verification and behavior verification before final review.",
2189
+ "frontend-implementation DAGs must complete deterministic static verification before final review. Behavior verification is also required when the task declares a behavior entrypoint or the implementation contract contains a non-static verification target; static-only contracts must map every target to the declared static entrypoint.",
2162
2190
  "frontend review must block closeout unless review verdict is exactly VERDICT: pass.",
2163
2191
  `Frontend risk classification: ${frontendRisk.selectedRisk} — ${frontendRisk.reason}`,
2164
2192
  frontendRisk.forceFullGates
@@ -2183,6 +2211,10 @@ async function buildFrontendHybridDagFromTask(sources) {
2183
2211
  ...explicitFrontendVerifyCommands.behaviorCommands,
2184
2212
  ].map(verifyCommandKey));
2185
2213
  const adapterVerifyCommands = (sources.verifyCommands?.final ?? []).filter((command) => !explicitCommandKeys.has(verifyCommandKey(command)));
2214
+ const hasDeclaredFrontendVerification = explicitFrontendVerifyCommands.staticCommands.length > 0 ||
2215
+ explicitFrontendVerifyCommands.behaviorCommands.length > 0 ||
2216
+ parsedFrontendVerifyCommands.staticCommands.length > 0 ||
2217
+ parsedFrontendVerifyCommands.behaviorCommands.length > 0;
2186
2218
  const staticVerifyCommands = chooseFrontendVerifyCommands({
2187
2219
  explicitCommands: explicitFrontendVerifyCommands.staticCommands,
2188
2220
  parsedCommands: parsedFrontendVerifyCommands.staticCommands,
@@ -2197,6 +2229,9 @@ async function buildFrontendHybridDagFromTask(sources) {
2197
2229
  explicitCommands: explicitFrontendVerifyCommands.behaviorCommands,
2198
2230
  parsedCommands: parsedFrontendVerifyCommands.behaviorCommands,
2199
2231
  adapterCommands: adapterVerifyCommands,
2232
+ // A declared task verifier owns this task's verification boundary. A
2233
+ // static-only task must not inherit unrelated root-level test commands.
2234
+ allowAdapter: !hasDeclaredFrontendVerification,
2200
2235
  });
2201
2236
  const staticShellCommands = buildVerifyShellCommands({
2202
2237
  repoRoot: sources.repoRoot,
@@ -2211,8 +2246,13 @@ async function buildFrontendHybridDagFromTask(sources) {
2211
2246
  const behaviorShellCommands = buildVerifyShellCommands({
2212
2247
  repoRoot: sources.repoRoot,
2213
2248
  commands: behaviorVerifyCommands.commands,
2214
- fallbackCommands: behaviorFallbackCommands,
2249
+ fallbackCommands: hasDeclaredFrontendVerification
2250
+ ? []
2251
+ : behaviorFallbackCommands,
2215
2252
  });
2253
+ const effectiveBehaviorFallbackCommands = hasDeclaredFrontendVerification
2254
+ ? []
2255
+ : behaviorFallbackCommands;
2216
2256
  const staticVerifyEvidence = buildVerifyEvidence({
2217
2257
  phase: "intermediate",
2218
2258
  quota: strategy.intermediateQuota ?? "full",
@@ -2238,7 +2278,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2238
2278
  quota: "full",
2239
2279
  commandSource: behaviorVerifyCommands.commandSource,
2240
2280
  commands: behaviorVerifyCommands.commands,
2241
- fallbackCommands: behaviorFallbackCommands,
2281
+ fallbackCommands: effectiveBehaviorFallbackCommands,
2242
2282
  commandTexts: behaviorShellCommands,
2243
2283
  finalFullRequired: true,
2244
2284
  commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
@@ -2352,7 +2392,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2352
2392
  subtask_prompt: [
2353
2393
  "Audit the frontend plan before implementation.",
2354
2394
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
2355
- "Request revision when the Mock strategy is MOCK_STRATEGY: blocked, missing, unsupported by repository evidence, inconsistent with the API contract, outside authorized paths/dependencies, unable to prove production-default-off behavior with the fixed production/default-real-path static check, or missing deterministic behavior verification for the selected strategy. Mock strategies require Mock-backed evidence. not-needed requires applicable real/no-remote behavior evidence unless auto mode explicitly skipped Mock because no project Mock capability exists; in that case the plan must preserve the real request path and record the Real Integration Gap.",
2395
+ "Request revision when the Mock strategy is MOCK_STRATEGY: blocked, missing, unsupported by repository evidence, inconsistent with the API contract, outside authorized paths/dependencies, unable to prove production-default-off behavior with the fixed production/default-real-path static check, or missing deterministic behavior verification for a declared behavior target or selected Mock strategy. Mock strategies require Mock-backed evidence. A static-only contract is allowed only when every verification target is static and maps to a declared static entrypoint. not-needed otherwise requires applicable real/no-remote behavior evidence unless auto mode explicitly skipped Mock because no project Mock capability exists; in that case the plan must preserve the real request path and record the Real Integration Gap.",
2356
2396
  "Also request revision for missing applicable UI states, unsupported dependency additions, design-system drift without reason, weak interaction coverage, broad scope, inline fake data, schema drift, or missing deterministic verification commands.",
2357
2397
  "Read-only: do not modify repository files.",
2358
2398
  fixedVerificationContext,
@@ -3586,7 +3626,7 @@ async function buildBackendTestHybridDag(sources) {
3586
3626
  outputContract: "Convert every final automatable Markdown case into pytest assets whose actual test function region contains the exact Case ID, preferably in the function name or docstring. Each testcase/md/<module>.md (excluding README.md) maps one-to-one to testcase/test_<module>.py; never merge or split modules. No JSON and no pytest execution.",
3587
3627
  subtask_prompt: [
3588
3628
  "Convert testcase/md/** to pytest using upstream environment and advisory validation evidence plus only bounded pytest config/conftest. A FAIL advisory report does not authorize inventing missing behavior; use the final Markdown facts that are present.",
3589
- "Ensure every final Markdown Case ID appears in exactly one primary pytest test function or pytest test class method region, using the exact `primary symbol` declared by Markdown. The symbol must start with `test_BE_<MODULE>_<NNN>_` so every parameterized collected item remains associated with its Case. Module-level functions and class-based pytest methods are both supported. Only `变体测试点` may use stable `pytest.param(..., id=\"TP-...\")` IDs, and every atomic variant ID must appear exactly once with a genuine input/state/outcome change. Use `pytest.param(..., id=...)` for every row; do not use decorator-level `ids=[...]`, generated suffixes, or IDs that extend/shorten the exact Markdown TP. Do not parameterize `场景断言测试点` or `横切证据测试点`; execute all assertion checkpoints within the same business journey/item and use shared helpers for cross-cutting evidence. The primary symbol docstring must contain exact metadata lines `Case-ID: BE-...`, `Assertion-Test-Points: TP-...;TP-...` and `Cross-Cutting-Test-Points: TP-...;TP-...` (use `none` when empty). No Test Point may be invented, renamed, omitted or bound in two modes. The generated pytest collection shape must equal the Markdown prediction `sum(max(1, variant count per Case))`; keep it at or below the task's explicit budget by removing duplicate execution, never by collapsing multiple parameter rows under a coarse family TP. Assertions come only from 预期结果 and setup comes only from 前置条件/测试数据/自动化映射.",
3629
+ 'Ensure every final Markdown Case ID appears in exactly one primary pytest test function or pytest test class method region, using the exact `primary symbol` declared by Markdown. The symbol must start with `test_BE_<MODULE>_<NNN>_` so every parameterized collected item remains associated with its Case. Module-level functions and class-based pytest methods are both supported. Only `变体测试点` may use stable `pytest.param(..., id="TP-...")` IDs, and every atomic variant ID must appear exactly once with a genuine input/state/outcome change. Use `pytest.param(..., id=...)` for every row; do not use decorator-level `ids=[...]`, generated suffixes, or IDs that extend/shorten the exact Markdown TP. Do not parameterize `场景断言测试点` or `横切证据测试点`; execute all assertion checkpoints within the same business journey/item and use shared helpers for cross-cutting evidence. The primary symbol docstring must contain exact metadata lines `Case-ID: BE-...`, `Assertion-Test-Points: TP-...;TP-...` and `Cross-Cutting-Test-Points: TP-...;TP-...` (use `none` when empty). No Test Point may be invented, renamed, omitted or bound in two modes. The generated pytest collection shape must equal the Markdown prediction `sum(max(1, variant count per Case))`; keep it at or below the task\'s explicit budget by removing duplicate execution, never by collapsing multiple parameter rows under a coarse family TP. Assertions come only from 预期结果 and setup comes only from 前置条件/测试数据/自动化映射.',
3590
3630
  "Name each generated pytest file so it corresponds one-to-one with its source Markdown module file: for each `testcase/md/<module>.md` (excluding README.md), emit exactly one `testcase/test_<module>.py`. The <module> stem is the Markdown filename without the `.md` extension, lowercased and with non-alphanumeric characters replaced by underscores. For example, `testcase/md/resource_notes.md` maps to `testcase/test_resource_notes.py`, `testcase/md/health.md` maps to `testcase/test_health.py`, `testcase/md/BE-HEALTH.md` maps to `testcase/test_be_health.py`, and `testcase/md/order-api.md` maps to `testcase/test_order_api.py`. If Markdown automation mapping names a different path than this module stem path, still write the module stem path and do not invent prefixes such as `test_be_*` unless the module filename itself normalizes to that stem. Never merge multiple Markdown modules into one pytest file, never split one module across several files, and never invent pytest filenames unrelated to the Markdown modules.",
3591
3631
  "Generate a reusable HTTP logging helper (or equivalent client wrapper) and call it for every interface request. The request log must include method, URL/path, and request parameters (query plus JSON/body/payload summary). The response log must include status code and response result (JSON/text/body summary), and both records must be visible in pytest stdout/stderr without changing assertions.",
3592
3632
  "HTTP response header names are case-insensitive. If the helper stores a lower-case normalized header map, every Content-Type or other header assertion must query the lower-case key (for example `content-type`) or use an explicitly case-insensitive accessor; never call a case-sensitive plain dict with `Content-Type` when the stored key is lower-case. Preserve the actual media-type assertion rather than dropping it.",
@@ -4125,12 +4165,20 @@ function buildFrontendTestHybridDag(sources) {
4125
4165
  forbiddenPaths: forbidden,
4126
4166
  outputContract: "Deterministic frontend L-5 Markdown and self-contained HTML dashboard derived only from frontend-test-result-v1.",
4127
4167
  subtask_prompt: "Render the authoritative frontend L-5 report from frontend-test-result-v1. Do not use Pi prose or invent code coverage. Missing line/branch coverage remains unavailable and makes L-5 NOT READY.",
4128
- shell: { frontendTestL5Report: {}, commands: [], cwd: ".", timeoutMs: 120000 },
4168
+ shell: {
4169
+ frontendTestL5Report: {},
4170
+ commands: [],
4171
+ cwd: ".",
4172
+ timeoutMs: 120000,
4173
+ },
4129
4174
  });
4130
4175
  if (strictOutcomeGate) {
4131
4176
  tasks.push({
4132
4177
  id: "frontend-test-result-outcome-gate-shell",
4133
- depends_on: ["materialize-frontend-test-result-shell", "frontend-test-l5-report-shell"],
4178
+ depends_on: [
4179
+ "materialize-frontend-test-result-shell",
4180
+ "frontend-test-l5-report-shell",
4181
+ ],
4134
4182
  role: "verifier",
4135
4183
  executor: "shell",
4136
4184
  complexity: "LOW",
@@ -4148,7 +4196,10 @@ function buildFrontendTestHybridDag(sources) {
4148
4196
  }
4149
4197
  tasks.push({
4150
4198
  id: "frontend-test-retrospect-pi",
4151
- depends_on: ["materialize-frontend-test-result-shell", "frontend-test-l5-report-shell"],
4199
+ depends_on: [
4200
+ "materialize-frontend-test-result-shell",
4201
+ "frontend-test-l5-report-shell",
4202
+ ],
4152
4203
  role: "closeout",
4153
4204
  executor: "pi",
4154
4205
  toolProfile: "write",
@@ -5379,7 +5430,7 @@ function buildReviewVerdictRecoveryNode(sources) {
5379
5430
  writePolicy: "read-only",
5380
5431
  allowedPaths: commonReadOnlyPaths(sources),
5381
5432
  forbiddenPaths: commonForbiddenPaths(sources),
5382
- outputContract: 'Structured JSON review verdict only, preserving the original review conclusion/findings without substantive changes. No file writes.',
5433
+ outputContract: "Structured JSON review verdict only, preserving the original review conclusion/findings without substantive changes. No file writes.",
5383
5434
  outputProtocol: REVIEW_JSON_VERDICT_OUTPUT_PROTOCOL,
5384
5435
  subtask_prompt: [
5385
5436
  "Normalize the output format of review-pi; this is the single read-only format-recovery attempt for the review verdict protocol.",
@@ -5734,8 +5785,9 @@ function buildProcessSupervisorNode(sources) {
5734
5785
  "Supervise the implementation process after soft verification.",
5735
5786
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
5736
5787
  "Immediately after the verdict, include one fenced block labelled REPAIR_ARTIFACT_JSON with this JSON shape:",
5737
- '{"schemaVersion":1,"verdict":"pass|request-revision","failureClass":"syntax|runtime|logic|boundary|environment|governance|unknown","rootCause":"one concise reason","fixScope":["path/or/component"],"invariant":"behavior or contract to preserve","evidenceRefs":["relative/path/or/node"],"rawLogFallbackAllowed":false}',
5788
+ '{"schemaVersion":1,"verdict":"pass|request-revision","failureClass":"syntax|runtime|logic|boundary|environment|governance|unknown","rootCause":"one concise reason","fixScope":["path/or/component"],"invariant":"behavior or contract to preserve","evidenceRefs":["relative/path/or/node"],"rawLogFallbackAllowed":false,"disposition":"repairable|blocked-boundary|no-op-pass","requiredScope":["out-of-boundary/path"]}',
5738
5789
  `For request-revision, fixScope must be inside ${repairId} allowedPaths/writeSet. For pass, use an empty fixScope array.`,
5790
+ "disposition defaults to repairable when omitted (backward compatible). Use blocked-boundary when the needed fix is outside repair write authority; put the out-of-boundary paths in requiredScope (report-only, never expands write authority). Use no-op-pass only when no code change is warranted.",
5739
5791
  `Audit boundary drift, verification gaps, and whether ${repairId} should perform bounded fixes. Read-only: do not modify files.`,
5740
5792
  buildSourceContextBlock(sources),
5741
5793
  ].join("\n\n"),
@@ -5771,7 +5823,9 @@ function buildProcessGateNode(sources) {
5771
5823
  };
5772
5824
  }
5773
5825
  function buildRepairNode(sources) {
5774
- const implement = resolveImplementPaths(sources.taskConfig);
5826
+ const implement = resolveImplementPaths(sources.taskConfig, {
5827
+ repoRoot: sources.repoRoot,
5828
+ });
5775
5829
  return {
5776
5830
  id: repairNodeId(),
5777
5831
  depends_on: ["process-gate-shell", "process-supervisor-pi"],
@@ -9,17 +9,31 @@ export const repairFailureClassSchema = z.enum([
9
9
  "governance",
10
10
  "unknown",
11
11
  ]);
12
+ export const repairDispositionSchema = z.enum([
13
+ "repairable",
14
+ "blocked-boundary",
15
+ "no-op-pass",
16
+ ]);
12
17
  export const repairArtifactSchema = z.object({
13
18
  schemaVersion: z.literal(1),
14
19
  verdict: z.enum(["pass", "request-revision"]),
15
20
  failureClass: repairFailureClassSchema,
16
- rootCause: z
17
- .string()
18
- .min(1, { message: 'repair artifact field "rootCause" must be a non-empty string' }),
21
+ rootCause: z.string().min(1, {
22
+ message: 'repair artifact field "rootCause" must be a non-empty string',
23
+ }),
19
24
  fixScope: z.array(z.string().min(1)).default([]),
20
25
  invariant: z.string().min(1),
21
26
  evidenceRefs: z.array(z.string().min(1)).default([]),
22
27
  rawLogFallbackAllowed: z.boolean().default(false),
28
+ /**
29
+ * Optional disposition for convergence routing. Omitted values are treated as
30
+ * `repairable` for backward compatibility with older artifacts.
31
+ */
32
+ disposition: repairDispositionSchema.optional(),
33
+ /**
34
+ * Report-only out-of-boundary paths. Never expands write authority.
35
+ */
36
+ requiredScope: z.array(z.string().min(1)).optional(),
23
37
  });
24
38
  /**
25
39
  * Canonical rootCause substituted when a `verdict: "pass"` repair artifact
@@ -117,7 +131,23 @@ function scopeWithinPatterns(scope, patterns) {
117
131
  }
118
132
  export function validateRepairArtifactScope(input) {
119
133
  const { artifact, repairTask } = input;
134
+ const disposition = artifact.disposition ?? "repairable";
135
+ const requiredScope = artifact.requiredScope ?? [];
136
+ for (const scope of requiredScope) {
137
+ if (!isPathLikeScope(scope)) {
138
+ return {
139
+ ok: false,
140
+ reason: `requiredScope "${scope}" must be a concrete path or glob (report-only; does not expand write authority)`,
141
+ };
142
+ }
143
+ }
120
144
  if (artifact.verdict === "pass") {
145
+ if (disposition === "blocked-boundary") {
146
+ return {
147
+ ok: false,
148
+ reason: "pass repair artifact cannot use blocked-boundary disposition",
149
+ };
150
+ }
121
151
  if (artifact.fixScope.length > 0) {
122
152
  return {
123
153
  ok: false,
@@ -126,6 +156,67 @@ export function validateRepairArtifactScope(input) {
126
156
  }
127
157
  return { ok: true, artifact };
128
158
  }
159
+ if (disposition === "no-op-pass") {
160
+ return {
161
+ ok: false,
162
+ reason: "request-revision repair artifact cannot use no-op-pass disposition",
163
+ };
164
+ }
165
+ // blocked-boundary may report empty fixScope (nothing in-boundary to fix)
166
+ // while requiredScope describes the out-of-boundary need.
167
+ if (disposition === "blocked-boundary") {
168
+ if (requiredScope.length === 0) {
169
+ return {
170
+ ok: false,
171
+ reason: "blocked-boundary request-revision requires non-empty requiredScope (report-only)",
172
+ };
173
+ }
174
+ if (!repairTask) {
175
+ return {
176
+ ok: false,
177
+ reason: "repair artifact gate cannot find downstream repair task",
178
+ };
179
+ }
180
+ const allowed = repairTask.allowedPaths ?? [];
181
+ const writeSet = repairTask.writeSet ?? [];
182
+ const forbidden = repairTask.forbiddenPaths ?? [];
183
+ const hasOutOfBoundaryRequiredScope = requiredScope.some((scope) => {
184
+ const normalized = normalizePath(scope);
185
+ return (!scopeWithinPatterns(scope, allowed) ||
186
+ !scopeWithinPatterns(scope, writeSet) ||
187
+ forbidden.some((pattern) => pathMatchesPattern(normalized, pattern)));
188
+ });
189
+ if (!hasOutOfBoundaryRequiredScope) {
190
+ return {
191
+ ok: false,
192
+ reason: "blocked-boundary requiredScope must include a path outside repair task allowedPaths/writeSet",
193
+ };
194
+ }
195
+ if (artifact.fixScope.length > 0) {
196
+ for (const scope of artifact.fixScope) {
197
+ if (!isPathLikeScope(scope)) {
198
+ return {
199
+ ok: false,
200
+ reason: `fixScope "${scope}" must be a concrete path or glob inside repair task allowedPaths/writeSet`,
201
+ };
202
+ }
203
+ if (!scopeWithinPatterns(scope, allowed) ||
204
+ !scopeWithinPatterns(scope, writeSet)) {
205
+ return {
206
+ ok: false,
207
+ reason: `fixScope "${scope}" is outside repair task allowedPaths/writeSet`,
208
+ };
209
+ }
210
+ if (forbidden.some((pattern) => pathMatchesPattern(normalizePath(scope), pattern))) {
211
+ return {
212
+ ok: false,
213
+ reason: `fixScope "${scope}" matches repair task forbiddenPaths`,
214
+ };
215
+ }
216
+ }
217
+ }
218
+ return { ok: true, artifact };
219
+ }
129
220
  if (artifact.fixScope.length === 0) {
130
221
  return {
131
222
  ok: false,
@@ -148,7 +239,8 @@ export function validateRepairArtifactScope(input) {
148
239
  reason: `fixScope "${scope}" must be a concrete path or glob inside repair task allowedPaths/writeSet`,
149
240
  };
150
241
  }
151
- if (!scopeWithinPatterns(scope, allowed) || !scopeWithinPatterns(scope, writeSet)) {
242
+ if (!scopeWithinPatterns(scope, allowed) ||
243
+ !scopeWithinPatterns(scope, writeSet)) {
152
244
  return {
153
245
  ok: false,
154
246
  reason: `fixScope "${scope}" is outside repair task allowedPaths/writeSet`,
@@ -255,12 +347,16 @@ export function resolveRepairTaskForGate(input) {
255
347
  };
256
348
  }
257
349
  export function formatRepairArtifactForPrompt(artifact) {
350
+ const disposition = artifact.disposition ?? "repairable";
351
+ const requiredScope = artifact.requiredScope ?? [];
258
352
  return [
259
353
  "Repair artifact:",
260
354
  `- verdict: ${artifact.verdict}`,
261
355
  `- failureClass: ${artifact.failureClass}`,
262
356
  `- rootCause: ${artifact.rootCause}`,
357
+ `- disposition: ${disposition}`,
263
358
  `- fixScope: ${artifact.fixScope.length > 0 ? artifact.fixScope.join(", ") : "(none)"}`,
359
+ `- requiredScope: ${requiredScope.length > 0 ? requiredScope.join(", ") : "(none)"}`,
264
360
  `- invariant: ${artifact.invariant}`,
265
361
  `- evidenceRefs: ${artifact.evidenceRefs.length > 0 ? artifact.evidenceRefs.join(", ") : "(none)"}`,
266
362
  ].join("\n");
@@ -254,7 +254,10 @@ export function computeResetClosure(spec, effectiveFromNodeId) {
254
254
  if (!tasks.has(effectiveFromNodeId)) {
255
255
  return [];
256
256
  }
257
- return [effectiveFromNodeId, ...collectReachableDescendants(spec, effectiveFromNodeId)].sort();
257
+ return [
258
+ effectiveFromNodeId,
259
+ ...collectReachableDescendants(spec, effectiveFromNodeId),
260
+ ].sort();
258
261
  }
259
262
  export function assessResetClosureSafety(spec, resetNodeIds) {
260
263
  const tasks = taskById(spec);
@@ -355,7 +358,8 @@ function computeVerificationPolicy(spec, parentRunId, resetNodeIds, importedNode
355
358
  function deriveSuggestedAction(input) {
356
359
  if (input.eligible)
357
360
  return "dag-rerun";
358
- if (input.workerManaged || input.reasonCodes.includes("worker-managed-rerun-unsupported")) {
361
+ if (input.workerManaged ||
362
+ input.reasonCodes.includes("worker-managed-rerun-unsupported")) {
359
363
  return "worker-task-retry";
360
364
  }
361
365
  if (input.parentLifecycle === "paused")
@@ -449,7 +453,8 @@ export async function evaluateDagRerunPlan(input) {
449
453
  input.currentControllerFingerprint === input.parentControllerFingerprint;
450
454
  const workspaceMatched = input.parentTerminalWorkspace?.fingerprint !== undefined &&
451
455
  input.currentWorkspace?.fingerprint !== undefined &&
452
- input.parentTerminalWorkspace.fingerprint === input.currentWorkspace.fingerprint;
456
+ input.parentTerminalWorkspace.fingerprint ===
457
+ input.currentWorkspace.fingerprint;
453
458
  const skillSnapshotVerified = input.skillSnapshotOk !== false;
454
459
  const sourceBindingMatched = input.bindingStatus?.sourceBindingMatched ?? true;
455
460
  const taskContractBindingMatched = input.bindingStatus?.taskContractBindingMatched ?? true;
@@ -499,7 +504,8 @@ export async function evaluateDagRerunPlan(input) {
499
504
  blockedReasons.push("workspace-checkpoint-missing");
500
505
  }
501
506
  else if (input.currentWorkspace?.fingerprint &&
502
- input.parentTerminalWorkspace.fingerprint !== input.currentWorkspace.fingerprint) {
507
+ input.parentTerminalWorkspace.fingerprint !==
508
+ input.currentWorkspace.fingerprint) {
503
509
  reasonCodes.push("workspace-drift");
504
510
  blockedReasons.push("workspace-drift");
505
511
  }
@@ -566,9 +572,10 @@ export async function evaluateDagRerunPlan(input) {
566
572
  const totalPi = countExecutorNodes(input.parentSpec, allNodeIds, "pi");
567
573
  const resetPi = countExecutorNodes(input.parentSpec, resetNodeIds, "pi");
568
574
  const resetShell = countExecutorNodes(input.parentSpec, resetNodeIds, "shell");
569
- const workerAssociation = input.workerAssociation ?? (input.workerManaged
570
- ? { kind: "worker-managed" }
571
- : { kind: "standalone" });
575
+ const workerAssociation = input.workerAssociation ??
576
+ (input.workerManaged
577
+ ? { kind: "worker-managed" }
578
+ : { kind: "standalone" });
572
579
  const planWithoutHash = {
573
580
  schemaVersion: 1,
574
581
  eligible,
@@ -163,7 +163,7 @@ export const dagFrontendVerificationBundleSchema = z
163
163
  mockCommands: z.array(z.string()).default([]),
164
164
  lintCommands: z.array(z.string().min(1)).optional(),
165
165
  staticCommands: z.array(z.string()).min(1),
166
- behaviorCommands: z.array(z.string()).min(1),
166
+ behaviorCommands: z.array(z.string()).default([]),
167
167
  mockEvidence: dagShellVerifyEvidenceSchema.optional(),
168
168
  lintEvidence: dagShellVerifyEvidenceSchema.optional(),
169
169
  staticEvidence: dagShellVerifyEvidenceSchema,
@@ -391,6 +391,12 @@ export const dagShellConfigSchema = z.object({
391
391
  repairArtifactGate: dagRepairArtifactGateSchema.optional(),
392
392
  /** fail (default): any nonzero command fails the node. record: finish node FINISHED with failure facts for downstream assess/repair. */
393
393
  nonZeroExitPolicy: z.enum(["fail", "record"]).optional(),
394
+ /**
395
+ * When true/omitted, stop after the first failed command (runtime default true).
396
+ * When false, continue executing remaining commands and aggregate failures.
397
+ * Optional on the schema so existing shell literals stay valid; executor applies default.
398
+ */
399
+ failFast: z.boolean().optional(),
394
400
  envAllowlist: z.array(z.string()).optional(),
395
401
  timeoutMs: z.number().optional(),
396
402
  cwd: z.string().optional(),
@@ -94,11 +94,47 @@ function computeFingerprint(input) {
94
94
  gitHeadSha: input.gitHeadSha,
95
95
  statusPorcelainSha256: input.statusPorcelainSha256,
96
96
  changedPaths: input.changedPaths,
97
+ stableGovernanceInputs: input.stableGovernanceInputs,
97
98
  });
98
99
  return sha256Hex(payload);
99
100
  }
101
+ /**
102
+ * Stable Task Contract governance inputs for same-run hard-verify reuse.
103
+ * Explicitly excludes `.harness/dag-runs/**` and other runner-owned volatile facts.
104
+ */
105
+ export function listStableGovernanceInputPaths(taskId) {
106
+ const safeTaskId = String(taskId ?? "").trim();
107
+ if (!safeTaskId ||
108
+ safeTaskId.includes("..") ||
109
+ safeTaskId.includes("/") ||
110
+ safeTaskId.includes("\\")) {
111
+ return [];
112
+ }
113
+ const base = `.harness/tasks/${safeTaskId}`;
114
+ return [
115
+ `${base}/task.json`,
116
+ `${base}/dag.json`,
117
+ `${base}/source/需求.md`,
118
+ `${base}/source/执行约束.md`,
119
+ `${base}/source/source-manifest.json`,
120
+ ];
121
+ }
122
+ async function hashStableGovernanceInputs(repoRoot, taskId) {
123
+ const paths = listStableGovernanceInputPaths(taskId);
124
+ const entries = [];
125
+ for (const relPath of paths) {
126
+ const hashed = await hashWorkspacePath(repoRoot, relPath, "modified");
127
+ entries.push({
128
+ path: relPath,
129
+ contentSha256: hashed.contentSha256 ?? sha256Hex(`missing:${relPath}`),
130
+ });
131
+ }
132
+ entries.sort((a, b) => a.path.localeCompare(b.path));
133
+ return entries;
134
+ }
100
135
  /**
101
136
  * Capture a workspace checkpoint for the current working tree.
137
+ * When `taskId` is provided, fingerprint includes stable ignored Task Contract inputs.
102
138
  */
103
139
  export async function captureWorkspaceCheckpoint(repoRoot, options) {
104
140
  const capturedAt = (options?.now ?? new Date()).toISOString();
@@ -127,10 +163,15 @@ export async function captureWorkspaceCheckpoint(repoRoot, options) {
127
163
  });
128
164
  }
129
165
  changedPaths.sort((a, b) => a.path.localeCompare(b.path));
166
+ const taskId = options?.taskId?.trim();
167
+ const stableGovernanceInputs = taskId
168
+ ? await hashStableGovernanceInputs(repoRoot, taskId)
169
+ : undefined;
130
170
  const fingerprint = computeFingerprint({
131
171
  gitHeadSha,
132
172
  statusPorcelainSha256,
133
173
  changedPaths,
174
+ stableGovernanceInputs,
134
175
  });
135
176
  return {
136
177
  schemaVersion: 1,
@@ -138,6 +179,7 @@ export async function captureWorkspaceCheckpoint(repoRoot, options) {
138
179
  gitHeadSha,
139
180
  statusPorcelainSha256,
140
181
  changedPaths,
182
+ ...(stableGovernanceInputs ? { stableGovernanceInputs } : {}),
141
183
  fingerprint,
142
184
  };
143
185
  }
@@ -29,7 +29,7 @@
29
29
  主会话(含 openCode、Cursor Chat、其他宿主 agent)= **operator-only**;skills 与本文件是纪律文档,**不能**替代 `task.json` / DAG `writeSet` / runtime 执法。
30
30
 
31
31
  | 类别 | 规则 |
32
- |---|---|
32
+ | --- | --- |
33
33
  | **允许** | 已发布 `loop-agent` / `agent-worker` CLI;只读 status/doctor/report/inspect/observe;准备 `source/*` 与 `task.json` 边界;human gate;shell 验证与 handoff。 |
34
34
  | **禁止** | 绕过 CLI 用宿主 Edit/Write/ApplyPatch 直接改业务实现;CLI/DAG 失败后「救火改文件」;用聊天自述代替 shell 验证。 |
35
35
  | **失败时** | `dag doctor` / `dag report` / `dag reconcile-run`(及适用 worker reconcile);修正任务源/`task.json`/DAG 后经 CLI 重跑。 |
@@ -40,7 +40,7 @@
40
40
  ### 自然语言入口路由
41
41
 
42
42
  | 用户表达 | 入口 | 执行动作 |
43
- |---|---|---|
43
+ | --- | --- | --- |
44
44
  | loop-agent 初始化 / loop agent 初始化 / loop agent初始化 / 初始化 loop-agent | 初始化 | 完成确定性初始化闭环 |
45
45
  | 初始化更新校验 / 检查初始化更新 / loop-agent 初始化更新校验 / loop agent初始化更新校验 | 更新校验 | 只读报告,不写入 |
46
46
  | 初始化对齐 / 升级后对齐 / reconcile 初始化 / loop-agent 初始化对齐 | 升级对齐 | 自动应用确定性安全动作;surface 缺失、人工决策、活跃 DAG/Worker 或 Worker 状态无法确认时零写入 |
@@ -127,7 +127,7 @@ agent-worker console serve --repo . --port 8790 # 兼容入口,等价于上
127
127
 
128
128
  live run 先用 `loop-agent dag status --run-id <run-id>` 看 lifecycle 与 liveness;用 `loop-agent dag report --run-id <run-id> --markdown` 读 facts;失败/paused 用 `loop-agent dag doctor --run-id <run-id> --markdown`。生命周期对齐先只读运行 `loop-agent dag reconcile-run --run-id <run-id>`;只有 runner 已停止且 operator 明确提供 `--action supersede|abandon --reason "..."` 时才允许变更。失败 run 用 `loop-agent dag closeout-draft --run-id <run-id>` 生成 failure handoff,不要写成成功 closeout。
129
129
 
130
- Runner heartbeat 只证明 lease;Provider/tool/output 活动才代表 meaningful progress,不得仅凭运行时长结束节点。恢复:status/doctor/report → classify → reconcile/replan → CLI 重跑 → shell verify。**禁止**把主会话直接 Edit 业务代码当作恢复手段。
130
+ Operator 须持续监控 live run,直到 controller 报告 run 已结束(节点/流程终态如 `FINISHED`、`FAILED` 或 `partial_failed`),或 Decision Gate **需要 approve**;不要在节点仍运行时假定完成。判活须组合 runner heartbeat、session events 与 `dag doctor` liveness/provider meaningful progress;Runner heartbeat 只证明 lease,alone ≠ progress,不得仅凭运行时长结束节点。exclusive writer(如 `implement-pi` / `repair-pi`)运行期间:主会话与其他 writer **不得并发修改工作区**,以免 write-guard 错误归因;只读 status/doctor/report/observe approve/reject/resume CLI 仍允许。恢复:status/doctor/report → classify → reconcile/replan → CLI 重跑 → shell verify。**禁止**把主会话直接 Edit 业务代码当作恢复手段。
131
131
 
132
132
  ### 运行态与验证
133
133
 
package/harness.json CHANGED
@@ -59,7 +59,7 @@
59
59
  "pi": {
60
60
  "description": "Pi planning, review, diagnosis, and bounded writing when DAG toolProfile=write",
61
61
  "LOW": "minimax-m3",
62
- "MED": {"model": "deepseek/deepseek-v4-flash","thinking": "max"},
62
+ "MED": "grok-4.5",
63
63
  "HIGH": "gpt-5.6-sol"
64
64
  }
65
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.26.2",
3
+ "version": "0.26.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",