@tea-agent/loop-agent 0.39.0-beta.7 → 0.39.0-beta.9

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 (164) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +23 -5
  3. package/README.md +2 -2
  4. package/dist/application/task-lifecycle/advance.js +12 -0
  5. package/dist/build-stamp.json +3 -3
  6. package/dist/cli/command-definitions.js +7 -0
  7. package/dist/cli/program.js +5 -0
  8. package/dist/cli/update/runtime-activity.js +1 -29
  9. package/dist/commands/dag-follow-up.js +138 -0
  10. package/dist/commands/dag-rerun.js +1 -1
  11. package/dist/commands/init.js +26 -0
  12. package/dist/executors/dag-pi-executor.js +326 -8
  13. package/dist/executors/pi-read-budget-policy.js +166 -0
  14. package/dist/executors/shell-executor.js +26 -30
  15. package/dist/shared/dag-prompt-override.js +4 -5
  16. package/dist/shared/update/npm-client.js +33 -11
  17. package/dist/task/config-types.js +15 -0
  18. package/dist/task/contract/apply.js +11 -1
  19. package/dist/task/contract/constants.js +2 -0
  20. package/dist/task/contract/hash.js +3 -0
  21. package/dist/task/contract/observe.js +25 -4
  22. package/dist/task/contract/paths.js +2 -1
  23. package/dist/task/contract/project.js +15 -1
  24. package/dist/task/contract/schema.js +2 -0
  25. package/dist/task/contract/transaction.js +12 -0
  26. package/dist/task/frontend-project-capability.js +123 -4
  27. package/dist/task/source-prepare/completeness.js +188 -0
  28. package/dist/task/source-prepare/fragment-inventory.js +477 -0
  29. package/dist/task/source-prepare/index.js +3 -0
  30. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  31. package/dist/task/source-prepare/ledger-review.js +214 -0
  32. package/dist/task/source-prepare/ledger.js +545 -0
  33. package/dist/task/source-prepare/prepare.js +262 -1
  34. package/dist/task/source-prepare/semantic-intake.js +25 -5
  35. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  36. package/dist/task/source-prepare/types.js +22 -0
  37. package/dist/worker/cli.js +12 -26
  38. package/dist/worker/console/console-handoff.js +82 -0
  39. package/dist/worker/console/console-update-and-init.js +115 -0
  40. package/dist/worker/console/console-update-runtime.js +84 -0
  41. package/dist/worker/console/draft-store.js +49 -0
  42. package/dist/worker/console/operation-store.js +1 -1
  43. package/dist/worker/console/operator-actions.js +129 -4
  44. package/dist/worker/console/operator-surface-health.js +1 -0
  45. package/dist/worker/console/prd-intake-bridge.js +13 -0
  46. package/dist/worker/console/routes.js +3 -0
  47. package/dist/worker/console/server.js +73 -5
  48. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-bPer1waP.js → abnfDiagram-N423BO3Z-D1-Z-BBa.js} +1 -1
  49. package/dist/worker/console/static/assets/{arc-BLdkwucW.js → arc-q8qAYm5m.js} +1 -1
  50. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-Bw4wo4I1.js → architectureDiagram-T3A2C74G-CAu4_lve.js} +1 -1
  51. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DUUkc0gr.js → blockDiagram-VBNYF7ZC-D5GhQvHm.js} +1 -1
  52. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BC3O0nob.js → c4Diagram-5PPSVZJV-ClQjeLkO.js} +1 -1
  53. package/dist/worker/console/static/assets/channel-ClAuhSUL.js +1 -0
  54. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-Dd4g2nti.js → chunk-2GRJ4B5K-DLqWlFbo.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-s2EDm7-n.js → chunk-2Q5K7J3B-DkRT_usL.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-GqtHM2yQ.js → chunk-5RXB4S5H-Cqp7Chi3.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BzRFrN8j.js → chunk-5VM5RSS4-CedEdFB9.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bw9nFjCJ.js → chunk-6Q2QTUOP-BHM8vP5x.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B4n-03Qm.js → chunk-GF5L2VYU-D7EFr377.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-Dt75ZdBD.js → chunk-JWPE2WC7-DR38mTWV.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-B_afMhHB.js → chunk-KBJHAD2P-BFv2LsR3.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Bd8Rqqg9.js → chunk-RYQCIY6F-BAvpJS5z.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-9boOMji4.js → chunk-XXDRQBXY-eKI9TO72.js} +1 -1
  64. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-cBgeCpVU.js +1 -0
  65. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-cBgeCpVU.js +1 -0
  66. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BtCycfCc.js → cose-bilkent-JH36ORCC-BMJXpgo4.js} +1 -1
  67. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-eTOts9L1.js → cynefin-VYW2F7L2-CKJ2ofZx.js} +1 -1
  68. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Cibd2mjg.js → cynefinDiagram-MW4NZA55-CsKnsazm.js} +1 -1
  69. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CDjDZtDV.js → dagre-VZM6K2ZE-DVQ46vIw.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Btexn-JJ.js → diagram-7IWD3JNH-D_RuonYS.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cyl8sdpM.js → diagram-B4RE2ZJO-D2Mt0nMu.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Ev-83E1t.js → diagram-LBJQPF4R-DjBDuye6.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-41LqE0Mc.js → diagram-Q27KOJAE-8VZkqpE_.js} +1 -1
  74. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BC39T5R3.js → diagram-UB23O5K3-D0izYxia.js} +1 -1
  75. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DzcNRuri.js → ebnfDiagram-BXEA7PRR-CncG2I95.js} +1 -1
  76. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-B5Frvvnz.js → erDiagram-JOGREHBK-DiBUUXR8.js} +1 -1
  77. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BapxROIM.js → flowDiagram-UKHOOZJN-Dtc0fArb.js} +1 -1
  78. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Cd4d4wQ0.js → ganttDiagram-PKOTCBZU-BPNFP5OZ.js} +1 -1
  79. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CrL6buFW.js → gitGraphDiagram-DS77QQ5N-CsgpxEEd.js} +1 -1
  80. package/dist/worker/console/static/assets/{index-DBh_x_Ek.js → index-Bai8Z25q.js} +81 -81
  81. package/dist/worker/console/static/assets/index-CH0Tr5hY.css +1 -0
  82. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BrSiNxyd.js → infoDiagram-6WML65LV-kHGxkJUS.js} +1 -1
  83. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-gvlvY-n9.js → ishikawaDiagram-WSZJBQD7-CbLe9wrr.js} +1 -1
  84. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-D-YaPzRQ.js → journeyDiagram-NVQOT4AX-BOkwxAcP.js} +1 -1
  85. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BsmDv9E_.js → kanban-definition-27J2QSJJ-BHYQ2rDu.js} +1 -1
  86. package/dist/worker/console/static/assets/{linear-UWaoDN3h.js → linear-CHT4ujob.js} +1 -1
  87. package/dist/worker/console/static/assets/{mermaid.core-BlvpGVG8.js → mermaid.core-yL8sfqnY.js} +5 -5
  88. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CUCIP5CP.js → mindmap-definition-FAOFIHXS-DZx1coV-.js} +1 -1
  89. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CB1aQUAK.js → pegDiagram-VL7TDLO6-DStWRCI8.js} +1 -1
  90. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BwehHxhf.js → pieDiagram-7S7Q4E2Y-DSKZkwzK.js} +1 -1
  91. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CFz30APB.js → quadrantDiagram-CIZ2JOQS-CmoEGOuW.js} +1 -1
  92. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwNmw1i6.js → railroadDiagram-AXF67PYL-BLqzhKyq.js} +1 -1
  93. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-jg-tPXlI.js → requirementDiagram-LRYGKXZP-gbHeUabM.js} +1 -1
  94. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BlyReDKl.js → sankeyDiagram-W5VNT64P-CaKkBCKO.js} +1 -1
  95. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DBUzW1f3.js → sequenceDiagram-SI44F4Z6-PUbi3Upu.js} +1 -1
  96. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-BYcI2RAr.js → sizeCapture-X5ZJPWSS-dm7S9Qfe.js} +1 -1
  97. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-D7AiyJdo.js → stateDiagram-OKZ733FA-C7WSzv4p.js} +1 -1
  98. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-sQDv5AP3.js +1 -0
  99. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BHGt7Kkv.js → swimlanes-SLNWSIFB-CBKCiQwd.js} +2 -2
  100. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BXwsmcPs.js +8 -0
  101. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-OPjooWcX.js → timeline-definition-Z64GVDOM-CvnUlylk.js} +1 -1
  102. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-ggsWDvrO.js → vennDiagram-T6HMQDX7-xM0StY1O.js} +1 -1
  103. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DjNe-BjZ.js → wardleyDiagram-T6FBY63Y-CWtJMagy.js} +1 -1
  104. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-Byp63Hei.js → xychartDiagram-ELKLHX3M-BO_-MeK8.js} +1 -1
  105. package/dist/worker/console/static/index.html +2 -2
  106. package/dist/worker/console/static-src/app/console-types.js +40 -1
  107. package/dist/worker/console/static-src/app/useOperatorActions.js +5 -0
  108. package/dist/worker/console/static-src/app/useTaskWizard.js +73 -1
  109. package/dist/worker/observability/init-runtime-activity.js +26 -0
  110. package/dist/worker/observe/node-input.js +88 -2
  111. package/dist/worker/observe/routes.js +188 -0
  112. package/dist/worker/observe/static/api.js +3 -3
  113. package/dist/worker/observe/static/format.js +37 -0
  114. package/dist/worker/observe/static/index.html +5 -0
  115. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  116. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  117. package/dist/worker/observe/static/state.js +2 -2
  118. package/dist/worker/observe/static/styles.css +331 -35
  119. package/dist/worker/observe/static/views/dag-inspector.js +509 -78
  120. package/dist/worker/observe/static/views/task.js +55 -0
  121. package/dist/workflows/dag/frontend-design-policy.js +19 -9
  122. package/dist/workflows/dag/frontend-implementation-contract.js +117 -50
  123. package/dist/workflows/dag/frontend-plan-render.js +27 -0
  124. package/dist/workflows/dag/frontend-prewrite-gate.js +45 -1
  125. package/dist/workflows/dag/frontend-recovery-controller.js +1 -0
  126. package/dist/workflows/dag/frontend-review-context.js +102 -15
  127. package/dist/workflows/dag/frontend-review-findings.js +42 -10
  128. package/dist/workflows/dag/frontend-shadow-dual-write.js +36 -2
  129. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  130. package/dist/workflows/dag/frontend-worktree-diff.js +154 -0
  131. package/dist/workflows/dag/frontend-writer-admission.js +12 -0
  132. package/dist/workflows/dag/init-hybrid.js +297 -65
  133. package/dist/workflows/dag/interrupt-request.js +7 -0
  134. package/dist/workflows/dag/node-execution.js +111 -1
  135. package/dist/workflows/dag/prompt.js +34 -1
  136. package/dist/workflows/dag/recovery-lease.js +80 -0
  137. package/dist/workflows/dag/rerun-feedback.js +31 -8
  138. package/dist/workflows/dag/rerun-run.js +23 -0
  139. package/dist/workflows/dag/rerun-task.js +29 -0
  140. package/dist/workflows/dag/retry-policy.js +34 -9
  141. package/dist/workflows/dag/runner.js +83 -16
  142. package/dist/workflows/dag/types.js +67 -11
  143. package/docs/README.md +1 -0
  144. package/docs/architecture/evolution.md +45 -0
  145. package/docs/init-surface.manifest.json +4 -0
  146. package/docs/templates/README.md +2 -1
  147. package/docs/templates/frontend-implementation-contract.schema.json +684 -24
  148. package/docs/templates/spec-registry.schema.json +45 -0
  149. package/harness.json +3 -3
  150. package/package.json +1 -1
  151. package/skills/frontend-bounded-implement/SKILL.md +8 -14
  152. package/skills/frontend-design-review/SKILL.md +20 -41
  153. package/skills/frontend-implementation/SKILL.md +2 -3
  154. package/skills/frontend-implementation/references/design-spec.md +4 -3
  155. package/skills/frontend-implementation/references/node-contracts.md +4 -3
  156. package/skills/frontend-review/SKILL.md +22 -14
  157. package/skills/frontend-review/references/review-findings.md +6 -7
  158. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  159. package/dist/worker/console/static/assets/channel-AbFtVnFm.js +0 -1
  160. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +0 -1
  161. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +0 -1
  162. package/dist/worker/console/static/assets/index-DwOGauxU.css +0 -1
  163. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +0 -1
  164. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +0 -8
@@ -16,6 +16,7 @@ import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend
16
16
  import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
17
17
  import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
18
18
  import { assertFrontendSourceBindingFresh, buildContractFromCommittedFacts, deterministicSha256, frontendImplementationContractSchema, serializeDeterministicJson, sha256Hex, writeFrontendImplementationContractArtifact, } from "../workflows/dag/frontend-implementation-contract.js";
19
+ import { checkFrontendSourceFidelityLedger } from "../workflows/dag/frontend-source-fidelity-ledger.js";
19
20
  import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
20
21
  import { copyFrontendTestStandardScenarios } from "../workflows/dag/frontend-test-standard-scenarios.js";
21
22
  import { probeFrontendTestEnvironment } from "../workflows/dag/frontend-test-environment-probe.js";
@@ -27,7 +28,7 @@ import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate
27
28
  import { captureFrontendWorktreeBaseline, formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
28
29
  import { readTypedEventStoreFromJsonl } from "../workflows/dag/frontend-typed-event-store.js";
29
30
  import { classifyFrontendFailure, } from "../workflows/dag/frontend-repair.js";
30
- import { FRONTEND_CONTRACT_MAX_BYTES, formatFrontendPrewriteGateStdout, runFrontendPrewriteGate, } from "../workflows/dag/frontend-prewrite-gate.js";
31
+ const FRONTEND_CONTRACT_MAX_BYTES = 65536;
31
32
  import { evaluateFrontendDesignPolicy, } from "../workflows/dag/frontend-design-policy.js";
32
33
  import { deriveFrontendWriterAdmissionShell, FRONTEND_WRITER_ADMISSION_RESULT_ARTIFACT, frontendWriterAdmissionResultV1Schema, } from "../workflows/dag/frontend-writer-admission.js";
33
34
  import { deriveFrontendIntegrationFacts, buildFrontendTestHandoff, frontendCloseoutFactsSchema, renderFrontendCloseout, } from "../workflows/dag/frontend-closeout.js";
@@ -2413,6 +2414,29 @@ async function executeFrontendDesignPolicy(input, meta) {
2413
2414
  binding: meta.spec.sourceBinding,
2414
2415
  });
2415
2416
  }
2417
+ // Byte freshness is necessary but not sufficient: require the same ledger
2418
+ // semantic checks as the shadow prewrite gate before production writer
2419
+ // admission can be materialized.
2420
+ if (meta.spec.sourceBinding) {
2421
+ const ledgerCheck = await checkFrontendSourceFidelityLedger({
2422
+ workspaceRoot: input.cwd,
2423
+ binding: meta.spec.sourceBinding,
2424
+ contractRequirements: contract.requirements.map((requirement) => ({
2425
+ id: requirement.id,
2426
+ sourceFragmentIds: requirement.sourceFragmentIds,
2427
+ sourceRefs: requirement.sourceRefs,
2428
+ })),
2429
+ });
2430
+ if (!ledgerCheck.ok) {
2431
+ return {
2432
+ ok: false,
2433
+ stdout: "",
2434
+ stderr: `frontend design policy blocked: ${ledgerCheck.failureReason ?? "source fidelity ledger validation failed"}`,
2435
+ failureCategory: "invalid-output",
2436
+ durationMs: Date.now() - started,
2437
+ };
2438
+ }
2439
+ }
2416
2440
  const sourceFreshness = await collectFrontendSourceFreshness({
2417
2441
  workspaceRoot: input.cwd,
2418
2442
  sourceBinding: meta.spec.sourceBinding,
@@ -2455,7 +2479,7 @@ async function executeFrontendDesignPolicy(input, meta) {
2455
2479
  }
2456
2480
  return {
2457
2481
  ok: true,
2458
- stdout: `Frontend design policy: approved findings=0\nContract: ${path.posix.join(config.outputDir, config.artifactName)}`,
2482
+ stdout: `Frontend design policy: approved findings=0\nContract: ${path.resolve(meta.runDir, config.outputDir, config.artifactName)}`,
2459
2483
  stderr: "",
2460
2484
  failureCategory: "success",
2461
2485
  durationMs: Date.now() - started,
@@ -3377,34 +3401,6 @@ async function executeDagShellNodeInner(input, meta) {
3377
3401
  if (shell?.frontendWriterAdmission) {
3378
3402
  return executeFrontendWriterAdmission(input, meta);
3379
3403
  }
3380
- if (shell?.frontendPrewriteGate) {
3381
- const started = Date.now();
3382
- try {
3383
- const result = await runFrontendPrewriteGate({
3384
- runDir: meta.runDir,
3385
- config: shell.frontendPrewriteGate,
3386
- sourceBinding: meta.spec.sourceBinding,
3387
- workspaceRoot: input.cwd,
3388
- repoRoot: input.cwd,
3389
- });
3390
- return {
3391
- ok: true,
3392
- stdout: formatFrontendPrewriteGateStdout(result),
3393
- stderr: "",
3394
- failureCategory: "success",
3395
- durationMs: Date.now() - started,
3396
- };
3397
- }
3398
- catch (error) {
3399
- return {
3400
- ok: false,
3401
- stdout: "",
3402
- stderr: error instanceof Error ? error.message : String(error),
3403
- failureCategory: "invalid-output",
3404
- durationMs: Date.now() - started,
3405
- };
3406
- }
3407
- }
3408
3404
  if (shell?.frontendVerificationBundle) {
3409
3405
  return executeFrontendVerificationBundle(input, meta);
3410
3406
  }
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * Internal switch for the experimental "rerun failed node with an edited
3
- * prompt" capability. Default is OFF; nothing may depend on it being enabled.
4
- * The switch is deliberately env-based (no harness.json surface) while the
5
- * feature is internal.
3
+ * prompt" capability. The feature is enabled by default; the environment
4
+ * variable remains as an emergency kill switch while the feature is internal.
6
5
  */
7
6
  export const DAG_RERUN_PROMPT_OVERRIDE_ENV = "HARNESS_DAG_RERUN_PROMPT_OVERRIDE";
8
7
  /** Upper bound for an override input body (characters). */
9
8
  export const MAX_DAG_RERUN_PROMPT_OVERRIDE_CHARS = 65_536;
10
9
  export function isDagRerunPromptOverrideEnabled(env = process.env) {
11
- const value = env[DAG_RERUN_PROMPT_OVERRIDE_ENV];
12
- return value === "1" || value === "true";
10
+ const value = env[DAG_RERUN_PROMPT_OVERRIDE_ENV]?.trim().toLowerCase();
11
+ return value !== "0" && value !== "false" && value !== "off";
13
12
  }
14
13
  export const DAG_RERUN_PROMPT_ADDENDUM_TAG = "operator_addendum";
15
14
  /**
@@ -11,10 +11,10 @@ export function createNpmUpdateClient(options) {
11
11
  const timeoutMs = options.timeoutMs ?? DEFAULT_NPM_QUERY_TIMEOUT_MS;
12
12
  const cwd = options.cwd;
13
13
  const env = options.env;
14
- const latestVersionCheck = async () => {
14
+ const distTagVersionCheck = async (channel) => {
15
15
  let result;
16
16
  try {
17
- result = await runner(npmCommand, ["view", PACKAGE_NAME, "dist-tags.latest", "--json"], { timeoutMs, cwd, env });
17
+ result = await runner(npmCommand, ["view", PACKAGE_NAME, `dist-tags.${channel}`, "--json"], { timeoutMs, cwd, env });
18
18
  }
19
19
  catch {
20
20
  return { ok: false, reason: "spawn-failure" };
@@ -27,17 +27,19 @@ export function createNpmUpdateClient(options) {
27
27
  if (result.exitCode !== 0) {
28
28
  return { ok: false, reason: "command-failed" };
29
29
  }
30
- const version = parseNpmViewLatest(result.stdout);
30
+ const version = parseNpmViewDistTag(result.stdout, channel);
31
31
  return version
32
32
  ? { ok: true, version }
33
33
  : { ok: false, reason: "invalid-response" };
34
34
  };
35
+ const latestVersionCheck = () => distTagVersionCheck("latest");
35
36
  return {
36
37
  async latestVersion() {
37
38
  const result = await latestVersionCheck();
38
39
  return result.ok ? result.version : undefined;
39
40
  },
40
41
  latestVersionCheck,
42
+ distTagVersionCheck,
41
43
  async canAutoInstall() {
42
44
  return proveGlobalNpmInstall({
43
45
  currentVersion: options.currentVersion,
@@ -49,10 +51,11 @@ export function createNpmUpdateClient(options) {
49
51
  timeoutMs,
50
52
  });
51
53
  },
52
- async installExact(version) {
53
- if (!isStrictRelease(version))
54
+ async installExact(version, channel = "latest") {
55
+ if (!isAllowedChannelVersion(version, channel)) {
54
56
  return { ok: false, reason: "invalid-version" };
55
- const result = await runner(npmCommand, buildExactInstallArgs(version), {
57
+ }
58
+ const result = await runner(npmCommand, buildExactInstallArgs(version, channel), {
56
59
  timeoutMs: Math.max(timeoutMs, 120000),
57
60
  cwd,
58
61
  env,
@@ -79,6 +82,13 @@ export function createNpmUpdateClient(options) {
79
82
  };
80
83
  }
81
84
  export function parseNpmViewLatest(stdout) {
85
+ return parseNpmViewDistTag(stdout, "latest");
86
+ }
87
+ /**
88
+ * `latest` stays stable-only. `next` is explicitly selected by a human and
89
+ * may legitimately be a SemVer prerelease, but remains an exact version.
90
+ */
91
+ export function parseNpmViewDistTag(stdout, channel) {
82
92
  const trimmed = stdout.trim();
83
93
  let value = trimmed;
84
94
  try {
@@ -89,11 +99,12 @@ export function parseNpmViewLatest(stdout) {
89
99
  }
90
100
  if (typeof value !== "string")
91
101
  return undefined;
92
- return isStrictRelease(value) ? value : undefined;
102
+ return isAllowedChannelVersion(value, channel) ? value : undefined;
93
103
  }
94
- export function buildExactInstallArgs(version) {
95
- if (!isStrictRelease(version))
96
- throw new Error(`invalid release version: ${version}`);
104
+ export function buildExactInstallArgs(version, channel = "latest") {
105
+ if (!isAllowedChannelVersion(version, channel)) {
106
+ throw new Error(`invalid ${channel} version: ${version}`);
107
+ }
97
108
  return ["install", "-g", `${PACKAGE_NAME}@${version}`, "--no-fund", "--no-audit"];
98
109
  }
99
110
  export function verifyNpmListVersion(stdout, expectedVersion) {
@@ -106,7 +117,13 @@ export function verifyNpmListVersion(stdout, expectedVersion) {
106
117
  }
107
118
  }
108
119
  export function verifyPathVersion(stdout, expectedVersion) {
109
- return stdout.trim() === expectedVersion;
120
+ // `loop-agent --version` may append a build provenance marker. The release
121
+ // version itself must still match exactly; accepting no other trailing form
122
+ // keeps PATH verification bound to the requested stable package.
123
+ return new RegExp(`^${escapeRegExp(expectedVersion)}(?: \\(build [^)]+\\))?$`).test(stdout.trim());
124
+ }
125
+ function escapeRegExp(value) {
126
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
110
127
  }
111
128
  async function proveGlobalNpmInstall(options) {
112
129
  const root = await options.runner(options.npmCommand, ["root", "-g"], {
@@ -142,6 +159,11 @@ function isStrictRelease(version) {
142
159
  const parsed = semver.parse(version);
143
160
  return Boolean(parsed && parsed.prerelease.length === 0);
144
161
  }
162
+ function isAllowedChannelVersion(version, channel) {
163
+ if (channel === "latest")
164
+ return isStrictRelease(version);
165
+ return Boolean(semver.valid(version));
166
+ }
145
167
  async function runCommand(command, args, options) {
146
168
  return new Promise((resolve) => {
147
169
  const child = spawn(command, [...args], {
@@ -219,6 +219,21 @@ const taskConfigObjectSchema = z.object({
219
219
  maxFixLoops: z.number().int().min(0).optional().default(2),
220
220
  /** Supervised DAG convergence is opt-in until runtime smoke evidence is stronger. */
221
221
  convergence: convergenceConfigSchema.optional().default({ enabled: false }),
222
+ /** Frontend recovery continuation quota. Bounds how many times a failed
223
+ * frontend run may auto-recover via M6 recovery operations (1 = current
224
+ * default; raise to 2-3 for more tolerance of transient verify failures). */
225
+ frontendRecovery: z
226
+ .object({
227
+ maxContinuations: z
228
+ .number()
229
+ .int()
230
+ .min(1)
231
+ .max(5)
232
+ .optional()
233
+ .default(1),
234
+ })
235
+ .strict()
236
+ .optional(),
222
237
  /** Outer loop auto mode never writes by default; DAG execute requires this policy plus path/writeSet gates. */
223
238
  loopAutoExecutionPolicy: loopAutoExecutionPolicySchema
224
239
  .optional()
@@ -232,8 +232,11 @@ export async function applyTaskContract(input) {
232
232
  // Re-parse through schema to ensure full TaskConfig shape for hash stability
233
233
  const fullConfig = parseTaskConfigForRuntime(mergedConfig);
234
234
  const taskConfigContent = serializeTaskConfigForDisk(fullConfig);
235
- const requirementContent = projectRequirementMarkdown(input.draft);
235
+ const requirementContent = projectRequirementMarkdown(input.draft, input.requirementLedger);
236
236
  const constraintsContent = projectConstraintsMarkdown(input.draft);
237
+ const requirementLedgerContent = input.requirementLedger !== undefined
238
+ ? `${JSON.stringify(input.requirementLedger, null, 2)}\n`
239
+ : undefined;
237
240
  // Validate references manifest hash if present (do not modify)
238
241
  const paths = getTaskPaths(input.repoRoot, input.taskId);
239
242
  const manifestText = await readFile(getSourceManifestPath(paths.sourceDir), "utf-8").catch(() => null);
@@ -241,6 +244,7 @@ export async function applyTaskContract(input) {
241
244
  requirementBytes: requirementContent,
242
245
  constraintsBytes: constraintsContent,
243
246
  referenceManifestBytes: manifestText,
247
+ requirementLedgerBytes: requirementLedgerContent,
244
248
  });
245
249
  const taskConfigSha256 = computeTaskConfigSha256(fullConfig);
246
250
  const canonicalHash = computeCanonicalHash({
@@ -277,6 +281,9 @@ export async function applyTaskContract(input) {
277
281
  constraintsContent,
278
282
  taskConfigContent,
279
283
  contractRef: nextRef,
284
+ ...(requirementLedgerContent !== undefined
285
+ ? { requirementLedgerContent }
286
+ : {}),
280
287
  },
281
288
  });
282
289
  await appendRequestLedgerEntry({
@@ -312,6 +319,9 @@ export async function applyTaskContract(input) {
312
319
  changedFiles: [
313
320
  "source/需求.md",
314
321
  "source/执行约束.md",
322
+ ...(requirementLedgerContent !== undefined
323
+ ? ["source/requirement-ledger.json"]
324
+ : []),
315
325
  "task.json",
316
326
  "contract.json",
317
327
  ],
@@ -29,3 +29,5 @@ export const TRANSACTIONS_DIRNAME = TRANSACTIONS_DIR_NAME;
29
29
  export const REQUEST_LEDGER_BASENAME = REQUEST_LEDGER_FILE_NAME;
30
30
  export const REQUIREMENT_FILE_NAME = "需求.md";
31
31
  export const CONSTRAINTS_FILE_NAME = "执行约束.md";
32
+ /** Source fidelity ledger (20260824-frontend-source-fidelity-ledger-v1). */
33
+ export const REQUIREMENT_LEDGER_FILE_NAME = "requirement-ledger.json";
@@ -35,6 +35,9 @@ export function computeSourceHashes(input) {
35
35
  if (input.referenceManifestBytes) {
36
36
  sourceHashes.referenceManifestSha256 = sha256Utf8(input.referenceManifestBytes);
37
37
  }
38
+ if (input.requirementLedgerBytes) {
39
+ sourceHashes.requirementLedgerSha256 = sha256Utf8(input.requirementLedgerBytes);
40
+ }
38
41
  return sourceHashes;
39
42
  }
40
43
  export function refMatchesObserved(ref, observedCanonicalHash) {
@@ -1,6 +1,6 @@
1
1
  import { access, readdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { CONSTRAINTS_FILE_NAME, CONTRACT_FILE_NAME, REQUIREMENT_FILE_NAME, TRANSACTIONS_DIR_NAME, } from "./constants.js";
3
+ import { CONSTRAINTS_FILE_NAME, CONTRACT_FILE_NAME, REQUIREMENT_FILE_NAME, REQUIREMENT_LEDGER_FILE_NAME, TRANSACTIONS_DIR_NAME, } from "./constants.js";
4
4
  import { computeCanonicalHash, computeSourceHashes, computeTaskConfigSha256, } from "./hash.js";
5
5
  import { parseTaskContractRef } from "./schema.js";
6
6
  import { getTaskPaths } from "../runtime.js";
@@ -33,11 +33,13 @@ export async function loadObservedTaskBytes(repoRoot, taskId) {
33
33
  const paths = getTaskPaths(repoRoot, taskId);
34
34
  const requirementPath = path.join(paths.sourceDir, REQUIREMENT_FILE_NAME);
35
35
  const constraintsPath = path.join(paths.sourceDir, CONSTRAINTS_FILE_NAME);
36
- const [requirementBytes, constraintsBytes, taskConfigText, manifestText] = await Promise.all([
36
+ const requirementLedgerPath = path.join(paths.sourceDir, REQUIREMENT_LEDGER_FILE_NAME);
37
+ const [requirementBytes, constraintsBytes, taskConfigText, manifestText, requirementLedgerBytes] = await Promise.all([
37
38
  readTextIfExists(requirementPath),
38
39
  readTextIfExists(constraintsPath),
39
40
  readTextIfExists(paths.taskConfigPath),
40
41
  readTextIfExists(getSourceManifestPath(paths.sourceDir)),
42
+ readTextIfExists(requirementLedgerPath),
41
43
  ]);
42
44
  let taskConfigRaw = null;
43
45
  let taskConfigSha256 = null;
@@ -52,6 +54,7 @@ export async function loadObservedTaskBytes(repoRoot, taskId) {
52
54
  taskConfigRaw,
53
55
  taskConfigSha256,
54
56
  referenceManifestBytes: manifestText,
57
+ requirementLedgerBytes,
55
58
  requirementPath,
56
59
  constraintsPath,
57
60
  taskConfigPath: paths.taskConfigPath,
@@ -164,7 +167,9 @@ export async function observeTaskContractState(repoRoot, taskId) {
164
167
  message: error instanceof Error ? error.message : String(error),
165
168
  });
166
169
  }
167
- const observed = await computeObservedCanonicalHashSafe(repoRoot, taskId);
170
+ const observed = await computeObservedCanonicalHashSafe(repoRoot, taskId, {
171
+ ref: ref ?? undefined,
172
+ });
168
173
  errors.push(...(observed.errors ?? []));
169
174
  if (!ref) {
170
175
  return {
@@ -226,7 +231,7 @@ export async function observeTaskContractState(repoRoot, taskId) {
226
231
  observationErrors: errors,
227
232
  };
228
233
  }
229
- async function computeObservedCanonicalHashSafe(repoRoot, taskId) {
234
+ async function computeObservedCanonicalHashSafe(repoRoot, taskId, options) {
230
235
  const errors = [];
231
236
  try {
232
237
  const bytes = await loadObservedTaskBytes(repoRoot, taskId);
@@ -256,10 +261,26 @@ async function computeObservedCanonicalHashSafe(repoRoot, taskId) {
256
261
  }
257
262
  return { hash: null, errors };
258
263
  }
264
+ // Legacy compatibility (AC-FIDELITY-004): only include the ledger bytes
265
+ // when the stored ref actually declares requirementLedgerSha256. A ref
266
+ // without the ledger field keeps the existing semantics and never
267
+ // misreports drift for tasks that predate the ledger.
268
+ const refLedgerHash = options?.ref?.sourceHashes.requirementLedgerSha256 ?? null;
269
+ if (refLedgerHash && bytes.requirementLedgerBytes === null) {
270
+ errors.push({
271
+ code: "MISSING_REQUIREMENT_LEDGER",
272
+ path: "source/requirement-ledger.json",
273
+ message: `contract records requirementLedgerSha256 but ${REQUIREMENT_LEDGER_FILE_NAME} is missing`,
274
+ });
275
+ return { hash: null, errors };
276
+ }
259
277
  const sourceHashes = computeSourceHashes({
260
278
  requirementBytes: bytes.requirementBytes,
261
279
  constraintsBytes: bytes.constraintsBytes,
262
280
  referenceManifestBytes: bytes.referenceManifestBytes,
281
+ ...(refLedgerHash
282
+ ? { requirementLedgerBytes: bytes.requirementLedgerBytes }
283
+ : {}),
263
284
  });
264
285
  const hash = computeCanonicalHash({
265
286
  sourceHashes,
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { getTaskPaths } from "../runtime.js";
3
- import { CONTRACT_JSON_BASENAME, REQUEST_LEDGER_BASENAME, TRANSACTIONS_DIRNAME, } from "./constants.js";
3
+ import { CONTRACT_JSON_BASENAME, REQUIREMENT_LEDGER_FILE_NAME, REQUEST_LEDGER_BASENAME, TRANSACTIONS_DIRNAME, } from "./constants.js";
4
4
  export function getTaskContractPaths(repoRoot, taskId) {
5
5
  const base = getTaskPaths(repoRoot, taskId);
6
6
  return {
@@ -15,5 +15,6 @@ export function getTaskContractPaths(repoRoot, taskId) {
15
15
  transactionsDir: path.join(base.taskDir, TRANSACTIONS_DIRNAME),
16
16
  requestLedgerPath: path.join(base.taskDir, REQUEST_LEDGER_BASENAME),
17
17
  lockPath: path.join(base.taskDir, ".contract.lock"),
18
+ requirementLedgerPath: path.join(base.sourceDir, REQUIREMENT_LEDGER_FILE_NAME),
18
19
  };
19
20
  }
@@ -1,8 +1,10 @@
1
1
  import { canonicalizePathArray, canonicalizeTextProjection, } from "./canonicalize.js";
2
2
  /**
3
3
  * Project Draft → source/需求.md (deterministic template, projectionVersion=1).
4
+ * 当携带 ledger 时追加 `## Source ledger` 区块:明确 需求.md 是派生导航视图,
5
+ * 语义权威为原始 requirement/acceptance 文档与受管 requirement-ledger.json。
4
6
  */
5
- export function projectRequirementMarkdown(draft) {
7
+ export function projectRequirementMarkdown(draft, ledger) {
6
8
  const lines = [
7
9
  `# 需求:${draft.title}`,
8
10
  "",
@@ -62,6 +64,18 @@ export function projectRequirementMarkdown(draft) {
62
64
  lines.push(`- [${ref.role}] ${ref.ref}`);
63
65
  }
64
66
  }
67
+ if (ledger) {
68
+ const gateStatus = ledger.stats.unresolvedCount > 0 ||
69
+ ledger.stats.conflictCount > 0 ||
70
+ ledger.stats.highRiskUncoveredCount > 0
71
+ ? "blocked"
72
+ : "pass";
73
+ const ledgerLine = ledger.sourcePaths.length > 0
74
+ ? `source/requirement-ledger.json (${ledger.sourcePaths.length} source document(s))`
75
+ : "source/requirement-ledger.json";
76
+ lines.push("", "## Source ledger", "", "> 本文件是派生导航视图,不替代原始 requirement/acceptance 文档。" +
77
+ " 语义权威为原始文档与 `source/requirement-ledger.json`(确定性切分账本)。", "", `- ledger: ${ledgerLine.length > 160 ? `${ledgerLine.slice(0, 160)}…` : ledgerLine}`, `- inputDigest: ${ledger.inputDigest}`, `- fragments: ${ledger.stats.fragmentCount}`, `- canonical requirements: ${ledger.stats.canonicalRequirementCount}`, `- status: ${gateStatus}`);
78
+ }
65
79
  lines.push("");
66
80
  return canonicalizeTextProjection(lines.join("\n"));
67
81
  }
@@ -108,6 +108,8 @@ export const taskContractSourceHashesSchema = z
108
108
  requirementSha256: sha256Schema,
109
109
  constraintsSha256: sha256Schema,
110
110
  referenceManifestSha256: sha256Schema.optional(),
111
+ /** Source fidelity ledger hash; present only when the task binds a ledger. */
112
+ requirementLedgerSha256: sha256Schema.optional(),
111
113
  })
112
114
  .strict();
113
115
  export const taskContractRefSchema = z
@@ -21,6 +21,7 @@ export async function runJournaledContractCommit(input) {
21
21
  const constraintsRel = `source/${CONSTRAINTS_FILE_NAME}`;
22
22
  const taskConfigRel = "task.json";
23
23
  const contractRel = CONTRACT_FILE_NAME;
24
+ const requirementLedgerRel = `source/requirement-ledger.json`;
24
25
  const targetSpecs = [
25
26
  {
26
27
  kind: "requirement",
@@ -46,6 +47,17 @@ export async function runJournaledContractCommit(input) {
46
47
  content: input.targets.contractRef,
47
48
  isJson: true,
48
49
  },
50
+ ...(input.targets.requirementLedgerContent !== undefined
51
+ ? [
52
+ {
53
+ kind: "requirement-ledger",
54
+ relativePath: requirementLedgerRel,
55
+ // Content is already serialized JSON text; stageText avoids double-encoding.
56
+ content: input.targets.requirementLedgerContent,
57
+ isJson: false,
58
+ },
59
+ ]
60
+ : []),
49
61
  ];
50
62
  const targetRecords = [];
51
63
  for (const spec of targetSpecs) {
@@ -6,7 +6,7 @@
6
6
  import { lstat, readFile, readdir, realpath, stat } from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { z } from "zod";
9
- import { DEFAULT_OPENSPEC_GOVERNANCE_ROOT, MAX_OPENSPEC_CANDIDATE_PATHS, OPENSPEC_SPEC_DIRS, OPENSPEC_SPEC_EXT_RE, isGovernanceSubtreePath, } from "../shared/openspec-spec.js";
9
+ import { DEFAULT_OPENSPEC_GOVERNANCE_ROOT, MAX_OPENSPEC_CANDIDATE_PATHS, OPENSPEC_SPEC_DIRS, OPENSPEC_SPEC_EXT_RE, isGovernanceSubtreePath, isValidFrontendSpecRoot, } from "../shared/openspec-spec.js";
10
10
  const classifiedPathSchema = z
11
11
  .string()
12
12
  .refine((candidate) => OPENSPEC_SPEC_EXT_RE.test(candidate), "classified path must be a repo-relative supported spec file");
@@ -81,6 +81,77 @@ async function exists(filePath) {
81
81
  return false;
82
82
  }
83
83
  }
84
+ export const FRONTEND_SPEC_REGISTRY_PATH = "ai_workspace/loop-agent/spec-registry.json";
85
+ const SPEC_SIGNAL_RE = /(?:规范|规约|标准|约定|guideline|standard|convention|design[- ]?system|rule|token|component|style|theme)/i;
86
+ const SPEC_EXCLUDE_RE = /(?:^|\/)(?:archive|history|changelog|changes|proposals?|release[-_ ]notes?)(?:\/|$)/i;
87
+ /** Stable, bounded heuristic used only for candidate ordering/classification. */
88
+ export function scoreFrontendSpecCandidate(filePath, contentPreview = "", options = {}) {
89
+ const normalized = filePath.replaceAll("\\", "/");
90
+ const lower = normalized.toLowerCase();
91
+ const reasons = [];
92
+ let score = 0;
93
+ if (options.registryMode === "normative") {
94
+ score += 100;
95
+ reasons.push("registry-normative");
96
+ }
97
+ else if (options.registryMode === "advisory") {
98
+ score += 30;
99
+ reasons.push("registry-advisory");
100
+ }
101
+ if (options.taskRelated) {
102
+ score += 25;
103
+ reasons.push("task-related");
104
+ }
105
+ if (/(?:design-system|guidelines?|standards?|conventions?|rules?|specs?)(?:\/|$)/i.test(lower)) {
106
+ score += 20;
107
+ reasons.push("spec-directory");
108
+ }
109
+ if (/(?:guidelines?|standards?|conventions?|rules?|tokens?|components?|theme|style)/i.test(path.basename(lower))) {
110
+ score += 15;
111
+ reasons.push("spec-filename");
112
+ }
113
+ if (SPEC_SIGNAL_RE.test(contentPreview)) {
114
+ score += 20;
115
+ reasons.push("spec-content");
116
+ }
117
+ if (/(?:^|\n)\s*(?:#{1,6}\s*)?(?:rules?|do|don't|必须|禁止|约定|规范)\b/im.test(contentPreview)) {
118
+ score += 15;
119
+ reasons.push("normative-structure");
120
+ }
121
+ if (SPEC_EXCLUDE_RE.test(lower)) {
122
+ score -= 80;
123
+ reasons.push("excluded-history-path");
124
+ }
125
+ const kind = options.registryMode === "normative" || score >= 80
126
+ ? "normative"
127
+ : score >= 35
128
+ ? "advisory"
129
+ : "unknown";
130
+ return { path: normalized, kind, score, reasons, source: options.registryMode ? "registry" : "auto-discovered" };
131
+ }
132
+ /** Read the optional project-level registry without making it mandatory. */
133
+ export async function readFrontendSpecRegistry(repoRoot) {
134
+ const raw = await readJson(path.join(repoRoot, FRONTEND_SPEC_REGISTRY_PATH));
135
+ if (!raw || typeof raw !== "object")
136
+ return null;
137
+ const value = raw;
138
+ if (value.version !== 1 || !Array.isArray(value.roots))
139
+ return null;
140
+ const roots = value.roots
141
+ .filter((item) => Boolean(item) && typeof item === "object")
142
+ .map((item) => ({
143
+ path: typeof item.path === "string" ? item.path.replaceAll("\\", "/").replace(/\/+$/, "") : "",
144
+ scope: Array.isArray(item.scope) ? item.scope.filter((v) => typeof v === "string") : undefined,
145
+ mode: (item.mode === "normative" || item.mode === "advisory" ? item.mode : undefined),
146
+ description: typeof item.description === "string" ? item.description : undefined,
147
+ }))
148
+ .filter((item) => isValidFrontendSpecRoot(item.path));
149
+ return {
150
+ version: 1,
151
+ roots,
152
+ discovery: value.discovery && typeof value.discovery === "object" ? value.discovery : undefined,
153
+ };
154
+ }
84
155
  /**
85
156
  * Resolve only unambiguous symlink-root aliases. The physical target is never
86
157
  * emitted as a candidate path: it merely lets task-source wording such as
@@ -133,6 +204,14 @@ export async function listOpenspecDiscovery(repoRoot, options = {}) {
133
204
  .filter(Boolean);
134
205
  const collected = [];
135
206
  let excludedGovernanceCount = 0;
207
+ let scannedFiles = 0;
208
+ let scannedBytes = 0;
209
+ const warnings = [];
210
+ const maxDepth = options.maxDepth ?? 4;
211
+ const maxFiles = options.maxFiles ?? MAX_OPENSPEC_CANDIDATE_PATHS;
212
+ const maxBytes = options.maxBytes ?? 1024 * 1024;
213
+ const timeoutMs = options.timeoutMs ?? 2000;
214
+ const startedAt = Date.now();
136
215
  // Metadata / bookkeeping directories that are not normative design specs:
137
216
  // changelogs, ADR/history archives, proposals under review, and scratch
138
217
  // state. Reading them inflates the candidate list (and the plan prompt)
@@ -181,10 +260,19 @@ export async function listOpenspecDiscovery(repoRoot, options = {}) {
181
260
  excludedGovernanceCount,
182
261
  truncated,
183
262
  maxCandidates: MAX_OPENSPEC_CANDIDATE_PATHS,
263
+ warnings,
184
264
  };
185
265
  async function walk(dir, depth) {
186
- if (depth > 4)
266
+ if (Date.now() - startedAt >= timeoutMs) {
267
+ if (!warnings.includes("timeout-exceeded"))
268
+ warnings.push("timeout-exceeded");
187
269
  return;
270
+ }
271
+ if (depth > maxDepth) {
272
+ if (!warnings.includes("max-depth-exceeded"))
273
+ warnings.push("max-depth-exceeded");
274
+ return;
275
+ }
188
276
  let entries = [];
189
277
  try {
190
278
  entries = (await readdir(dir)).sort();
@@ -193,12 +281,30 @@ export async function listOpenspecDiscovery(repoRoot, options = {}) {
193
281
  return;
194
282
  }
195
283
  for (const name of entries) {
284
+ if (Date.now() - startedAt >= timeoutMs) {
285
+ if (!warnings.includes("timeout-exceeded"))
286
+ warnings.push("timeout-exceeded");
287
+ return;
288
+ }
289
+ if (scannedFiles >= maxFiles) {
290
+ if (!warnings.includes("max-files-exceeded"))
291
+ warnings.push("max-files-exceeded");
292
+ return;
293
+ }
196
294
  const full = path.join(dir, name);
197
295
  try {
198
296
  const info = await stat(full);
199
297
  if (info.isDirectory())
200
298
  await walk(full, depth + 1);
201
299
  else if (OPENSPEC_SPEC_EXT_RE.test(name)) {
300
+ const size = info.size;
301
+ if (scannedBytes + size > maxBytes) {
302
+ if (!warnings.includes("max-bytes-exceeded"))
303
+ warnings.push("max-bytes-exceeded");
304
+ return;
305
+ }
306
+ scannedFiles++;
307
+ scannedBytes += size;
202
308
  const rel = path.relative(repoRoot, full).replace(/\\/g, "/");
203
309
  if (isExcluded(rel)) {
204
310
  excludedGovernanceCount++;
@@ -387,6 +493,7 @@ function toOpenspecDiscovery(discovery, designEvidence) {
387
493
  excludedGovernanceCount: discovery.excludedGovernanceCount,
388
494
  truncated: discovery.truncated,
389
495
  maxCandidates: discovery.maxCandidates,
496
+ warnings: discovery.warnings,
390
497
  };
391
498
  }
392
499
  export function buildAdapterGuidance(capability) {
@@ -424,12 +531,24 @@ export function buildAdapterGuidance(capability) {
424
531
  * Strong evidence: package.json direct deps + config/bootstrap files.
425
532
  */
426
533
  export async function discoverFrontendProjectCapability(repoRoot, options = {}) {
534
+ const registry = await readFrontendSpecRegistry(repoRoot);
535
+ const registryRoots = registry?.roots
536
+ .filter((root) => !root.scope || root.scope.includes("frontend"))
537
+ .map((root) => root.path) ?? [];
538
+ const discoveryOptions = {
539
+ ...options,
540
+ specRoots: [...new Set([...OPENSPEC_SPEC_DIRS, ...(options.specRoots ?? []), ...registryRoots])],
541
+ maxDepth: registry?.discovery?.maxDepth ?? options.maxDepth,
542
+ maxFiles: registry?.discovery?.maxFiles ?? options.maxFiles,
543
+ maxBytes: registry?.discovery?.maxBytes ?? options.maxBytes,
544
+ timeoutMs: registry?.discovery?.timeoutMs ?? options.timeoutMs,
545
+ };
427
546
  const evidencePaths = [];
428
547
  const reasons = [];
429
548
  const pkgPath = path.join(repoRoot, "package.json");
430
549
  const pkgRaw = await readJson(pkgPath);
431
550
  if (!pkgRaw || typeof pkgRaw !== "object") {
432
- const discovery = await listOpenspecDiscovery(repoRoot, options);
551
+ const discovery = await listOpenspecDiscovery(repoRoot, discoveryOptions);
433
552
  const designEvidence = buildDesignEvidence(discovery.paths);
434
553
  const base = {
435
554
  schemaVersion: 1,
@@ -602,7 +721,7 @@ export async function discoverFrontendProjectCapability(repoRoot, options = {})
602
721
  router = router ?? "next-router";
603
722
  if (hasDep(deps, "vue-router"))
604
723
  router = "vue-router";
605
- const discovery = await listOpenspecDiscovery(repoRoot, options);
724
+ const discovery = await listOpenspecDiscovery(repoRoot, discoveryOptions);
606
725
  const designEvidence = buildDesignEvidence(discovery.paths);
607
726
  if (discovery.paths.length)
608
727
  evidencePaths.push(...discovery.paths.slice(0, 5));