@agile-vibe-coding/avc 0.1.1 → 0.3.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 (239) hide show
  1. package/cli/agent-loader.js +21 -0
  2. package/cli/agents/agent-selector.md +152 -0
  3. package/cli/agents/architecture-recommender.md +418 -0
  4. package/cli/agents/code-implementer.md +117 -0
  5. package/cli/agents/code-validator.md +80 -0
  6. package/cli/agents/context-reviewer-epic.md +101 -0
  7. package/cli/agents/context-reviewer-story.md +92 -0
  8. package/cli/agents/context-writer-epic.md +145 -0
  9. package/cli/agents/context-writer-story.md +111 -0
  10. package/cli/agents/database-deep-dive.md +470 -0
  11. package/cli/agents/database-recommender.md +634 -0
  12. package/cli/agents/doc-distributor.md +176 -0
  13. package/cli/agents/doc-writer-epic.md +42 -0
  14. package/cli/agents/doc-writer-story.md +43 -0
  15. package/cli/agents/documentation-updater.md +203 -0
  16. package/cli/agents/duplicate-detector.md +110 -0
  17. package/cli/agents/epic-story-decomposer.md +559 -0
  18. package/cli/agents/feature-context-generator.md +91 -0
  19. package/cli/agents/gap-checker-epic.md +52 -0
  20. package/cli/agents/impact-checker-story.md +51 -0
  21. package/cli/agents/migration-guide-generator.md +305 -0
  22. package/cli/agents/mission-scope-generator.md +143 -0
  23. package/cli/agents/mission-scope-validator.md +146 -0
  24. package/cli/agents/project-context-extractor.md +122 -0
  25. package/cli/agents/project-documentation-creator.json +226 -0
  26. package/cli/agents/project-documentation-creator.md +595 -0
  27. package/cli/agents/question-prefiller.md +269 -0
  28. package/cli/agents/refiner-epic.md +39 -0
  29. package/cli/agents/refiner-story.md +42 -0
  30. package/cli/agents/scaffolding-generator.md +99 -0
  31. package/cli/agents/seed-validator.md +71 -0
  32. package/cli/agents/story-doc-enricher.md +133 -0
  33. package/cli/agents/story-scope-reviewer.md +147 -0
  34. package/cli/agents/story-splitter.md +83 -0
  35. package/cli/agents/suggestion-business-analyst.md +88 -0
  36. package/cli/agents/suggestion-deployment-architect.md +263 -0
  37. package/cli/agents/suggestion-product-manager.md +129 -0
  38. package/cli/agents/suggestion-security-specialist.md +156 -0
  39. package/cli/agents/suggestion-technical-architect.md +269 -0
  40. package/cli/agents/suggestion-ux-researcher.md +93 -0
  41. package/cli/agents/task-subtask-decomposer.md +188 -0
  42. package/cli/agents/validator-documentation.json +183 -0
  43. package/cli/agents/validator-documentation.md +455 -0
  44. package/cli/agents/validator-selector.md +211 -0
  45. package/cli/ansi-colors.js +21 -0
  46. package/cli/api-reference-tool.js +368 -0
  47. package/cli/build-docs.js +29 -8
  48. package/cli/ceremony-history.js +369 -0
  49. package/cli/checks/catalog.json +76 -0
  50. package/cli/checks/code/quality.json +26 -0
  51. package/cli/checks/code/testing.json +14 -0
  52. package/cli/checks/code/traceability.json +26 -0
  53. package/cli/checks/cross-refs/epic.json +171 -0
  54. package/cli/checks/cross-refs/story.json +149 -0
  55. package/cli/checks/epic/api.json +114 -0
  56. package/cli/checks/epic/backend.json +126 -0
  57. package/cli/checks/epic/cloud.json +126 -0
  58. package/cli/checks/epic/data.json +102 -0
  59. package/cli/checks/epic/database.json +114 -0
  60. package/cli/checks/epic/developer.json +182 -0
  61. package/cli/checks/epic/devops.json +174 -0
  62. package/cli/checks/epic/frontend.json +162 -0
  63. package/cli/checks/epic/mobile.json +102 -0
  64. package/cli/checks/epic/qa.json +90 -0
  65. package/cli/checks/epic/security.json +184 -0
  66. package/cli/checks/epic/solution-architect.json +192 -0
  67. package/cli/checks/epic/test-architect.json +90 -0
  68. package/cli/checks/epic/ui.json +102 -0
  69. package/cli/checks/epic/ux.json +90 -0
  70. package/cli/checks/fixes/epic-fix-template.md +10 -0
  71. package/cli/checks/fixes/story-fix-template.md +10 -0
  72. package/cli/checks/story/api.json +186 -0
  73. package/cli/checks/story/backend.json +102 -0
  74. package/cli/checks/story/cloud.json +102 -0
  75. package/cli/checks/story/data.json +210 -0
  76. package/cli/checks/story/database.json +102 -0
  77. package/cli/checks/story/developer.json +168 -0
  78. package/cli/checks/story/devops.json +102 -0
  79. package/cli/checks/story/frontend.json +174 -0
  80. package/cli/checks/story/mobile.json +102 -0
  81. package/cli/checks/story/qa.json +210 -0
  82. package/cli/checks/story/security.json +198 -0
  83. package/cli/checks/story/solution-architect.json +230 -0
  84. package/cli/checks/story/test-architect.json +210 -0
  85. package/cli/checks/story/ui.json +102 -0
  86. package/cli/checks/story/ux.json +102 -0
  87. package/cli/coding-order.js +401 -0
  88. package/cli/command-logger.js +49 -12
  89. package/cli/components/static-output.js +63 -0
  90. package/cli/console-output-manager.js +94 -0
  91. package/cli/dependency-checker.js +72 -0
  92. package/cli/docs-sync.js +306 -0
  93. package/cli/epic-story-validator.js +659 -0
  94. package/cli/evaluation-prompts.js +1008 -0
  95. package/cli/execution-context.js +195 -0
  96. package/cli/generate-summary-table.js +340 -0
  97. package/cli/init-model-config.js +704 -0
  98. package/cli/init.js +1737 -278
  99. package/cli/kanban-server-manager.js +227 -0
  100. package/cli/llm-claude.js +150 -1
  101. package/cli/llm-gemini.js +109 -0
  102. package/cli/llm-local.js +493 -0
  103. package/cli/llm-mock.js +233 -0
  104. package/cli/llm-openai.js +454 -0
  105. package/cli/llm-provider.js +379 -3
  106. package/cli/llm-token-limits.js +211 -0
  107. package/cli/llm-verifier.js +662 -0
  108. package/cli/llm-xiaomi.js +143 -0
  109. package/cli/message-constants.js +49 -0
  110. package/cli/message-manager.js +334 -0
  111. package/cli/message-types.js +96 -0
  112. package/cli/messaging-api.js +291 -0
  113. package/cli/micro-check-fixer.js +335 -0
  114. package/cli/micro-check-runner.js +449 -0
  115. package/cli/micro-check-scorer.js +148 -0
  116. package/cli/micro-check-validator.js +538 -0
  117. package/cli/model-pricing.js +192 -0
  118. package/cli/model-query-engine.js +468 -0
  119. package/cli/model-recommendation-analyzer.js +495 -0
  120. package/cli/model-selector.js +270 -0
  121. package/cli/output-buffer.js +107 -0
  122. package/cli/process-manager.js +73 -2
  123. package/cli/prompt-logger.js +57 -0
  124. package/cli/repl-ink.js +4625 -1094
  125. package/cli/repl-old.js +3 -4
  126. package/cli/seed-processor.js +962 -0
  127. package/cli/sprint-planning-processor.js +4162 -0
  128. package/cli/template-processor.js +2149 -105
  129. package/cli/templates/project.md +25 -8
  130. package/cli/templates/vitepress-config.mts.template +5 -4
  131. package/cli/token-tracker.js +547 -0
  132. package/cli/tools/generate-story-validators.js +317 -0
  133. package/cli/tools/generate-validators.js +669 -0
  134. package/cli/update-checker.js +19 -17
  135. package/cli/update-notifier.js +4 -4
  136. package/cli/validation-router.js +667 -0
  137. package/cli/verification-tracker.js +563 -0
  138. package/cli/worktree-runner.js +654 -0
  139. package/kanban/README.md +386 -0
  140. package/kanban/client/README.md +205 -0
  141. package/kanban/client/components.json +20 -0
  142. package/kanban/client/dist/assets/index-D_KC5EQT.css +1 -0
  143. package/kanban/client/dist/assets/index-DjY5zqW7.js +351 -0
  144. package/kanban/client/dist/index.html +16 -0
  145. package/kanban/client/dist/vite.svg +1 -0
  146. package/kanban/client/index.html +15 -0
  147. package/kanban/client/package-lock.json +9442 -0
  148. package/kanban/client/package.json +44 -0
  149. package/kanban/client/postcss.config.js +6 -0
  150. package/kanban/client/public/vite.svg +1 -0
  151. package/kanban/client/src/App.jsx +651 -0
  152. package/kanban/client/src/components/ProjectFileEditorPopup.jsx +117 -0
  153. package/kanban/client/src/components/ceremony/AskArchPopup.jsx +420 -0
  154. package/kanban/client/src/components/ceremony/AskModelPopup.jsx +629 -0
  155. package/kanban/client/src/components/ceremony/CeremonyWorkflowModal.jsx +1133 -0
  156. package/kanban/client/src/components/ceremony/EpicStorySelectionModal.jsx +254 -0
  157. package/kanban/client/src/components/ceremony/ProviderSwitcherButton.jsx +290 -0
  158. package/kanban/client/src/components/ceremony/SponsorCallModal.jsx +686 -0
  159. package/kanban/client/src/components/ceremony/SprintPlanningModal.jsx +838 -0
  160. package/kanban/client/src/components/ceremony/steps/ArchitectureStep.jsx +150 -0
  161. package/kanban/client/src/components/ceremony/steps/CompleteStep.jsx +136 -0
  162. package/kanban/client/src/components/ceremony/steps/DatabaseStep.jsx +202 -0
  163. package/kanban/client/src/components/ceremony/steps/DeploymentStep.jsx +123 -0
  164. package/kanban/client/src/components/ceremony/steps/MissionStep.jsx +106 -0
  165. package/kanban/client/src/components/ceremony/steps/ReviewAnswersStep.jsx +329 -0
  166. package/kanban/client/src/components/ceremony/steps/RunningStep.jsx +249 -0
  167. package/kanban/client/src/components/kanban/CardDetailModal.jsx +646 -0
  168. package/kanban/client/src/components/kanban/EpicSection.jsx +146 -0
  169. package/kanban/client/src/components/kanban/FilterToolbar.jsx +222 -0
  170. package/kanban/client/src/components/kanban/GroupingSelector.jsx +63 -0
  171. package/kanban/client/src/components/kanban/KanbanBoard.jsx +211 -0
  172. package/kanban/client/src/components/kanban/KanbanCard.jsx +147 -0
  173. package/kanban/client/src/components/kanban/KanbanColumn.jsx +90 -0
  174. package/kanban/client/src/components/kanban/RefineWorkItemPopup.jsx +784 -0
  175. package/kanban/client/src/components/kanban/RunButton.jsx +162 -0
  176. package/kanban/client/src/components/kanban/SeedButton.jsx +176 -0
  177. package/kanban/client/src/components/layout/LoadingScreen.jsx +82 -0
  178. package/kanban/client/src/components/process/ProcessMonitorBar.jsx +80 -0
  179. package/kanban/client/src/components/settings/AgentEditorPopup.jsx +171 -0
  180. package/kanban/client/src/components/settings/AgentsTab.jsx +381 -0
  181. package/kanban/client/src/components/settings/ApiKeysTab.jsx +142 -0
  182. package/kanban/client/src/components/settings/CeremonyModelsTab.jsx +105 -0
  183. package/kanban/client/src/components/settings/CheckEditorPopup.jsx +507 -0
  184. package/kanban/client/src/components/settings/CostThresholdsTab.jsx +95 -0
  185. package/kanban/client/src/components/settings/ModelPricingTab.jsx +269 -0
  186. package/kanban/client/src/components/settings/OpenAIAuthSection.jsx +412 -0
  187. package/kanban/client/src/components/settings/ServersTab.jsx +121 -0
  188. package/kanban/client/src/components/settings/SettingsModal.jsx +84 -0
  189. package/kanban/client/src/components/stats/CostModal.jsx +384 -0
  190. package/kanban/client/src/components/ui/badge.jsx +27 -0
  191. package/kanban/client/src/components/ui/dialog.jsx +121 -0
  192. package/kanban/client/src/components/ui/tabs.jsx +85 -0
  193. package/kanban/client/src/hooks/__tests__/useGrouping.test.js +232 -0
  194. package/kanban/client/src/hooks/useGrouping.js +177 -0
  195. package/kanban/client/src/hooks/useWebSocket.js +120 -0
  196. package/kanban/client/src/lib/__tests__/api.test.js +196 -0
  197. package/kanban/client/src/lib/__tests__/status-grouping.test.js +94 -0
  198. package/kanban/client/src/lib/api.js +515 -0
  199. package/kanban/client/src/lib/status-grouping.js +154 -0
  200. package/kanban/client/src/lib/utils.js +11 -0
  201. package/kanban/client/src/main.jsx +10 -0
  202. package/kanban/client/src/store/__tests__/kanbanStore.test.js +164 -0
  203. package/kanban/client/src/store/ceremonyStore.js +172 -0
  204. package/kanban/client/src/store/filterStore.js +201 -0
  205. package/kanban/client/src/store/kanbanStore.js +123 -0
  206. package/kanban/client/src/store/processStore.js +65 -0
  207. package/kanban/client/src/store/sprintPlanningStore.js +33 -0
  208. package/kanban/client/src/styles/globals.css +59 -0
  209. package/kanban/client/tailwind.config.js +77 -0
  210. package/kanban/client/vite.config.js +28 -0
  211. package/kanban/client/vitest.config.js +28 -0
  212. package/kanban/dev-start.sh +47 -0
  213. package/kanban/package.json +12 -0
  214. package/kanban/server/index.js +537 -0
  215. package/kanban/server/routes/ceremony.js +454 -0
  216. package/kanban/server/routes/costs.js +163 -0
  217. package/kanban/server/routes/openai-oauth.js +366 -0
  218. package/kanban/server/routes/processes.js +50 -0
  219. package/kanban/server/routes/settings.js +736 -0
  220. package/kanban/server/routes/websocket.js +281 -0
  221. package/kanban/server/routes/work-items.js +487 -0
  222. package/kanban/server/services/CeremonyService.js +1441 -0
  223. package/kanban/server/services/FileSystemScanner.js +95 -0
  224. package/kanban/server/services/FileWatcher.js +144 -0
  225. package/kanban/server/services/HierarchyBuilder.js +196 -0
  226. package/kanban/server/services/ProcessRegistry.js +122 -0
  227. package/kanban/server/services/TaskRunnerService.js +261 -0
  228. package/kanban/server/services/WorkItemReader.js +123 -0
  229. package/kanban/server/services/WorkItemRefineService.js +510 -0
  230. package/kanban/server/start.js +49 -0
  231. package/kanban/server/utils/kanban-logger.js +132 -0
  232. package/kanban/server/utils/markdown.js +91 -0
  233. package/kanban/server/utils/status-grouping.js +107 -0
  234. package/kanban/server/workers/run-task-worker.js +121 -0
  235. package/kanban/server/workers/seed-worker.js +94 -0
  236. package/kanban/server/workers/sponsor-call-worker.js +92 -0
  237. package/kanban/server/workers/sprint-planning-worker.js +212 -0
  238. package/package.json +19 -7
  239. package/cli/agents/documentation.md +0 -302
@@ -0,0 +1,736 @@
1
+ import express from 'express';
2
+ import fs from 'fs/promises';
3
+ import { readdirSync, readFileSync, existsSync } from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __routeDir = path.dirname(fileURLToPath(import.meta.url));
8
+ const LIB_AGENTS_PATH = path.join(__routeDir, '../../../cli/agents');
9
+ const LIB_CHECKS_PATH = path.join(__routeDir, '../../../cli/checks');
10
+ const customAgentsDir = (root) => path.join(root, '.avc', 'customized-agents');
11
+ const customChecksDir = (root) => path.join(root, '.avc', 'customized-agents', 'checks');
12
+
13
+ /**
14
+ * Default model catalogue — mirrors the defaults in src/cli/init.js.
15
+ * Used as a fallback when a project's avc.json pre-dates model pricing support.
16
+ */
17
+ const PRICING_SOURCES = {
18
+ claude: 'https://www.anthropic.com/pricing',
19
+ gemini: 'https://ai.google.dev/pricing',
20
+ openai: 'https://openai.com/api/pricing',
21
+ };
22
+
23
+ const DEFAULT_MODELS = {
24
+ // Anthropic Claude models (prices per 1M tokens in USD)
25
+ 'claude-opus-4-6': { provider: 'claude', displayName: 'Claude Opus 4.6', pricing: { input: 5.00, output: 25.00, unit: 'million', source: PRICING_SOURCES.claude, lastUpdated: '2026-02-24' } },
26
+ 'claude-sonnet-4-6': { provider: 'claude', displayName: 'Claude Sonnet 4.6', pricing: { input: 3.00, output: 15.00, unit: 'million', source: PRICING_SOURCES.claude, lastUpdated: '2026-02-24' } },
27
+ 'claude-haiku-4-5-20251001': { provider: 'claude', displayName: 'Claude Haiku 4.5', pricing: { input: 1.00, output: 5.00, unit: 'million', source: PRICING_SOURCES.claude, lastUpdated: '2026-02-24' } },
28
+ // Google Gemini models (prices per 1M tokens in USD)
29
+ 'gemini-3.1-pro-preview': { provider: 'gemini', displayName: 'Gemini 3.1 Pro Preview', pricing: { input: 2.00, output: 12.00, unit: 'million', source: PRICING_SOURCES.gemini, lastUpdated: '2026-03-05' } },
30
+ 'gemini-3-flash-preview': { provider: 'gemini', displayName: 'Gemini 3 Flash Preview', pricing: { input: 0.50, output: 3.00, unit: 'million', source: PRICING_SOURCES.gemini, lastUpdated: '2026-03-05' } },
31
+ 'gemini-2.5-pro': { provider: 'gemini', displayName: 'Gemini 2.5 Pro', pricing: { input: 1.25, output: 10.00, unit: 'million', source: PRICING_SOURCES.gemini, lastUpdated: '2026-02-24' } },
32
+ 'gemini-2.5-flash': { provider: 'gemini', displayName: 'Gemini 2.5 Flash', pricing: { input: 0.30, output: 2.50, unit: 'million', source: PRICING_SOURCES.gemini, lastUpdated: '2026-02-24' } },
33
+ 'gemini-2.5-flash-lite': { provider: 'gemini', displayName: 'Gemini 2.5 Flash-Lite', pricing: { input: 0.10, output: 0.40, unit: 'million', source: PRICING_SOURCES.gemini, lastUpdated: '2026-02-24' } },
34
+ // OpenAI models (prices per 1M tokens in USD)
35
+ 'gpt-5.4': { provider: 'openai', displayName: 'GPT-5.4', pricing: { input: 2.50, output: 15.00, unit: 'million', source: PRICING_SOURCES.openai, lastUpdated: '2026-03-06' } },
36
+ 'gpt-5.4-pro': { provider: 'openai', displayName: 'GPT-5.4 Pro', pricing: { input: 30.00, output: 180.00,unit: 'million', source: PRICING_SOURCES.openai, lastUpdated: '2026-03-06' } },
37
+ 'gpt-5-mini': { provider: 'openai', displayName: 'GPT-5 mini', pricing: { input: 0.25, output: 2.00, unit: 'million', source: PRICING_SOURCES.openai, lastUpdated: '2026-02-24' } },
38
+ 'gpt-5-nano': { provider: 'openai', displayName: 'GPT-5 nano', pricing: { input: 0.05, output: 0.40, unit: 'million', source: PRICING_SOURCES.openai, lastUpdated: '2026-03-06' } },
39
+ };
40
+
41
+ /**
42
+ * Provider preset defaults — mirrors providerPresets in src/cli/init.js.
43
+ * Injected server-side into ceremony objects that pre-date this feature.
44
+ */
45
+ const PROVIDER_PRESETS = {
46
+ 'sponsor-call': {
47
+ claude: {
48
+ provider: 'claude', defaultModel: 'claude-sonnet-4-6',
49
+ stages: {
50
+ suggestions: { provider: 'claude', model: 'claude-sonnet-4-6' },
51
+ documentation: { provider: 'claude', model: 'claude-sonnet-4-6' },
52
+ 'architecture-recommendation': { provider: 'claude', model: 'claude-opus-4-6' },
53
+ 'question-prefilling': { provider: 'claude', model: 'claude-haiku-4-5-20251001' },
54
+ },
55
+ validation: {
56
+ provider: 'claude', model: 'claude-haiku-4-5-20251001',
57
+ documentation: { provider: 'claude', model: 'claude-haiku-4-5-20251001' },
58
+ refinement: { provider: 'claude', model: 'claude-sonnet-4-6' },
59
+ },
60
+ },
61
+ gemini: {
62
+ provider: 'gemini', defaultModel: 'gemini-2.5-flash',
63
+ stages: {
64
+ suggestions: { provider: 'gemini', model: 'gemini-2.5-flash' },
65
+ documentation: { provider: 'gemini', model: 'gemini-2.5-flash' },
66
+ 'architecture-recommendation': { provider: 'gemini', model: 'gemini-2.5-pro' },
67
+ 'question-prefilling': { provider: 'gemini', model: 'gemini-2.5-flash-lite' },
68
+ },
69
+ validation: {
70
+ provider: 'gemini', model: 'gemini-2.5-flash-lite',
71
+ documentation: { provider: 'gemini', model: 'gemini-2.5-flash-lite' },
72
+ refinement: { provider: 'gemini', model: 'gemini-2.5-flash' },
73
+ },
74
+ },
75
+ openai: {
76
+ provider: 'openai', defaultModel: 'gpt-5.4',
77
+ stages: {
78
+ suggestions: { provider: 'openai', model: 'gpt-5.4' },
79
+ documentation: { provider: 'openai', model: 'gpt-5.4' },
80
+ 'architecture-recommendation': { provider: 'openai', model: 'gpt-5.4' },
81
+ 'question-prefilling': { provider: 'openai', model: 'gpt-5-mini' },
82
+ },
83
+ validation: {
84
+ provider: 'openai', model: 'gpt-5.4',
85
+ documentation: { provider: 'openai', model: 'gpt-5.4' },
86
+ refinement: { provider: 'openai', model: 'gpt-5.4' },
87
+ },
88
+ },
89
+ xiaomi: {
90
+ provider: 'xiaomi', defaultModel: 'mimo-v2-flash',
91
+ stages: {
92
+ suggestions: { provider: 'xiaomi', model: 'mimo-v2-flash' },
93
+ documentation: { provider: 'xiaomi', model: 'mimo-v2-pro' },
94
+ 'architecture-recommendation': { provider: 'xiaomi', model: 'mimo-v2-pro' },
95
+ 'question-prefilling': { provider: 'xiaomi', model: 'mimo-v2-flash' },
96
+ },
97
+ validation: {
98
+ provider: 'xiaomi', model: 'mimo-v2-flash',
99
+ documentation: { provider: 'xiaomi', model: 'mimo-v2-flash' },
100
+ refinement: { provider: 'xiaomi', model: 'mimo-v2-pro' },
101
+ },
102
+ },
103
+ },
104
+ 'sprint-planning': {
105
+ claude: {
106
+ provider: 'claude', defaultModel: 'claude-sonnet-4-6',
107
+ stages: {
108
+ decomposition: { provider: 'claude', model: 'claude-opus-4-6' },
109
+ validation: { provider: 'claude', model: 'claude-sonnet-4-6', useContextualSelection: true, epicConcurrency: 2, concurrency: 5, batchSize: 8, maxFixAttempts: 3 },
110
+ 'context-generation': { provider: 'claude', model: 'claude-sonnet-4-6' },
111
+ 'doc-generation': { provider: 'claude', model: 'claude-sonnet-4-6' },
112
+ enrichment: { provider: 'claude', model: 'claude-sonnet-4-6' },
113
+ },
114
+ },
115
+ gemini: {
116
+ provider: 'gemini', defaultModel: 'gemini-2.5-flash',
117
+ stages: {
118
+ decomposition: { provider: 'gemini', model: 'gemini-2.5-pro' },
119
+ validation: { provider: 'gemini', model: 'gemini-2.5-flash', useContextualSelection: true, epicConcurrency: 2, concurrency: 5, batchSize: 8, maxFixAttempts: 3 },
120
+ 'context-generation': { provider: 'gemini', model: 'gemini-2.5-flash' },
121
+ 'doc-generation': { provider: 'gemini', model: 'gemini-2.5-flash' },
122
+ enrichment: { provider: 'gemini', model: 'gemini-2.5-flash' },
123
+ },
124
+ },
125
+ openai: {
126
+ provider: 'openai', defaultModel: 'gpt-5.4',
127
+ stages: {
128
+ decomposition: { provider: 'openai', model: 'gpt-5.4' },
129
+ validation: { provider: 'openai', model: 'gpt-5.4', useContextualSelection: true, epicConcurrency: 2, concurrency: 5, batchSize: 8, maxFixAttempts: 3 },
130
+ 'context-generation': { provider: 'openai', model: 'gpt-5.4' },
131
+ 'doc-generation': { provider: 'openai', model: 'gpt-5.4' },
132
+ enrichment: { provider: 'openai', model: 'gpt-5.4' },
133
+ },
134
+ },
135
+ xiaomi: {
136
+ provider: 'xiaomi', defaultModel: 'mimo-v2-pro',
137
+ stages: {
138
+ decomposition: { provider: 'xiaomi', model: 'mimo-v2-pro' },
139
+ validation: { provider: 'xiaomi', model: 'mimo-v2-flash', useContextualSelection: true, epicConcurrency: 2, concurrency: 5, batchSize: 8, maxFixAttempts: 3 },
140
+ 'context-generation': { provider: 'xiaomi', model: 'mimo-v2-pro' },
141
+ 'doc-generation': { provider: 'xiaomi', model: 'mimo-v2-pro' },
142
+ enrichment: { provider: 'xiaomi', model: 'mimo-v2-pro' },
143
+ },
144
+ },
145
+ },
146
+ 'seed': {
147
+ claude: {
148
+ provider: 'claude', defaultModel: 'claude-sonnet-4-6',
149
+ stages: {
150
+ decomposition: { provider: 'claude', model: 'claude-opus-4-6' },
151
+ 'doc-distribution': { provider: 'claude', model: 'claude-sonnet-4-6' },
152
+ },
153
+ },
154
+ gemini: {
155
+ provider: 'gemini', defaultModel: 'gemini-2.5-flash',
156
+ stages: {
157
+ decomposition: { provider: 'gemini', model: 'gemini-2.5-pro' },
158
+ 'doc-distribution': { provider: 'gemini', model: 'gemini-2.5-flash' },
159
+ },
160
+ },
161
+ openai: {
162
+ provider: 'openai', defaultModel: 'gpt-5.4',
163
+ stages: {
164
+ decomposition: { provider: 'openai', model: 'gpt-5.4' },
165
+ 'doc-distribution': { provider: 'openai', model: 'gpt-5.4' },
166
+ },
167
+ },
168
+ xiaomi: {
169
+ provider: 'xiaomi', defaultModel: 'mimo-v2-pro',
170
+ stages: {
171
+ decomposition: { provider: 'xiaomi', model: 'mimo-v2-pro' },
172
+ 'doc-distribution': { provider: 'xiaomi', model: 'mimo-v2-pro' },
173
+ 'context-generation': { provider: 'xiaomi', model: 'mimo-v2-pro' },
174
+ },
175
+ },
176
+ },
177
+ };
178
+
179
+ async function readOAuthStatus(projectRoot, env = {}) {
180
+ try {
181
+ const raw = await fs.readFile(path.join(projectRoot, '.avc', 'openai-oauth.json'), 'utf8');
182
+ const { accountId, expires } = JSON.parse(raw);
183
+ return { connected: true, accountId, expiresAt: expires,
184
+ expiresIn: Math.max(0, Math.round((expires - Date.now()) / 1000)),
185
+ fallback: env.OPENAI_OAUTH_FALLBACK === 'true' };
186
+ } catch { return { connected: false, fallback: false }; }
187
+ }
188
+
189
+ /**
190
+ * Settings Router
191
+ * Handles GET /api/settings and PUT sub-routes for project configuration.
192
+ * @param {string} projectRoot - Absolute path to project root
193
+ */
194
+ export function createSettingsRouter(projectRoot) {
195
+ const router = express.Router();
196
+ const avcJsonPath = path.join(projectRoot, '.avc', 'avc.json');
197
+ const envPath = path.join(projectRoot, '.env');
198
+
199
+ const readAvcConfig = async () => {
200
+ try {
201
+ return JSON.parse(await fs.readFile(avcJsonPath, 'utf8'));
202
+ } catch {
203
+ return {};
204
+ }
205
+ };
206
+
207
+ const writeAvcConfig = async (config) => {
208
+ await fs.writeFile(avcJsonPath, JSON.stringify(config, null, 2), 'utf8');
209
+ };
210
+
211
+ // Parse .env into key→value map
212
+ const readEnv = async () => {
213
+ try {
214
+ const lines = (await fs.readFile(envPath, 'utf8')).split('\n');
215
+ const map = {};
216
+ for (const line of lines) {
217
+ const m = line.match(/^([A-Z_]+)\s*=\s*(.*)$/);
218
+ if (m) map[m[1]] = m[2].replace(/^["']|["']$/g, '');
219
+ }
220
+ return map;
221
+ } catch {
222
+ return {};
223
+ }
224
+ };
225
+
226
+ // Update or insert a single key in .env, preserving all other lines
227
+ const upsertEnvKey = async (key, value) => {
228
+ let content = '';
229
+ try { content = await fs.readFile(envPath, 'utf8'); } catch {}
230
+ const lines = content.split('\n');
231
+ const idx = lines.findIndex(l => l.match(new RegExp(`^${key}\\s*=`)));
232
+ const newLine = value ? `${key}=${value}` : '';
233
+ if (idx >= 0) {
234
+ if (newLine) {
235
+ lines[idx] = newLine;
236
+ } else {
237
+ lines.splice(idx, 1);
238
+ }
239
+ } else if (newLine) {
240
+ lines.push(newLine);
241
+ }
242
+ await fs.writeFile(envPath, lines.join('\n'), 'utf8');
243
+ };
244
+
245
+ // GET /api/settings/local-models — discover running local inference servers
246
+ router.get('/local-models', async (req, res) => {
247
+ try {
248
+ const { discoverLocalServers } = await import('../../../cli/llm-local.js');
249
+ const servers = await discoverLocalServers();
250
+ res.json({ servers });
251
+ } catch (err) {
252
+ res.status(500).json({ error: err.message, servers: [] });
253
+ }
254
+ });
255
+
256
+ // GET /api/settings — snapshot of all configurable settings
257
+ router.get('/', async (req, res) => {
258
+ try {
259
+ const [config, env] = await Promise.all([readAvcConfig(), readEnv()]);
260
+ const oauthStatus = await readOAuthStatus(projectRoot, env);
261
+ // Migrate existing projects: inject providerPresets if missing or incomplete
262
+ let configChanged = false;
263
+ const ceremonies = config?.settings?.ceremonies || [];
264
+ ceremonies.forEach((ceremony) => {
265
+ const defaultPresets = PROVIDER_PRESETS[ceremony.name];
266
+ if (!ceremony.providerPresets && defaultPresets) {
267
+ ceremony.providerPresets = defaultPresets;
268
+ configChanged = true;
269
+ } else if (ceremony.providerPresets && defaultPresets) {
270
+ for (const [provider, preset] of Object.entries(defaultPresets)) {
271
+ if (!ceremony.providerPresets[provider]) {
272
+ ceremony.providerPresets[provider] = preset;
273
+ configChanged = true;
274
+ }
275
+ }
276
+ }
277
+ // Migrate sprint-planning: doc-distribution → context-generation + doc-generation
278
+ if (ceremony.name === 'sprint-planning' && ceremony.stages) {
279
+ const old = ceremony.stages['doc-distribution'];
280
+ if (old) {
281
+ if (!ceremony.stages['context-generation']) ceremony.stages['context-generation'] = { ...old };
282
+ if (!ceremony.stages['doc-generation']) ceremony.stages['doc-generation'] = { ...old };
283
+ }
284
+ }
285
+ });
286
+
287
+ // Inject missing models for new providers (e.g., xiaomi added after project init)
288
+ const models = config?.settings?.models || {};
289
+ const DEFAULT_MODELS = {
290
+ 'mimo-v2-flash': { provider: 'xiaomi', displayName: 'MiMo V2 Flash', pricing: { input: 0.09, output: 0.29, unit: 'million', source: 'https://platform.xiaomimimo.com', lastUpdated: '2026-03-25' } },
291
+ 'mimo-v2-pro': { provider: 'xiaomi', displayName: 'MiMo V2 Pro', pricing: { input: 1.00, output: 3.00, unit: 'million', source: 'https://platform.xiaomimimo.com', lastUpdated: '2026-03-25' } },
292
+ 'mimo-v2-omni': { provider: 'xiaomi', displayName: 'MiMo V2 Omni', pricing: { input: 0.40, output: 2.00, unit: 'million', source: 'https://platform.xiaomimimo.com', lastUpdated: '2026-03-25' } },
293
+ };
294
+ for (const [modelId, modelDef] of Object.entries(DEFAULT_MODELS)) {
295
+ if (!models[modelId]) {
296
+ models[modelId] = modelDef;
297
+ configChanged = true;
298
+ } else if (!models[modelId].pricing?.source || !models[modelId].pricing?.lastUpdated) {
299
+ // Backfill missing pricing metadata for existing models
300
+ models[modelId].pricing = { ...models[modelId].pricing, ...modelDef.pricing };
301
+ configChanged = true;
302
+ }
303
+ }
304
+ if (config.settings) config.settings.models = models;
305
+
306
+ // Auto-switch ceremony providers when configured provider has no API key
307
+ // but another provider with a valid key is available.
308
+ const keyAvailable = (provider) => {
309
+ if (provider === 'local') return true;
310
+ if (provider === 'claude' || provider === 'anthropic') return !!env.ANTHROPIC_API_KEY;
311
+ if (provider === 'openai') return !!(env.OPENAI_API_KEY || (env.OPENAI_AUTH_MODE === 'oauth'));
312
+ if (provider === 'gemini') return !!env.GEMINI_API_KEY;
313
+ if (provider === 'xiaomi') return !!env.XIAOMI_API_KEY;
314
+ return false;
315
+ };
316
+ const FALLBACK_ORDER = ['claude', 'gemini', 'openai', 'xiaomi'];
317
+ const FALLBACK_MODELS = { claude: 'claude-sonnet-4-6', gemini: 'gemini-2.5-flash', openai: 'gpt-4.1', xiaomi: 'MiMo-V2-Flash' };
318
+ const findAvailable = () => FALLBACK_ORDER.find(p => keyAvailable(p)) || null;
319
+
320
+ for (const ceremony of ceremonies) {
321
+ const mainProvider = ceremony.provider || 'claude';
322
+ if (!keyAvailable(mainProvider)) {
323
+ const fallback = findAvailable();
324
+ if (fallback) {
325
+ console.log(`[settings] auto-switch: ${mainProvider}→${fallback} for ceremony ${ceremony.name}`);
326
+ const preset = ceremony.providerPresets?.[fallback];
327
+ const fallbackModel = preset?.defaultModel || FALLBACK_MODELS[fallback];
328
+
329
+ ceremony.provider = fallback;
330
+ ceremony.defaultModel = fallbackModel;
331
+
332
+ // Apply preset stage config (model + extra props like concurrency)
333
+ if (ceremony.stages && typeof ceremony.stages === 'object') {
334
+ for (const [stageName, stage] of Object.entries(ceremony.stages)) {
335
+ const presetStage = preset?.stages?.[stageName];
336
+ if (presetStage) {
337
+ Object.assign(stage, presetStage);
338
+ } else {
339
+ stage.provider = fallback;
340
+ stage.model = fallbackModel;
341
+ }
342
+ }
343
+ }
344
+
345
+ // Apply preset validation models or fall back to default model
346
+ if (ceremony.validation && typeof ceremony.validation === 'object') {
347
+ const presetVal = preset?.validation;
348
+ ceremony.validation.provider = fallback;
349
+ ceremony.validation.model = presetVal?.model || fallbackModel;
350
+ for (const [k, v] of Object.entries(ceremony.validation)) {
351
+ if (v && typeof v === 'object' && typeof v.provider === 'string') {
352
+ const presetSub = presetVal?.[k];
353
+ v.provider = fallback;
354
+ v.model = presetSub?.model || presetVal?.model || fallbackModel;
355
+ }
356
+ }
357
+ }
358
+ configChanged = true;
359
+ }
360
+ }
361
+ }
362
+
363
+ // Re-apply provider presets to repair stale stage models
364
+ // (handles case where a previous auto-switch set all stages to a single default model)
365
+ for (const ceremony of ceremonies) {
366
+ const provider = ceremony.provider || 'claude';
367
+ const preset = ceremony.providerPresets?.[provider];
368
+ if (!preset) continue;
369
+
370
+ let repaired = false;
371
+ if (preset.defaultModel && ceremony.defaultModel !== preset.defaultModel) {
372
+ ceremony.defaultModel = preset.defaultModel;
373
+ repaired = true;
374
+ }
375
+ if (ceremony.stages && preset.stages) {
376
+ for (const [stageName, stage] of Object.entries(ceremony.stages)) {
377
+ const ps = preset.stages[stageName];
378
+ if (ps && stage.model !== ps.model) {
379
+ Object.assign(stage, ps);
380
+ repaired = true;
381
+ }
382
+ }
383
+ }
384
+ if (ceremony.validation && typeof ceremony.validation === 'object' && preset.validation) {
385
+ if (preset.validation.model && ceremony.validation.model !== preset.validation.model) {
386
+ ceremony.validation.provider = provider;
387
+ ceremony.validation.model = preset.validation.model;
388
+ repaired = true;
389
+ }
390
+ for (const [k, v] of Object.entries(ceremony.validation)) {
391
+ if (v && typeof v === 'object' && typeof v.provider === 'string' && preset.validation[k]) {
392
+ if (v.model !== preset.validation[k].model) {
393
+ v.provider = provider;
394
+ v.model = preset.validation[k].model;
395
+ repaired = true;
396
+ }
397
+ }
398
+ }
399
+ }
400
+ if (repaired) {
401
+ console.log(`[settings] repaired stage models for ${ceremony.name} using ${provider} preset`);
402
+ configChanged = true;
403
+ }
404
+ }
405
+
406
+ // Persist all migrations to avc.json so other endpoints see them
407
+ if (configChanged) {
408
+ try { await writeAvcConfig(config); } catch {}
409
+ }
410
+
411
+ res.json({
412
+ apiKeys: {
413
+ anthropic: {
414
+ isSet: !!env.ANTHROPIC_API_KEY,
415
+ preview: env.ANTHROPIC_API_KEY ? env.ANTHROPIC_API_KEY.slice(0, 10) + '…' : '',
416
+ },
417
+ gemini: {
418
+ isSet: !!env.GEMINI_API_KEY,
419
+ preview: env.GEMINI_API_KEY ? env.GEMINI_API_KEY.slice(0, 10) + '…' : '',
420
+ },
421
+ openai: {
422
+ isSet: !!(env.OPENAI_API_KEY || oauthStatus.connected),
423
+ preview: env.OPENAI_API_KEY ? env.OPENAI_API_KEY.slice(0, 10) + '…' : '',
424
+ authMode: env.OPENAI_AUTH_MODE || 'api-key',
425
+ oauth: oauthStatus,
426
+ },
427
+ xiaomi: {
428
+ isSet: !!env.XIAOMI_API_KEY,
429
+ preview: env.XIAOMI_API_KEY ? env.XIAOMI_API_KEY.slice(0, 10) + '…' : '',
430
+ },
431
+ local: {
432
+ isSet: true, // No API key needed — availability checked via /local-models
433
+ preview: 'localhost',
434
+ },
435
+ },
436
+ ceremonies,
437
+ models: (config?.settings?.models && Object.keys(config.settings.models).length > 0)
438
+ ? config.settings.models
439
+ : DEFAULT_MODELS,
440
+ missionGenerator: config?.settings?.missionGenerator || { validation: { maxIterations: 3, acceptanceThreshold: 95 } },
441
+ kanbanPort: config?.settings?.kanban?.port || 4174,
442
+ docsPort: config?.settings?.documentation?.port || 4173,
443
+ boardTitle: config?.settings?.kanban?.title || 'AVC Kanban Board',
444
+ costThresholds: config?.settings?.costThresholds || { 'sponsor-call': null, 'sprint-planning': null, 'seed': null },
445
+ });
446
+ } catch (err) {
447
+ res.status(500).json({ error: err.message });
448
+ }
449
+ });
450
+
451
+ // PUT /api/settings/api-keys — only sends keys that are being updated
452
+ router.put('/api-keys', async (req, res) => {
453
+ try {
454
+ const { anthropic, gemini, openai, xiaomi } = req.body;
455
+ if (anthropic !== undefined) {
456
+ await upsertEnvKey('ANTHROPIC_API_KEY', anthropic);
457
+ if (anthropic) process.env.ANTHROPIC_API_KEY = anthropic;
458
+ else delete process.env.ANTHROPIC_API_KEY;
459
+ }
460
+ if (gemini !== undefined) {
461
+ await upsertEnvKey('GEMINI_API_KEY', gemini);
462
+ if (gemini) process.env.GEMINI_API_KEY = gemini;
463
+ else delete process.env.GEMINI_API_KEY;
464
+ }
465
+ if (openai !== undefined) {
466
+ await upsertEnvKey('OPENAI_API_KEY', openai);
467
+ if (openai) process.env.OPENAI_API_KEY = openai;
468
+ else delete process.env.OPENAI_API_KEY;
469
+ }
470
+ if (xiaomi !== undefined) {
471
+ await upsertEnvKey('XIAOMI_API_KEY', xiaomi);
472
+ if (xiaomi) process.env.XIAOMI_API_KEY = xiaomi;
473
+ else delete process.env.XIAOMI_API_KEY;
474
+ }
475
+ res.json({ status: 'ok' });
476
+ } catch (err) {
477
+ res.status(500).json({ error: err.message });
478
+ }
479
+ });
480
+
481
+ // PUT /api/settings/ceremonies — also accepts missionGenerator alongside ceremonies
482
+ router.put('/ceremonies', async (req, res) => {
483
+ try {
484
+ const { ceremonies, missionGenerator } = req.body;
485
+ if (!Array.isArray(ceremonies)) {
486
+ return res.status(400).json({ error: 'ceremonies must be an array' });
487
+ }
488
+ const config = await readAvcConfig();
489
+ if (!config.settings) config.settings = {};
490
+ config.settings.ceremonies = ceremonies;
491
+ // Persist missionGenerator validation params if provided
492
+ if (missionGenerator?.validation && typeof missionGenerator.validation === 'object') {
493
+ if (!config.settings.missionGenerator) config.settings.missionGenerator = {};
494
+ config.settings.missionGenerator.validation = {
495
+ maxIterations: Number(missionGenerator.validation.maxIterations) || 3,
496
+ acceptanceThreshold: Number(missionGenerator.validation.acceptanceThreshold) || 95,
497
+ };
498
+ }
499
+ await writeAvcConfig(config);
500
+ res.json({ status: 'ok' });
501
+ } catch (err) {
502
+ res.status(500).json({ error: err.message });
503
+ }
504
+ });
505
+
506
+ // PUT /api/settings/models — update pricing for all models
507
+ router.put('/models', async (req, res) => {
508
+ try {
509
+ const { models } = req.body;
510
+ if (!models || typeof models !== 'object' || Array.isArray(models)) {
511
+ return res.status(400).json({ error: 'models must be an object' });
512
+ }
513
+ const config = await readAvcConfig();
514
+ if (!config.settings) config.settings = {};
515
+ // Seed from defaults if models have never been persisted (migration for old projects)
516
+ if (!config.settings.models || Object.keys(config.settings.models).length === 0) {
517
+ config.settings.models = JSON.parse(JSON.stringify(DEFAULT_MODELS));
518
+ }
519
+ for (const [modelId, data] of Object.entries(models)) {
520
+ if (!config.settings.models[modelId]) continue; // only update existing models
521
+ if (data.pricing && typeof data.pricing === 'object') {
522
+ const today = new Date().toISOString().split('T')[0];
523
+ config.settings.models[modelId].pricing = {
524
+ input: Number(data.pricing.input) || 0,
525
+ output: Number(data.pricing.output) || 0,
526
+ unit: data.pricing.unit === 'thousand' ? 'thousand' : 'million',
527
+ source: typeof data.pricing.source === 'string' ? data.pricing.source.trim() : '',
528
+ lastUpdated: today,
529
+ };
530
+ }
531
+ }
532
+ await writeAvcConfig(config);
533
+ res.json({ status: 'ok' });
534
+ } catch (err) {
535
+ res.status(500).json({ error: err.message });
536
+ }
537
+ });
538
+
539
+ // PUT /api/settings/general — board title and/or ports
540
+ router.put('/general', async (req, res) => {
541
+ try {
542
+ const { boardTitle, kanbanPort, docsPort } = req.body;
543
+ const config = await readAvcConfig();
544
+ if (!config.settings) config.settings = {};
545
+ if (!config.settings.kanban) config.settings.kanban = {};
546
+ if (!config.settings.documentation) config.settings.documentation = {};
547
+ if (boardTitle !== undefined) config.settings.kanban.title = boardTitle.trim();
548
+ if (kanbanPort !== undefined) config.settings.kanban.port = Number(kanbanPort);
549
+ if (docsPort !== undefined) config.settings.documentation.port = Number(docsPort);
550
+ await writeAvcConfig(config);
551
+ res.json({ status: 'ok' });
552
+ } catch (err) {
553
+ res.status(500).json({ error: err.message });
554
+ }
555
+ });
556
+
557
+ // PUT /api/settings/cost-thresholds — update per-ceremony cost limits
558
+ router.put('/cost-thresholds', async (req, res) => {
559
+ try {
560
+ const { thresholds } = req.body;
561
+ const config = await readAvcConfig();
562
+ if (!config.settings) config.settings = {};
563
+ config.settings.costThresholds = thresholds;
564
+ await writeAvcConfig(config);
565
+ res.json({ ok: true });
566
+ } catch (err) {
567
+ res.status(500).json({ error: err.message });
568
+ }
569
+ });
570
+
571
+ // GET /api/settings/agents — list all agent names with customization status
572
+ router.get('/agents', (req, res) => {
573
+ try {
574
+ const names = readdirSync(LIB_AGENTS_PATH).filter(f => f.endsWith('.md')).sort();
575
+ const customDir = customAgentsDir(projectRoot);
576
+ const agents = names.map(name => ({
577
+ name,
578
+ isCustomized: existsSync(path.join(customDir, name)),
579
+ }));
580
+ res.json({ agents });
581
+ } catch (err) {
582
+ res.status(500).json({ error: err.message });
583
+ }
584
+ });
585
+
586
+ // GET /api/settings/agents/:name — get agent content (customized or default)
587
+ router.get('/agents/:name', (req, res) => {
588
+ const { name } = req.params;
589
+ if (!name.endsWith('.md') || name.includes('/') || name.includes('\\')) {
590
+ return res.status(400).json({ error: 'Invalid name' });
591
+ }
592
+ try {
593
+ const customPath = path.join(customAgentsDir(projectRoot), name);
594
+ const libPath = path.join(LIB_AGENTS_PATH, name);
595
+ if (!existsSync(libPath)) return res.status(404).json({ error: 'Agent not found' });
596
+ const isCustomized = existsSync(customPath);
597
+ const content = readFileSync(isCustomized ? customPath : libPath, 'utf8');
598
+ const defaultContent = readFileSync(libPath, 'utf8');
599
+ res.json({ name, content, isCustomized, defaultContent });
600
+ } catch (err) {
601
+ res.status(500).json({ error: err.message });
602
+ }
603
+ });
604
+
605
+ // PUT /api/settings/agents/:name — save customized agent
606
+ router.put('/agents/:name', async (req, res) => {
607
+ const { name } = req.params;
608
+ const { content } = req.body;
609
+ if (!name.endsWith('.md') || name.includes('/') || name.includes('\\')) {
610
+ return res.status(400).json({ error: 'Invalid name' });
611
+ }
612
+ if (typeof content !== 'string') return res.status(400).json({ error: 'content must be a string' });
613
+ try {
614
+ const dir = customAgentsDir(projectRoot);
615
+ await fs.mkdir(dir, { recursive: true });
616
+ await fs.writeFile(path.join(dir, name), content, 'utf8');
617
+ res.json({ status: 'ok' });
618
+ } catch (err) {
619
+ res.status(500).json({ error: err.message });
620
+ }
621
+ });
622
+
623
+ // DELETE /api/settings/agents/:name — reset to library default
624
+ router.delete('/agents/:name', async (req, res) => {
625
+ const { name } = req.params;
626
+ if (!name.endsWith('.md') || name.includes('/') || name.includes('\\')) {
627
+ return res.status(400).json({ error: 'Invalid name' });
628
+ }
629
+ try {
630
+ const customPath = path.join(customAgentsDir(projectRoot), name);
631
+ try { await fs.unlink(customPath); } catch {}
632
+ res.json({ status: 'ok' });
633
+ } catch (err) {
634
+ res.status(500).json({ error: err.message });
635
+ }
636
+ });
637
+
638
+ // ── Micro-Check Definitions API ────────────────────────────────────────────
639
+
640
+ // GET /api/settings/checks — list all check files with customization status
641
+ router.get('/checks', (req, res) => {
642
+ try {
643
+ const checks = [];
644
+ for (const scope of ['epic', 'story', 'code']) {
645
+ const scopeDir = path.join(LIB_CHECKS_PATH, scope);
646
+ if (!existsSync(scopeDir)) continue;
647
+ const files = readdirSync(scopeDir).filter(f => f.endsWith('.json')).sort();
648
+ for (const file of files) {
649
+ const perspective = file.replace(/\.json$/, '');
650
+ const customPath = path.join(customChecksDir(projectRoot), scope, file);
651
+ const content = JSON.parse(readFileSync(path.join(scopeDir, file), 'utf8'));
652
+ checks.push({
653
+ scope,
654
+ perspective,
655
+ checkCount: Array.isArray(content) ? content.length : 0,
656
+ isCustomized: existsSync(customPath),
657
+ });
658
+ }
659
+ }
660
+ // Cross-refs
661
+ const crossDir = path.join(LIB_CHECKS_PATH, 'cross-refs');
662
+ if (existsSync(crossDir)) {
663
+ for (const file of readdirSync(crossDir).filter(f => f.endsWith('.json')).sort()) {
664
+ const scope = file.replace(/\.json$/, '');
665
+ const customPath = path.join(customChecksDir(projectRoot), 'cross-refs', file);
666
+ const content = JSON.parse(readFileSync(path.join(crossDir, file), 'utf8'));
667
+ checks.push({
668
+ scope: 'cross-refs',
669
+ perspective: scope,
670
+ checkCount: Array.isArray(content) ? content.length : 0,
671
+ isCustomized: existsSync(customPath),
672
+ });
673
+ }
674
+ }
675
+ res.json({ checks });
676
+ } catch (err) {
677
+ res.status(500).json({ error: err.message });
678
+ }
679
+ });
680
+
681
+ // GET /api/settings/checks/:scope/:perspective — get check content
682
+ router.get('/checks/:scope/:perspective', (req, res) => {
683
+ const { scope, perspective } = req.params;
684
+ if (!['epic', 'story', 'code', 'cross-refs'].includes(scope) || /[/\\]/.test(perspective)) {
685
+ return res.status(400).json({ error: 'Invalid scope or perspective' });
686
+ }
687
+ try {
688
+ const libPath = path.join(LIB_CHECKS_PATH, scope, `${perspective}.json`);
689
+ if (!existsSync(libPath)) return res.status(404).json({ error: 'Check file not found' });
690
+ const customPath = path.join(customChecksDir(projectRoot), scope, `${perspective}.json`);
691
+ const isCustomized = existsSync(customPath);
692
+ const content = readFileSync(isCustomized ? customPath : libPath, 'utf8');
693
+ const defaultContent = readFileSync(libPath, 'utf8');
694
+ res.json({ scope, perspective, content, isCustomized, defaultContent });
695
+ } catch (err) {
696
+ res.status(500).json({ error: err.message });
697
+ }
698
+ });
699
+
700
+ // PUT /api/settings/checks/:scope/:perspective — save customized check file
701
+ router.put('/checks/:scope/:perspective', async (req, res) => {
702
+ const { scope, perspective } = req.params;
703
+ const { content } = req.body;
704
+ if (!['epic', 'story', 'code', 'cross-refs'].includes(scope) || /[/\\]/.test(perspective)) {
705
+ return res.status(400).json({ error: 'Invalid scope or perspective' });
706
+ }
707
+ if (typeof content !== 'string') return res.status(400).json({ error: 'content must be a string' });
708
+ // Validate JSON
709
+ try { JSON.parse(content); } catch { return res.status(400).json({ error: 'Invalid JSON' }); }
710
+ try {
711
+ const dir = path.join(customChecksDir(projectRoot), scope);
712
+ await fs.mkdir(dir, { recursive: true });
713
+ await fs.writeFile(path.join(dir, `${perspective}.json`), content, 'utf8');
714
+ res.json({ status: 'ok' });
715
+ } catch (err) {
716
+ res.status(500).json({ error: err.message });
717
+ }
718
+ });
719
+
720
+ // DELETE /api/settings/checks/:scope/:perspective — reset to built-in default
721
+ router.delete('/checks/:scope/:perspective', async (req, res) => {
722
+ const { scope, perspective } = req.params;
723
+ if (!['epic', 'story', 'code', 'cross-refs'].includes(scope) || /[/\\]/.test(perspective)) {
724
+ return res.status(400).json({ error: 'Invalid scope or perspective' });
725
+ }
726
+ try {
727
+ const customPath = path.join(customChecksDir(projectRoot), scope, `${perspective}.json`);
728
+ try { await fs.unlink(customPath); } catch {}
729
+ res.json({ status: 'ok' });
730
+ } catch (err) {
731
+ res.status(500).json({ error: err.message });
732
+ }
733
+ });
734
+
735
+ return router;
736
+ }