@tea-agent/loop-agent 0.42.0-next.2 → 0.42.0-next.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +244 -1
  2. package/dist/application/dag/generate-task-dag.js +75 -19
  3. package/dist/application/task-lifecycle/advance.js +24 -5
  4. package/dist/application/task-lifecycle/observe.js +171 -17
  5. package/dist/application/task-lifecycle/plan-transitions.js +42 -7
  6. package/dist/application/task-lifecycle/recommendations.js +13 -2
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/cli/command-definitions.js +7 -0
  9. package/dist/cli/program.js +5 -0
  10. package/dist/commands/client-recovery.js +3 -0
  11. package/dist/commands/dag-follow-up.js +138 -0
  12. package/dist/commands/init.js +27 -1
  13. package/dist/commands/task-advance.js +19 -0
  14. package/dist/executors/dag-pi-executor.js +3789 -232
  15. package/dist/executors/pi-executor.js +15 -4
  16. package/dist/executors/pi-read-budget-policy.js +239 -0
  17. package/dist/executors/shell-executor.js +942 -187
  18. package/dist/executors/shell-write-guard.js +7 -0
  19. package/dist/infrastructure/console/operation-store.js +226 -6
  20. package/dist/shared/dag-failure-category.js +12 -0
  21. package/dist/shared/openspec-spec.js +70 -4
  22. package/dist/shared/operator/capabilities.js +7 -0
  23. package/dist/task/config-types.js +34 -5
  24. package/dist/task/contract/adopt.js +4 -0
  25. package/dist/task/contract/import-revision.js +4 -0
  26. package/dist/task/contract/project.js +3 -0
  27. package/dist/task/contract/schema.js +2 -1
  28. package/dist/task/frontend-project-capability.js +203 -20
  29. package/dist/task/runtime.js +5 -2
  30. package/dist/task/source-prepare/build-draft.js +3 -3
  31. package/dist/task/source-prepare/fragment-inventory.js +15 -9
  32. package/dist/task/source-prepare/prepare.js +89 -1
  33. package/dist/task/source-prepare/semantic-intake.js +6 -2
  34. package/dist/task/source-references.js +22 -1
  35. package/dist/worker/console/chat/chat-ui-policy.js +4 -2
  36. package/dist/worker/console/chat/pi-runtime.js +173 -7
  37. package/dist/worker/console/chat/repo-browser.js +10 -3
  38. package/dist/worker/console/chat/routes.js +30 -19
  39. package/dist/worker/console/chat/session-store.js +117 -70
  40. package/dist/worker/console/chat/shortcuts.js +1 -1
  41. package/dist/worker/console/chat/turn-process.js +111 -56
  42. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  43. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  44. package/dist/worker/console/index.js +3 -0
  45. package/dist/worker/console/operator-actions.js +169 -7
  46. package/dist/worker/console/server.js +3 -0
  47. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DZZ8m3PO.js → abnfDiagram-N423BO3Z-CeznJ4Iw.js} +1 -1
  48. package/dist/worker/console/static/assets/{arc-D6PvaVd-.js → arc-B4u9u7SX.js} +1 -1
  49. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-B_OTOiI8.js → architectureDiagram-T3A2C74G-oghpT81p.js} +1 -1
  50. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Bv6rqHBg.js → blockDiagram-VBNYF7ZC-BPJ5Kvcj.js} +1 -1
  51. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B8eHr0oz.js → c4Diagram-5PPSVZJV-CoIu69pd.js} +1 -1
  52. package/dist/worker/console/static/assets/channel-DmIXqJ6B.js +1 -0
  53. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DYwR0im2.js → chunk-2GRJ4B5K-DtgiAsSc.js} +1 -1
  54. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D2WPGqXt.js → chunk-2Q5K7J3B-YSu_EVSD.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CQISJ_I7.js → chunk-5RXB4S5H-DaRO-NB6.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C0o2Du1e.js → chunk-5VM5RSS4-Dh0ZKiGA.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-4f8kr-U7.js → chunk-6Q2QTUOP-DOrvbR-H.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D_OWvXzX.js → chunk-GF5L2VYU-C3b22LN2.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CasdPz5X.js → chunk-JWPE2WC7-CXlqO8l6.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Cj8lRrla.js → chunk-KBJHAD2P-BblTBrDu.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CUvD1FSd.js → chunk-RYQCIY6F-DswqPALe.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CqLw_eqB.js → chunk-XXDRQBXY-CfTwVOlc.js} +1 -1
  63. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-MFV5Qxb5.js +1 -0
  64. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-MFV5Qxb5.js +1 -0
  65. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-ku-WqJPx.js → cose-bilkent-JH36ORCC-bLD8jVFo.js} +1 -1
  66. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-Bq_PYDhl.js → cynefin-VYW2F7L2-BvoG1fh5.js} +1 -1
  67. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BiGZV641.js → cynefinDiagram-MW4NZA55-DEPAdJu3.js} +1 -1
  68. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C4JRlglF.js → dagre-VZM6K2ZE-DmrNvBLy.js} +1 -1
  69. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B0dNeWYG.js → diagram-7IWD3JNH-CdwzIwvs.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cj35YvYM.js → diagram-B4RE2ZJO-CJ3hj-md.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-uzQoJ2-8.js → diagram-LBJQPF4R-C6YJT2a4.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D1a-Buoz.js → diagram-Q27KOJAE-C_LPfjhX.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-UB23O5K3-XjRrLRSs.js → diagram-UB23O5K3-xvsSb5Ie.js} +1 -1
  74. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Da_O24cW.js → ebnfDiagram-BXEA7PRR-Rxp2tKcD.js} +1 -1
  75. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BLJ8jrYU.js → erDiagram-JOGREHBK-DRyLYmZh.js} +1 -1
  76. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-B9GrLjM0.js → flowDiagram-UKHOOZJN-TLSU_D2B.js} +1 -1
  77. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CeJ0TqiK.js → ganttDiagram-PKOTCBZU-DGqeTvHC.js} +1 -1
  78. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js → gitGraphDiagram-DS77QQ5N-BZxyH2wh.js} +1 -1
  79. package/dist/worker/console/static/assets/index-C8V8K9b4.js +451 -0
  80. package/dist/worker/console/static/assets/index-Cnf_KxTQ.css +1 -0
  81. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DB26i3d8.js → infoDiagram-6WML65LV-BsYtzx01.js} +1 -1
  82. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BOGRyeZT.js → ishikawaDiagram-WSZJBQD7-Qr-ezGv-.js} +1 -1
  83. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CZRoBO_6.js → journeyDiagram-NVQOT4AX-DYbB3ibo.js} +1 -1
  84. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-qlWhJyoB.js → kanban-definition-27J2QSJJ-BHJHewZO.js} +1 -1
  85. package/dist/worker/console/static/assets/{linear-CfUiDDB3.js → linear-CmPIPmCG.js} +1 -1
  86. package/dist/worker/console/static/assets/{mermaid.core-BQe6fpqj.js → mermaid.core-F--C1uV0.js} +5 -5
  87. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-zFzWHw64.js → mindmap-definition-FAOFIHXS-YTXfL1st.js} +1 -1
  88. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DFGUqjZO.js → pegDiagram-VL7TDLO6-DdDMYXP9.js} +1 -1
  89. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BB5i0l8Z.js → pieDiagram-7S7Q4E2Y-C3nH4KVd.js} +1 -1
  90. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-obbVZ_X5.js → quadrantDiagram-CIZ2JOQS-kXyxjG7Y.js} +1 -1
  91. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwdaNzc1.js → railroadDiagram-AXF67PYL-KNzbl-7w.js} +1 -1
  92. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CkTJaugh.js → requirementDiagram-LRYGKXZP-CSwI6dG0.js} +1 -1
  93. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BwJ-hIgq.js → sankeyDiagram-W5VNT64P-Bl1AyuXy.js} +1 -1
  94. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BzSgmm7w.js → sequenceDiagram-SI44F4Z6-2y4EmXQa.js} +1 -1
  95. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Bn3oj5Q1.js → sizeCapture-X5ZJPWSS-E8v8YrMg.js} +1 -1
  96. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-dxhGtL8K.js → stateDiagram-OKZ733FA-eGGlR856.js} +1 -1
  97. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8e4J3at.js +1 -0
  98. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BLpEInya.js → swimlanes-SLNWSIFB-B7g7xc07.js} +2 -2
  99. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Do88I1Nb.js +8 -0
  100. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ChJGYcXy.js → timeline-definition-Z64GVDOM-DR3VCHBG.js} +1 -1
  101. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DK-qjRer.js → vennDiagram-T6HMQDX7-LUvapZIy.js} +1 -1
  102. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-3qeaWAg-.js → wardleyDiagram-T6FBY63Y-C7GDcaXq.js} +1 -1
  103. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CootlyP9.js → xychartDiagram-ELKLHX3M-CbNAqoGK.js} +1 -1
  104. package/dist/worker/console/static/index.html +2 -2
  105. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  106. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +19 -8
  107. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +15 -7
  108. package/dist/worker/console/workspace-context.js +111 -1
  109. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  110. package/dist/worker/observe/static/dag-node-purpose.js +5 -0
  111. package/dist/workflows/dag/backend-test-writer-completeness.js +10 -0
  112. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  113. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  114. package/dist/workflows/dag/frontend-closeout.js +221 -0
  115. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  116. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  117. package/dist/workflows/dag/frontend-implementation-contract.js +907 -162
  118. package/dist/workflows/dag/frontend-plan-render.js +2 -1
  119. package/dist/workflows/dag/frontend-prewrite-gate.js +255 -349
  120. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  121. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  122. package/dist/workflows/dag/frontend-recovery-controller.js +202 -0
  123. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  124. package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
  125. package/dist/workflows/dag/frontend-recovery-run.js +33 -20
  126. package/dist/workflows/dag/frontend-repair.js +1 -432
  127. package/dist/workflows/dag/frontend-review-context.js +261 -15
  128. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  129. package/dist/workflows/dag/frontend-shadow-dual-write.js +914 -0
  130. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  131. package/dist/workflows/dag/frontend-shape-facts.js +419 -0
  132. package/dist/workflows/dag/frontend-shape.js +435 -0
  133. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  134. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  135. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  136. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  137. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  138. package/dist/workflows/dag/frontend-verification-trace.js +249 -24
  139. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  140. package/dist/workflows/dag/frontend-writer-admission.js +285 -0
  141. package/dist/workflows/dag/frontend-writer-status.js +256 -0
  142. package/dist/workflows/dag/init-hybrid.js +677 -492
  143. package/dist/workflows/dag/interrupt-request.js +7 -0
  144. package/dist/workflows/dag/node-execution.js +732 -4
  145. package/dist/workflows/dag/prompt.js +34 -1
  146. package/dist/workflows/dag/recovery-lease.js +80 -0
  147. package/dist/workflows/dag/report.js +37 -1
  148. package/dist/workflows/dag/rerun-feedback.js +256 -1
  149. package/dist/workflows/dag/rerun-plan.js +17 -6
  150. package/dist/workflows/dag/rerun-run.js +23 -0
  151. package/dist/workflows/dag/rerun-task.js +29 -0
  152. package/dist/workflows/dag/retry-policy.js +214 -104
  153. package/dist/workflows/dag/runner.js +433 -126
  154. package/dist/workflows/dag/scheduler.js +114 -16
  155. package/dist/workflows/dag/types.js +239 -12
  156. package/dist/workflows/dag/validate.js +28 -12
  157. package/docs/examples/README.md +5 -0
  158. package/docs/init-surface.manifest.json +30 -12
  159. package/docs/skills/vetted-skill-registry.md +4 -2
  160. package/docs/templates/README.md +2 -0
  161. package/docs/templates/agent-dag-report.schema.json +8 -2
  162. package/docs/templates/agent-dag.schema.json +1 -1
  163. package/docs/templates/backend-test-dag.json +5 -2
  164. package/docs/templates/frontend-implementation-contract.schema.json +4 -1
  165. package/docs/templates/frontend-implementation-dag.json +89 -0
  166. package/docs/templates/spec-registry.schema.json +45 -0
  167. package/harness.json +1 -1
  168. package/package.json +1 -1
  169. package/skills/frontend-bounded-implement/SKILL.md +15 -14
  170. package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
  171. package/skills/frontend-contract/SKILL.md +23 -0
  172. package/skills/frontend-contract/references/contract-protocol.md +34 -0
  173. package/skills/frontend-design-review/SKILL.md +22 -41
  174. package/skills/frontend-plan/SKILL.md +26 -0
  175. package/skills/frontend-plan/references/decision-contract.md +37 -0
  176. package/skills/frontend-plan/references/design-decisions.md +17 -0
  177. package/skills/frontend-review/SKILL.md +20 -15
  178. package/skills/frontend-review/references/review-findings.md +6 -7
  179. package/skills/frontend-scout/SKILL.md +25 -0
  180. package/skills/frontend-scout/references/design-evidence.md +16 -0
  181. package/skills/frontend-scout/references/scout-evidence.md +23 -0
  182. package/skills/frontend-verification/SKILL.md +1 -1
  183. package/skills/loop-agent/references/command-reference.md +1 -0
  184. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  185. package/dist/worker/console/static/assets/channel-BU5gOilw.js +0 -1
  186. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +0 -1
  187. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +0 -1
  188. package/dist/worker/console/static/assets/index-H9rFJiGL.css +0 -1
  189. package/dist/worker/console/static/assets/index-xwu9GxEc.js +0 -451
  190. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +0 -1
  191. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +0 -8
  192. package/skills/frontend-implementation/SKILL.md +0 -52
  193. package/skills/frontend-implementation/references/code-standards.md +0 -33
  194. package/skills/frontend-implementation/references/design-spec.md +0 -56
  195. package/skills/frontend-implementation/references/node-contracts.md +0 -31
@@ -3,13 +3,13 @@
3
3
  * Strong evidence only: package.json deps, config files, source imports.
4
4
  * Lockfile-only or directory-name-only must NOT activate adapters.
5
5
  */
6
- import { readFile, readdir, stat } from "node:fs/promises";
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, isOpenspecSpecFilePath, } 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
- .refine((candidate) => isOpenspecSpecFilePath(candidate), "classified path must be a repo-relative supported spec file under openspec/schemas/, openspec/project-specs/, or ai_workspace/");
12
+ .refine((candidate) => OPENSPEC_SPEC_EXT_RE.test(candidate), "classified path must be a repo-relative supported spec file");
13
13
  /**
14
14
  * Stable semantic partition of `designEvidence.normativePaths`. Keys are a
15
15
  * generic directory convention (not project-specific file names); the same
@@ -81,6 +81,105 @@ 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
+ }
155
+ /**
156
+ * Resolve only unambiguous symlink-root aliases. The physical target is never
157
+ * emitted as a candidate path: it merely lets task-source wording such as
158
+ * `ai_doc/rules.md` resolve to the stable repo-local path `ai_workspace/rules.md`.
159
+ */
160
+ export async function resolveFrontendSpecRootAliases(repoRoot, specRoots) {
161
+ const candidates = [];
162
+ for (const logicalRoot of specRoots) {
163
+ const full = path.join(repoRoot, logicalRoot);
164
+ try {
165
+ if (!(await lstat(full)).isSymbolicLink())
166
+ continue;
167
+ const alias = path.basename(await realpath(full));
168
+ if (alias && alias !== path.basename(logicalRoot)) {
169
+ candidates.push({ alias, logicalRoot });
170
+ }
171
+ }
172
+ catch {
173
+ // Missing/broken links are handled by normal candidate discovery drift.
174
+ }
175
+ }
176
+ const counts = new Map();
177
+ for (const item of candidates)
178
+ counts.set(item.alias, (counts.get(item.alias) ?? 0) + 1);
179
+ return candidates
180
+ .filter((item) => counts.get(item.alias) === 1)
181
+ .sort((a, b) => a.alias.localeCompare(b.alias));
182
+ }
84
183
  /**
85
184
  * Resolve the governance subtree root from harness.json.governanceRoot.
86
185
  * Falls back to DEFAULT_OPENSPEC_GOVERNANCE_ROOT when the manifest is missing,
@@ -105,28 +204,54 @@ export async function listOpenspecDiscovery(repoRoot, options = {}) {
105
204
  .filter(Boolean);
106
205
  const collected = [];
107
206
  let excludedGovernanceCount = 0;
207
+ let scannedFiles = 0;
208
+ let maxFilesHit = false;
209
+ let scannedBytes = 0;
210
+ const warnings = [];
211
+ const maxDepth = options.maxDepth ?? 4;
212
+ const maxFiles = options.maxFiles ?? MAX_OPENSPEC_CANDIDATE_PATHS;
213
+ const maxBytes = options.maxBytes ?? 1024 * 1024;
214
+ const timeoutMs = options.timeoutMs ?? 2000;
215
+ const startedAt = Date.now();
216
+ // Metadata / bookkeeping directories that are not normative design specs:
217
+ // changelogs, ADR/history archives, proposals under review, and scratch
218
+ // state. Reading them inflates the candidate list (and the plan prompt)
219
+ // with files no component selection should cite. A task that genuinely
220
+ // needs one can still reference it explicitly — the prewrite gate
221
+ // candidates come from this discovery, but explicit task-source citations
222
+ // flow through openspecMandatoryPaths independently.
223
+ const DEFAULT_EXCLUDED_SPEC_PREFIXES = [
224
+ "openspec/changes",
225
+ "openspec/project-specs/changes",
226
+ "openspec/project-specs/changelog",
227
+ "openspec/project-specs/history",
228
+ "openspec/project-specs/archive",
229
+ "openspec/project-specs/decisions",
230
+ "openspec/project-specs/proposals",
231
+ ];
108
232
  const isExcluded = (rel) => {
109
233
  if (isGovernanceSubtreePath(rel, governanceRoot))
110
234
  return true;
235
+ if (DEFAULT_EXCLUDED_SPEC_PREFIXES.some((prefix) => rel === prefix || rel.startsWith(prefix + "/"))) {
236
+ return true;
237
+ }
111
238
  return extraExcludedPrefixes.some((prefix) => rel === prefix || rel.startsWith(prefix + "/"));
112
239
  };
113
- for (const specDir of OPENSPEC_SPEC_DIRS) {
240
+ for (const specDir of options.specRoots ?? [...OPENSPEC_SPEC_DIRS]) {
114
241
  const root = path.join(repoRoot, specDir);
115
242
  if (!(await exists(root)))
116
243
  continue;
117
244
  await walk(root, 0);
118
245
  }
119
- // openspec/schemas/** and openspec/project-specs/** sort first; remaining
120
- // ai_workspace/** business files follow. Dedupe preserves discovery order.
246
+ // Keep the established default-root priority for deterministic compatibility;
247
+ // configured extra roots are appended in lexical order.
121
248
  const unique = [...new Set(collected)];
122
- const openspecPaths = unique
123
- .filter((candidate) => candidate.startsWith("openspec/"))
124
- .sort();
125
- const aiWorkspacePaths = unique
126
- .filter((candidate) => candidate.startsWith("ai_workspace/"))
249
+ const defaultPaths = OPENSPEC_SPEC_DIRS.flatMap((root) => unique.filter((candidate) => candidate.startsWith(root + "/")).sort());
250
+ const customPaths = unique
251
+ .filter((candidate) => !defaultPaths.includes(candidate))
127
252
  .sort();
128
- const deduped = [...openspecPaths, ...aiWorkspacePaths];
129
- const truncated = deduped.length > MAX_OPENSPEC_CANDIDATE_PATHS;
253
+ const deduped = [...defaultPaths, ...customPaths];
254
+ const truncated = maxFilesHit || deduped.length > MAX_OPENSPEC_CANDIDATE_PATHS;
130
255
  const paths = truncated
131
256
  ? deduped.slice(0, MAX_OPENSPEC_CANDIDATE_PATHS)
132
257
  : deduped;
@@ -136,10 +261,19 @@ export async function listOpenspecDiscovery(repoRoot, options = {}) {
136
261
  excludedGovernanceCount,
137
262
  truncated,
138
263
  maxCandidates: MAX_OPENSPEC_CANDIDATE_PATHS,
264
+ warnings,
139
265
  };
140
266
  async function walk(dir, depth) {
141
- if (depth > 4)
267
+ if (Date.now() - startedAt >= timeoutMs) {
268
+ if (!warnings.includes("timeout-exceeded"))
269
+ warnings.push("timeout-exceeded");
270
+ return;
271
+ }
272
+ if (depth > maxDepth) {
273
+ if (!warnings.includes("max-depth-exceeded"))
274
+ warnings.push("max-depth-exceeded");
142
275
  return;
276
+ }
143
277
  let entries = [];
144
278
  try {
145
279
  entries = (await readdir(dir)).sort();
@@ -148,12 +282,31 @@ export async function listOpenspecDiscovery(repoRoot, options = {}) {
148
282
  return;
149
283
  }
150
284
  for (const name of entries) {
285
+ if (Date.now() - startedAt >= timeoutMs) {
286
+ if (!warnings.includes("timeout-exceeded"))
287
+ warnings.push("timeout-exceeded");
288
+ return;
289
+ }
290
+ if (scannedFiles >= maxFiles) {
291
+ if (!warnings.includes("max-files-exceeded"))
292
+ warnings.push("max-files-exceeded");
293
+ maxFilesHit = true;
294
+ return;
295
+ }
151
296
  const full = path.join(dir, name);
152
297
  try {
153
298
  const info = await stat(full);
154
299
  if (info.isDirectory())
155
300
  await walk(full, depth + 1);
156
301
  else if (OPENSPEC_SPEC_EXT_RE.test(name)) {
302
+ const size = info.size;
303
+ if (scannedBytes + size > maxBytes) {
304
+ if (!warnings.includes("max-bytes-exceeded"))
305
+ warnings.push("max-bytes-exceeded");
306
+ return;
307
+ }
308
+ scannedFiles++;
309
+ scannedBytes += size;
157
310
  const rel = path.relative(repoRoot, full).replace(/\\/g, "/");
158
311
  if (isExcluded(rel)) {
159
312
  excludedGovernanceCount++;
@@ -241,6 +394,14 @@ function emptyClassified() {
241
394
  * project-specific file names. Basename stems use word-boundary matching so
242
395
  * e.g. `*api*` (case-insensitive) maps to `rule.api` without hardcoding names.
243
396
  */
397
+ /** True when any path segment (other than the file itself) carries a
398
+ * component/theme kind marker. Directory-organized libraries such as
399
+ * `ui/ucp-components-md/AdvancedSearch.md` rely on this; templates/ and
400
+ * rules/ are handled by their own branches before this fallback runs. */
401
+ function segmentMatchesComponentKind(segments) {
402
+ const dirSegments = segments.slice(0, -1);
403
+ return dirSegments.some((segment) => /\bcomponents?\b/.test(segment.toLowerCase()));
404
+ }
244
405
  function classifyOpenspecPaths(paths) {
245
406
  const classified = emptyClassified();
246
407
  const ruleStem = (basename) => {
@@ -260,7 +421,7 @@ function classifyOpenspecPaths(paths) {
260
421
  return "other";
261
422
  };
262
423
  for (const candidate of paths) {
263
- if (!isOpenspecSpecFilePath(candidate))
424
+ if (!OPENSPEC_SPEC_EXT_RE.test(candidate))
264
425
  continue;
265
426
  const segments = candidate.split("/");
266
427
  const basename = segments[segments.length - 1] ?? "";
@@ -279,8 +440,17 @@ function classifyOpenspecPaths(paths) {
279
440
  classified.theme.push(candidate);
280
441
  else if (/\bcomponents?\b/.test(lower))
281
442
  classified.component.push(candidate);
282
- else
443
+ else if (segmentMatchesComponentKind(segments)) {
444
+ // Directory-organized component libraries (e.g.
445
+ // ui/ucp-components-md/AdvancedSearch.md) carry the kind in the
446
+ // parent directory name, not the file name. The parent-dir rule
447
+ // is a fallback so such layouts still land in the component
448
+ // bucket instead of being dropped into uiOther.
449
+ classified.component.push(candidate);
450
+ }
451
+ else {
283
452
  classified.uiOther.push(candidate);
453
+ }
284
454
  }
285
455
  else {
286
456
  classified.advisoryOther.push(candidate);
@@ -325,6 +495,7 @@ function toOpenspecDiscovery(discovery, designEvidence) {
325
495
  excludedGovernanceCount: discovery.excludedGovernanceCount,
326
496
  truncated: discovery.truncated,
327
497
  maxCandidates: discovery.maxCandidates,
498
+ warnings: discovery.warnings,
328
499
  };
329
500
  }
330
501
  export function buildAdapterGuidance(capability) {
@@ -344,7 +515,7 @@ export function buildAdapterGuidance(capability) {
344
515
  lines.push("Vue adapter: prefer Composition API if project already uses it; reuse Pinia/Vue Router/Vue Query only when evidenced; Vitest + Vue Test Utils when present.");
345
516
  }
346
517
  else {
347
- lines.push("Unknown framework: use generic frontend-implementation skill only; do not assume React/Vue/Next APIs.");
518
+ lines.push("Unknown framework: use only the node-scoped generic frontend guidance; do not assume React/Vue/Next APIs.");
348
519
  }
349
520
  if (capability.a11y.status === "present") {
350
521
  lines.push(`A11y tools present: ${capability.a11y.tools.join(", ")}. Report static/component-level evidence only; Browser a11y remains not-run.`);
@@ -361,13 +532,25 @@ export function buildAdapterGuidance(capability) {
361
532
  * Discover frontend project capability from repo root.
362
533
  * Strong evidence: package.json direct deps + config/bootstrap files.
363
534
  */
364
- export async function discoverFrontendProjectCapability(repoRoot) {
535
+ export async function discoverFrontendProjectCapability(repoRoot, options = {}) {
536
+ const registry = await readFrontendSpecRegistry(repoRoot);
537
+ const registryRoots = registry?.roots
538
+ .filter((root) => !root.scope || root.scope.includes("frontend"))
539
+ .map((root) => root.path) ?? [];
540
+ const discoveryOptions = {
541
+ ...options,
542
+ specRoots: [...new Set([...OPENSPEC_SPEC_DIRS, ...(options.specRoots ?? []), ...registryRoots])],
543
+ maxDepth: registry?.discovery?.maxDepth ?? options.maxDepth,
544
+ maxFiles: registry?.discovery?.maxFiles ?? options.maxFiles,
545
+ maxBytes: registry?.discovery?.maxBytes ?? options.maxBytes,
546
+ timeoutMs: registry?.discovery?.timeoutMs ?? options.timeoutMs,
547
+ };
365
548
  const evidencePaths = [];
366
549
  const reasons = [];
367
550
  const pkgPath = path.join(repoRoot, "package.json");
368
551
  const pkgRaw = await readJson(pkgPath);
369
552
  if (!pkgRaw || typeof pkgRaw !== "object") {
370
- const discovery = await listOpenspecDiscovery(repoRoot);
553
+ const discovery = await listOpenspecDiscovery(repoRoot, discoveryOptions);
371
554
  const designEvidence = buildDesignEvidence(discovery.paths);
372
555
  const base = {
373
556
  schemaVersion: 1,
@@ -540,7 +723,7 @@ export async function discoverFrontendProjectCapability(repoRoot) {
540
723
  router = router ?? "next-router";
541
724
  if (hasDep(deps, "vue-router"))
542
725
  router = "vue-router";
543
- const discovery = await listOpenspecDiscovery(repoRoot);
726
+ const discovery = await listOpenspecDiscovery(repoRoot, discoveryOptions);
544
727
  const designEvidence = buildDesignEvidence(discovery.paths);
545
728
  if (discovery.paths.length)
546
729
  evidencePaths.push(...discovery.paths.slice(0, 5));
@@ -72,7 +72,7 @@ export function getTaskPaths(repoRoot, taskId) {
72
72
  dagDraftPath: path.join(taskDir, 'dag.json'),
73
73
  };
74
74
  }
75
- export async function createTask(repoRoot, taskId, title) {
75
+ export async function createTask(repoRoot, taskId, title, options) {
76
76
  const validation = validateNewTaskId(taskId);
77
77
  for (const warning of validation.warnings) {
78
78
  console.warn(`[new-task] warning: ${warning}`);
@@ -97,7 +97,10 @@ export async function createTask(repoRoot, taskId, title) {
97
97
  taskId,
98
98
  title: title ?? taskId,
99
99
  sourceFiles: [],
100
- taskKind: 'standard',
100
+ taskKind: options?.taskKind ?? 'standard',
101
+ ...(options?.taskKindExplicit !== undefined
102
+ ? { taskKindExplicit: options.taskKindExplicit }
103
+ : {}),
101
104
  referenceRepos: [],
102
105
  referenceDocs: [],
103
106
  allowedPaths: [],
@@ -17,10 +17,10 @@ function resolveTaskKind(flags, baseDraft, existing) {
17
17
  const parsed = taskKindSchema.safeParse(candidate);
18
18
  return parsed.success ? parsed.data : String(candidate);
19
19
  }
20
- function resolveTaskKindExplicit(flags, baseDraft) {
20
+ function resolveTaskKindExplicit(flags, baseDraft, existing) {
21
21
  if (flags.taskKindExplicit !== undefined)
22
22
  return flags.taskKindExplicit;
23
- return baseDraft?.taskKindExplicit ?? false;
23
+ return (baseDraft?.taskKindExplicit ?? existing?.taskKindExplicit ?? false);
24
24
  }
25
25
  /**
26
26
  * Resolve managed backend-test layout config.
@@ -122,7 +122,7 @@ export function buildPrepareDraft(input) {
122
122
  const { taskId, existingTaskConfig, baseDraft, facts, flags, references } = input;
123
123
  const title = resolveTitle(taskId, flags, facts, baseDraft, existingTaskConfig);
124
124
  const taskKind = resolveTaskKind(flags, baseDraft, existingTaskConfig);
125
- const taskKindExplicit = resolveTaskKindExplicit(flags, baseDraft);
125
+ const taskKindExplicit = resolveTaskKindExplicit(flags, baseDraft, existingTaskConfig);
126
126
  const featureId = trimNonEmpty(flags.featureId) ??
127
127
  trimNonEmpty(baseDraft?.featureId) ??
128
128
  trimNonEmpty(existingTaskConfig?.featureId) ??
@@ -175,8 +175,11 @@ export function splitDocumentIntoFragments(doc) {
175
175
  const explicitSectionAc = matchExplicitAcHeading(lastHeadingSegment);
176
176
  const sectionKind = classifySectionHeading(section.headingPath);
177
177
  const effectiveKind = explicitSectionAc ? "acceptance" : sectionKind;
178
+ // V4 显式 AC 标题行已在下方入口 emit(explicitSectionAc 分支);正文循环
179
+ // 必须从标题行的下一行开始,否则同一标题行会被正文循环的 heading 分支
180
+ // 再次 emit,产生结构性 duplicate(每个 AC 标题式片段出现两次)。
178
181
  const bodyStart = section.headingPath
179
- ? section.startLine + (explicitSectionAc ? 0 : 1)
182
+ ? section.startLine + 1
180
183
  : section.startLine;
181
184
  const bodyEnd = section.endLine;
182
185
  const bodyLines = lines.slice(Math.max(0, bodyStart - 1), Math.min(bodyEnd, lines.length));
@@ -438,14 +441,17 @@ export function buildFragmentInventory(input) {
438
441
  disposition = "non-goal";
439
442
  }
440
443
  else {
441
- const executable = EXECUTABLE_HINT_RE.test(text);
442
- disposition = executable ? "unresolved" : "context-only";
443
- if (disposition === "unresolved") {
444
- reason = "executable fragment outside recognized requirement/acceptance section";
445
- }
446
- else {
447
- reason = "context-only fragment; excluded from canonical requirement coverage";
448
- }
444
+ // Non-goal/constraint/acceptance/objective sections are classified
445
+ // above. An "other" section (背景 / 概述 / 说明 …) is contextual
446
+ // description, not an executable requirement: even when it mentions
447
+ // implementation verbs ("需要", "渲染", "组件"), it must not be
448
+ // promoted to a high-risk unresolved executable fragment — that
449
+ // blocks PRDs whose 背景 prose happens to use such words (merge
450
+ // regression: "需要一个小组件" was flagged executable via 需要).
451
+ // Explicit AC ids are already handled by the branches above, so
452
+ // this branch is always contextual.
453
+ disposition = "context-only";
454
+ reason = "context-only fragment; excluded from canonical requirement coverage";
449
455
  }
450
456
  const priorId = seenText.get(text);
451
457
  if (priorId) {
@@ -5,6 +5,9 @@ import { ContractMutationError, applyTaskContract, logicalRevisionForState, obse
5
5
  import { TASK_CONTRACT_DRAFT_SCHEMA_VERSION } from "../contract/constants.js";
6
6
  import { getTaskContractPaths } from "../contract/paths.js";
7
7
  import { loadTaskConfig } from "../runtime.js";
8
+ import { discoverFrontendProjectCapability, resolveFrontendSpecRootAliases, } from "../frontend-project-capability.js";
9
+ import { classifyTaskDemand } from "../task-demand-routing.js";
10
+ import { DEFAULT_FRONTEND_SPEC_ROOTS, extractDeclaredFrontendSpecRoots, } from "../../shared/openspec-spec.js";
8
11
  import { buildPrepareDraft } from "./build-draft.js";
9
12
  import { hasBlockingGaps, hasBlockingRisks, isFreshLegacyUnversioned, listPrepareGaps, appendLedgerGaps, } from "./completeness.js";
10
13
  import { detectProductArtifactMeta, isIntakeSoftGapCode, } from "./artifact-meta.js";
@@ -681,7 +684,7 @@ export async function prepareTaskSource(input) {
681
684
  facts = importedFacts ?? mergedSupplemental;
682
685
  }
683
686
  }
684
- const built = buildPrepareDraft({
687
+ let built = buildPrepareDraft({
685
688
  taskId,
686
689
  existingTaskConfig,
687
690
  baseDraft,
@@ -689,6 +692,91 @@ export async function prepareTaskSource(input) {
689
692
  flags: input.flags,
690
693
  references,
691
694
  });
695
+ // Resolve an implicit frontend workflow before the first managed contract is
696
+ // projected. Explicit taskKind (including explicit standard) remains a hard
697
+ // contract; only a standard task with strong source/path/project evidence may
698
+ // be promoted here. This keeps task.json, contract.json, and the first DAG
699
+ // generation on the same workflow without a later hash-changing re-projection.
700
+ if (built.draft.taskKind === "standard" &&
701
+ !built.draft.taskKindExplicit) {
702
+ const routingRequirement = [
703
+ ...(sourceIntegrity?.documents ?? [])
704
+ .map((document) => document.content)
705
+ .filter((content) => Boolean(content?.trim())),
706
+ ...(input.intent.kind === "facts" && input.intent.text
707
+ ? [input.intent.text]
708
+ : []),
709
+ built.draft.requirement.objective,
710
+ ...built.draft.requirement.scope,
711
+ ...built.draft.requirement.acceptanceCriteria.map((item) => item.text),
712
+ ].join("\n");
713
+ let frontendProject;
714
+ try {
715
+ frontendProject = await discoverFrontendProjectCapability(repoRoot);
716
+ }
717
+ catch {
718
+ // Capability discovery is advisory for routing. A failed probe must not
719
+ // turn an otherwise valid standard contract preparation into an error.
720
+ }
721
+ const classification = classifyTaskDemand({
722
+ title: built.draft.title,
723
+ requirementMarkdown: routingRequirement,
724
+ allowedPaths: built.draft.constraints.allowedPaths,
725
+ frontendProject: frontendProject
726
+ ? {
727
+ framework: frontendProject.framework,
728
+ evidencePaths: frontendProject.evidencePaths,
729
+ }
730
+ : undefined,
731
+ });
732
+ if (classification.kind === "frontend") {
733
+ built = buildPrepareDraft({
734
+ taskId,
735
+ existingTaskConfig,
736
+ baseDraft,
737
+ facts,
738
+ flags: {
739
+ ...input.flags,
740
+ taskKind: "frontend-implementation",
741
+ },
742
+ references,
743
+ });
744
+ }
745
+ }
746
+ // A user can explicitly name a non-default normative directory in the task
747
+ // prompt (for example “规范目录 `design-system`”). Freeze that root into
748
+ // the managed task config before the DAG exists; normal DAG selection consumes
749
+ // the already-resolved task kind instead of mutating task.json later.
750
+ if (built.draft.taskKind === "frontend-implementation") {
751
+ // An imported PRD is materialized under sourceIntegrity rather than copied
752
+ // into intent.text. Use both inputs so a `--prd` invocation can declare a
753
+ // task-owned spec root just like supplemental free-form text can.
754
+ const sourceText = [
755
+ ...(sourceIntegrity?.documents ?? []).flatMap((document) => document.content ? [document.content] : []),
756
+ ...(input.intent.kind === "facts" && input.intent.text
757
+ ? [input.intent.text]
758
+ : []),
759
+ ].join("\n");
760
+ const inferredRoots = extractDeclaredFrontendSpecRoots(sourceText);
761
+ if (inferredRoots.length > 0) {
762
+ const current = existingTaskConfig?.frontendOpenspec;
763
+ const configuredRoots = current?.specRoots ?? [...DEFAULT_FRONTEND_SPEC_ROOTS];
764
+ // Include roots declared by this source before resolving physical-target
765
+ // aliases. A newly declared root may itself be a symlink (for example
766
+ // `project-specs -> .../ai_doc`), so resolving aliases against only the
767
+ // previous config cannot map `ai_doc/rules.md` back to project-specs.
768
+ const provisionalRoots = [
769
+ ...new Set([...configuredRoots, ...inferredRoots]),
770
+ ].sort();
771
+ const aliases = await resolveFrontendSpecRootAliases(repoRoot, provisionalRoots);
772
+ const aliasMap = new Map(aliases.map(({ alias, logicalRoot }) => [alias, logicalRoot]));
773
+ built.draft.frontendOpenspec = {
774
+ policy: current?.policy ?? "cited",
775
+ specRoots: [...new Set([...configuredRoots, ...inferredRoots.map((root) => aliasMap.get(root) ?? root)])].sort(),
776
+ requiredReadPaths: current?.requiredReadPaths ?? [],
777
+ };
778
+ }
779
+ }
692
780
  const { gaps, risks } = listPrepareGaps({
693
781
  draft: built.draft,
694
782
  state,
@@ -457,8 +457,12 @@ async function buildPrompt(input) {
457
457
  "Do NOT invent product behavior that is not supported by the documents.",
458
458
  "You MAY recommend an optional engineering block when the repo layout hint supports it:",
459
459
  "- allowedPaths / forbiddenPaths as minimal precise globs (e.g. source/packages/**/*.test.ts)",
460
- "- verifyCommands as real package scripts (e.g. pnpm -C source test)",
461
- "- taskKind when clearly implied (backend-test / frontend-test / standard / …)",
460
+ "- verifyCommands as real package scripts (e.g. pnpm -C source test); when acceptance criteria name a specific test file to run (e.g. 定向 vitest(test/.../xxx.test.ts)), emit the corresponding targeted command as its own entry (e.g. npx vitest run test/.../xxx.test.ts) instead of only the full-suite script — targeted commands are what a frontend implementation plan maps to behavior verification targets",
461
+ "- taskKind when clearly implied, choosing from {standard / frontend-implementation / frontend-test / backend-test / knowledge-sync}:",
462
+ " - frontend-implementation: the deliverable is a frontend component/page/UI feature (even when it ships with its own tests)",
463
+ " - frontend-test: the deliverable is a browser/UI automation test suite for EXISTING functionality (no implementation work)",
464
+ " - backend-test: pytest/backend test suite; standard: general repo implementation",
465
+ " A task that IMPLEMENTS a component and adds tests for it is frontend-implementation, never frontend-test.",
462
466
  "Prefer smallest correct write boundary; do not invent product paths that are not in the layout hint.",
463
467
  "Do NOT rewrite or suggest editing the original PRD files.",
464
468
  "Prefer explicit text; use provenance derived for rephrasing; use inferred only when clearly implied — if you must invent ACs, leave acceptanceCriteria empty so the system can stop for clarification.",
@@ -22,6 +22,18 @@ function slugifyName(value) {
22
22
  .replace(/-{2,}/g, "-");
23
23
  return slug || "requirement";
24
24
  }
25
+ /** Stable identity of a manifest document for change detection. Timestamps and
26
+ * byte sizes are excluded: identical content re-imported later must not appear
27
+ * "changed" (the canonical hash includes manifest bytes). */
28
+ function documentIdentity(doc) {
29
+ return `${doc.role}|${doc.name}|${doc.materializedPath}|${doc.sha256}`;
30
+ }
31
+ function sameDocumentSet(left, right) {
32
+ if (left.length !== right.length)
33
+ return false;
34
+ const rightIds = new Set(right.map(documentIdentity));
35
+ return left.every((doc) => rightIds.has(documentIdentity(doc)));
36
+ }
25
37
  function ensureUniqueReferenceName(existing, baseName, extension) {
26
38
  const used = new Set(existing);
27
39
  const candidate = `${baseName}${extension}`;
@@ -149,10 +161,19 @@ export async function importPrdDocument(input) {
149
161
  const documents = [...(existingManifest?.documents ?? [])].filter((entry) => entry.materializedPath !== materializedRelativePath);
150
162
  documents.push(document);
151
163
  documents.sort((left, right) => left.materializedPath.localeCompare(right.materializedPath));
164
+ // Keep `updatedAt` stable across re-imports of the same content: the
165
+ // canonical contract hash includes the manifest bytes, so a fresh
166
+ // timestamp on every identical re-import would make the observed
167
+ // canonical hash drift and fail task verification. Only bump it when the
168
+ // document set actually changed (a new/changed materialized file).
169
+ const documentSetChanged = !existingManifest ||
170
+ !sameDocumentSet(existingManifest.documents, documents);
152
171
  const manifest = {
153
172
  schemaVersion: 1,
154
173
  taskId: input.taskId,
155
- updatedAt: importedAt,
174
+ updatedAt: documentSetChanged
175
+ ? importedAt
176
+ : (existingManifest?.updatedAt ?? importedAt),
156
177
  documents,
157
178
  };
158
179
  await writeSourceManifest(paths.sourceDir, manifest);
@@ -19,7 +19,9 @@ export function shouldPlayCompletionSound(input) {
19
19
  export function copyTextForMessage(input) {
20
20
  return input.code ?? input.text;
21
21
  }
22
- /** Empty sandbox means scripts, same-origin access, navigation and popups are denied. */
22
+ /** Project HTML may run its own UI scripts, but remains an opaque origin with
23
+ * forms, popups and top-level navigation denied by omission. */
24
+ export const HTML_PREVIEW_SANDBOX = "allow-scripts";
23
25
  export function previewSandbox() {
24
- return { sandbox: "" };
26
+ return { sandbox: HTML_PREVIEW_SANDBOX };
25
27
  }