@tea-agent/loop-agent 0.22.0 → 0.24.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 (103) hide show
  1. package/AGENTS.md +42 -108
  2. package/CHANGELOG.md +85 -0
  3. package/README.md +8 -5
  4. package/bin/agent-worker.js +0 -0
  5. package/dist/application/context-usage/skill-resolution-stats.js +263 -0
  6. package/dist/application/dag/generate-task-dag.js +17 -3
  7. package/dist/cli/command-definitions.js +8 -7
  8. package/dist/cli/program.js +17 -15
  9. package/dist/commands/doctor.js +269 -18
  10. package/dist/commands/init.js +101 -86
  11. package/dist/commands/stats.js +40 -11
  12. package/dist/executors/shell-executor.js +20 -7
  13. package/dist/shared/operator/capabilities.js +486 -3
  14. package/dist/worker/console/app-data.js +6 -0
  15. package/dist/worker/console/chat/artifact-card.js +23 -0
  16. package/dist/worker/console/chat/chat-event-store.js +495 -0
  17. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  18. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  19. package/dist/worker/console/chat/context-panel.js +54 -0
  20. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  21. package/dist/worker/console/chat/explore-tools.js +299 -0
  22. package/dist/worker/console/chat/human-gate-card.js +37 -0
  23. package/dist/worker/console/chat/instruction-skills.js +217 -0
  24. package/dist/worker/console/chat/interview-adapter.js +136 -0
  25. package/dist/worker/console/chat/model-resolver.js +106 -0
  26. package/dist/worker/console/chat/operation-card.js +23 -0
  27. package/dist/worker/console/chat/pi-console-config.js +158 -0
  28. package/dist/worker/console/chat/pi-runtime.js +1143 -0
  29. package/dist/worker/console/chat/repo-browser.js +140 -0
  30. package/dist/worker/console/chat/repo-walk.js +116 -0
  31. package/dist/worker/console/chat/resource-loader.js +67 -0
  32. package/dist/worker/console/chat/routes.js +1646 -0
  33. package/dist/worker/console/chat/runtime-context.js +24 -0
  34. package/dist/worker/console/chat/runtime-selection.js +37 -0
  35. package/dist/worker/console/chat/session-store.js +437 -0
  36. package/dist/worker/console/chat/shortcuts.js +15 -0
  37. package/dist/worker/console/chat/tool-adapter.js +125 -0
  38. package/dist/worker/console/chat/tools.js +195 -0
  39. package/dist/worker/console/chat/usage.js +37 -0
  40. package/dist/worker/console/chat/workspace-landing.js +56 -0
  41. package/dist/worker/console/dag-confirmation.js +42 -8
  42. package/dist/worker/console/human-gate-token.js +130 -0
  43. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  44. package/dist/worker/console/operation-runner.js +6 -2
  45. package/dist/worker/console/operation-sse.js +26 -0
  46. package/dist/worker/console/operator-actions.js +420 -7
  47. package/dist/worker/console/server.js +68 -1
  48. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  49. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  50. package/dist/worker/console/static/index.html +2 -2
  51. package/dist/worker/feature/profile-schema.js +1 -1
  52. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  53. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  54. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  55. package/dist/workflows/dag/init-hybrid.js +71 -22
  56. package/dist/workflows/dag/node-execution.js +38 -1
  57. package/dist/workflows/dag/output-protocol.js +89 -0
  58. package/dist/workflows/dag/prompt.js +35 -1
  59. package/dist/workflows/dag/recovery-recommendation.js +45 -0
  60. package/dist/workflows/dag/report.js +28 -1
  61. package/dist/workflows/dag/rerun-task.js +1 -1
  62. package/dist/workflows/dag/scheduler.js +9 -0
  63. package/dist/workflows/dag/types.js +12 -0
  64. package/dist/workflows/dag/validate.js +55 -0
  65. package/docs/README.md +73 -156
  66. package/docs/architecture/README.md +7 -6
  67. package/docs/architecture/dag-execution.md +2 -2
  68. package/docs/architecture/evolution.md +16 -14
  69. package/docs/architecture/system-overview.md +1 -1
  70. package/docs/architecture/worker-and-feature.md +3 -3
  71. package/docs/governance/README.md +15 -0
  72. package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
  73. package/docs/init-surface.manifest.json +22 -4
  74. package/docs/operations/README.md +12 -0
  75. package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
  76. package/docs/skills/vetted-skill-registry.md +23 -3
  77. package/docs/templates/README.md +55 -0
  78. package/docs/templates/backend-test-dag.json +2 -2
  79. package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
  80. package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
  81. package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
  82. package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
  83. package/docs/templates/hybrid-dag.json +1 -1
  84. package/docs/templates/progress-log.md +9 -2
  85. package/harness.json +4 -4
  86. package/package.json +5 -5
  87. package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
  88. package/skills/agent-worker/SKILL.md +1 -1
  89. package/skills/grill-with-docs/SKILL.md +44 -52
  90. package/skills/grill-with-docs/adr-format.md +37 -26
  91. package/skills/grill-with-docs/context-format.md +18 -26
  92. package/skills/loop-agent/SKILL.md +28 -112
  93. package/skills/loop-agent/references/command-reference.md +9 -3
  94. package/skills/loop-agent/references/harness-policy.md +3 -3
  95. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  96. package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
  97. package/skills/loop-agent/references/task-workflow.md +2 -0
  98. package/skills/systematic-debugging/SKILL.md +20 -4
  99. package/skills/test-driven-development/SKILL.md +10 -3
  100. package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
  101. package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
  102. /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
  103. /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Loop 操作台 · Operator Console</title>
7
- <script type="module" crossorigin src="/assets/index-CUDke82y.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-wSEksVSO.css">
7
+ <script type="module" crossorigin src="/assets/index-D9qLevoP.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BTbrEHnO.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  * `fullstack-v1` is NOT a new DAG or taskKind. It is `agent-worker`'s
6
6
  * deterministic structural validation + read-model projection policy over a
7
7
  * Feature Packet. See
8
- * `docs/design/agent-worker-fullstack-workflow-integration.md` §9.
8
+ * `docs/design/active/agent-worker-fullstack-workflow-integration.md` §9.
9
9
  *
10
10
  * A Feature Packet declares its profile via an optional `feature.yaml` next to
11
11
  * `acceptance.yaml`. A packet without `feature.yaml` (or one that does not set
@@ -97,13 +97,19 @@ export async function inspectBackendTestEnvironment(input) {
97
97
  }
98
98
  fixtures.push(...extractFixtures(content));
99
99
  }
100
- const htmlRenderer = /--html\b/.test(input.pytestHelp)
100
+ const hasPytestHtml = /--html\b/.test(input.pytestHelp);
101
+ const htmlRenderer = hasPytestHtml
101
102
  ? "pytest-html --self-contained-html"
102
- : "loop-agent built-in JUnit-to-HTML";
103
+ : "missing pytest-html (required: pip install pytest-html)";
103
104
  const warnings = [
104
105
  ...(pytestConfigs.length === 0
105
106
  ? ["No explicit pytest configuration file was found."]
106
107
  : []),
108
+ ...(!hasPytestHtml
109
+ ? [
110
+ "pytest-html is not installed; the backend-test pipeline requires it to render the self-contained HTML report. Install with: pip install pytest-html",
111
+ ]
112
+ : []),
107
113
  ...(conftestFiles.length === 0
108
114
  ? ["No bounded conftest.py candidate was found."]
109
115
  : []),
@@ -843,7 +849,7 @@ export function renderBackendTestFacts(input) {
843
849
  `| ${input.pytestExitCode === 0 ? "通过" : "未通过"} | ${input.parsed.tests} | ${input.parsed.passed} | ${input.parsed.failed} | ${input.parsed.errors} | ${input.parsed.skipped} | ${(passRate * 100).toFixed(2)}% | ${formatDuration(input.parsed.durationMs)} |`,
844
850
  "",
845
851
  `- Pytest exit code: ${input.pytestExitCode}`,
846
- "- JUnit valid: yes",
852
+ "- Pytest-html report: valid",
847
853
  "- HTML valid: yes",
848
854
  "",
849
855
  "## 质量校验",
@@ -872,8 +878,6 @@ export function renderBackendTestFacts(input) {
872
878
  "",
873
879
  "## 证据与完整校验原文",
874
880
  "",
875
- `- JUnit: ${input.junitRelativePath}`,
876
- `- JUnit SHA-256: ${createHash("sha256").update(input.junitContent).digest("hex")}`,
877
881
  `- HTML: ${input.htmlRelativePath}`,
878
882
  `- HTML SHA-256: ${createHash("sha256").update(input.htmlContent).digest("hex")}`,
879
883
  "",
@@ -354,6 +354,235 @@ export function parseJunitXml(xml) {
354
354
  failures: failures.slice(0, MAX_FAILURES),
355
355
  };
356
356
  }
357
+ const HTML_ENTITY_MAP = {
358
+ "&amp;": "&",
359
+ "&lt;": "<",
360
+ "&gt;": ">",
361
+ "&quot;": '"',
362
+ "&#34;": '"',
363
+ "&#39;": "'",
364
+ "&apos;": "'",
365
+ };
366
+ function decodeHtmlEntities(value) {
367
+ return value.replace(/&(?:amp|lt|gt|quot|#34|#39|apos);/g, (entity) => HTML_ENTITY_MAP[entity] ?? entity);
368
+ }
369
+ function parseDurationLabelMs(raw) {
370
+ if (!raw)
371
+ return undefined;
372
+ const value = Number.parseFloat(raw);
373
+ if (!Number.isFinite(value))
374
+ return undefined;
375
+ if (/\bms\b/i.test(raw))
376
+ return Math.round(value);
377
+ if (/\bsec|s\b/i.test(raw))
378
+ return Math.round(value * 1000);
379
+ // pytest-html commonly emits bare milliseconds without a unit suffix.
380
+ return Math.round(value);
381
+ }
382
+ /**
383
+ * Extract the pytest-html 4.x JSON island from a self-contained HTML report.
384
+ *
385
+ * pytest-html 4.x embeds the full report payload as an HTML-entity-escaped
386
+ * JSON string in `<div id="data-container" data-jsonblob="...">`. This helper
387
+ * locates that attribute, decodes entities and parses the JSON. Fail-closed on
388
+ * a non-pytest-html document so the pipeline never silently drops test facts.
389
+ */
390
+ export function extractPytestHtmlReportData(html) {
391
+ const match = html.match(/id=["']data-container["'][^>]*data-jsonblob=["']([^"']*)["']/i);
392
+ if (!match || !match[1]) {
393
+ throw new Error("invalid pytest-html report: missing data-container data-jsonblob island");
394
+ }
395
+ const decoded = decodeHtmlEntities(match[1]);
396
+ try {
397
+ return JSON.parse(decoded);
398
+ }
399
+ catch (error) {
400
+ throw new Error(`invalid pytest-html report: data-jsonblob is not valid JSON (${error instanceof Error ? error.message : String(error)})`);
401
+ }
402
+ }
403
+ /**
404
+ * Parse a self-contained pytest-html 4.x report into the same ParsedJunit
405
+ * shape used by the HTML/facts renderers, so the downstream rendering path
406
+ * stays uniform regardless of the evidence source.
407
+ *
408
+ * Unlike JUnit, pytest-html stores per-test captured stdout in a `log` field
409
+ * (with section markers like "Captured stdout call"). We surface that as the
410
+ * case `stdout` so HTTP_REQUEST/HTTP_RESPONSE lines remain visible in the
411
+ * per-case cards.
412
+ */
413
+ export function parsePytestHtmlReport(html) {
414
+ const data = extractPytestHtmlReportData(html);
415
+ const entries = Object.entries(data.tests ?? {});
416
+ if (entries.length === 0) {
417
+ throw new Error("invalid pytest-html report: no test entries in data-jsonblob");
418
+ }
419
+ const cases = [];
420
+ const failures = [];
421
+ let passed = 0;
422
+ let failed = 0;
423
+ let errors = 0;
424
+ let skipped = 0;
425
+ for (const [nodeId, records] of entries) {
426
+ const record = records[0];
427
+ if (!record)
428
+ continue;
429
+ const rawResult = (record.result ?? "").toLowerCase();
430
+ const testId = record.testId ?? nodeId;
431
+ const { classname, name } = splitPytestNodeId(testId);
432
+ const durationMs = parseDurationLabelMs(record.duration);
433
+ const capturedLog = record.log ?? "";
434
+ // pytest-html collapses captured stdout/stderr into a single `log` field
435
+ // annotated with section markers. Preserve the whole log as stdout so the
436
+ // HTTP_REQUEST/HTTP_RESPONSE lines stay reachable for the per-case card.
437
+ const stdout = splitPytestHtmlLogSections(capturedLog).stdout || undefined;
438
+ const stderr = splitPytestHtmlLogSections(capturedLog).stderr || undefined;
439
+ if (rawResult === "passed") {
440
+ passed += 1;
441
+ cases.push({
442
+ classname,
443
+ name,
444
+ durationMs,
445
+ status: "passed",
446
+ ...(stdout ? { stdout } : {}),
447
+ ...(stderr ? { stderr } : {}),
448
+ });
449
+ }
450
+ else if (rawResult === "failed") {
451
+ failed += 1;
452
+ const message = extractPytestHtmlFailureMessage(capturedLog) || "failure";
453
+ const summary = truncate(message);
454
+ failures.push({ classname, name, message: summary, kind: "failure" });
455
+ cases.push({
456
+ classname,
457
+ name,
458
+ durationMs,
459
+ status: "failure",
460
+ message: summary,
461
+ details: capturedLog || summary,
462
+ ...(stdout ? { stdout } : {}),
463
+ ...(stderr ? { stderr } : {}),
464
+ });
465
+ }
466
+ else if (rawResult === "error") {
467
+ errors += 1;
468
+ const message = extractPytestHtmlFailureMessage(capturedLog) || "error";
469
+ const summary = truncate(message);
470
+ failures.push({ classname, name, message: summary, kind: "error" });
471
+ cases.push({
472
+ classname,
473
+ name,
474
+ durationMs,
475
+ status: "error",
476
+ message: summary,
477
+ details: capturedLog || summary,
478
+ ...(stdout ? { stdout } : {}),
479
+ ...(stderr ? { stderr } : {}),
480
+ });
481
+ }
482
+ else if (rawResult === "skipped" || rawResult === "xfailed") {
483
+ skipped += 1;
484
+ cases.push({
485
+ classname,
486
+ name,
487
+ durationMs,
488
+ status: "skipped",
489
+ ...(stdout ? { stdout } : {}),
490
+ ...(stderr ? { stderr } : {}),
491
+ });
492
+ }
493
+ else {
494
+ // Unknown outcome label: treat as error to stay fail-safe.
495
+ errors += 1;
496
+ const message = `unexpected pytest-html result label: ${record.result ?? "(empty)"}`;
497
+ const summary = truncate(message);
498
+ failures.push({ classname, name, message: summary, kind: "error" });
499
+ cases.push({
500
+ classname,
501
+ name,
502
+ durationMs,
503
+ status: "error",
504
+ message: summary,
505
+ details: capturedLog || summary,
506
+ ...(stdout ? { stdout } : {}),
507
+ ...(stderr ? { stderr } : {}),
508
+ });
509
+ }
510
+ }
511
+ const tests = cases.length;
512
+ return {
513
+ tests,
514
+ passed,
515
+ failed,
516
+ errors,
517
+ skipped,
518
+ durationMs: undefined,
519
+ cases,
520
+ failures: failures.slice(0, MAX_FAILURES),
521
+ };
522
+ }
523
+ function splitPytestNodeId(testId) {
524
+ // pytest-html uses node ids like "path/to/test_x.py::TestClass::test_name".
525
+ const sepIndex = testId.lastIndexOf("::");
526
+ if (sepIndex < 0) {
527
+ return { classname: "unknown", name: testId };
528
+ }
529
+ const prefix = testId.slice(0, sepIndex);
530
+ const name = testId.slice(sepIndex + 2);
531
+ const lastParen = name.indexOf("(");
532
+ const cleanName = lastParen >= 0 ? name.slice(0, lastParen) : name;
533
+ const moduleSep = prefix.lastIndexOf("::");
534
+ const filePart = moduleSep >= 0 ? prefix.slice(moduleSep + 2) : prefix;
535
+ const moduleWithoutExt = filePart.replace(/\.py$/i, "");
536
+ return { classname: moduleWithoutExt.replace(/\//g, ".") || "unknown", name: cleanName };
537
+ }
538
+ function splitPytestHtmlLogSections(log) {
539
+ if (!log)
540
+ return { stdout: "", stderr: "" };
541
+ // pytest-html interleaves captured stdout/stderr with section markers like
542
+ // "----------------------------- Captured stdout call -----------------------------".
543
+ // String.split includes capture-group matches in the result array, so the
544
+ // layout is [beforeText, label, body, label, body, ...]. Iterate label/body
545
+ // pairs starting at index 1.
546
+ const sections = log.split(/^-+ Captured (stdout|stderr|log|call|setup|teardown)(?: call| setup| teardown)? -+$/m);
547
+ let stdout = "";
548
+ let stderr = "";
549
+ for (let index = 1; index < sections.length - 1; index += 2) {
550
+ const label = sections[index]?.toLowerCase() ?? "";
551
+ const body = sections[index + 1] ?? "";
552
+ if (label.includes("stderr")) {
553
+ stderr = `${stderr}${body}`.trim();
554
+ }
555
+ else {
556
+ stdout = `${stdout}${body}`.trim();
557
+ }
558
+ }
559
+ if (!stdout && !stderr) {
560
+ // No recognizable section markers: treat the whole log as stdout so HTTP
561
+ // request/response lines remain visible.
562
+ stdout = log.trim();
563
+ }
564
+ return { stdout, stderr };
565
+ }
566
+ function extractPytestHtmlFailureMessage(log) {
567
+ if (!log)
568
+ return "";
569
+ // pytest-html failure logs contain assertion lines prefixed with "E " and a
570
+ // trailing location line like "test_x.py:N: AssertionError". Prefer the
571
+ // explicit AssertionError/Error line; fall back to the last "E " line.
572
+ const assertionLine = log
573
+ .split(/\r?\n/)
574
+ .map((line) => line.trim())
575
+ .find((line) => /:\s*AssertionError/.test(line));
576
+ if (assertionLine)
577
+ return assertionLine.replace(/^.*?:\s*/, "");
578
+ const eLines = log
579
+ .split(/\r?\n/)
580
+ .filter((line) => /^E\s+/.test(line))
581
+ .map((line) => line.replace(/^E\s+/, "").trim());
582
+ if (eLines.length > 0)
583
+ return eLines[eLines.length - 1];
584
+ return "";
585
+ }
357
586
  export function deriveBackendTestResult(input) {
358
587
  assertNoSecrets("commandSummary", input.commandSummary);
359
588
  const emptyJunitMeta = {
@@ -87,14 +87,14 @@ function stripAnsi(value) {
87
87
  function normalizeRepoPath(workspaceRoot, candidate) {
88
88
  const clean = stripAnsi(candidate).trim().replace(/^file:\/\//, "");
89
89
  const windowsAbsolute = /^[A-Za-z]:[\\/]/.test(clean);
90
- const windowsRoot = /^[A-Za-z]:[\\/]/.test(workspaceRoot);
90
+ const posixAbsolute = path.posix.isAbsolute(clean);
91
91
  let relative;
92
- if (windowsAbsolute || windowsRoot) {
93
- if (!windowsAbsolute || !windowsRoot)
92
+ if (windowsAbsolute) {
93
+ if (!/^[A-Za-z]:[\\/]/.test(workspaceRoot))
94
94
  return null;
95
95
  relative = path.win32.relative(workspaceRoot, clean);
96
96
  }
97
- else if (path.posix.isAbsolute(clean)) {
97
+ else if (posixAbsolute) {
98
98
  relative = path.posix.relative(workspaceRoot.replaceAll("\\", "/"), clean.replaceAll("\\", "/"));
99
99
  }
100
100
  else {
@@ -18,7 +18,8 @@ import { discoverProjectGovernancePresence } from "./project-governance-context.
18
18
  import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
19
19
  import { materializeTaskReferenceDocs } from "../../task/source-references.js";
20
20
  import { observeTaskContract } from "../../task/contract/observe.js";
21
- import { resolveVerifyPreset } from "../../executors/shell-verification.js";
21
+ import { dagHasWriterExecution } from "./task-contract-binding.js";
22
+ import { DEFAULT_VERIFY_TIMEOUT_MS, resolveVerifyPreset, } from "../../executors/shell-verification.js";
22
23
  import { resolveExecutorModelMatrices } from "../../executors/model-routing.js";
23
24
  import { normalizeTaskRequirementText, resolveTaskDagTemplateSelection, } from "./task-demand-routing.js";
24
25
  import { BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT, buildBackendTestExecutionPreflightShellSnippet, } from "./backend-test-execution-contract.js";
@@ -556,7 +557,7 @@ export function hasApiDependency(sources) {
556
557
  /**
557
558
  * Resolve frontend Mock mode from capability seed, task config, and interface dependency analysis.
558
559
  *
559
- * Decision matrix (from docs/design/frontend-mock-data-workflow.md):
560
+ * Decision matrix (from docs/design/active/frontend-mock-data-workflow.md):
560
561
  *
561
562
  * | 接口/异步数据依赖 | 既有 Mock 服务 | policy | 结果 |
562
563
  * |---|---|---|---|
@@ -871,21 +872,29 @@ function buildExplicitFrontendVerifyCommands(taskConfig, repoRoot) {
871
872
  function verifyCommandKey(command) {
872
873
  return `${command.cwd}\0${command.args.join("\0")}`;
873
874
  }
874
- function resolveDagVerifyStrategy(taskConfig) {
875
+ function resolveDagVerifyStrategy(taskConfig, defaultIntermediateQuotaWhenFull = "full") {
876
+ const explicitIntermediateQuota = taskConfig.dagVerifyStrategy?.intermediateQuota;
875
877
  return {
876
- intermediateQuota: taskConfig.dagVerifyStrategy?.intermediateQuota ?? taskConfig.verifyQuota,
878
+ intermediateQuota: explicitIntermediateQuota ??
879
+ (taskConfig.verifyQuota === "full"
880
+ ? defaultIntermediateQuotaWhenFull
881
+ : taskConfig.verifyQuota),
877
882
  finalQuota: "full",
878
883
  focusedCommandSource: taskConfig.dagVerifyStrategy?.focusedCommandSource ?? "adapter",
879
884
  };
880
885
  }
881
886
  function buildVerifyEvidence(input) {
887
+ const selectedCommands = input.commands && input.commands.length > 0 ? input.commands : undefined;
888
+ const commandCount = selectedCommands?.length ?? input.fallbackCommands.length;
882
889
  return {
883
890
  phase: input.phase,
884
891
  quota: input.quota,
885
892
  commandSource: input.commandSource,
886
- commandCount: input.commands?.length ?? input.fallbackCommands.length,
887
- commandLabels: input.commands?.map((command) => command.label) ?? input.fallbackCommands,
893
+ commandCount,
894
+ commandLabels: selectedCommands?.map((command) => command.label) ?? input.fallbackCommands,
888
895
  commandTexts: input.commandTexts ?? [],
896
+ commandTimeoutMs: input.commandTimeoutMs,
897
+ totalTimeoutBudgetMs: commandCount * input.commandTimeoutMs,
889
898
  finalFullRequired: input.finalFullRequired,
890
899
  };
891
900
  }
@@ -1369,9 +1378,10 @@ export function buildStandardHybridDagFromTask(sources) {
1369
1378
  commands: finalVerifyCommands,
1370
1379
  fallbackCommands: [],
1371
1380
  finalFullRequired: true,
1381
+ commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
1372
1382
  }),
1373
1383
  cwd: ".",
1374
- timeoutMs: 300000,
1384
+ timeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
1375
1385
  },
1376
1386
  },
1377
1387
  ]
@@ -1677,9 +1687,10 @@ function buildFrontendMockVerifyNode(sources, implementId, readOnlyPaths, forbid
1677
1687
  commands: verifyCommands.length > 0 ? verifyCommands : undefined,
1678
1688
  fallbackCommands: [],
1679
1689
  commandTexts: commands,
1690
+ commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
1680
1691
  }),
1681
1692
  cwd: ".",
1682
- timeoutMs: 300000,
1693
+ timeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
1683
1694
  },
1684
1695
  };
1685
1696
  }
@@ -2017,6 +2028,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2017
2028
  commands: partitionedStaticVerifyCommands.static.commands,
2018
2029
  fallbackCommands: staticFallbackCommands,
2019
2030
  commandTexts: staticShellCommands,
2031
+ commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
2020
2032
  });
2021
2033
  const lintVerifyEvidence = lintShellCommands.length > 0
2022
2034
  ? buildVerifyEvidence({
@@ -2026,6 +2038,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2026
2038
  commands: partitionedStaticVerifyCommands.lint.commands,
2027
2039
  fallbackCommands: [],
2028
2040
  commandTexts: lintShellCommands,
2041
+ commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
2029
2042
  })
2030
2043
  : undefined;
2031
2044
  const behaviorVerifyEvidence = buildVerifyEvidence({
@@ -2036,6 +2049,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2036
2049
  fallbackCommands: behaviorFallbackCommands,
2037
2050
  commandTexts: behaviorShellCommands,
2038
2051
  finalFullRequired: true,
2052
+ commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
2039
2053
  });
2040
2054
  const mockVerifyTemplate = mockMode === "required" && hasMockVerifyCommands
2041
2055
  ? buildFrontendMockVerifyNode(frontendSources, implementId, readOnlyPaths, forbiddenPaths)
@@ -2273,7 +2287,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2273
2287
  lintEvidence: lintVerifyEvidence,
2274
2288
  },
2275
2289
  cwd: ".",
2276
- timeoutMs: 300000,
2290
+ timeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
2277
2291
  },
2278
2292
  },
2279
2293
  ]
@@ -2345,7 +2359,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2345
2359
  mode: "initial",
2346
2360
  },
2347
2361
  cwd: ".",
2348
- timeoutMs: 300000,
2362
+ timeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
2349
2363
  },
2350
2364
  },
2351
2365
  {
@@ -2406,7 +2420,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2406
2420
  mode: "repair",
2407
2421
  },
2408
2422
  cwd: ".",
2409
- timeoutMs: 300000,
2423
+ timeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
2410
2424
  },
2411
2425
  },
2412
2426
  {
@@ -3122,9 +3136,10 @@ function buildExecuteBackendPytestNode(sources, options = {}) {
3122
3136
  commandSource: "inline",
3123
3137
  fallbackCommands: [pytestCommand],
3124
3138
  finalFullRequired: true,
3139
+ commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
3125
3140
  }),
3126
3141
  cwd: ".",
3127
- timeoutMs: 300000,
3142
+ timeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
3128
3143
  },
3129
3144
  };
3130
3145
  }
@@ -3241,6 +3256,7 @@ function buildBackendTestOutcomeGateNode(sources) {
3241
3256
  commandSource: "inline",
3242
3257
  fallbackCommands: [gateCommand],
3243
3258
  finalFullRequired: true,
3259
+ commandTimeoutMs: 60_000,
3244
3260
  }),
3245
3261
  cwd: ".",
3246
3262
  timeoutMs: 60000,
@@ -3333,10 +3349,11 @@ async function buildBackendTestHybridDag(sources) {
3333
3349
  executor: "pi", toolProfile: "write", complexity: "HIGH", writePolicy: "exclusive",
3334
3350
  writeSet: ["testcase/**/test_*.py", "testcase/**/helpers/**", "testcase/**/factories/**"],
3335
3351
  allowedPaths: Array.from(new Set([...ro, "testcase/**"])), forbiddenPaths: forbidden,
3336
- 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; no JSON and no pytest execution.",
3352
+ 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.",
3337
3353
  subtask_prompt: [
3338
3354
  "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.",
3339
3355
  "Ensure every final Markdown Case ID appears in at least one real pytest test function or pytest test class method region, preferably as `test_BE_<MODULE>_<NNN>_<description>` and in that function/method docstring. Module-level functions and class-based pytest methods are both supported. Multiple test functions may cover one Case ID; assertions come only from 预期结果/Expected Results and setup comes only from 前置条件 plus any optional 测试数据/自动化映射 or their legacy English aliases.",
3356
+ "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`, and `testcase/md/order-api.md` maps to `testcase/test_order_api.py`. 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.",
3340
3357
  "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.",
3341
3358
  "Compare timestamps and other semantically equivalent protocol values by parsed meaning, not byte-for-byte serialization. In particular, normalize valid ISO-8601 instants before equality/order assertions so differences such as omitted trailing fractional seconds do not create TestBug failures; preserve exact-string assertions only when the Markdown explicitly requires representation equality.",
3342
3359
  "Before logging, recursively redact sensitive keys and header values including authorization, proxy-authorization, cookie, set-cookie, token, password, secret, api key and credentials. Never print full Authorization/Cookie values. Apply bounded truncation to serialized request and response bodies (with an explicit truncation marker) so large payloads cannot flood pytest or report artifacts.",
@@ -3981,6 +3998,7 @@ function buildMultiPerspectiveReviewNodes(input) {
3981
3998
  commandSource: "inline",
3982
3999
  fallbackCommands: [aggregateScript],
3983
4000
  finalFullRequired: true,
4001
+ commandTimeoutMs: 60_000,
3984
4002
  }),
3985
4003
  cwd: ".",
3986
4004
  timeoutMs: 60000,
@@ -4165,6 +4183,7 @@ function buildKnowledgeSyncValidateNode(sources, featureId) {
4165
4183
  commandSource: "inline",
4166
4184
  fallbackCommands: [validateScript],
4167
4185
  finalFullRequired: true,
4186
+ commandTimeoutMs: 120_000,
4168
4187
  }),
4169
4188
  cwd: ".",
4170
4189
  timeoutMs: 120000,
@@ -4385,6 +4404,7 @@ function buildKgBootstrapPreflightNode(sources) {
4385
4404
  quota: "full",
4386
4405
  commandSource: "inline",
4387
4406
  fallbackCommands: [script],
4407
+ commandTimeoutMs: 60_000,
4388
4408
  }),
4389
4409
  cwd: ".",
4390
4410
  timeoutMs: 60000,
@@ -4432,6 +4452,7 @@ function buildKgBootstrapInventoryNode(sources) {
4432
4452
  quota: "full",
4433
4453
  commandSource: "inline",
4434
4454
  fallbackCommands: [script],
4455
+ commandTimeoutMs: 120_000,
4435
4456
  }),
4436
4457
  cwd: ".",
4437
4458
  timeoutMs: 120000,
@@ -4526,6 +4547,7 @@ function buildKgBootstrapValidateNode(sources) {
4526
4547
  commandSource: "inline",
4527
4548
  fallbackCommands: [script],
4528
4549
  finalFullRequired: true,
4550
+ commandTimeoutMs: 120_000,
4529
4551
  }),
4530
4552
  cwd: ".",
4531
4553
  timeoutMs: 120000,
@@ -4653,6 +4675,7 @@ function buildKgBootstrapPromoteNode(sources) {
4653
4675
  commandSource: "inline",
4654
4676
  fallbackCommands: [script],
4655
4677
  finalFullRequired: true,
4678
+ commandTimeoutMs: 120_000,
4656
4679
  }),
4657
4680
  cwd: ".",
4658
4681
  timeoutMs: 120000,
@@ -4718,6 +4741,7 @@ function buildKgBootstrapMaterializeNode(sources) {
4718
4741
  commandSource: "inline",
4719
4742
  fallbackCommands: [script],
4720
4743
  finalFullRequired: true,
4744
+ commandTimeoutMs: 120_000,
4721
4745
  }),
4722
4746
  cwd: ".",
4723
4747
  timeoutMs: 120000,
@@ -4774,7 +4798,22 @@ export async function requireManagedTaskContractBinding(input) {
4774
4798
  taskId: input.taskId,
4775
4799
  });
4776
4800
  if (state.effectiveStatus !== "managed" || !state.ref) {
4777
- const err = new Error(`dag generate requires managed Task Contract for task ${input.taskId} (effectiveStatus=${state.effectiveStatus}); run: loop-agent task contract adopt|apply --task ${input.taskId} ...`);
4801
+ const expectedRevision = state.ref?.revision ?? 0;
4802
+ const observedCanonicalHash = state.observedCanonicalHash ?? "<observedCanonicalHash-from-show>";
4803
+ const recovery = state.effectiveStatus === "transaction-incomplete"
4804
+ ? `Recover the unfinished transaction first: loop-agent task contract recover --task ${input.taskId} --json`
4805
+ : [
4806
+ "After reviewing the show JSON, either adopt the current on-disk facts:",
4807
+ `loop-agent task contract adopt --task ${input.taskId} --expected-revision ${expectedRevision} --expected-observed-hash ${observedCanonicalHash} --request-id <unique-request-id> --request-payload-sha256 <sha256-of-this-adopt-request> --json`,
4808
+ "or apply a reviewed draft:",
4809
+ `loop-agent task contract apply --task ${input.taskId} --input <reviewed-draft.json> --expected-revision ${expectedRevision} --expected-observed-hash ${observedCanonicalHash} --request-id <unique-request-id> --request-payload-sha256 <sha256-of-input-file> --json`,
4810
+ ].join(" ");
4811
+ const err = new Error([
4812
+ `dag generate requires managed Task Contract for task ${input.taskId} (effectiveStatus=${state.effectiveStatus})`,
4813
+ `Inspect current state: loop-agent task contract show ${input.taskId} --json`,
4814
+ recovery,
4815
+ "Do not auto-adopt or auto-apply before confirming the Task Contract facts and concurrency fields.",
4816
+ ].join("; "));
4778
4817
  err.code =
4779
4818
  state.effectiveStatus === "externally-modified"
4780
4819
  ? "EXTERNALLY_MODIFIED"
@@ -4828,10 +4867,6 @@ function synthesizeInMemoryTaskContractBinding(sources) {
4828
4867
  };
4829
4868
  }
4830
4869
  async function resolveTaskContractBindingForGenerate(sources) {
4831
- // Real CLI generation freezes the managed ref. Direct builder unit tests
4832
- // may provide repoRoot-shaped fixture paths without a persisted task; keep
4833
- // their deterministic synthetic binding path so topology/prompt tests remain
4834
- // isolated from contract storage.
4835
4870
  if (sources.repoRoot) {
4836
4871
  const state = await observeTaskContract({
4837
4872
  repoRoot: sources.repoRoot,
@@ -4986,7 +5021,12 @@ function buildReviewVerdictRecoveryNode(sources) {
4986
5021
  return {
4987
5022
  id: "review-verdict-recovery-pi",
4988
5023
  depends_on: ["review-pi"],
5024
+ // AC3: recovery is a read-only failure-aware node. It runs after review-pi
5025
+ // settles, consuming either normal FINISHED output or an explicitly tolerated
5026
+ // ERROR (for example protocol-invalid after retries). It must not bypass the
5027
+ // writer, write guard, or final shell gate, which remain fail-closed.
4989
5028
  role: "reviewer",
5029
+ failureAwareDependsOn: ["review-pi"],
4990
5030
  executor: "pi",
4991
5031
  complexity: "LOW",
4992
5032
  writePolicy: "read-only",
@@ -5271,7 +5311,7 @@ function buildWriteSetGateNode(sources) {
5271
5311
  }
5272
5312
  function buildSoftVerifyNode(sources) {
5273
5313
  const implementId = implementationNodeId();
5274
- const strategy = resolveDagVerifyStrategy(sources.taskConfig);
5314
+ const strategy = resolveDagVerifyStrategy(sources.taskConfig, "1");
5275
5315
  const fallbackCommands = ["npm run typecheck"];
5276
5316
  const commands = buildVerifyShellCommands({
5277
5317
  repoRoot: sources.repoRoot,
@@ -5297,9 +5337,10 @@ function buildSoftVerifyNode(sources) {
5297
5337
  commandSource: sources.verifyCommands ? "adapter" : "inline",
5298
5338
  commands: sources.verifyCommands?.intermediate,
5299
5339
  fallbackCommands,
5340
+ commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
5300
5341
  }),
5301
5342
  cwd: ".",
5302
- timeoutMs: 300000,
5343
+ timeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
5303
5344
  },
5304
5345
  };
5305
5346
  }
@@ -5309,6 +5350,7 @@ function buildProcessSupervisorNode(sources) {
5309
5350
  return {
5310
5351
  id: "process-supervisor-pi",
5311
5352
  depends_on: ["soft-verify-shell", implementId],
5353
+ failureAwareDependsOn: ["soft-verify-shell"],
5312
5354
  role: "supervisor",
5313
5355
  executor: "pi",
5314
5356
  complexity: "HIGH",
@@ -5415,9 +5457,10 @@ function buildHardVerifyNode(sources) {
5415
5457
  commands: sources.verifyCommands?.final,
5416
5458
  fallbackCommands,
5417
5459
  finalFullRequired: true,
5460
+ commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
5418
5461
  }),
5419
5462
  cwd: ".",
5420
- timeoutMs: 300000,
5463
+ timeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
5421
5464
  },
5422
5465
  };
5423
5466
  }
@@ -5466,7 +5509,7 @@ function buildSupervisedHybridDag(standard, sources) {
5466
5509
  objective: `${standard.objective ?? ""}\n\nRoute: supervised implementation DAG selected by workflowPolicy/governanceProfile or explicit CLI profile.`.trim(),
5467
5510
  globalConstraints: supervisedConstraints,
5468
5511
  convergence: sources.taskConfig.convergence,
5469
- verifyStrategy: resolveDagVerifyStrategy(sources.taskConfig),
5512
+ verifyStrategy: resolveDagVerifyStrategy(sources.taskConfig, "1"),
5470
5513
  tasks: [
5471
5514
  cloneTask(contract),
5472
5515
  cloneTask(scoutSrc),
@@ -5536,6 +5579,12 @@ export async function writeHybridDagDraft(sources, outputPath, options = {}) {
5536
5579
  ? await prepareFrontendMockSources(sources)
5537
5580
  : sources;
5538
5581
  const spec = await buildHybridDagForTemplate(preparedSources, template);
5582
+ if (preparedSources.repoRoot && dagHasWriterExecution(spec)) {
5583
+ await requireManagedTaskContractBinding({
5584
+ repoRoot: preparedSources.repoRoot,
5585
+ taskId: preparedSources.taskId,
5586
+ });
5587
+ }
5539
5588
  await writeFile(outputPath, `${JSON.stringify(spec, null, 2)}\n`, "utf-8");
5540
5589
  return {
5541
5590
  taskId: sources.taskId,