@tea-agent/loop-agent 0.38.0-next.1 → 0.38.1

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 (117) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/commands/init-upgrade.js +97 -19
  4. package/dist/commands/init.js +14 -2
  5. package/dist/executors/dag-pi-executor.js +1 -0
  6. package/dist/governance/manifest-types.js +2 -0
  7. package/dist/infrastructure/harness/atomic-write.js +2 -1
  8. package/dist/shared/openspec-spec.js +76 -0
  9. package/dist/shared/operator/capabilities.js +65 -8
  10. package/dist/task/config-types.js +21 -0
  11. package/dist/task/frontend-project-capability.js +292 -38
  12. package/dist/worker/console/chat/mcp-inventory.js +221 -0
  13. package/dist/worker/console/chat/pi-runtime.js +155 -78
  14. package/dist/worker/console/chat/repo-walk.js +10 -1
  15. package/dist/worker/console/chat/routes.js +19 -8
  16. package/dist/worker/console/chat/semantic-activity.js +17 -5
  17. package/dist/worker/console/chat/turn-process.js +17 -30
  18. package/dist/worker/console/harness-pi-model-matrix.js +146 -0
  19. package/dist/worker/console/operator-actions.js +123 -0
  20. package/dist/worker/console/operator-user-error.js +19 -0
  21. package/dist/worker/console/pi-readiness.js +7 -1
  22. package/dist/worker/console/security.js +20 -1
  23. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-Hfsot6rP.js} +1 -1
  24. package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-YGwEASin.js} +1 -1
  25. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-CmOM0F92.js} +1 -1
  26. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-DDLuaHC5.js} +1 -1
  27. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-B0Um43W4.js} +1 -1
  28. package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +1 -0
  29. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-DvBN4JZc.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-B1UVgW4E.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-Cf0GgXzX.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-DbNA24sj.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-Bi_0Be06.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-C8pPGvRd.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-BitQY8Z-.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-U67GCiEo.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-IoJwXPBA.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-YDZ_UHgE.js} +1 -1
  39. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +1 -0
  40. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +1 -0
  41. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-CUZgsiRC.js} +1 -1
  42. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-COmCh4pT.js} +1 -1
  43. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-Ta6FSm68.js} +1 -1
  44. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-UdcR-VW9.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-DybcO3Et.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-wErDFWLw.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-DkVHZWqz.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-DdqWs7QN.js} +1 -1
  49. package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-C9dMVmDo.js} +1 -1
  50. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-B2T2WcBK.js} +1 -1
  51. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-Bt9rjDFq.js} +1 -1
  52. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-U8ic_tvi.js} +1 -1
  53. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-BfQJdQoO.js} +1 -1
  54. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-CpMftH4O.js} +1 -1
  55. package/dist/worker/console/static/assets/index-BA5-N5ER.css +1 -0
  56. package/dist/worker/console/static/assets/index-DKPXBrdc.js +325 -0
  57. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-DzqWXNO_.js} +1 -1
  58. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js} +1 -1
  59. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-ChPtKFLz.js} +1 -1
  60. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-PRe7wB5u.js} +1 -1
  61. package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-DpKX6QNp.js} +1 -1
  62. package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-Dnl76b7Y.js} +5 -5
  63. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-S9PCXc3J.js} +1 -1
  64. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-Du8e-FSX.js} +1 -1
  65. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-CY6OmHTc.js} +1 -1
  66. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-C65GPhpj.js} +1 -1
  67. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-U6IUiTCL.js} +1 -1
  68. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-D-I_9Qhi.js} +1 -1
  69. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-B57Fhed0.js} +1 -1
  70. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-CJOtiw21.js} +1 -1
  71. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-FyX8zxSh.js} +1 -1
  72. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-eF-pbs9t.js} +1 -1
  73. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +1 -0
  74. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-CcfkA-Dq.js} +2 -2
  75. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.js +8 -0
  76. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-DH8FjnOK.js} +1 -1
  77. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-beoTagJg.js} +1 -1
  78. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-CQSfw4cK.js} +1 -1
  79. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-CQgzQ6jn.js} +1 -1
  80. package/dist/worker/console/static/index.html +2 -2
  81. package/dist/worker/console/static-src/app/console-types.js +51 -0
  82. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +44 -21
  83. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
  84. package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
  85. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +15 -0
  86. package/dist/worker/console/static-src/operator-chat/useChatStream.js +17 -4
  87. package/dist/worker/console/static-src/operator-chat/useChatThread.js +28 -22
  88. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
  89. package/dist/workflows/dag/contract-validator-registrations.js +2 -1
  90. package/dist/workflows/dag/failure-routing.js +9 -4
  91. package/dist/workflows/dag/frontend-implementation-contract.js +158 -0
  92. package/dist/workflows/dag/frontend-plan-render.js +90 -0
  93. package/dist/workflows/dag/frontend-prewrite-gate.js +411 -20
  94. package/dist/workflows/dag/init-hybrid.js +272 -27
  95. package/dist/workflows/dag/lifecycle.js +4 -0
  96. package/dist/workflows/dag/node-execution.js +51 -9
  97. package/dist/workflows/dag/report.js +6 -0
  98. package/dist/workflows/dag/retry-policy.js +15 -0
  99. package/dist/workflows/dag/types.js +50 -4
  100. package/dist/workflows/dag/validate.js +7 -2
  101. package/docs/templates/frontend-design-contract.md +40 -14
  102. package/docs/templates/frontend-implementation-contract.schema.json +9 -2
  103. package/docs/templates/frontend-task-constraints.md +40 -13
  104. package/docs/templates/frontend-task-requirement.md +54 -20
  105. package/docs/templates/harness.schema.json +8 -0
  106. package/harness.json +11 -2
  107. package/package.json +1 -1
  108. package/skills/frontend-design-review/references/review-checklist.md +2 -0
  109. package/skills/frontend-implementation/references/design-spec.md +16 -19
  110. package/skills/frontend-implementation/references/node-contracts.md +15 -11
  111. package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
  112. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
  113. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
  114. package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
  115. package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
  116. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
  117. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
@@ -5,7 +5,73 @@
5
5
  */
6
6
  import { readFile, readdir, stat } from "node:fs/promises";
7
7
  import path from "node:path";
8
- import { OPENSPEC_SPEC_DIRS, OPENSPEC_SPEC_EXT_RE } from "../shared/openspec-spec.js";
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";
10
+ const classifiedPathSchema = z
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/");
13
+ /**
14
+ * Stable semantic partition of `designEvidence.normativePaths`. Keys are a
15
+ * generic directory convention (not project-specific file names); the same
16
+ * repository therefore yields the same classification on repeated probes.
17
+ */
18
+ export const frontendDesignClassifiedSchema = z
19
+ .object({
20
+ schemas: z.array(classifiedPathSchema),
21
+ codeTemplate: z.array(classifiedPathSchema),
22
+ rule: z
23
+ .object({
24
+ api: z.array(classifiedPathSchema),
25
+ mock: z.array(classifiedPathSchema),
26
+ router: z.array(classifiedPathSchema),
27
+ hooks: z.array(classifiedPathSchema),
28
+ utils: z.array(classifiedPathSchema),
29
+ components: z.array(classifiedPathSchema),
30
+ other: z.array(classifiedPathSchema),
31
+ })
32
+ .strict(),
33
+ theme: z.array(classifiedPathSchema),
34
+ component: z.array(classifiedPathSchema),
35
+ uiOther: z.array(classifiedPathSchema),
36
+ advisoryOther: z.array(classifiedPathSchema),
37
+ })
38
+ .strict();
39
+ export const frontendDesignEvidenceSchema = z
40
+ .object({
41
+ normativePaths: z.array(z.string()),
42
+ advisoryPaths: z.array(z.string()),
43
+ conflicts: z.array(z.string()),
44
+ classified: frontendDesignClassifiedSchema,
45
+ })
46
+ .strict()
47
+ .superRefine((evidence, ctx) => {
48
+ const classified = [
49
+ ...evidence.classified.schemas,
50
+ ...evidence.classified.codeTemplate,
51
+ ...evidence.classified.rule.api,
52
+ ...evidence.classified.rule.mock,
53
+ ...evidence.classified.rule.router,
54
+ ...evidence.classified.rule.hooks,
55
+ ...evidence.classified.rule.utils,
56
+ ...evidence.classified.rule.components,
57
+ ...evidence.classified.rule.other,
58
+ ...evidence.classified.theme,
59
+ ...evidence.classified.component,
60
+ ...evidence.classified.uiOther,
61
+ ...evidence.classified.advisoryOther,
62
+ ];
63
+ const classifiedSet = new Set(classified);
64
+ const normativeSet = new Set(evidence.normativePaths);
65
+ const onlyClassified = [...classifiedSet].filter((candidate) => !normativeSet.has(candidate));
66
+ const onlyNormative = [...normativeSet].filter((candidate) => !classifiedSet.has(candidate));
67
+ if (onlyClassified.length > 0 || onlyNormative.length > 0) {
68
+ ctx.addIssue({
69
+ code: z.ZodIssueCode.custom,
70
+ message: `classified must partition normativePaths exactly; extra in classified: ${onlyClassified.join(", ") || "(none)"}; missing from classified: ${onlyNormative.join(", ") || "(none)"}`,
71
+ path: ["classified"],
72
+ });
73
+ }
74
+ });
9
75
  async function exists(filePath) {
10
76
  try {
11
77
  await stat(filePath);
@@ -15,39 +81,68 @@ async function exists(filePath) {
15
81
  return false;
16
82
  }
17
83
  }
18
- async function readJson(filePath) {
19
- try {
20
- return JSON.parse(await readFile(filePath, "utf8"));
21
- }
22
- catch {
23
- return null;
84
+ /**
85
+ * Resolve the governance subtree root from harness.json.governanceRoot.
86
+ * Falls back to DEFAULT_OPENSPEC_GOVERNANCE_ROOT when the manifest is missing,
87
+ * unreadable, malformed, or lacks a string governanceRoot. This intentionally
88
+ * does NOT apply init.ts's "docs" -> default migration (init-only behavior).
89
+ */
90
+ export async function resolveOpenspecGovernanceRoot(repoRoot) {
91
+ const manifest = await readJson(path.join(repoRoot, "harness.json"));
92
+ if (manifest && typeof manifest === "object") {
93
+ const candidate = manifest.governanceRoot;
94
+ if (typeof candidate === "string" && candidate.trim()) {
95
+ return candidate.trim().replace(/\\/g, "/").replace(/\/+$/, "");
96
+ }
24
97
  }
98
+ return DEFAULT_OPENSPEC_GOVERNANCE_ROOT;
25
99
  }
26
- function allDeps(pkg) {
27
- return {
28
- ...(pkg.dependencies ?? {}),
29
- ...(pkg.devDependencies ?? {}),
30
- ...(pkg.peerDependencies ?? {}),
100
+ export async function listOpenspecDiscovery(repoRoot, options = {}) {
101
+ const governanceRoot = options.governanceRootOverride ??
102
+ (await resolveOpenspecGovernanceRoot(repoRoot));
103
+ const extraExcludedPrefixes = (options.extraExcludedPrefixes ?? [])
104
+ .map((prefix) => prefix.replace(/\\/g, "/").replace(/\/+$/, ""))
105
+ .filter(Boolean);
106
+ const collected = [];
107
+ let excludedGovernanceCount = 0;
108
+ const isExcluded = (rel) => {
109
+ if (isGovernanceSubtreePath(rel, governanceRoot))
110
+ return true;
111
+ return extraExcludedPrefixes.some((prefix) => rel === prefix || rel.startsWith(prefix + "/"));
31
112
  };
32
- }
33
- function hasDep(deps, name) {
34
- return Object.hasOwn(deps, name);
35
- }
36
- async function listOpenspec(repoRoot) {
37
- const out = [];
38
113
  for (const specDir of OPENSPEC_SPEC_DIRS) {
39
114
  const root = path.join(repoRoot, specDir);
40
115
  if (!(await exists(root)))
41
116
  continue;
42
117
  await walk(root, 0);
43
118
  }
44
- return out.slice(0, 40);
119
+ // openspec/schemas/** and openspec/project-specs/** sort first; remaining
120
+ // ai_workspace/** business files follow. Dedupe preserves discovery order.
121
+ 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/"))
127
+ .sort();
128
+ const deduped = [...openspecPaths, ...aiWorkspacePaths];
129
+ const truncated = deduped.length > MAX_OPENSPEC_CANDIDATE_PATHS;
130
+ const paths = truncated
131
+ ? deduped.slice(0, MAX_OPENSPEC_CANDIDATE_PATHS)
132
+ : deduped;
133
+ return {
134
+ paths,
135
+ governanceRoot,
136
+ excludedGovernanceCount,
137
+ truncated,
138
+ maxCandidates: MAX_OPENSPEC_CANDIDATE_PATHS,
139
+ };
45
140
  async function walk(dir, depth) {
46
141
  if (depth > 4)
47
142
  return;
48
143
  let entries = [];
49
144
  try {
50
- entries = await readdir(dir);
145
+ entries = (await readdir(dir)).sort();
51
146
  }
52
147
  catch {
53
148
  return;
@@ -59,7 +154,13 @@ async function listOpenspec(repoRoot) {
59
154
  if (info.isDirectory())
60
155
  await walk(full, depth + 1);
61
156
  else if (OPENSPEC_SPEC_EXT_RE.test(name)) {
62
- out.push(path.relative(repoRoot, full).replace(/\\/g, "/"));
157
+ const rel = path.relative(repoRoot, full).replace(/\\/g, "/");
158
+ if (isExcluded(rel)) {
159
+ excludedGovernanceCount++;
160
+ }
161
+ else {
162
+ collected.push(rel);
163
+ }
63
164
  }
64
165
  }
65
166
  catch {
@@ -68,6 +169,164 @@ async function listOpenspec(repoRoot) {
68
169
  }
69
170
  }
70
171
  }
172
+ /**
173
+ * Count spec-extension files under a governance subtree that candidate
174
+ * discovery would exclude. Bounded, read-only diagnostic helper used by the
175
+ * prewrite gate to reproduce exclusion stats at runtime.
176
+ */
177
+ export async function countExcludedGovernanceSpecFiles(repoRoot, governanceRoot) {
178
+ const root = path.join(repoRoot, governanceRoot);
179
+ if (!(await exists(root)))
180
+ return 0;
181
+ let count = 0;
182
+ await walkGovernance(root, 0);
183
+ return count;
184
+ async function walkGovernance(dir, depth) {
185
+ if (depth > 4)
186
+ return;
187
+ let entries = [];
188
+ try {
189
+ entries = (await readdir(dir)).sort();
190
+ }
191
+ catch {
192
+ return;
193
+ }
194
+ for (const name of entries) {
195
+ const full = path.join(dir, name);
196
+ try {
197
+ const info = await stat(full);
198
+ if (info.isDirectory())
199
+ await walkGovernance(full, depth + 1);
200
+ else if (OPENSPEC_SPEC_EXT_RE.test(name))
201
+ count++;
202
+ }
203
+ catch {
204
+ // skip
205
+ }
206
+ }
207
+ }
208
+ }
209
+ async function readJson(filePath) {
210
+ try {
211
+ return JSON.parse(await readFile(filePath, "utf8"));
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
217
+ function allDeps(pkg) {
218
+ return {
219
+ ...(pkg.dependencies ?? {}),
220
+ ...(pkg.devDependencies ?? {}),
221
+ ...(pkg.peerDependencies ?? {}),
222
+ };
223
+ }
224
+ function hasDep(deps, name) {
225
+ return Object.hasOwn(deps, name);
226
+ }
227
+ function emptyClassified() {
228
+ return {
229
+ schemas: [],
230
+ codeTemplate: [],
231
+ rule: { api: [], mock: [], router: [], hooks: [], utils: [], components: [], other: [] },
232
+ theme: [],
233
+ component: [],
234
+ uiOther: [],
235
+ advisoryOther: [],
236
+ };
237
+ }
238
+ /**
239
+ * Classify discovered openspec paths by generic directory convention. Only
240
+ * `isOpenspecSpecFilePath` paths enter a bucket; the classifier never invents
241
+ * project-specific file names. Basename stems use word-boundary matching so
242
+ * e.g. `*api*` (case-insensitive) maps to `rule.api` without hardcoding names.
243
+ */
244
+ function classifyOpenspecPaths(paths) {
245
+ const classified = emptyClassified();
246
+ const ruleStem = (basename) => {
247
+ const lower = basename.toLowerCase();
248
+ const stems = [
249
+ ["api", /\bapi\b/],
250
+ ["mock", /\bmock\b/],
251
+ ["router", /\brouter\b/],
252
+ ["hooks", /\bhooks?\b/],
253
+ ["utils", /\butils?\b/],
254
+ ["components", /\bcomponents?\b/],
255
+ ];
256
+ for (const [key, re] of stems) {
257
+ if (re.test(lower))
258
+ return key;
259
+ }
260
+ return "other";
261
+ };
262
+ for (const candidate of paths) {
263
+ if (!isOpenspecSpecFilePath(candidate))
264
+ continue;
265
+ const segments = candidate.split("/");
266
+ const basename = segments[segments.length - 1] ?? "";
267
+ if (candidate.startsWith("openspec/schemas/")) {
268
+ classified.schemas.push(candidate);
269
+ }
270
+ else if (candidate.startsWith("openspec/project-specs/templates/")) {
271
+ classified.codeTemplate.push(candidate);
272
+ }
273
+ else if (candidate.startsWith("openspec/project-specs/rules/")) {
274
+ classified.rule[ruleStem(basename)].push(candidate);
275
+ }
276
+ else if (candidate.startsWith("openspec/project-specs/ui/")) {
277
+ const lower = basename.toLowerCase();
278
+ if (/\btheme\b/.test(lower))
279
+ classified.theme.push(candidate);
280
+ else if (/\bcomponents?\b/.test(lower))
281
+ classified.component.push(candidate);
282
+ else
283
+ classified.uiOther.push(candidate);
284
+ }
285
+ else {
286
+ classified.advisoryOther.push(candidate);
287
+ }
288
+ }
289
+ const sortBucket = (bucket) => bucket.sort();
290
+ classified.schemas = sortBucket(classified.schemas);
291
+ classified.codeTemplate = sortBucket(classified.codeTemplate);
292
+ for (const key of [
293
+ "api",
294
+ "mock",
295
+ "router",
296
+ "hooks",
297
+ "utils",
298
+ "components",
299
+ "other",
300
+ ]) {
301
+ classified.rule[key] = sortBucket(classified.rule[key]);
302
+ }
303
+ classified.theme = sortBucket(classified.theme);
304
+ classified.component = sortBucket(classified.component);
305
+ classified.uiOther = sortBucket(classified.uiOther);
306
+ classified.advisoryOther = sortBucket(classified.advisoryOther);
307
+ return classified;
308
+ }
309
+ function buildDesignEvidence(openspec) {
310
+ // Preserve discovery priority order (openspec first, then ai_workspace)
311
+ // instead of re-sorting; the exact-partition schema identity is set-based.
312
+ const normativePaths = [...new Set(openspec)];
313
+ return frontendDesignEvidenceSchema.parse({
314
+ normativePaths,
315
+ advisoryPaths: [],
316
+ conflicts: [],
317
+ classified: classifyOpenspecPaths(normativePaths),
318
+ });
319
+ }
320
+ function toOpenspecDiscovery(discovery, designEvidence) {
321
+ return {
322
+ source: "auto-discovered",
323
+ governanceRoot: discovery.governanceRoot,
324
+ candidateCount: designEvidence.normativePaths.length,
325
+ excludedGovernanceCount: discovery.excludedGovernanceCount,
326
+ truncated: discovery.truncated,
327
+ maxCandidates: discovery.maxCandidates,
328
+ };
329
+ }
71
330
  export function buildAdapterGuidance(capability) {
72
331
  const lines = [
73
332
  "## Frontend project capability (generation-time)",
@@ -108,7 +367,8 @@ export async function discoverFrontendProjectCapability(repoRoot) {
108
367
  const pkgPath = path.join(repoRoot, "package.json");
109
368
  const pkgRaw = await readJson(pkgPath);
110
369
  if (!pkgRaw || typeof pkgRaw !== "object") {
111
- const openspec = await listOpenspec(repoRoot);
370
+ const discovery = await listOpenspecDiscovery(repoRoot);
371
+ const designEvidence = buildDesignEvidence(discovery.paths);
112
372
  const base = {
113
373
  schemaVersion: 1,
114
374
  framework: "unknown",
@@ -119,16 +379,13 @@ export async function discoverFrontendProjectCapability(repoRoot) {
119
379
  testRunner: [],
120
380
  mock: [],
121
381
  a11y: { status: "unknown", tools: [], evidencePaths: [] },
122
- evidencePaths: openspec.slice(0, 5),
382
+ evidencePaths: discovery.paths.slice(0, 5),
123
383
  reasons: ["package.json missing or unreadable"],
124
- designEvidence: {
125
- normativePaths: openspec,
126
- advisoryPaths: [],
127
- conflicts: [],
128
- },
384
+ designEvidence,
385
+ openspecDiscovery: toOpenspecDiscovery(discovery, designEvidence),
129
386
  };
130
- if (openspec.length) {
131
- base.reasons.push(`openspec spec files discovered: ${openspec.slice(0, 5).join(", ")}`);
387
+ if (discovery.paths.length) {
388
+ base.reasons.push(`openspec spec files discovered: ${discovery.paths.slice(0, 5).join(", ")}`);
132
389
  }
133
390
  return { ...base, adapterGuidance: buildAdapterGuidance(base) };
134
391
  }
@@ -283,14 +540,10 @@ export async function discoverFrontendProjectCapability(repoRoot) {
283
540
  router = router ?? "next-router";
284
541
  if (hasDep(deps, "vue-router"))
285
542
  router = "vue-router";
286
- const openspec = await listOpenspec(repoRoot);
287
- const designEvidence = {
288
- normativePaths: openspec,
289
- advisoryPaths: [],
290
- conflicts: [],
291
- };
292
- if (openspec.length)
293
- evidencePaths.push(...openspec.slice(0, 5));
543
+ const discovery = await listOpenspecDiscovery(repoRoot);
544
+ const designEvidence = buildDesignEvidence(discovery.paths);
545
+ if (discovery.paths.length)
546
+ evidencePaths.push(...discovery.paths.slice(0, 5));
294
547
  const base = {
295
548
  schemaVersion: 1,
296
549
  framework,
@@ -311,6 +564,7 @@ export async function discoverFrontendProjectCapability(repoRoot) {
311
564
  evidencePaths: [...new Set(evidencePaths)],
312
565
  reasons,
313
566
  designEvidence,
567
+ openspecDiscovery: toOpenspecDiscovery(discovery, designEvidence),
314
568
  };
315
569
  return { ...base, adapterGuidance: buildAdapterGuidance(base) };
316
570
  }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Read-only MCP inventory for Operator Chat.
3
+ *
4
+ * Pi has no built-in MCP catalog. Console projects two real facts:
5
+ * 1. mcp.json server specs (configured, may or may not have registered tools)
6
+ * 2. tools already on the live session that look like MCP registrations
7
+ *
8
+ * Never fabricate servers or tools. Missing files stay empty.
9
+ */
10
+ import { readFile } from "node:fs/promises";
11
+ import path from "node:path";
12
+ const MCP_CONFIG_NAMES = ["mcp.json"];
13
+ export function isMcpToolName(name) {
14
+ const lower = name.trim().toLowerCase();
15
+ return lower === "mcp" || lower.startsWith("mcp_") || lower.startsWith("mcp-");
16
+ }
17
+ export function inferMcpServerName(toolName, knownServers = []) {
18
+ const name = toolName.trim();
19
+ if (/^mcp$/i.test(name))
20
+ return undefined;
21
+ const rest = name.replace(/^mcp[_-]/i, "");
22
+ if (!rest || rest === name || knownServers.length === 0)
23
+ return undefined;
24
+ const ranked = [...knownServers].sort((left, right) => right.length - left.length);
25
+ const lower = rest.toLowerCase();
26
+ return ranked.find((server) => {
27
+ const key = server.toLowerCase();
28
+ return lower === key || lower.startsWith(`${key}_`) || lower.startsWith(`${key}-`);
29
+ });
30
+ }
31
+ export function looksLikeMcpTool(tool) {
32
+ const name = typeof tool.name === "string" ? tool.name : "";
33
+ if (!name)
34
+ return false;
35
+ if (isMcpToolName(name))
36
+ return true;
37
+ const source = `${tool.sourceInfo?.source ?? ""} ${tool.sourceInfo?.path ?? ""}`.toLowerCase();
38
+ return source.includes("mcp");
39
+ }
40
+ function asRecord(value) {
41
+ return value && typeof value === "object" && !Array.isArray(value)
42
+ ? value
43
+ : undefined;
44
+ }
45
+ function readEnabled(entry) {
46
+ return entry.enabled !== false;
47
+ }
48
+ function readTransport(entry) {
49
+ if (typeof entry.transport === "string" && entry.transport.trim()) {
50
+ return entry.transport.trim();
51
+ }
52
+ if (typeof entry.type === "string" && entry.type.trim()) {
53
+ return entry.type.trim();
54
+ }
55
+ if (typeof entry.url === "string")
56
+ return "http";
57
+ if (Array.isArray(entry.command) || typeof entry.command === "string") {
58
+ return "stdio";
59
+ }
60
+ return undefined;
61
+ }
62
+ function readCommand(entry) {
63
+ if (typeof entry.command === "string" && entry.command.trim()) {
64
+ const args = Array.isArray(entry.args)
65
+ ? entry.args.filter((item) => typeof item === "string").join(" ")
66
+ : "";
67
+ return args ? `${entry.command} ${args}` : entry.command;
68
+ }
69
+ if (Array.isArray(entry.command)) {
70
+ return entry.command
71
+ .filter((item) => typeof item === "string")
72
+ .join(" ")
73
+ .trim();
74
+ }
75
+ return undefined;
76
+ }
77
+ function extractServerMap(parsed) {
78
+ const root = asRecord(parsed);
79
+ if (!root)
80
+ return {};
81
+ const mcpServers = asRecord(root.mcpServers);
82
+ if (mcpServers)
83
+ return mcpServers;
84
+ const mcp = asRecord(root.mcp);
85
+ if (mcp)
86
+ return mcp;
87
+ const servers = asRecord(root.servers);
88
+ if (servers)
89
+ return servers;
90
+ return {};
91
+ }
92
+ export function parseMcpConfig(raw, scope, configPath) {
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(raw);
96
+ }
97
+ catch {
98
+ return [];
99
+ }
100
+ const servers = [];
101
+ for (const [name, value] of Object.entries(extractServerMap(parsed))) {
102
+ if (!name.trim())
103
+ continue;
104
+ const entry = asRecord(value) ?? {};
105
+ const command = readCommand(entry);
106
+ const url = typeof entry.url === "string" ? entry.url.trim() : undefined;
107
+ servers.push({
108
+ name: name.trim(),
109
+ scope,
110
+ enabled: readEnabled(entry),
111
+ ...(readTransport(entry) ? { transport: readTransport(entry) } : {}),
112
+ ...(command ? { command } : {}),
113
+ ...(url ? { url } : {}),
114
+ configPath,
115
+ });
116
+ }
117
+ return servers;
118
+ }
119
+ export function projectSessionMcpTools(tools, activeToolNames = [], knownServers = []) {
120
+ const active = new Set(activeToolNames.map((name) => name.toLowerCase()));
121
+ const seen = new Set();
122
+ const projected = [];
123
+ for (const tool of tools) {
124
+ if (!looksLikeMcpTool(tool) || typeof tool.name !== "string")
125
+ continue;
126
+ const name = tool.name.trim();
127
+ if (!name || seen.has(name))
128
+ continue;
129
+ seen.add(name);
130
+ const server = inferMcpServerName(name, knownServers);
131
+ projected.push({
132
+ name,
133
+ description: typeof tool.description === "string" ? tool.description : "",
134
+ ...(server ? { server } : {}),
135
+ source: tool.sourceInfo?.source ?? "",
136
+ scope: tool.sourceInfo?.scope ?? "session",
137
+ ...(tool.sourceInfo?.path ? { path: tool.sourceInfo.path } : {}),
138
+ active: active.has(name.toLowerCase()),
139
+ });
140
+ }
141
+ return projected;
142
+ }
143
+ export function assembleMcpInventory(servers, tools) {
144
+ const byServer = new Map();
145
+ const unassigned = [];
146
+ for (const tool of tools) {
147
+ const key = tool.server?.toLowerCase();
148
+ if (!key) {
149
+ unassigned.push(tool);
150
+ continue;
151
+ }
152
+ const list = byServer.get(key) ?? [];
153
+ list.push(tool);
154
+ byServer.set(key, list);
155
+ }
156
+ const views = servers.map((server) => {
157
+ const matched = byServer.get(server.name.toLowerCase()) ?? [];
158
+ byServer.delete(server.name.toLowerCase());
159
+ const status = !server.enabled
160
+ ? "disabled"
161
+ : matched.length > 0
162
+ ? "loaded"
163
+ : "configured";
164
+ return {
165
+ name: server.name,
166
+ scope: server.scope,
167
+ status,
168
+ enabled: server.enabled,
169
+ ...(server.transport ? { transport: server.transport } : {}),
170
+ ...(server.command ? { command: server.command } : {}),
171
+ ...(server.url ? { url: server.url } : {}),
172
+ configPath: server.configPath,
173
+ tools: matched,
174
+ };
175
+ });
176
+ for (const [name, matched] of byServer) {
177
+ views.push({
178
+ name,
179
+ scope: "session",
180
+ status: "loaded",
181
+ enabled: true,
182
+ tools: matched,
183
+ });
184
+ }
185
+ return { servers: views, unassignedTools: unassigned };
186
+ }
187
+ async function readConfigFile(filePath) {
188
+ try {
189
+ return await readFile(filePath, "utf8");
190
+ }
191
+ catch {
192
+ return undefined;
193
+ }
194
+ }
195
+ export async function loadMcpServerSpecs(options) {
196
+ const candidates = [
197
+ { path: path.join(options.agentDir, ...MCP_CONFIG_NAMES), scope: "user" },
198
+ { path: path.join(options.cwd, ".pi", ...MCP_CONFIG_NAMES), scope: "project" },
199
+ { path: path.join(options.cwd, ".mcp.json"), scope: "project" },
200
+ ];
201
+ const servers = [];
202
+ const seen = new Set();
203
+ for (const candidate of candidates) {
204
+ const raw = await readConfigFile(candidate.path);
205
+ if (!raw)
206
+ continue;
207
+ for (const server of parseMcpConfig(raw, candidate.scope, candidate.path)) {
208
+ const key = `${server.scope}:${server.name.toLowerCase()}`;
209
+ if (seen.has(key))
210
+ continue;
211
+ seen.add(key);
212
+ servers.push(server);
213
+ }
214
+ }
215
+ return servers;
216
+ }
217
+ export async function readMcpInventory(options) {
218
+ const servers = await loadMcpServerSpecs(options);
219
+ const tools = projectSessionMcpTools(options.tools ?? [], options.activeToolNames ?? [], servers.map((server) => server.name));
220
+ return assembleMcpInventory(servers, tools);
221
+ }