@tea-agent/loop-agent 0.26.0 → 0.26.2

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 (197) hide show
  1. package/CHANGELOG.md +1056 -1020
  2. package/README.md +8 -3
  3. package/bin/loop-agent.js +21 -21
  4. package/dist/application/dag/generate-task-dag.js +33 -0
  5. package/dist/cli/command-definitions.js +25 -10
  6. package/dist/cli/help.js +4 -3
  7. package/dist/cli/program.js +43 -17
  8. package/dist/commands/cursor-prompt.js +6 -6
  9. package/dist/commands/import-prd.js +7 -2
  10. package/dist/commands/init.js +7 -5
  11. package/dist/commands/loop-benchmark.js +11 -11
  12. package/dist/commands/pi-reuse-benchmark.js +16 -16
  13. package/dist/commands/task-source-prepare.js +474 -0
  14. package/dist/executors/dag-pi-executor.js +40 -5
  15. package/dist/executors/shell-executor.js +111 -0
  16. package/dist/executors/shell-presets.js +12 -4
  17. package/dist/executors/shell-write-guard.js +161 -25
  18. package/dist/sidecars/cursor-prompt/executor.js +1 -1
  19. package/dist/task/config-types.js +6 -0
  20. package/dist/task/contract/constants.js +1 -0
  21. package/dist/task/contract/project.js +8 -0
  22. package/dist/task/contract/schema.js +1 -0
  23. package/dist/task/frontend-preflight.js +131 -0
  24. package/dist/task/runtime.js +2 -4
  25. package/dist/task/source-prepare/build-draft.js +224 -0
  26. package/dist/task/source-prepare/completeness.js +195 -0
  27. package/dist/task/source-prepare/index.js +7 -0
  28. package/dist/task/source-prepare/parse-intent.js +373 -0
  29. package/dist/task/source-prepare/path-policy.js +197 -0
  30. package/dist/task/source-prepare/prepare.js +506 -0
  31. package/dist/task/source-prepare/reference-integrity.js +274 -0
  32. package/dist/task/source-prepare/types.js +7 -0
  33. package/dist/worker/observability/read-model.js +134 -0
  34. package/dist/worker/observe/static/copy.js +67 -67
  35. package/dist/worker/observe/static/dag-layout.d.ts +31 -31
  36. package/dist/worker/observe/static/dag-layout.js +83 -83
  37. package/dist/worker/observe/static/dom.js +220 -220
  38. package/dist/worker/observe/static/relations.js +133 -133
  39. package/dist/worker/observe/static/router.js +93 -93
  40. package/dist/worker/observe/static/run-processing.js +148 -148
  41. package/dist/worker/observe/static/state.js +61 -0
  42. package/dist/worker/observe/static/styles.css +8 -0
  43. package/dist/worker/observe/static/views/batch.js +227 -227
  44. package/dist/worker/observe/static/views/dag-graph.js +248 -172
  45. package/dist/worker/observe/static/views/dag-inspector.js +374 -157
  46. package/dist/worker/observe/static/views/dag.js +4 -11
  47. package/dist/worker/observe/static/views/failures.js +143 -143
  48. package/dist/worker/observe/static/views/feature.js +492 -492
  49. package/dist/worker/observe/static/views/run.js +453 -453
  50. package/dist/worker/observe/static/views/shell.js +7 -7
  51. package/dist/worker/observe/static/views/timeline.js +163 -163
  52. package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
  53. package/dist/workflows/dag/canvas-observer.js +275 -275
  54. package/dist/workflows/dag/convergence/controller.js +110 -21
  55. package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
  56. package/dist/workflows/dag/frontend-review-context.js +7 -1
  57. package/dist/workflows/dag/frontend-verification-trace.js +14 -3
  58. package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
  59. package/dist/workflows/dag/init-hybrid.js +96 -34
  60. package/dist/workflows/dag/output-protocol.js +180 -7
  61. package/dist/workflows/dag/runner.js +141 -52
  62. package/dist/workflows/dag/types.js +4 -0
  63. package/dist/workflows/dag/validate.js +3 -2
  64. package/docs/skills/README.md +7 -7
  65. package/docs/templates/adr.md +60 -60
  66. package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
  67. package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
  68. package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
  69. package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
  70. package/docs/templates/agent-dag-report.schema.json +473 -473
  71. package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
  72. package/docs/templates/backend-test-dag.json +100 -8
  73. package/docs/templates/backend-test-result.schema.json +99 -99
  74. package/docs/templates/feature-spec.md +53 -53
  75. package/docs/templates/frontend-design-contract.md +42 -42
  76. package/docs/templates/frontend-eval/fixtures/failures/01-type-build-error.md +17 -17
  77. package/docs/templates/frontend-eval/fixtures/failures/02-unit-component-test-fail.md +16 -16
  78. package/docs/templates/frontend-eval/fixtures/failures/03-fixture-schema-drift.md +16 -16
  79. package/docs/templates/frontend-eval/fixtures/failures/04-missing-loading-empty-error-state.md +16 -16
  80. package/docs/templates/frontend-eval/fixtures/failures/05-forbidden-write-writeset-expansion.md +16 -16
  81. package/docs/templates/frontend-eval/fixtures/failures/06-unapproved-dependency-add.md +16 -16
  82. package/docs/templates/frontend-eval/fixtures/failures/07-mock-production-on.md +21 -21
  83. package/docs/templates/frontend-eval/fixtures/functional/01-simple-component-style.md +29 -29
  84. package/docs/templates/frontend-eval/fixtures/functional/02-form-validation.md +28 -28
  85. package/docs/templates/frontend-eval/fixtures/functional/03-list-detail-page.md +28 -28
  86. package/docs/templates/frontend-eval/fixtures/functional/04-api-mock.md +29 -29
  87. package/docs/templates/frontend-eval/fixtures/functional/05-permission-auth-gated-ui.md +27 -27
  88. package/docs/templates/frontend-eval/fixtures/functional/06-ssr-server-client-boundary.md +28 -28
  89. package/docs/templates/frontend-eval/fixtures/functional/07-shared-public-component-api.md +28 -28
  90. package/docs/templates/frontend-eval/fixtures/functional/08-pure-local-no-remote.md +27 -27
  91. package/docs/templates/frontend-eval/metrics.md +138 -138
  92. package/docs/templates/frontend-eval/smoke-targets.md +53 -53
  93. package/docs/templates/frontend-task-constraints.md +35 -35
  94. package/docs/templates/frontend-task-requirement.md +70 -70
  95. package/docs/templates/init-evolution-review.md +35 -35
  96. package/docs/templates/init-managed-agents.md +10 -5
  97. package/docs/templates/interactive-ui-round2-experiment.md +66 -66
  98. package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -118
  99. package/docs/templates/knowledge-sync-dag.json +178 -178
  100. package/docs/templates/knowledge-sync-draft.schema.json +71 -71
  101. package/docs/templates/product-line/closeout.yaml +9 -9
  102. package/docs/templates/product-line/design.md +13 -13
  103. package/docs/templates/product-line/links.md +10 -10
  104. package/docs/templates/product-line/requirement.md +17 -17
  105. package/docs/templates/product-line/test-plan.md +7 -7
  106. package/docs/templates/production-readiness-checklist.md +57 -57
  107. package/docs/templates/project-start-checklist.md +9 -9
  108. package/docs/templates/qa-report.md +48 -48
  109. package/docs/templates/sprint-contract.md +29 -29
  110. package/docs/templates/worker-dogfood-evidence.md +80 -80
  111. package/docs/templates/worker-dogfood-setup.md +68 -68
  112. package/package.json +1 -1
  113. package/scripts/kb-bootstrap-init-skeleton.sh +0 -0
  114. package/scripts/kb-graph-incremental-prepare.mjs +386 -386
  115. package/scripts/kb-graph-materialize.mjs +105 -105
  116. package/scripts/kb-graph-promote.mjs +164 -164
  117. package/scripts/kb-query.mjs +554 -554
  118. package/skills/ai-engineering-context/SKILL.md +48 -48
  119. package/skills/analyze-product-dependencies/SKILL.md +67 -67
  120. package/skills/analyze-product-dependencies/agents/openai.yaml +4 -4
  121. package/skills/analyze-product-dependencies/references/api-documentation-schema.md +30 -30
  122. package/skills/analyze-product-dependencies/references/dependency-analysis-schema.md +28 -28
  123. package/skills/analyze-product-dependencies/references/example.md +76 -76
  124. package/skills/analyze-product-dependencies/references/forward-test-cases.md +35 -35
  125. package/skills/analyze-product-dependencies/references/input-contract.md +11 -11
  126. package/skills/analyze-product-dependencies/references/scouting-rules.md +61 -61
  127. package/skills/analyze-product-dependencies/scripts/test-validators.mjs +267 -267
  128. package/skills/analyze-product-dependencies/scripts/validate-api-documentation.mjs +101 -101
  129. package/skills/analyze-product-dependencies/scripts/validate-dependency-analysis.mjs +142 -142
  130. package/skills/analyze-product-dependencies/scripts/validate-product-requirement-input.mjs +76 -76
  131. package/skills/analyze-product-dependencies/scripts/validation-helpers.mjs +146 -146
  132. package/skills/analyze-product-requirements/SKILL.md +90 -90
  133. package/skills/analyze-product-requirements/agents/openai.yaml +4 -4
  134. package/skills/analyze-product-requirements/references/acceptance-criteria.md +91 -91
  135. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +56 -56
  136. package/skills/analyze-product-requirements/references/example.md +86 -86
  137. package/skills/analyze-product-requirements/references/forward-test-cases.md +66 -66
  138. package/skills/analyze-product-requirements/references/product-analysis-schema.md +32 -32
  139. package/skills/analyze-product-requirements/references/product-requirement-schema.md +33 -33
  140. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +35 -35
  141. package/skills/analyze-product-requirements/scripts/test-validators.mjs +193 -193
  142. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +69 -69
  143. package/skills/analyze-product-requirements/scripts/validate-product-requirement.mjs +97 -97
  144. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +98 -98
  145. package/skills/analyze-product-requirements/scripts/validation-helpers.mjs +156 -156
  146. package/skills/browser-tools/browser-content.js +103 -103
  147. package/skills/browser-tools/browser-cookies.js +35 -35
  148. package/skills/browser-tools/browser-eval.js +53 -53
  149. package/skills/browser-tools/browser-hn-scraper.js +108 -108
  150. package/skills/browser-tools/browser-nav.js +44 -44
  151. package/skills/browser-tools/browser-pick.js +162 -162
  152. package/skills/browser-tools/browser-screenshot.js +34 -34
  153. package/skills/browser-tools/browser-start.js +86 -86
  154. package/skills/browser-tools/package-lock.json +2556 -2556
  155. package/skills/browser-tools/package.json +19 -19
  156. package/skills/code-review-core/SKILL.md +20 -20
  157. package/skills/codebase-scout/SKILL.md +19 -19
  158. package/skills/grill-me/SKILL.md +10 -10
  159. package/skills/loop-agent/SKILL.md +5 -2
  160. package/skills/loop-agent/references/README.md +67 -67
  161. package/skills/loop-agent/references/command-reference.md +17 -15
  162. package/skills/loop-agent/references/docs-converge.md +126 -126
  163. package/skills/loop-agent/references/harness-policy.md +3 -4
  164. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  165. package/skills/loop-agent/references/learned/README.md +21 -21
  166. package/skills/loop-agent/references/long-running-loop.md +57 -57
  167. package/skills/loop-agent/references/one-shot-runs.md +85 -85
  168. package/skills/loop-agent/references/pi-prompt.md +23 -23
  169. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +84 -84
  170. package/skills/loop-agent/references/post-implementation-and-patterns.md +1 -1
  171. package/skills/loop-agent/references/source-and-plan-practice.md +3 -2
  172. package/skills/loop-agent/references/task-workflow.md +7 -5
  173. package/skills/playwright-cli/SKILL.md +420 -420
  174. package/skills/playwright-cli/references/element-attributes.md +23 -23
  175. package/skills/playwright-cli/references/playwright-tests.md +39 -39
  176. package/skills/playwright-cli/references/request-mocking.md +87 -87
  177. package/skills/playwright-cli/references/running-code.md +241 -241
  178. package/skills/playwright-cli/references/session-management.md +225 -225
  179. package/skills/playwright-cli/references/storage-state.md +275 -275
  180. package/skills/playwright-cli/references/test-generation.md +433 -433
  181. package/skills/playwright-cli/references/tracing.md +139 -139
  182. package/skills/playwright-cli/references/video-recording.md +143 -143
  183. package/skills/requesting-code-review/SKILL.md +101 -101
  184. package/skills/requesting-code-review/code-reviewer.md +168 -168
  185. package/skills/systematic-debugging/CREATION-LOG.md +119 -119
  186. package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
  187. package/skills/systematic-debugging/condition-based-waiting.md +115 -115
  188. package/skills/systematic-debugging/defense-in-depth.md +122 -122
  189. package/skills/systematic-debugging/find-polluter.sh +63 -63
  190. package/skills/systematic-debugging/root-cause-tracing.md +169 -169
  191. package/skills/systematic-debugging/test-academic.md +14 -14
  192. package/skills/systematic-debugging/test-pressure-1.md +58 -58
  193. package/skills/systematic-debugging/test-pressure-2.md +68 -68
  194. package/skills/systematic-debugging/test-pressure-3.md +69 -69
  195. package/skills/using-git-worktrees/SKILL.md +215 -215
  196. package/skills/verification-before-completion/SKILL.md +154 -154
  197. package/skills/webapp-testing/SKILL.md +19 -19
@@ -24,6 +24,7 @@ import { materializeBackendTestExecutionContract } from "../workflows/dag/backen
24
24
  import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
25
25
  import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
26
26
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
27
+ import { assessBackendPytestCollection, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
27
28
  import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
28
29
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
29
30
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
@@ -476,6 +477,114 @@ async function executeBackendTestPipeline(input, meta) {
476
477
  };
477
478
  }
478
479
  }
480
+ else if (pipeline === "markdown-collection-assess") {
481
+ const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
482
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
483
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
484
+ const targets = mappedScripts.map(shellQuote).join(" ");
485
+ const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
486
+ const [result] = await executePipelineCommands(input, meta, [command]);
487
+ if (!result)
488
+ throw new Error("backend pytest collection command did not produce a result");
489
+ if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
490
+ return {
491
+ ok: false,
492
+ stdout: result.stdout,
493
+ stderr: result.stderr || "backend pytest collection could not start",
494
+ failureCategory: result.failureCategory,
495
+ durationMs: Date.now() - started,
496
+ };
497
+ }
498
+ const facts = assessBackendPytestCollection({
499
+ phase: "initial",
500
+ mappedScripts,
501
+ inventory,
502
+ exitCode: result.exitCode ?? 2,
503
+ stdout: result.stdout,
504
+ stderr: result.stderr,
505
+ });
506
+ const artifacts = await writeBackendPytestCollectionArtifacts({
507
+ runDir: meta.runDir,
508
+ stem: "initial",
509
+ facts,
510
+ });
511
+ outputs.push(`collectionFacts=${artifacts.factsPath}`, `collectionReport=${artifacts.reportPath}`);
512
+ return {
513
+ ok: true,
514
+ stdout: JSON.stringify({
515
+ status: facts.status,
516
+ repairEligible: facts.repairEligible,
517
+ pytestExitCode: facts.pytestExitCode,
518
+ collectedItemCount: facts.collectedItemCount,
519
+ factsPath: "contracts/backend-test-pytest-collection-initial.json",
520
+ }),
521
+ stderr: "",
522
+ durationMs: Date.now() - started,
523
+ };
524
+ }
525
+ else if (pipeline === "markdown-collection-effective") {
526
+ const initial = await readBackendPytestCollectionFacts(path.join(meta.runDir, "contracts", "backend-test-pytest-collection-initial.json"));
527
+ let effective;
528
+ if (initial.status === "PASS") {
529
+ effective = await materializeEffectiveBackendPytestCollection({
530
+ workspaceRoot: input.cwd,
531
+ initial,
532
+ });
533
+ }
534
+ else if (initial.status === "REPAIRABLE") {
535
+ const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
536
+ const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
537
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
538
+ const targets = mappedScripts.map(shellQuote).join(" ");
539
+ const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
540
+ const [result] = await executePipelineCommands(input, meta, [command]);
541
+ if (!result)
542
+ throw new Error("backend pytest final collection command did not produce a result");
543
+ if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
544
+ return {
545
+ ok: false,
546
+ stdout: result.stdout,
547
+ stderr: result.stderr || "backend pytest final collection could not start",
548
+ failureCategory: result.failureCategory,
549
+ durationMs: Date.now() - started,
550
+ };
551
+ }
552
+ const finalFacts = assessBackendPytestCollection({
553
+ phase: "final",
554
+ mappedScripts,
555
+ inventory,
556
+ exitCode: result.exitCode ?? 2,
557
+ stdout: result.stdout,
558
+ stderr: result.stderr,
559
+ });
560
+ effective = await materializeEffectiveBackendPytestCollection({
561
+ workspaceRoot: input.cwd,
562
+ initial,
563
+ final: finalFacts,
564
+ });
565
+ }
566
+ else {
567
+ throw new Error("backend pytest collection is blocked and does not authorize repair");
568
+ }
569
+ const artifacts = await writeBackendPytestCollectionArtifacts({
570
+ runDir: meta.runDir,
571
+ stem: "effective",
572
+ facts: effective,
573
+ });
574
+ return {
575
+ ok: true,
576
+ stdout: JSON.stringify({
577
+ status: effective.status,
578
+ collectionSource: effective.collectionSource,
579
+ repairAttempt: effective.repairAttempt,
580
+ collectedItemCount: effective.collectedItemCount,
581
+ factsPath: "contracts/backend-test-pytest-collection-effective.json",
582
+ reportPath: artifacts.reportPath,
583
+ }),
584
+ stderr: "",
585
+ durationMs: Date.now() - started,
586
+ };
587
+ }
479
588
  else if (pipeline === "markdown-traceability") {
480
589
  let report;
481
590
  try {
@@ -542,6 +651,8 @@ async function executeBackendTestPipeline(input, meta) {
542
651
  outputs.push(`manifest=${manifestPath}`, `materializationStatus=${manifest.materializationStatus ?? "available"}`, `coverageSummary.explicitAcCount=${summary?.explicitAcCount ?? "unavailable"}`, `coverageSummary.coveredAcCount=${summary?.coveredAcCount ?? "unavailable"}`, `coverageSummary.caseCount=${summary?.caseCount ?? "unavailable"}`, `coverageSummary.generatedCount=${summary?.generatedCount ?? "unavailable"}`, `ruleCoverageSummary.ruleCount=${manifest.ruleCoverageSummary?.ruleCount ?? "unavailable"}`, `correspondenceSummary.exactCorrespondenceCount=${manifest.correspondenceSummary?.exactCorrespondenceCount ?? "unavailable"}`, `correspondenceSummary.primarySymbolCount=${manifest.correspondenceSummary?.primarySymbolCount ?? "unavailable"}`, `correspondenceSummary.testPoints=${manifest.correspondenceSummary?.mappedTestPointCount ?? "unavailable"}/${manifest.correspondenceSummary?.testPointCount ?? "unavailable"}`, `correspondenceSummary.variantTestPointCount=${manifest.correspondenceSummary?.variantTestPointCount ?? "unavailable"}`, `correspondenceSummary.assertionTestPointCount=${manifest.correspondenceSummary?.assertionTestPointCount ?? "unavailable"}`, `correspondenceSummary.crossCuttingTestPointCount=${manifest.correspondenceSummary?.crossCuttingTestPointCount ?? "unavailable"}`, `correspondenceSummary.unclassifiedTestPointCount=${manifest.correspondenceSummary?.unclassifiedTestPointCount ?? "unavailable"}`, `correspondenceSummary.duplicateBindingTestPointCount=${manifest.correspondenceSummary?.duplicateBindingTestPointCount ?? "unavailable"}`);
543
652
  }
544
653
  else if (pipeline === "markdown-execute-html") {
654
+ const effectiveCollection = await readBackendPytestCollectionFacts(path.join(meta.runDir, "contracts", "backend-test-pytest-collection-effective.json"));
655
+ await assertBackendPytestCollectionFresh(input.cwd, effectiveCollection);
545
656
  const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
546
657
  const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
547
658
  const pytestTargets = mappedScripts.map(shellQuote).join(" ");
@@ -15,7 +15,10 @@ function escapeShellSingleQuoted(value) {
15
15
  /**
16
16
  * Build a single shell command that reads `$HARNESS_DAG_RUN_DIR/<fromNodeId>.json`
17
17
  * (injected by the shell executor from runtime meta), parses a single candidate
18
- * verdict line from `assistantText ?? stdout`, and accepts only exact verdict values.
18
+ * verdict from `assistantText ?? stdout`, and accepts only exact verdict values.
19
+ * Sources:
20
+ * - verdict-line: line-based legacy VERDICT protocol
21
+ * - json-review-verdict: structured review JSON with verdict: pass/request-revision
19
22
  * Modes:
20
23
  * - first-non-empty: first non-empty line
21
24
  * - first-verdict-line: first line matching /^VERDICT:/ after trim and optional whole-line Markdown emphasis normalization
@@ -25,6 +28,7 @@ function escapeShellSingleQuoted(value) {
25
28
  */
26
29
  export function buildVerdictGateShellCommand(gate) {
27
30
  const gateLabel = gate.label ?? `${gate.fromNodeId} verdict`;
31
+ const source = gate.source ?? "verdict-line";
28
32
  const lineMode = gate.lineMode ?? "first-non-empty";
29
33
  const candidateNodeIds = [
30
34
  gate.fromNodeId,
@@ -32,8 +36,10 @@ export function buildVerdictGateShellCommand(gate) {
32
36
  ];
33
37
  const config = {
34
38
  label: gateLabel,
39
+ source,
35
40
  lineMode,
36
41
  accept: gate.accept,
42
+ routingAccept: gate.routingAccept ?? [],
37
43
  candidateNodeIds,
38
44
  };
39
45
  const configLiteral = escapeShellSingleQuoted(JSON.stringify(config));
@@ -48,10 +54,12 @@ export function buildVerdictGateShellCommand(gate) {
48
54
  'if(!file){console.error("missing "+cfg.label+" JSON output (tried: "+cfg.candidateNodeIds.join(", ")+")");process.exit(1);}',
49
55
  'const raw=JSON.parse(fs.readFileSync(file,"utf8"));',
50
56
  'const text=String(raw.assistantText ?? raw.stdout ?? "");',
57
+ 'const extractJson=(value)=>{const text=String(value).trim();const fenced=[...text.matchAll(/```(?:json)?\\s*([\\s\\S]*?)\\s*```/gi)];const source=fenced.length===1?String(fenced[0][1]).trim():text;for(let start=0;start<source.length;start+=1){if(source[start]!=="{")continue;let depth=0,inString=false,escaped=false;for(let i=start;i<source.length;i+=1){const ch=source[i];if(inString){if(escaped){escaped=false;}else if(ch==="\\\\"){escaped=true;}else if(ch===String.fromCharCode(34)){inString=false;}continue;}if(ch===String.fromCharCode(34)){inString=true;continue;}if(ch==="{"){depth+=1;}else if(ch===String.fromCharCode(125)){depth-=1;if(depth===0){const candidate=source.slice(start,i+1);try{const parsed=JSON.parse(candidate);if(parsed&&typeof parsed==="object"&&!Array.isArray(parsed))return candidate;}catch{}break;}}}}return "";};',
58
+ 'let first="";',
59
+ 'if(cfg.source==="json-review-verdict"){const jsonText=extractJson(text);if(!jsonText){console.error(cfg.label+" gate blocked: missing JSON review verdict");process.exit(1);}let parsed;try{parsed=JSON.parse(jsonText);}catch(error){console.error(cfg.label+" gate blocked: invalid JSON review verdict: "+error.message);process.exit(1);}first=String(parsed&&parsed.verdict||"");}',
51
60
  'const normalize=(value)=>{const trimmed=String(value).trim();const emphasized=trimmed.match(/^\\*{1,3}\\s*(VERDICT:[^*]+?)\\s*\\*{1,3}$/);return (emphasized?emphasized[1]:trimmed).trim();};',
52
- 'const lines=text.split(/\\r?\\n/).map(normalize);',
53
- 'const first=cfg.lineMode==="first-verdict-line"? (lines.find((value)=>/^VERDICT:/.test(value))??"") : (lines.find((value)=>value.length>0)??"");',
54
- 'if(!cfg.accept.includes(first)){console.error(cfg.label+" gate blocked: "+(first||"missing VERDICT line"));process.exit(1);}',
61
+ 'if(cfg.source!=="json-review-verdict"){const lines=text.split(/\\r?\\n/).map(normalize);first=cfg.lineMode==="first-verdict-line"? (lines.find((value)=>/^VERDICT:/.test(value))??"") : (lines.find((value)=>value.length>0)??"");}',
62
+ 'const routing=Array.isArray(cfg.routingAccept)?cfg.routingAccept:[];if(!cfg.accept.includes(first)&&!routing.includes(first)){console.error(cfg.label+" gate blocked: "+(first|| (cfg.source==="json-review-verdict"?"missing JSON verdict":"missing VERDICT line")));process.exit(1);}',
55
63
  'process.stdout.write(first+"\\n");',
56
64
  ].join("");
57
65
  return `node -e '${escapeShellSingleQuoted(program)}' '${configLiteral}'`;
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { createReadStream } from "node:fs";
4
- import { lstat, readlink } from "node:fs/promises";
3
+ import { constants, createReadStream } from "node:fs";
4
+ import { access, lstat, readlink } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { pathMatchesPattern } from "../shared/git-progress.js";
7
7
  async function sha256File(filePath) {
@@ -164,40 +164,176 @@ export function validateShellWriteGuard(input) {
164
164
  }
165
165
  return { ok: violations.length === 0, violations };
166
166
  }
167
+ export class GitStatusUnavailableError extends Error {
168
+ diagnostics;
169
+ constructor(input) {
170
+ const last = input.attempts.at(-1);
171
+ const exitCode = last?.exitCode === undefined ? "unavailable" : String(last.exitCode);
172
+ const signal = last?.signal ?? "unavailable";
173
+ const phase = input.phase ?? "unspecified";
174
+ super(`git status failed after ${input.attempts.length} attempts (phase=${phase}, exit code=${exitCode}, exit code hex=${last?.exitCodeHex ?? "unavailable"}, signal=${signal}, cwd=${path.resolve(input.cwd)}): ${last?.detail ?? "unknown error"}`);
175
+ this.name = "GitStatusUnavailableError";
176
+ this.diagnostics = {
177
+ schemaVersion: 1,
178
+ phase,
179
+ cwd: path.resolve(input.cwd),
180
+ platform: input.platform ?? process.platform,
181
+ executableCandidates: input.executableCandidates ?? [],
182
+ requiredWindowsEnvironment: input.requiredWindowsEnvironment ?? requiredWindowsEnvironment(process.env),
183
+ attempts: input.attempts,
184
+ };
185
+ }
186
+ }
187
+ function requiredWindowsEnvironment(env) {
188
+ return {
189
+ SystemRootPresent: Boolean(env.SystemRoot ?? env.SYSTEMROOT),
190
+ windirPresent: Boolean(env.windir ?? env.WINDIR),
191
+ ComSpecPresent: Boolean(env.ComSpec ?? env.COMSPEC),
192
+ PATHEXTPresent: Boolean(env.PATHEXT),
193
+ };
194
+ }
195
+ function errorExitCode(error) {
196
+ if (error &&
197
+ typeof error === "object" &&
198
+ "exitCode" in error &&
199
+ typeof error.exitCode === "number") {
200
+ return error.exitCode;
201
+ }
202
+ return undefined;
203
+ }
204
+ function errorSignal(error) {
205
+ if (error &&
206
+ typeof error === "object" &&
207
+ "signal" in error &&
208
+ typeof error.signal === "string") {
209
+ return error.signal;
210
+ }
211
+ return null;
212
+ }
213
+ function formatWindowsExitCode(exitCode) {
214
+ if (exitCode === undefined)
215
+ return undefined;
216
+ return `0x${(exitCode >>> 0).toString(16).padStart(8, "0").toUpperCase()}`;
217
+ }
218
+ function isWindowsDllInitializationFailure(platform, exitCode) {
219
+ return platform === "win32" && exitCode !== undefined && (exitCode >>> 0) === 0xc0000142;
220
+ }
221
+ function boundedErrorDetail(error) {
222
+ const detail = error instanceof Error ? error.message : String(error);
223
+ return detail.length <= 1000 ? detail : `${detail.slice(0, 1000)}...[truncated]`;
224
+ }
225
+ export function deriveSameInstallationGitCandidates(primary) {
226
+ const normalized = path.win32.normalize(primary);
227
+ const lower = normalized.toLowerCase();
228
+ let fallback;
229
+ if (lower.endsWith("\\mingw64\\bin\\git.exe")) {
230
+ const root = path.win32.resolve(path.win32.dirname(normalized), "..", "..");
231
+ fallback = path.win32.join(root, "cmd", "git.exe");
232
+ }
233
+ else if (lower.endsWith("\\cmd\\git.exe")) {
234
+ const root = path.win32.resolve(path.win32.dirname(normalized), "..");
235
+ fallback = path.win32.join(root, "mingw64", "bin", "git.exe");
236
+ }
237
+ return Array.from(new Set([normalized, ...(fallback ? [fallback] : [])]));
238
+ }
239
+ async function resolveGitExecutableCandidates(platform, env) {
240
+ if (platform !== "win32")
241
+ return ["git"];
242
+ const pathValue = env.PATH ?? env.Path ?? "";
243
+ for (const rawEntry of pathValue.split(path.delimiter)) {
244
+ const entry = rawEntry.trim().replace(/^"|"$/g, "");
245
+ if (!entry)
246
+ continue;
247
+ const candidate = path.win32.join(entry, "git.exe");
248
+ try {
249
+ await access(candidate, constants.X_OK);
250
+ const sameInstallation = deriveSameInstallationGitCandidates(candidate);
251
+ const existing = [];
252
+ for (const executable of sameInstallation) {
253
+ try {
254
+ await access(executable, constants.X_OK);
255
+ existing.push(executable);
256
+ }
257
+ catch {
258
+ // Optional same-installation fallback is absent.
259
+ }
260
+ }
261
+ if (existing.length > 0)
262
+ return existing;
263
+ }
264
+ catch {
265
+ // Keep searching PATH entries.
266
+ }
267
+ }
268
+ return ["git"];
269
+ }
167
270
  export async function readGitStatusPorcelain(cwd, options = {}) {
168
- const attempts = Math.max(1, options.attempts ?? 5);
169
- const retryDelayMs = Math.max(0, options.retryDelayMs ?? 150);
170
- let lastError;
171
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
271
+ return readGitStatusPorcelainWithDependencies(cwd, options, {
272
+ platform: process.platform,
273
+ resolveExecutableCandidates: () => resolveGitExecutableCandidates(process.platform, process.env),
274
+ runAttempt: readGitStatusPorcelainOnce,
275
+ sleep: async (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
276
+ now: Date.now,
277
+ env: process.env,
278
+ });
279
+ }
280
+ export async function readGitStatusPorcelainWithDependencies(cwd, options, dependencies) {
281
+ const candidates = await dependencies.resolveExecutableCandidates();
282
+ const executableCandidates = candidates.length > 0 ? candidates : ["git"];
283
+ const normalMaxAttempts = Math.max(1, options.attempts ?? 5);
284
+ const transientMaxAttempts = Math.max(1, options.attempts ?? 10);
285
+ const normalRetryBaseMs = Math.max(0, options.retryDelayMs ?? 150);
286
+ const transientRetryBaseMs = Math.max(0, options.retryDelayMs ?? 250);
287
+ const attemptDiagnostics = [];
288
+ for (let attempt = 1; attempt <= transientMaxAttempts; attempt += 1) {
289
+ const executable = executableCandidates[(attempt - 1) % executableCandidates.length];
290
+ const startedAt = dependencies.now();
172
291
  try {
173
- return await readGitStatusPorcelainOnce(cwd);
292
+ return await dependencies.runAttempt(cwd, executable);
174
293
  }
175
294
  catch (error) {
176
- lastError = error;
177
- if (attempt < attempts && retryDelayMs > 0) {
178
- await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
179
- }
295
+ const exitCode = errorExitCode(error);
296
+ const transient = isWindowsDllInitializationFailure(dependencies.platform, exitCode);
297
+ attemptDiagnostics.push({
298
+ attempt,
299
+ executable,
300
+ ...(exitCode === undefined ? {} : { exitCode }),
301
+ ...(formatWindowsExitCode(exitCode)
302
+ ? { exitCodeHex: formatWindowsExitCode(exitCode) }
303
+ : {}),
304
+ signal: errorSignal(error),
305
+ durationMs: Math.max(0, dependencies.now() - startedAt),
306
+ ...(transient
307
+ ? { transientKind: "windows-dll-init-failed" }
308
+ : {}),
309
+ detail: boundedErrorDetail(error),
310
+ });
311
+ const maxAttempts = transient ? transientMaxAttempts : normalMaxAttempts;
312
+ if (attempt >= maxAttempts)
313
+ break;
314
+ const delayMs = transient
315
+ ? Math.min(4000, transientRetryBaseMs * 2 ** (attempt - 1))
316
+ : normalRetryBaseMs * attempt;
317
+ if (delayMs > 0)
318
+ await dependencies.sleep(delayMs);
180
319
  }
181
320
  }
182
- const detail = lastError instanceof Error ? lastError.message : String(lastError);
183
- const exitCode = lastError instanceof Error &&
184
- "exitCode" in lastError &&
185
- typeof lastError.exitCode === "number"
186
- ? String(lastError.exitCode)
187
- : "unavailable";
188
- const signal = lastError instanceof Error &&
189
- "signal" in lastError &&
190
- typeof lastError.signal === "string"
191
- ? lastError.signal
192
- : "unavailable";
193
- throw new Error(`git status failed after ${attempts} attempts (exit code=${exitCode}, signal=${signal}, cwd=${path.resolve(cwd)}): ${detail}`, { cause: lastError });
321
+ throw new GitStatusUnavailableError({
322
+ cwd,
323
+ phase: options.phase,
324
+ platform: dependencies.platform,
325
+ executableCandidates,
326
+ requiredWindowsEnvironment: requiredWindowsEnvironment(dependencies.env ?? process.env),
327
+ attempts: attemptDiagnostics,
328
+ });
194
329
  }
195
- function readGitStatusPorcelainOnce(cwd) {
330
+ function readGitStatusPorcelainOnce(cwd, executable) {
196
331
  return new Promise((resolve, reject) => {
197
- const child = spawn("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
332
+ const child = spawn(executable, ["status", "--porcelain=v1", "--untracked-files=all"], {
198
333
  cwd,
199
334
  env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
200
335
  stdio: ["ignore", "pipe", "pipe"],
336
+ windowsHide: true,
201
337
  });
202
338
  let stdout = "";
203
339
  let stderr = "";
@@ -29,7 +29,7 @@ export function resolveArtifactWriteDir(options) {
29
29
  }
30
30
  export function buildArtifactPathPrompt(writeDir) {
31
31
  if (!writeDir)
32
- return `After changes, write artifacts/修改记录.md and artifacts/验证结果.md with verification evidence.
32
+ return `After changes, write artifacts/修改记录.md and artifacts/验证结果.md with verification evidence.
33
33
  ${ARTIFACT_INSTRUCTIONS}`;
34
34
  return [
35
35
  `After changes, write the following files:`,
@@ -104,6 +104,12 @@ const taskConfigObjectSchema = z.object({
104
104
  referenceMaxFilesPerRepo: z.number().int().positive().optional(),
105
105
  referenceMaxTotalFiles: z.number().int().positive().optional(),
106
106
  allowedPaths: z.array(z.string()).optional().default([]),
107
+ /**
108
+ * Coarse repository roots used only by the frontend preflight scout. Before
109
+ * DAG generation, the scout must resolve these into the narrower
110
+ * `allowedPaths` consumed by writer nodes.
111
+ */
112
+ allowedRoots: z.array(z.string()).optional(),
107
113
  forbiddenPaths: z.array(z.string()).optional().default([]),
108
114
  hardConstraints: z.array(z.string()).optional().default([]),
109
115
  autoCommitAfterVerify: z.boolean().optional().default(true),
@@ -13,6 +13,7 @@ export const MANAGED_TASK_CONFIG_FIELDS = [
13
13
  "taskKind",
14
14
  "featureId",
15
15
  "allowedPaths",
16
+ "allowedRoots",
16
17
  "forbiddenPaths",
17
18
  "hardConstraints",
18
19
  "verifyCommands",
@@ -107,6 +107,11 @@ export function projectConstraintsMarkdown(draft) {
107
107
  lines.push(`- ${item}`);
108
108
  }
109
109
  }
110
+ if (draft.constraints.allowedRoots?.length) {
111
+ lines.push("", "## Allowed roots (preflight only)", "");
112
+ for (const item of draft.constraints.allowedRoots)
113
+ lines.push(`- ${item}`);
114
+ }
110
115
  lines.push("", "## Expected verification", "");
111
116
  if (draft.verification.commands.length === 0) {
112
117
  lines.push("- (none)");
@@ -148,6 +153,9 @@ export function mergeManagedTaskConfigFields(existing, draft) {
148
153
  title: draft.title,
149
154
  taskKind: draft.taskKind,
150
155
  allowedPaths: canonicalizePathArray(draft.constraints.allowedPaths),
156
+ ...(draft.constraints.allowedRoots !== undefined
157
+ ? { allowedRoots: canonicalizePathArray(draft.constraints.allowedRoots) }
158
+ : {}),
151
159
  forbiddenPaths: canonicalizePathArray(draft.constraints.forbiddenPaths),
152
160
  hardConstraints,
153
161
  verifyCommands: draft.verification.commands.map((cmd) => ({
@@ -23,6 +23,7 @@ export const taskContractDraftConstraintsSchema = z
23
23
  .object({
24
24
  invariants: z.array(z.string()),
25
25
  allowedPaths: z.array(z.string()),
26
+ allowedRoots: z.array(z.string()).optional(),
26
27
  forbiddenPaths: z.array(z.string()),
27
28
  protectedUserChanges: z.array(z.string()).optional(),
28
29
  expectedVerification: z.array(z.string()).optional(),
@@ -0,0 +1,131 @@
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { executePiStep } from "../executors/pi-executor.js";
5
+ import { resolveDagPiModelConfig } from "../executors/dag-pi-executor.js";
6
+ import { writeJsonAtomic } from "../infrastructure/harness/atomic-write.js";
7
+ import { pathMatchesPattern } from "../shared/git-progress.js";
8
+ import { loadHarnessManifest } from "../governance/harness.js";
9
+ import { resolveExecutorModelMatrices } from "../executors/model-routing.js";
10
+ const frontendScopeSchema = z.object({
11
+ schemaVersion: z.literal(1),
12
+ entrypoint: z.string().min(1),
13
+ implementationPaths: z.array(z.string().min(1)).min(1),
14
+ testPaths: z.array(z.string().min(1)).min(1),
15
+ dataSource: z.string().min(1),
16
+ });
17
+ function isSafeRepositoryPath(value) {
18
+ return value.length > 0 &&
19
+ !path.isAbsolute(value) &&
20
+ !value.includes("\\") &&
21
+ !value.split("/").includes("..");
22
+ }
23
+ function parseScope(text) {
24
+ const candidates = [];
25
+ for (let start = text.indexOf("{"); start >= 0; start = text.indexOf("{", start + 1)) {
26
+ let depth = 0;
27
+ let quoted = false;
28
+ let escaped = false;
29
+ for (let index = start; index < text.length; index += 1) {
30
+ const char = text[index];
31
+ if (quoted) {
32
+ if (escaped)
33
+ escaped = false;
34
+ else if (char === "\\")
35
+ escaped = true;
36
+ else if (char === '"')
37
+ quoted = false;
38
+ continue;
39
+ }
40
+ if (char === '"')
41
+ quoted = true;
42
+ else if (char === "{")
43
+ depth += 1;
44
+ else if (char === "}" && --depth === 0) {
45
+ try {
46
+ candidates.push(JSON.parse(text.slice(start, index + 1)));
47
+ }
48
+ catch { /* continue */ }
49
+ break;
50
+ }
51
+ }
52
+ }
53
+ if (candidates.length === 0)
54
+ throw new Error("frontend preflight output contains no JSON object");
55
+ const parsed = frontendScopeSchema.parse(candidates.at(-1));
56
+ for (const candidate of [
57
+ parsed.entrypoint,
58
+ ...parsed.implementationPaths,
59
+ ...parsed.testPaths,
60
+ ]) {
61
+ if (!isSafeRepositoryPath(candidate)) {
62
+ throw new Error(`unsafe frontend preflight path: ${candidate}`);
63
+ }
64
+ }
65
+ return parsed;
66
+ }
67
+ function ensureWithinRoots(scope, roots) {
68
+ const candidates = [
69
+ scope.entrypoint,
70
+ ...scope.implementationPaths,
71
+ ...scope.testPaths,
72
+ ];
73
+ const outside = candidates.filter((candidate) => !roots.some((root) => pathMatchesPattern(candidate, root)));
74
+ if (outside.length > 0) {
75
+ throw new Error(`frontend preflight discovered paths outside allowedRoots: ${outside.join(", ")}`);
76
+ }
77
+ }
78
+ export async function runFrontendPreflight(input) {
79
+ if (input.taskConfig.taskKind !== "frontend-implementation" ||
80
+ (input.taskConfig.allowedRoots?.length ?? 0) === 0) {
81
+ return undefined;
82
+ }
83
+ const requirementPath = path.join(input.repoRoot, ".harness", "tasks", input.taskId, "source", "需求.md");
84
+ const requirement = await readFile(requirementPath, "utf8");
85
+ const manifest = await loadHarnessManifest(input.repoRoot);
86
+ const models = resolveExecutorModelMatrices(manifest);
87
+ const model = models.pi.MED;
88
+ const prompt = [
89
+ "You are a read-only frontend preflight scout. Inspect the target repository before its implementation DAG is generated.",
90
+ "Locate the existing UI surface that satisfies the requirement. Do not invent a new page when an existing surface exists.",
91
+ `Allowed roots: ${input.taskConfig.allowedRoots.join(", ")}`,
92
+ "Return exactly one JSON object and no Markdown or prose:",
93
+ '{"schemaVersion":1,"entrypoint":"relative/path","implementationPaths":["relative/glob-or-file"],"testPaths":["relative/glob-or-file"],"dataSource":"existing store/API/module"}',
94
+ "Every path must be repository-relative POSIX, must be inside allowed roots, and implementationPaths/testPaths must be the narrowest directories or files that own the existing behavior.",
95
+ "Requirement:",
96
+ requirement,
97
+ ].join("\n\n");
98
+ const result = await executePiStep({
99
+ attachedFiles: [requirementPath],
100
+ modelConfig: resolveDagPiModelConfig(model),
101
+ prompt,
102
+ repoRoot: input.repoRoot,
103
+ step: "analyze",
104
+ toolNames: ["read", "grep", "find", "ls"],
105
+ userMessage: "Perform the frontend preflight scout and return the required JSON.",
106
+ validateOutput: (assistantText) => {
107
+ try {
108
+ const scope = parseScope(assistantText);
109
+ ensureWithinRoots(scope, input.taskConfig.allowedRoots);
110
+ return [];
111
+ }
112
+ catch (error) {
113
+ return [error instanceof Error ? error.message : String(error)];
114
+ }
115
+ },
116
+ });
117
+ if (!result.ok) {
118
+ throw new Error(`frontend preflight failed: ${result.failureCategory}: ${result.stderr || result.assistantText}`);
119
+ }
120
+ const scope = parseScope(result.assistantText);
121
+ ensureWithinRoots(scope, input.taskConfig.allowedRoots);
122
+ const preflightDir = path.join(input.repoRoot, ".harness", "tasks", input.taskId, "preflight");
123
+ await mkdir(preflightDir, { recursive: true });
124
+ await writeJsonAtomic(path.join(preflightDir, "frontend-scope.json"), {
125
+ ...scope,
126
+ resolvedAt: new Date().toISOString(),
127
+ allowedRoots: input.taskConfig.allowedRoots,
128
+ model,
129
+ });
130
+ return scope;
131
+ }
@@ -201,10 +201,8 @@ export function parseTaskConfigForRuntime(rawConfig) {
201
201
  }
202
202
  return parsed;
203
203
  }
204
- export function deriveMaxFixLoops(complexity) {
205
- if (complexity === 'small')
206
- return 2;
207
- return 3;
204
+ export function deriveMaxFixLoops(_complexity) {
205
+ return 2;
208
206
  }
209
207
  export async function listTaskSourceFiles(repoRoot, taskId) {
210
208
  return listFilesRecursive(getTaskPaths(repoRoot, taskId).sourceDir);