quoxcli 0.0.0-stage → 0.2.0

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 (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,1091 @@
1
+ /**
2
+ * `quox plugin doctor <name>` — the DEVSUITE-BLUEPRINT P1 enforcer.
3
+ *
4
+ * Checks a plugin against the dev-bundle definition of done: the 13-point
5
+ * registration chain (DEVBUNDLE_DOD.md §chain) and the five prohibitions
6
+ * measured from QuoxPlan (§defects). Every check greps the REAL codebase;
7
+ * nothing here trusts a manifest's claims about itself.
8
+ *
9
+ * The acceptance test for this tool is written into the BLUEPRINT plan: run
10
+ * against `quoxplan`, it MUST flag defects 5.1 (standalone CLI defaults to
11
+ * the ungoverned direct path), 5.2 (no licence gate on the proxy) and 5.5
12
+ * (manifest links disagree with the website nav). If it reports QuoxPlan
13
+ * clean, the checker is wrong, not the plugin.
14
+ *
15
+ * v2 (SDK-TEAM W2 P5a): --root makes every path derive from a checkout (default:
16
+ * the repo containing cwd), routes registered through imported modules are
17
+ * followed, a route the doctor cannot find is UNKNOWN (never read-only/n-a),
18
+ * core-free plugins get licence checks N/A with a reason, comments never count
19
+ * as evidence, and --json emits the quox.external-verdict/v1 contract that
20
+ * DoneEngine's external_verdict probe reads.
21
+ *
22
+ * Output: red/amber/green per line. GREEN means the grep found the wiring.
23
+ * AMBER means the check could not determine (missing input, pattern not
24
+ * applicable) — amber is never silently green, the same rule DoneEngine's
25
+ * verdicts follow. Exit codes match the house contract: 0 all green,
26
+ * 1 findings, 2 could not evaluate.
27
+ *
28
+ * Evidence class: everything here is MACHINE evidence (greps of real files).
29
+ * The human-judgement surfaces (promo quality, docs accuracy, QuoxSupport
30
+ * retrain) stay in DEVBUNDLE_DOD.md as attested items; this tool does not
31
+ * pretend to check them. The DoneEngine bridge (packs/plugin-dod.json
32
+ * consuming this tool's --json) is deliberately deferred until the engine
33
+ * grows a probe type for external verdicts — a pack that can run commands is
34
+ * a programming language, and that door stays shut.
35
+ */
36
+
37
+ import fs from 'node:fs';
38
+ import os from 'node:os';
39
+ import path from 'node:path';
40
+ import { execFileSync } from 'node:child_process';
41
+
42
+ const HOME = os.homedir();
43
+ export const PRODUCER_NAME = 'quox-plugin-doctor';
44
+ export const PRODUCER_VERSION = '2.0.0';
45
+ export const CONTRACT = 'quox.external-verdict/v1';
46
+
47
+ function read(p) {
48
+ try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Source scanning helpers. Every check below reads CODE, never commentary: the
53
+ // doctor's documented first false-green was a comment saying "no license gate
54
+ // yet" matching a licence regex. stripComments blanks comments but keeps every
55
+ // newline, so line numbers in evidence stay true.
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export function stripComments(src) {
59
+ let out = '';
60
+ let i = 0;
61
+ const n = src.length;
62
+ let quote = null;
63
+ let prevSig = '';
64
+ while (i < n) {
65
+ const ch = src[i];
66
+ const nx = src[i + 1];
67
+ if (quote) {
68
+ out += ch;
69
+ if (ch === '\\') { out += nx ?? ''; i += 2; continue; }
70
+ if (ch === quote) quote = null;
71
+ else if (quote !== '`' && ch === '\n') quote = null;
72
+ i++;
73
+ continue;
74
+ }
75
+ if (ch === '/' && nx === '/') { while (i < n && src[i] !== '\n') { out += ' '; i++; } continue; }
76
+ if (ch === '/' && nx === '*') {
77
+ out += ' '; i += 2;
78
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) { out += src[i] === '\n' ? '\n' : ' '; i++; }
79
+ out += ' '; i += 2;
80
+ continue;
81
+ }
82
+ if (ch === '"' || ch === "'" || ch === '`') { quote = ch; out += ch; prevSig = ch; i++; continue; }
83
+ // A regex literal can contain quotes and backticks; skip it whole so it
84
+ // does not open a phantom string. A slash after an operand is division.
85
+ if (ch === '/' && (prevSig === '' || '(,=:[!&|?{};'.includes(prevSig))) {
86
+ let j = i + 1;
87
+ let inClass = false;
88
+ while (j < n && src[j] !== '\n') {
89
+ if (src[j] === '\\') { j += 2; continue; }
90
+ if (src[j] === '[') inClass = true;
91
+ else if (src[j] === ']') inClass = false;
92
+ else if (src[j] === '/' && !inClass) break;
93
+ j++;
94
+ }
95
+ if (j < n && src[j] === '/') { out += src.slice(i, j + 1); i = j + 1; prevSig = ')'; continue; }
96
+ }
97
+ out += ch;
98
+ if (!/\s/.test(ch)) prevSig = ch;
99
+ i++;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ const codeCache = new Map();
105
+ function readCode(p) {
106
+ if (codeCache.has(p)) return codeCache.get(p);
107
+ const raw = read(p);
108
+ const code = raw === null ? null : stripComments(raw);
109
+ codeCache.set(p, code);
110
+ return code;
111
+ }
112
+
113
+ // 1-based line of an offset. The newline index is built once per text (server.js
114
+ // is 1.5MB with thousands of routes; counting from the start each time is quadratic).
115
+ const lineIndexCache = new Map();
116
+ const lineOf = (text, idx) => {
117
+ let nl = lineIndexCache.get(text);
118
+ if (!nl) {
119
+ nl = [];
120
+ for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) nl.push(i);
121
+ lineIndexCache.set(text, nl);
122
+ }
123
+ let lo = 0;
124
+ let hi = nl.length;
125
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (nl[mid] < idx) lo = mid + 1; else hi = mid; }
126
+ return lo + 1;
127
+ };
128
+
129
+ function skipString(code, i) {
130
+ const q = code[i];
131
+ for (i++; i < code.length; i++) {
132
+ if (code[i] === '\\') { i++; continue; }
133
+ if (code[i] === q) return i;
134
+ if (q !== '`' && code[i] === '\n') return i;
135
+ }
136
+ return i;
137
+ }
138
+
139
+ /** Index just past the bracket that closes the one at openIdx, or -1. */
140
+ function matchClose(code, openIdx) {
141
+ const open = code[openIdx];
142
+ const close = { '(': ')', '{': '}', '[': ']' }[open];
143
+ let depth = 0;
144
+ for (let i = openIdx; i < code.length; i++) {
145
+ const ch = code[i];
146
+ if (ch === '"' || ch === "'" || ch === '`') { i = skipString(code, i); continue; }
147
+ if (ch === open) depth++;
148
+ else if (ch === close) { depth--; if (depth === 0) return i + 1; }
149
+ }
150
+ return -1;
151
+ }
152
+
153
+ /** End of the expression starting at idx: first `;` at depth 0, or the closer of the enclosing bracket. */
154
+ function exprEnd(code, idx) {
155
+ let depth = 0;
156
+ const cap = Math.min(code.length, idx + 40000);
157
+ for (let i = idx; i < cap; i++) {
158
+ const ch = code[i];
159
+ if (ch === '"' || ch === "'" || ch === '`') { i = skipString(code, i); continue; }
160
+ if ('([{'.includes(ch)) depth++;
161
+ else if (')]}'.includes(ch)) { depth--; if (depth < 0) return i; }
162
+ else if (ch === ';' && depth === 0) return i;
163
+ }
164
+ return cap;
165
+ }
166
+
167
+ const KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'await', 'typeof', 'new', 'super', 'import', 'require']);
168
+
169
+ /** name -> concatenated body text of every function/arrow/method of that name in the file. */
170
+ function extractFunctions(code) {
171
+ const fns = new Map();
172
+ const add = (name, body) => fns.set(name, `${fns.get(name) || ''}\n${body}`);
173
+ let m;
174
+ const decl = /(?<![\w$.])(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g;
175
+ while ((m = decl.exec(code))) {
176
+ const pend = matchClose(code, m.index + m[0].length - 1);
177
+ if (pend < 0) continue;
178
+ const brace = /^\s*\{/.exec(code.slice(pend, pend + 20));
179
+ if (!brace) continue;
180
+ const b = pend + brace[0].length - 1;
181
+ const bend = matchClose(code, b);
182
+ if (bend > 0) add(m[1], code.slice(b, bend));
183
+ }
184
+ const arrow = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/g;
185
+ while ((m = arrow.exec(code))) {
186
+ const start = m.index + m[0].length;
187
+ add(m[1], code.slice(start, exprEnd(code, start)));
188
+ }
189
+ // `const name = wrapper((ctx) => { ... })`: the function is an argument of a
190
+ // wrapper call (ContactsQ's `mut(...)`), so the plain arrow form misses it.
191
+ const wrapped = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?!require\()[A-Za-z_$][\w$.]*\s*\(/g;
192
+ while ((m = wrapped.exec(code))) {
193
+ const start = m.index + m[0].length - 1;
194
+ const end = matchClose(code, start);
195
+ if (end < 0) continue;
196
+ const text = code.slice(start, end);
197
+ if (/=>|function\s*[\w$]*\s*\(/.test(text)) add(m[1], text);
198
+ }
199
+ const method = /^[ \t]*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/gm;
200
+ while ((m = method.exec(code))) {
201
+ if (KEYWORDS.has(m[1])) continue;
202
+ const pend = matchClose(code, m.index + m[0].length - 1);
203
+ if (pend < 0) continue;
204
+ const brace = /^\s*\{/.exec(code.slice(pend, pend + 20));
205
+ if (!brace) continue;
206
+ const b = pend + brace[0].length - 1;
207
+ const bend = matchClose(code, b);
208
+ if (bend > 0) add(m[1], code.slice(b, bend));
209
+ }
210
+ const prop = /([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?(?:function\s*[\w$]*\s*\([^)]*\)|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)\s*\{/g;
211
+ while ((m = prop.exec(code))) {
212
+ const b = m.index + m[0].length - 1;
213
+ const bend = matchClose(code, b);
214
+ if (bend > 0) add(m[1], code.slice(b, bend));
215
+ }
216
+ return fns;
217
+ }
218
+
219
+ const tokens = (text) => new Set(text.match(/[A-Za-z_$][\w$]*/g) || []);
220
+
221
+ /** Names of functions that match directRe themselves or call (mention) one that does. */
222
+ const closureCache = new WeakMap();
223
+ function closureNames(fns, directRe) {
224
+ let perRe = closureCache.get(fns);
225
+ if (!perRe) { perRe = new Map(); closureCache.set(fns, perRe); }
226
+ if (perRe.has(directRe.source)) return perRe.get(directRe.source);
227
+ const callers = new Map();
228
+ const hit = new Set();
229
+ const queue = [];
230
+ for (const [n, b] of fns) {
231
+ if (directRe.test(b)) { hit.add(n); queue.push(n); }
232
+ for (const t of tokens(b)) {
233
+ if (t !== n && fns.has(t)) { if (!callers.has(t)) callers.set(t, []); callers.get(t).push(n); }
234
+ }
235
+ }
236
+ while (queue.length) {
237
+ const cur = queue.pop();
238
+ for (const c of callers.get(cur) || []) if (!hit.has(c)) { hit.add(c); queue.push(c); }
239
+ }
240
+ perRe.set(directRe.source, hit);
241
+ return hit;
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Route table. The collector registers routes in more than one shape: inline in
246
+ // server.js, or through an imported module that server.js mounts
247
+ // (registerXRoutes(app, deps), require('./lib/x')(app), app.use(prefix, router)).
248
+ // Audit finding 2: following only the inline shape made ContactsQ and NotesQ
249
+ // read as "read-only / n-a". Anything this adapter cannot follow is recorded as
250
+ // UNRESOLVED, and unresolved is reported as unknown, never as absent.
251
+ // ---------------------------------------------------------------------------
252
+
253
+ const ROUTE_RE = /(?<![\w$.])((?:app|router|[A-Za-z_$][\w$]*Router|api))\.(all|use|get|post|put|patch|delete)\s*\(\s*(`[^`]*`|'[^'\n]*'|"[^"\n]*")/g;
254
+ const WRITE_METHODS = new Set(['post', 'put', 'patch', 'delete', 'all', 'use']);
255
+
256
+ function resolveModule(fromFile, spec) {
257
+ const base = path.resolve(path.dirname(fromFile), spec);
258
+ for (const c of [base, `${base}.js`, `${base}.cjs`, `${base}.mjs`, path.join(base, 'index.js')]) {
259
+ try { if (fs.statSync(c).isFile()) return c; } catch { /* next */ }
260
+ }
261
+ return null;
262
+ }
263
+
264
+ function constMap(code) {
265
+ const map = {};
266
+ const re = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(`[^`$]*`|'[^'\n]*'|"[^"\n]*")\s*[;,\n]/g;
267
+ let m;
268
+ while ((m = re.exec(code))) map[m[1]] = m[2].slice(1, -1);
269
+ return map;
270
+ }
271
+
272
+ function resolvePath(lit, consts) {
273
+ const raw = lit.slice(1, -1);
274
+ return raw.replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (all, name) => (name in consts ? consts[name] : all));
275
+ }
276
+
277
+ /** Bindings created by relative requires: ident -> { spec, whole }. */
278
+ function requireBindings(code) {
279
+ const b = {};
280
+ let m;
281
+ const destr = /(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\(\s*['"](\.[^'"]+)['"]\s*\)/g;
282
+ while ((m = destr.exec(code))) {
283
+ for (const part of m[1].split(',')) {
284
+ const [orig, alias] = part.split(':').map((s) => s.trim());
285
+ if (orig) b[alias || orig] = { spec: m[2], whole: false };
286
+ }
287
+ }
288
+ const whole = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"](\.[^'"]+)['"]\s*\)/g;
289
+ while ((m = whole.exec(code))) b[m[1]] = { spec: m[2], whole: true };
290
+ return b;
291
+ }
292
+
293
+ /** Modules this file hands `app` to, and app.use(prefix, router) mounts. */
294
+ function findMounts(code) {
295
+ const bind = requireBindings(code);
296
+ const mounts = [];
297
+ let m;
298
+ const direct = /require\(\s*['"](\.[^'"]+)['"]\s*\)\s*(?:\.\s*[A-Za-z_$][\w$]*\s*)?\(\s*app\b/g;
299
+ while ((m = direct.exec(code))) mounts.push({ spec: m[1], prefix: '', index: m.index });
300
+ const call = /(?<![\w$.])([A-Za-z_$][\w$]*)\s*(?:\.\s*[A-Za-z_$][\w$]*\s*)?\(\s*app\b/g;
301
+ while ((m = call.exec(code))) {
302
+ if (bind[m[1]]) mounts.push({ spec: bind[m[1]].spec, prefix: '', index: m.index });
303
+ }
304
+ const use = /(?<![\w$.])app\.use\s*\(\s*(`[^`]*`|'[^'\n]*'|"[^"\n]*")\s*,\s*(?:require\(\s*['"](\.[^'"]+)['"]\s*\)|([A-Za-z_$][\w$]*)\s*[,)])/g;
305
+ while ((m = use.exec(code))) {
306
+ const spec = m[2] || (bind[m[3]] && bind[m[3]].spec);
307
+ if (spec) mounts.push({ spec, prefix: m[1].slice(1, -1), index: m.index, viaUse: true });
308
+ }
309
+ return mounts;
310
+ }
311
+
312
+ /**
313
+ * Collect every route reachable from the collector entry file.
314
+ * Returns { routes, unresolved } where unresolved lists opaque mounts
315
+ * (an app.use(prefix, <something we cannot follow>) or a missing module).
316
+ */
317
+ function collectRoutes(root, serverFile) {
318
+ const routes = [];
319
+ const unresolved = [];
320
+ const seen = new Set();
321
+ const fnCache = new Map();
322
+ const closureFor = (file) => {
323
+ if (fnCache.has(file)) return fnCache.get(file);
324
+ // The module the route was registered from, plus the modules it directly
325
+ // requires: the emitter/gate a handler delegates to usually lives next
326
+ // door. Kept deliberately narrow. Own-file names always win; a name that a
327
+ // neighbour defines in two places, or a name shorter than 4 characters, is
328
+ // ignored, because a union over the whole require tree lets a stray
329
+ // `get`/`send` helper in an unrelated file vouch for every handler that
330
+ // happens to mention the word (over-approximation reads as a false green).
331
+ const fns = new Map();
332
+ const ownCode = readCode(file);
333
+ if (ownCode !== null) for (const [n, b] of extractFunctions(ownCode)) fns.set(n, b);
334
+ const near = new Map();
335
+ const dup = new Set();
336
+ const nearAll = new Map();
337
+ if (ownCode !== null && file !== serverFile) {
338
+ const re = /require\(\s*['"](\.[^'"]+)['"]\s*\)/g;
339
+ let m;
340
+ const done = new Set();
341
+ while ((m = re.exec(ownCode))) {
342
+ const t = resolveModule(file, m[1]);
343
+ if (!t || done.has(t)) continue;
344
+ done.add(t);
345
+ const code = readCode(t);
346
+ if (code === null) continue;
347
+ for (const [n, b] of extractFunctions(code)) {
348
+ if (fns.has(n) || n.length < 4) continue;
349
+ if (near.has(n)) dup.add(n); else near.set(n, b);
350
+ nearAll.set(n, `${nearAll.get(n) || ''}\n${b}`);
351
+ }
352
+ }
353
+ }
354
+ // Optimistic view: ambiguous names count with every definition merged. A
355
+ // route that only passes in this view is reported UNKNOWN, never green.
356
+ const optimistic = new Map(fns);
357
+ for (const [n, b] of near) if (!dup.has(n)) fns.set(n, b);
358
+ for (const [n, b] of nearAll) optimistic.set(n, fns.has(n) ? fns.get(n) : b);
359
+ for (const [n, b] of fns) if (!optimistic.has(n)) optimistic.set(n, b);
360
+ fns.optimistic = optimistic;
361
+ fnCache.set(file, fns);
362
+ return fns;
363
+ };
364
+
365
+ const parse = (file, prefix, depth) => {
366
+ const key = `${file}|${prefix}`;
367
+ if (seen.has(key) || depth > 3) return;
368
+ seen.add(key);
369
+ const code = readCode(file);
370
+ if (code === null) { unresolved.push({ file: path.relative(root, file), reason: 'unreadable module' }); return; }
371
+ const rel = path.relative(root, file);
372
+ const consts = constMap(code);
373
+ const bind = requireBindings(code);
374
+ ROUTE_RE.lastIndex = 0;
375
+ let m;
376
+ while ((m = ROUTE_RE.exec(code))) {
377
+ const method = m[2];
378
+ const openIdx = code.indexOf('(', m.index + m[1].length);
379
+ const end = matchClose(code, openIdx);
380
+ const handler = end > 0 ? code.slice(openIdx, end) : code.slice(openIdx, openIdx + 4000);
381
+ let p = resolvePath(m[3], consts);
382
+ if (prefix && !p.startsWith('/api/')) p = prefix + p;
383
+ const route = { method, path: p, file: rel, line: lineOf(code, m.index), handler, owner: file, opaque: false };
384
+ if (method === 'use') {
385
+ const inline = /=>|function\s*[\w$]*\s*\(/.test(handler);
386
+ if (!inline) {
387
+ // app.use(prefix, <ident|call>): follow it if it is a relative require, else it is opaque.
388
+ const arg = /,\s*(?:require\(\s*['"](\.[^'"]+)['"]\s*\)|([A-Za-z_$][\w$]*)\s*[,)])/.exec(handler);
389
+ const spec = arg && (arg[1] || (bind[arg[2]] && bind[arg[2]].spec));
390
+ const target = spec && resolveModule(file, spec);
391
+ if (target) { parse(target, p, depth + 1); continue; }
392
+ route.opaque = true;
393
+ }
394
+ }
395
+ routes.push(route);
396
+ }
397
+ for (const mt of findMounts(code)) {
398
+ if (mt.viaUse) continue; // handled above through ROUTE_RE (keeps one code path per mount)
399
+ const target = resolveModule(file, mt.spec);
400
+ if (target) parse(target, mt.prefix, depth + 1);
401
+ else unresolved.push({ file: rel, reason: `cannot resolve ${mt.spec}` });
402
+ }
403
+ };
404
+ parse(serverFile, '', 0);
405
+ return { routes, unresolved, closureFor };
406
+ }
407
+
408
+ // Prefix match, as before: quoxbuild serves /api/v1/build-receipts and
409
+ // /api/v1/build-status under the alias 'build', and a sibling route such as
410
+ // /api/v1/qlarity-allowlist is judged too (every route under the alias must
411
+ // enforce; an over-wide match can only make the gate stricter, never greener).
412
+ const underAlias = (routePath, alias) => routePath.startsWith(`/api/v1/${alias}`);
413
+
414
+ // What counts as the thing itself, in CODE (comments are already gone).
415
+ // An emitter: the in-process envelope writers, or an injected audit/emit call.
416
+ const EMITTER = /\b(?:emitEnvelope|storeAuditEnvelope|storeEnvelope|emitProxyAEE|createEmitter)\b|\.mutation\b|(?<![\w$.])(?:emit|audit)\s*\(|\.audit\s*\(/;
417
+ // Licence enforcement. A refusal is judged by contract: 402 or 403 naming the
418
+ // licence both count, as does a validator call.
419
+ const ENFORCE = /40[23][^\n]{0,160}licen[sc]|licen[sc][^\n]{0,160}40[23]|not licensed|licenseValidator|requireLicen|assertLicen|isPluginLicensedForOrg|(?<![\w$.])isPluginLicensed\s*\(|(?<![\w$.])require(?:Plugin|Bundle)Entitlement\s*\(/i;
420
+
421
+ /**
422
+ * Each check returns { status: 'green'|'red'|'amber', detail }.
423
+ * `point` ties it to DEVBUNDLE_DOD.md's chain table; `defect` to §defects.
424
+ */
425
+ function buildChecks(name, { root, service, websiteRoot, skillsRoot }) {
426
+ const DASH = root;
427
+ const SIBLINGS = path.dirname(root);
428
+ // The plugin id and its backend service name may legitimately differ:
429
+ // quox-nucleus (the store card) is wired as repobrain (/api/v1/repobrain,
430
+ // quoxpert-repobrain, ~/quox-repobrain). Service-side checks follow the
431
+ // service name; store-side checks follow the plugin id. Without the alias,
432
+ // doctor reported the estate's most correctly-governed plugin as ungoverned.
433
+ // Aliases, not an alias: quox-nucleus is wired as route 'nucleus' and repo
434
+ // 'quox-repobrain'. Three names for one product is itself a finding the DoD
435
+ // records; doctor accepts the reality rather than failing on it.
436
+ // Alias sources, widest first: the --service flag, then the manifest's own
437
+ // integration.collectorAlias, then the slug.
438
+ //
439
+ // The flag alone was "declared debt": a plugin whose collector routes sit
440
+ // under a different prefix than its slug read UNGOVERNED unless whoever ran
441
+ // the command happened to remember the flag. quoxslots serves /api/v1/slots,
442
+ // quoxbuild serves /api/v1/build, qloop serves /api/v1/objectives — all
443
+ // three reported "no proxy route — ungoverned" on 2026-08-19 against
444
+ // collector routes that plainly exist. A gate that is only correct when
445
+ // invoked with the right flag is not a gate, because the CI run and the
446
+ // half-remembered manual run disagree.
447
+ //
448
+ // Declaring it in the manifest makes `quox plugin doctor <slug>` correct on
449
+ // its own. The alias is still DEBT — it means slug and route prefix have
450
+ // drifted apart — it is just now visible debt rather than a wrong answer.
451
+ const manifestPath = path.join(DASH, 'src/config/plugins', `${name}.json`);
452
+ const manifest = (() => {
453
+ const raw = read(manifestPath);
454
+ if (!raw) return null;
455
+ try { return JSON.parse(raw); } catch { return undefined; } // undefined = unparseable
456
+ })();
457
+ const manifestAlias = (manifest && manifest.integration && manifest.integration.collectorAlias) || null;
458
+ const svcs = [...new Set([
459
+ ...(service ? service.split(',').map((x) => x.trim()) : []),
460
+ ...(manifestAlias ? [manifestAlias] : []),
461
+ name,
462
+ ])];
463
+ const firstHit = (fn) => { for (const a of svcs) { const r = fn(a); if (r) return r; } return null; };
464
+
465
+ // Code files are read with comments blanked: a comment is never evidence.
466
+ const registry = readCode(path.join(DASH, 'src/config/plugins/index.js')) || '';
467
+ const routes = readCode(path.join(DASH, 'src/config/routeRegistry.js')) || '';
468
+ const nav = readCode(path.join(DASH, 'src/config/navRegistry.js')) || '';
469
+ const serverFile = path.join(DASH, 'services/collector/server.js');
470
+ const collector = readCode(serverFile) || '';
471
+ const entitlements = readCode(path.join(DASH, 'services/auth/lib/entitlements.js')) || '';
472
+ const compose = read(path.join(DASH, 'docker-compose.yml')) || '';
473
+ const websiteNavPath = websiteRoot ? path.join(websiteRoot, 'src/config/navigation.js') : null;
474
+ const websiteNav = (websiteNavPath && readCode(websiteNavPath)) || '';
475
+ const rel = (p) => path.relative(DASH, p);
476
+
477
+ // Route table: inline routes plus everything reachable through mounted modules.
478
+ const table = collectRoutes(DASH, serverFile);
479
+ const aliasRoutes = table.routes.filter((r) => svcs.some((a) => underAlias(r.path, a)));
480
+ const resolvedRoutes = aliasRoutes.filter((r) => !r.opaque);
481
+ const opaqueRoutes = aliasRoutes.filter((r) => r.opaque);
482
+ const aliasMentioned = svcs.some((a) => collector.includes(`/api/v1/${a}`));
483
+ const unresolvedForAlias = table.unresolved.filter((u) => svcs.some((a) => u.file.includes(a)));
484
+ const routeCoverageUnknown = opaqueRoutes.length > 0 || unresolvedForAlias.length > 0;
485
+ const routeEvidence = (list) => [...new Map(list.map((r) => [`${r.file}:${r.line}`, { file: r.file, line: r.line }])).values()].slice(0, 5);
486
+ // core-free: the catalogue declares there is no paid gate to enforce.
487
+ const coreFree = Boolean(manifest && (String(manifest.tier || '').toLowerCase() === 'core-free' || manifest.coreFree === true));
488
+
489
+ // The service repo, when the plugin is a dev-bundle product (e.g. ~/quoxplan).
490
+ // Heuristic shared with the estate: repo named after the plugin.
491
+ const repoDir = svcs.flatMap((a) => [path.join(SIBLINGS, a), path.join(SIBLINGS, `quox-${a}`)])
492
+ .find((d) => fs.existsSync(d) && d !== DASH) || path.join(SIBLINGS, svcs[0]);
493
+ const hasServiceRepo = fs.existsSync(repoDir);
494
+
495
+ const checks = [];
496
+ const add = (point, label, fn) => {
497
+ const id = CHECK_IDS[point];
498
+ try { checks.push({ point, id, label, evidence: [], ...fn() }); }
499
+ catch (err) { checks.push({ point, id, label, status: 'amber', detail: `check error: ${err.message}`, evidence: [] }); }
500
+ };
501
+
502
+ add('1', 'manifest exists and parses', () => {
503
+ if (manifest === null) return { status: 'red', detail: `missing ${rel(manifestPath)}` };
504
+ if (manifest === undefined) return { status: 'red', detail: 'manifest is not valid JSON' };
505
+ return { status: 'green', detail: rel(manifestPath), evidence: [{ file: rel(manifestPath), line: 1 }] };
506
+ });
507
+
508
+ add('2', 'plugin registry: import and PLUGINS map entry', () => {
509
+ // Read the BINDING the import actually creates, rather than guessing it
510
+ // from the slug. `dev-servers.json` binds `devServers`, so any rule that
511
+ // derives an identifier by stripping punctuation gets `devservers` and
512
+ // misses.
513
+ const importMatch = registry.match(new RegExp(`import\\s+([A-Za-z0-9_$]+)\\s+from\\s+['"]\\./${name}\\.json['"]`));
514
+ const imported = Boolean(importMatch);
515
+ const binding = importMatch ? importMatch[1] : null;
516
+ // The map entry may be `name: value`, `'name': value`, or ES6 SHORTHAND
517
+ // (`binding,` / `binding}`). Version one required a colon, so every
518
+ // shorthand entry read "imported but not in PLUGINS map". On 2026-08-19
519
+ // that put qloop, quoxslots, quoxbuild and gripe red against a registry
520
+ // where all four are imported at lines 68-71 and mapped at 130-133 —
521
+ // correct code, failing gate. Shorthand is the normal style in this file,
522
+ // so the checker was wrong about the majority case, not an edge case.
523
+ const mapped = new RegExp(`['"]?${name}['"]?\\s*:`).test(registry)
524
+ || (binding !== null && new RegExp(`(^|[{,\\s])${binding}\\s*[,}]`, 'm').test(registry));
525
+ if (imported && mapped) return { status: 'green', detail: 'src/config/plugins/index.js', evidence: [{ file: 'src/config/plugins/index.js', line: lineOf(registry, importMatch.index) }] };
526
+ if (!imported && !mapped) return { status: 'red', detail: 'absent from src/config/plugins/index.js' };
527
+ return { status: 'red', detail: imported ? 'imported but not in PLUGINS map' : 'in map but not imported' };
528
+ });
529
+
530
+ // Renamed from "route registered with matching pluginId". The old wording,
531
+ // sitting in a licensing checklist, read as proof the route was licence
532
+ // gated. It is not: core routes are guarded by minRole/auditorAllowed only
533
+ // (src/App.jsx), licence filtering happens in the SIDEBAR (AppShell), and
534
+ // typing the URL still reaches the view. pluginId on a core route is
535
+ // metadata. The real refusal is server-side and is point 5.2 below.
536
+ //
537
+ // Route-level enforcement is NOT a quick win and must not be bolted on:
538
+ // isPluginLicensed fails closed while entitlements are still loading, so
539
+ // gating render on it would flash — or strand — a Locked page for a paying
540
+ // org that deep-links to its own plugin. That needs an entitlements-loaded
541
+ // signal first. Filed rather than faked.
542
+ add('3', 'dashboard route declared (metadata, not a gate — see 5.2)', () => {
543
+ const routeHit = new RegExp(`pluginId:\\s*['"]${name}['"]`).exec(routes);
544
+ if (routeHit) {
545
+ return { status: 'green', detail: 'src/config/routeRegistry.js', evidence: [{ file: 'src/config/routeRegistry.js', line: lineOf(routes, routeHit.index) }] };
546
+ }
547
+ // A core-free plugin's route carries no pluginId (there is nothing to
548
+ // gate), so a route registered at the plugin's own path counts too.
549
+ const pathHit = new RegExp(`path:\\s*['"]/${name}['"]`).exec(routes);
550
+ if (pathHit) {
551
+ return { status: 'green', detail: `src/config/routeRegistry.js (path /${name})`, evidence: [{ file: 'src/config/routeRegistry.js', line: lineOf(routes, pathHit.index) }] };
552
+ }
553
+ // LICENSE-LAUNCH LL-7 (2026-08-29/30) moved first-party dev-bundle
554
+ // plugins' routes OUT of this static array and into their signed plugin
555
+ // bundle entry (plugins-src/<name>/index.jsx), which registers routes at
556
+ // runtime via window.__quoxPluginRegister(PLUGIN_ID, { routes: {...} })
557
+ // rather than a `pluginId:` literal here. QuoxPlan and dev-servers still
558
+ // own a real route each — the static grep above just went blind to it
559
+ // the day the strip landed. Same evidence discipline as every other
560
+ // check: grep the file that actually owns the wiring now.
561
+ const bundleEntry = readCode(path.join(DASH, 'plugins-src', name, 'index.jsx'));
562
+ if (bundleEntry
563
+ && new RegExp(`PLUGIN_ID\\s*=\\s*['"]${name}['"]`).test(bundleEntry)
564
+ && /routes:\s*\{[^}]*['"][^'"]+['"]\s*:/.test(bundleEntry)) {
565
+ return { status: 'green', detail: `plugins-src/${name}/index.jsx (signed bundle route)`, evidence: [{ file: `plugins-src/${name}/index.jsx`, line: 1 }] };
566
+ }
567
+ // A plugin with no route of its own is not automatically a defect: it may
568
+ // be CLI-only, a tab on another surface, or an execution mode of a core
569
+ // route. But the exemption must be DECLARED in the manifest, never
570
+ // inferred from absence, or "no route" and "forgot the route" look
571
+ // identical to this checker.
572
+ // Only the three real exemptions (the same set point 4 honours). Any
573
+ // other value, "route" or "own" included, CLAIMS a route, so its absence
574
+ // is a defect: a declaration must never excuse the thing it promises.
575
+ const surface = (manifest && manifest.dashboardSurface) || null;
576
+ if (surface === 'cli' || surface === 'embedded' || surface === 'shared') {
577
+ const why = (manifest && manifest.dashboardSurfaceNote) || 'no reason declared';
578
+ return { status: 'na', detail: `declared dashboardSurface: ${surface} — ${why}` };
579
+ }
580
+ if (surface) return { status: 'red', detail: `manifest declares dashboardSurface '${surface}', which claims a route, but no routeRegistry entry carries pluginId '${name}' or path '/${name}'` };
581
+ return { status: 'red', detail: `no routeRegistry entry carries pluginId '${name}', and no dashboardSurface declared in the manifest` };
582
+ });
583
+
584
+ add('4', 'nav entry present', () => {
585
+ const navHit = new RegExp(`['"/]${name}['"]`).exec(nav);
586
+ if (navHit) return { status: 'green', detail: 'src/config/navRegistry.js', evidence: [{ file: 'src/config/navRegistry.js', line: lineOf(nav, navHit.index) }] };
587
+ // Same declared-exemption rule as point 3. A CLI-only plugin has nothing
588
+ // to put in a sidebar, and an embedded one is reached from its parent
589
+ // surface. Absence still has to be DECLARED — this check exists because
590
+ // /dev-suite shipped with no sidebar entry at all and nobody noticed for
591
+ // days, since everyone tested by navigating straight to the URL.
592
+ const surface = (manifest && manifest.dashboardSurface) || null;
593
+ if (surface === 'cli' || surface === 'embedded' || surface === 'shared') {
594
+ const why = (manifest && manifest.dashboardSurfaceNote) || 'no reason declared';
595
+ return { status: 'na', detail: `declared dashboardSurface: ${surface} — ${why}` };
596
+ }
597
+ return { status: 'red', detail: 'not in navRegistry; reachable only by URL' };
598
+ });
599
+
600
+ add('6', 'dashboard API client targets the governed proxy', () => {
601
+ const clientDir = path.join(DASH, 'src/components', name, 'lib');
602
+ if (!fs.existsSync(clientDir)) return { status: 'amber', detail: `no ${path.relative(DASH, clientDir)} (not a dev-bundle view, or different layout)` };
603
+ const files = fs.readdirSync(clientDir).filter((f) => f.endsWith('.js'));
604
+ // Strip comments before matching. This check flagged Qlarity's client for
605
+ // "mixing the proxy with a direct port" because a COMMENT explained why
606
+ // talking to :3109 directly would be ungoverned. Fifth occurrence of the
607
+ // same class in this stream: a regex over prose cannot tell an assertion
608
+ // from its negation, and a well-documented file is the likeliest to name
609
+ // the thing it refuses to do. Read code, never commentary.
610
+ const bodies = files
611
+ .map((f) => read(path.join(clientDir, f)) || '')
612
+ .join('\n')
613
+ .replace(/\/\*[\s\S]*?\*\//g, '')
614
+ .split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
615
+ const viaProxy = bodies.includes(`/api/v1/${name}`);
616
+ const directPort = /:3\d{3}|:5\d{3}|:9\d{3}/.test(bodies);
617
+ if (viaProxy && !directPort) return { status: 'green', detail: `client uses /api/v1/${name}` };
618
+ if (viaProxy && directPort) return { status: 'red', detail: 'client mixes the proxy with a direct service port' };
619
+ return { status: 'red', detail: 'client does not use the governed /api/v1 proxy' };
620
+ });
621
+
622
+ // Which alias actually owns routes (resolved through modules, not by substring).
623
+ const proxyAlias = firstHit((a) => (resolvedRoutes.some((r) => underAlias(r.path, a)) ? a : null));
624
+ add('7', 'collector proxy route exists', () => {
625
+ if (proxyAlias) {
626
+ const mine = resolvedRoutes.filter((r) => underAlias(r.path, proxyAlias));
627
+ const via = [...new Set(mine.map((r) => r.file))].join(', ');
628
+ return { status: 'green', detail: `${mine.length} route(s) at /api/v1/${proxyAlias} (${via})`, evidence: routeEvidence(mine) };
629
+ }
630
+ // The alias is referenced but no registration could be followed: the
631
+ // doctor does not know, and "does not know" is never "absent" or "fine".
632
+ if (routeCoverageUnknown || aliasMentioned) {
633
+ return {
634
+ status: 'amber',
635
+ detail: `/api/v1/{${svcs.join('|')}} is referenced by the collector but no route registration could be resolved (unsupported mount pattern?): ${[...opaqueRoutes.map((r) => `${r.file}:${r.line}`), ...unresolvedForAlias.map((u) => u.file)].join(', ') || 'mention only'}`,
636
+ evidence: routeEvidence(opaqueRoutes),
637
+ };
638
+ }
639
+ // Not every member serves its data through the collector's /api/v1 proxy,
640
+ // and two of them differ ON PURPOSE. qloop's objectives are served by the
641
+ // TASKS service at /api/objectives (authenticated + rate limited) because
642
+ // QLoop is an execution mode of the core objectives engine, not a
643
+ // standalone plugin backend. gripe has no HTTP data plane at all: it files
644
+ // through the GRIPE agent and the github_create_issue tool.
645
+ //
646
+ // Those are architectural facts, not missing routes, but they must be
647
+ // DECLARED in the manifest with the location, never inferred from absence,
648
+ // or "serves elsewhere on purpose" and "forgot the proxy" look identical
649
+ // here.
650
+ const plane = manifest && manifest.dataPlane;
651
+ if (plane && plane !== 'collector') {
652
+ const why = (manifest && manifest.dataPlaneNote) || 'no location declared';
653
+ return { status: 'na', detail: `declared dataPlane: ${plane} — ${why}` };
654
+ }
655
+ return { status: 'red', detail: `no /api/v1/{${svcs.join('|')}} route in the collector, and no dataPlane declared — ungoverned` };
656
+ });
657
+
658
+ add('8-9', 'writes emit evidence (AEE envelope)', () => {
659
+ // Match the EMITTER, never the vocabulary, and never a comment (comments
660
+ // are blanked before any of this runs). A write route passes when its own
661
+ // handler contains a real emitter OR calls a function that, within the
662
+ // module it was registered from (two require levels deep), does. That is
663
+ // how ContactsQ and NotesQ emit: the route is one line, the evidence call
664
+ // sits in the service function it delegates to.
665
+ //
666
+ // app.all / app.use HANDLE WRITES TOO: a catch-all write proxy must not
667
+ // read as "nothing to emit".
668
+ if (!resolvedRoutes.length) {
669
+ return { status: 'amber', detail: 'no route registrations could be resolved for this plugin, so write evidence cannot be judged (never assumed read-only)' };
670
+ }
671
+ const mine = resolvedRoutes.filter((r) => svcs.some((a) => underAlias(r.path, a)));
672
+ const writes = mine.filter((r) => WRITE_METHODS.has(r.method));
673
+ const exempt = (manifest && Array.isArray(manifest.evidenceExempt)) ? manifest.evidenceExempt : [];
674
+ const silent = [];
675
+ const exempted = [];
676
+ const maybe = [];
677
+ for (const w of writes) {
678
+ const fns = table.closureFor(w.owner);
679
+ const toks = tokens(w.handler);
680
+ const emitting = closureNames(fns, EMITTER);
681
+ if (EMITTER.test(w.handler) || [...toks].some((t) => emitting.has(t))) continue;
682
+ if (exempt.includes(w.path)) { exempted.push(w); continue; }
683
+ const loose = closureNames(fns.optimistic, EMITTER);
684
+ if ([...toks].some((t) => loose.has(t))) maybe.push(w);
685
+ else silent.push(w);
686
+ }
687
+ if (silent.length) {
688
+ return {
689
+ status: 'red',
690
+ detail: `${silent.length} of ${writes.length} write route(s) emit NO evidence (${[...new Set(silent.map((s) => s.path))].slice(0, 3).join(', ')}) — silent evidence loss`,
691
+ evidence: routeEvidence(silent),
692
+ };
693
+ }
694
+ if (maybe.length) {
695
+ return {
696
+ status: 'amber',
697
+ detail: `${maybe.length} of ${writes.length} write route(s) delegate through a function name defined in more than one module, so the emitter cannot be pinned (${[...new Set(maybe.map((s) => s.path))].slice(0, 3).join(', ')})`,
698
+ evidence: routeEvidence(maybe),
699
+ };
700
+ }
701
+ if (routeCoverageUnknown) {
702
+ return { status: 'amber', detail: `${writes.length} resolved write route(s) emit, but part of the route table is opaque (${opaqueRoutes.length} unresolved mount(s)); cannot call it complete`, evidence: routeEvidence(writes) };
703
+ }
704
+ if (!writes.length) {
705
+ // Only here is "read-only" a conclusion: routes WERE found, all reads.
706
+ return { status: 'na', detail: `all ${mine.length} resolved route(s) are reads — nothing to emit (read-only surface)`, evidence: routeEvidence(mine) };
707
+ }
708
+ if (exempted.length) {
709
+ const why = (manifest && manifest.evidenceExemptNote) || 'no reason declared';
710
+ return {
711
+ status: 'amber',
712
+ detail: `${writes.length - exempted.length}/${writes.length} write route(s) emit; ${[...new Set(exempted.map((e) => e.path))].join(', ')} declared exempt — ${why}`,
713
+ evidence: routeEvidence(writes),
714
+ };
715
+ }
716
+ return { status: 'green', detail: `all ${writes.length} write route(s) emit evidence`, evidence: routeEvidence(writes) };
717
+ });
718
+
719
+ add('11', 'entitlement: GOVERNED_PLUGIN_IDS contains it', () => {
720
+ // A core-free plugin has no paid gate by contract; "ungoverned" is the
721
+ // intended state, not a finding (audit finding 2: ContactsQ read red here).
722
+ if (coreFree) return { status: 'na', detail: 'core-free: manifest tier declares no paid gate, so no entitlement is expected' };
723
+ // Bound the search to the ACTUAL array literal, never a fixed byte window
724
+ // (version one sliced 2000 chars and reported five of six dev-suite
725
+ // plugins red while they sat plainly in a 6132-char list).
726
+ const start = entitlements.indexOf('GOVERNED_PLUGIN_IDS');
727
+ if (start === -1) return { status: 'red', detail: 'GOVERNED_PLUGIN_IDS not found in services/auth/lib/entitlements.js' };
728
+ const rest = entitlements.slice(start);
729
+ const open = rest.indexOf('[');
730
+ const close = rest.indexOf(']', open);
731
+ if (open === -1 || close === -1) return { status: 'amber', detail: 'could not bound the GOVERNED_PLUGIN_IDS array literal — check by hand' };
732
+ const block = rest.slice(open, close);
733
+ const hit = new RegExp(`['"]${name}['"]`).exec(block);
734
+ if (hit) return { status: 'green', detail: 'services/auth/lib/entitlements.js', evidence: [{ file: 'services/auth/lib/entitlements.js', line: lineOf(entitlements, start + open + hit.index) }] };
735
+ return { status: 'red', detail: 'not governed — installable free forever (defect 5.3 risk on retrofit)' };
736
+ });
737
+
738
+ add('5.2', 'licence gate on the collector proxy', () => {
739
+ if (coreFree) return { status: 'na', detail: 'core-free: manifest tier declares no paid gate, so no licence refusal is expected' };
740
+ if (!resolvedRoutes.length) {
741
+ const plane = manifest && manifest.dataPlane;
742
+ if (plane && plane !== 'collector') return { status: 'na', detail: `declared dataPlane: ${plane}, no collector proxy to gate (see point 7)` };
743
+ return { status: 'amber', detail: 'no collector routes resolved for this plugin, so the licence gate cannot be judged (see point 7)' };
744
+ }
745
+ // Match ENFORCEMENT in code, never vocabulary (the first version of this
746
+ // check matched the comment SAYING "no license gate yet" and reported
747
+ // QuoxPlan green). Each route is judged on its own handler plus the
748
+ // functions it delegates to inside its module; a refusal is judged by
749
+ // contract, so a 402 or a 403 naming the licence both count.
750
+ //
751
+ // Green requires EVERY route under the alias to enforce. "One of them is
752
+ // gated" is not an answer to "can an unlicensed org use this".
753
+ const svcRoutes = resolvedRoutes.filter((r) => svcs.some((a) => underAlias(r.path, a)));
754
+ const ungated = [];
755
+ const maybeGated = [];
756
+ for (const r of svcRoutes) {
757
+ const fns = table.closureFor(r.owner);
758
+ const toks = tokens(r.handler);
759
+ const enforcing = closureNames(fns, ENFORCE);
760
+ if (ENFORCE.test(r.handler) || [...toks].some((t) => enforcing.has(t))) continue;
761
+ const loose = closureNames(fns.optimistic, ENFORCE);
762
+ if ([...toks].some((t) => loose.has(t))) maybeGated.push(r);
763
+ else ungated.push(r);
764
+ }
765
+ if (ungated.length) {
766
+ const paths = [...new Set(ungated.map((r) => r.path))];
767
+ return {
768
+ status: 'red',
769
+ detail: `${ungated.length} of ${svcRoutes.length} route(s) have NO licence enforcement (${paths.slice(0, 4).join(', ')}${paths.length > 4 ? ', …' : ''}) — a paid plugin shipping free (defect 5.2)`,
770
+ evidence: routeEvidence(ungated),
771
+ };
772
+ }
773
+ if (maybeGated.length) {
774
+ return { status: 'amber', detail: `${maybeGated.length} of ${svcRoutes.length} route(s) reach a licence check only through a function name defined in more than one module; cannot pin it`, evidence: routeEvidence(maybeGated) };
775
+ }
776
+ if (routeCoverageUnknown) {
777
+ return { status: 'amber', detail: `${svcRoutes.length} resolved route(s) enforce a licence, but ${opaqueRoutes.length} mount(s) are opaque; cannot call the surface gated`, evidence: routeEvidence(svcRoutes) };
778
+ }
779
+ return { status: 'green', detail: `all ${svcRoutes.length} route(s) enforce a licence`, evidence: routeEvidence(svcRoutes) };
780
+ });
781
+
782
+ add('12', 'compose service + service URL on the collector', () => {
783
+ if (!hasServiceRepo) return { status: 'na', detail: 'no service repo — dashboard-only plugin, compose not applicable' };
784
+ const svcInCompose = svcs.some((a) => new RegExp(`^\\s{2}${a.replace(/^quox-?/, '(quox-?)?')}:`, 'm').test(compose));
785
+ const url = svcs.some((a) => new RegExp(`${a.replace(/-/g, '_').toUpperCase()}_URL|${a.replace(/^quox-?/, '').replace(/-/g, '_').toUpperCase()}_URL`).test(compose));
786
+ if (svcInCompose && url) return { status: 'green', detail: 'docker-compose.yml' };
787
+ if (svcInCompose) return { status: 'red', detail: 'service present but no *_URL env on the collector — works on host, dies in prod' };
788
+ return { status: 'red', detail: 'service not in docker-compose.yml' };
789
+ });
790
+
791
+ add('13a', 'quoxpert skill directory', () => {
792
+ if (!skillsRoot || !fs.existsSync(skillsRoot)) return { status: 'amber', detail: `skills root not available (${skillsRoot || 'unset'}); pass --skills-root` };
793
+ const short = name.replace(/^quox-?/, '');
794
+ const candidates = [...new Set([`quoxpert-${name}`, `quoxpert-${short}`, ...svcs.map((a) => `quoxpert-${a}`)])];
795
+ for (const c of candidates) {
796
+ if (fs.existsSync(path.join(skillsRoot, c))) return { status: 'green', detail: `skills/${c}`, evidence: [{ file: `skills/${c}`, line: 1 }] };
797
+ }
798
+ return { status: 'red', detail: `no ${candidates.join(' or ')} — a Claude cannot drive it headless` };
799
+ });
800
+
801
+ add('5.5', 'manifest links agree with the website nav', () => {
802
+ if (!manifest) return { status: 'amber', detail: 'no manifest to check' };
803
+ const links = [manifest.docsUrl, manifest.infoUrl].filter(Boolean);
804
+ if (!links.length) return { status: 'red', detail: 'manifest declares no docsUrl/infoUrl at all' };
805
+ if (!websiteNavPath || !websiteNav) return { status: 'amber', detail: 'website nav not readable (pass --website-root)' };
806
+ const disagreements = [];
807
+ for (const link of links) {
808
+ const p = link.replace(/^https?:\/\/[^/]+/, '');
809
+ if (!websiteNav.includes(p)) disagreements.push(p);
810
+ }
811
+ if (disagreements.length) return { status: 'red', detail: `not in website nav: ${disagreements.join(', ')} (defect 5.5 — link check, not a judgement call)` };
812
+ return { status: 'green', detail: 'manifest links appear in the website nav' };
813
+ });
814
+
815
+ add('5.1', 'standalone CLI defaults to the governed path', () => {
816
+ if (!hasServiceRepo) return { status: 'na', detail: 'no service repo, no standalone CLI to check' };
817
+ const cliDir = path.join(repoDir, 'cli');
818
+ if (!fs.existsSync(cliDir)) return { status: 'amber', detail: 'service repo has no cli/' };
819
+ // Find the client and ask which base URL wins by DEFAULT.
820
+ let verdict = null;
821
+ const walk = (dir) => {
822
+ for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
823
+ if (f.isDirectory() && f.name !== 'node_modules' && f.name !== 'test') walk(path.join(dir, f.name));
824
+ else if (f.name.endsWith('.js')) {
825
+ const body = read(path.join(dir, f.name)) || '';
826
+ if (/localhost:\d{4}/.test(body) && /BASE|baseUrl|directBase/i.test(body)) {
827
+ // Direct default is fine ONLY if mutations are routed via a
828
+ // collector base; look for the collector path in the same file.
829
+ const governedWrites = /collector/i.test(body) && /api\/v1/.test(body);
830
+ // A governed path that only engages when an env key is exported is
831
+ // not the default; it is opt-in governance, which is defect 5.1 by
832
+ // definition. QuoxPlan's client does exactly this
833
+ // (QUOX_INTERNAL_SERVICE_KEY guard at client.js:44) and every
834
+ // unconfigured session writes unwitnessed with a warning nobody
835
+ // reads twice.
836
+ const keyGuarded = /SERVICE_KEY/.test(body);
837
+ verdict = governedWrites && !keyGuarded
838
+ ? { status: 'green', detail: `${path.relative(repoDir, path.join(dir, f.name))}: writes route via the collector by default` }
839
+ : { status: 'red', detail: governedWrites
840
+ ? `${path.relative(repoDir, path.join(dir, f.name))}: governed path exists but is gated on an env key — the DEFAULT is direct and unwitnessed (defect 5.1)`
841
+ : `${path.relative(repoDir, path.join(dir, f.name))}: defaults to the direct service URL with no governed write path (defect 5.1 — unwitnessed writes)` };
842
+ }
843
+ }
844
+ }
845
+ };
846
+ walk(cliDir);
847
+ return verdict || { status: 'amber', detail: 'no direct-URL default found; confirm by reading the client' };
848
+ });
849
+
850
+ add('AC', 'agent chain wired end to end', () => {
851
+ // The step ContactsQ missed (SDK-TEAM W0-P3 baseline, the #1 defect class;
852
+ // repair f8b6e0057 touched 12 files). A plugin with an agent is not wired by
853
+ // the registry JSON alone: the agent has to exist in five registries, hold
854
+ // a tool grant, be reachable by the orchestrator, and have its chat sidebar
855
+ // mounted in the plugin view. Each link is a code-position grep (comments
856
+ // blanked), and a partial chain is a FAIL naming exactly what is missing.
857
+ const short = name.replace(/^quox-?/, '');
858
+ const ids = [...new Set([
859
+ ...(manifest && typeof manifest.agent === 'string' ? [manifest.agent] : []),
860
+ ...(manifest && manifest.agent && manifest.agent.id ? [manifest.agent.id] : []),
861
+ ...(manifest && typeof manifest.agentId === 'string' ? [manifest.agentId] : []),
862
+ name, short,
863
+ ])];
864
+ const declared = Boolean(manifest && (manifest.agent || manifest.agentId));
865
+ const q = (id) => id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
866
+ const keyRe = (id) => new RegExp(`(?:^|[\\s,{])['"]?${q(id)}['"]?\\s*:`, 'm');
867
+ const file = (rel) => path.join(DASH, rel);
868
+
869
+ const entityDir = 'src/config/entities/assistants';
870
+ const agentRegistry = readCode(file('src/config/agentRegistry.js')) || '';
871
+ const entityRegistry = readCode(file('src/services/entityRegistry.js')) || '';
872
+ const agentToolsSrc = readCode(file('services/collector/lib/agentTools.js')) || '';
873
+ const delegator = readCode(file('src/services/agentDelegator.js')) || '';
874
+
875
+ // Block of `const AGENT_TOOLS = { ... }` / `DEFAULT_ASSISTANTS = { ... }`.
876
+ const blockOf = (code, token) => {
877
+ const at = new RegExp(`(?:const|let|var)\\s+${token}\\s*=\\s*\\{`).exec(code);
878
+ if (!at) return null;
879
+ const open = at.index + at[0].length - 1;
880
+ const end = matchClose(code, open);
881
+ return end < 0 ? null : { text: code.slice(open, end), offset: open };
882
+ };
883
+ const toolsBlock = blockOf(agentToolsSrc, 'AGENT_TOOLS');
884
+ const defaultsBlock = blockOf(entityRegistry, 'DEFAULT_ASSISTANTS');
885
+ const compDirs = [...new Set([name, short])].map((d) => path.join(DASH, 'src/components', d)).filter((d) => fs.existsSync(d));
886
+ const listJsx = (dir) => {
887
+ const out = [];
888
+ const walk = (d) => {
889
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
890
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== '__tests__') walk(path.join(d, e.name)); }
891
+ else if (/\.(jsx|js|tsx)$/.test(e.name) && !/\.test\./.test(e.name)) out.push(path.join(d, e.name));
892
+ }
893
+ };
894
+ walk(dir);
895
+ return out;
896
+ };
897
+ const sidebarFiles = compDirs.flatMap(listJsx).filter((f) => /ChatSidebar\.(jsx|js|tsx)$/.test(f));
898
+
899
+ // Which of the candidate ids is the agent? The one with the most links wired.
900
+ const probe = (id) => {
901
+ const evidence = [];
902
+ const missing = [];
903
+ const ent = file(`${entityDir}/${id}.json`);
904
+ if (fs.existsSync(ent)) evidence.push({ file: `${entityDir}/${id}.json`, line: 1 }); else missing.push(`entity JSON (${entityDir}/${id}.json)`);
905
+
906
+ const ar = keyRe(id).exec(agentRegistry);
907
+ if (ar) evidence.push({ file: 'src/config/agentRegistry.js', line: lineOf(agentRegistry, ar.index) }); else missing.push('agentRegistry entry');
908
+
909
+ const imp = new RegExp(`import\\s+[A-Za-z0-9_$]+\\s+from\\s+['"][^'"]*assistants/${q(id)}\\.json['"]`).exec(entityRegistry);
910
+ const inDefaults = defaultsBlock && keyRe(id).test(defaultsBlock.text);
911
+ if (imp && inDefaults) evidence.push({ file: 'src/services/entityRegistry.js', line: lineOf(entityRegistry, imp.index) });
912
+ else missing.push(`entityRegistry DEFAULT_ASSISTANTS ${imp ? 'entry' : inDefaults ? 'import' : 'import + entry'}`);
913
+
914
+ const grant = toolsBlock && new RegExp(`(?:^|[\\s,{])['"]?${q(id)}['"]?\\s*:\\s*\\[`, 'm').exec(toolsBlock.text);
915
+ if (grant) evidence.push({ file: 'services/collector/lib/agentTools.js', line: lineOf(agentToolsSrc, toolsBlock.offset + grant.index) });
916
+ else missing.push('AGENT_TOOLS grant (services/collector/lib/agentTools.js)');
917
+
918
+ const dk = new RegExp(`(?:^|[\\s,{])['"]?${q(id)}['"]?\\s*:\\s*\\[`, 'm').exec(delegator);
919
+ if (dk) evidence.push({ file: 'src/services/agentDelegator.js', line: lineOf(delegator, dk.index) }); else missing.push('delegator keywords (src/services/agentDelegator.js)');
920
+
921
+ const cd = new RegExp(`canDelegateTo\\s*:\\s*\\[[^\\]]*['"]${q(id)}['"]`).exec(agentRegistry);
922
+ if (cd) evidence.push({ file: 'src/config/agentRegistry.js', line: lineOf(agentRegistry, cd.index) }); else missing.push('CommanderQ canDelegateTo entry');
923
+
924
+ // A sidebar that exists but is never rendered is not "mounted in the plugin view".
925
+ let mounted = null;
926
+ for (const sf of sidebarFiles) {
927
+ const comp = path.basename(sf).replace(/\.(jsx|js|tsx)$/, '');
928
+ for (const f of compDirs.flatMap(listJsx)) {
929
+ if (f === sf) continue;
930
+ const code = readCode(f) || '';
931
+ const m = new RegExp(`<${q(comp)}\\b`).exec(code);
932
+ if (m) { mounted = { file: path.relative(DASH, f), line: lineOf(code, m.index) }; break; }
933
+ }
934
+ if (mounted) break;
935
+ }
936
+ if (mounted) evidence.push(mounted);
937
+ else missing.push(sidebarFiles.length ? 'ChatSidebar not mounted in the plugin view' : `ChatSidebar component (src/components/${name}/*ChatSidebar.jsx)`);
938
+ return { id, evidence, missing };
939
+ };
940
+
941
+ const probes = ids.map(probe);
942
+ const best = probes.reduce((a, b) => (b.evidence.length > a.evidence.length ? b : a));
943
+ // The ChatSidebar link is plugin-wide (not per id), so it does not count as
944
+ // an "agent exists" signal on its own unless something else is wired too.
945
+ const agentSignals = best.evidence.filter((e) => !/ChatSidebar|\/components\//.test(e.file)).length;
946
+ if (!declared && agentSignals === 0 && !sidebarFiles.length) {
947
+ return { status: 'na', detail: 'no agent declared in the manifest and none found in any registry: this plugin has no agent' };
948
+ }
949
+ if (best.missing.length) {
950
+ return { status: 'red', detail: `agent '${best.id}' chain incomplete, missing: ${best.missing.join('; ')}`, evidence: best.evidence };
951
+ }
952
+ return { status: 'green', detail: `agent '${best.id}': all ${best.evidence.length} chain links present`, evidence: best.evidence };
953
+ });
954
+
955
+ return checks;
956
+ }
957
+
958
+
959
+ const CHECK_IDS = {
960
+ 1: 'doctor.manifest',
961
+ 2: 'doctor.registry_entry',
962
+ 3: 'doctor.dashboard_route',
963
+ 4: 'doctor.nav_entry',
964
+ 6: 'doctor.client_governed_proxy',
965
+ 7: 'doctor.collector_route',
966
+ '8-9': 'doctor.evidence_emission',
967
+ 11: 'doctor.entitlement_governed',
968
+ '5.2': 'doctor.licence_gate',
969
+ 12: 'doctor.compose_service',
970
+ '13a': 'doctor.quoxpert_skill',
971
+ '5.5': 'doctor.website_nav_links',
972
+ '5.1': 'doctor.cli_governed_default',
973
+ 'AC': 'doctor.agent_chain',
974
+ };
975
+
976
+ const STATUS_OUT = { green: 'pass', red: 'fail', amber: 'unknown', na: 'na' };
977
+
978
+ /** Fixed-argument git (execFile, never a shell). ref/tree are null unless root IS a git toplevel. */
979
+ function gitInfo(root) {
980
+ const g = (...a) => {
981
+ try { return execFileSync('git', ['-C', root, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
982
+ };
983
+ const abs = path.resolve(root);
984
+ const top = g('rev-parse', '--show-toplevel');
985
+ let isTop = false;
986
+ try { isTop = Boolean(top) && fs.realpathSync(top) === fs.realpathSync(abs); } catch { /* not a repo */ }
987
+ if (!isTop) return { root: abs, ref: null, tree: null, dirty: true };
988
+ const status = g('status', '--porcelain');
989
+ return { root: abs, ref: g('rev-parse', 'HEAD'), tree: g('rev-parse', 'HEAD^{tree}'), dirty: status === null ? true : status.length > 0 };
990
+ }
991
+
992
+ /** The repo containing cwd when it looks like quox-dashboard, else ~/quox-dashboard. */
993
+ export function resolveRoot(explicit, cwd = process.cwd()) {
994
+ if (explicit) return path.resolve(explicit);
995
+ try {
996
+ const top = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
997
+ if (top && fs.existsSync(path.join(top, 'src/config/plugins/index.js'))) return top;
998
+ } catch { /* not in a repo */ }
999
+ return path.join(HOME, 'quox-dashboard');
1000
+ }
1001
+
1002
+ export function toContract(name, checks, root) {
1003
+ return {
1004
+ contract: CONTRACT,
1005
+ producer: { name: PRODUCER_NAME, version: PRODUCER_VERSION },
1006
+ subject: name,
1007
+ repo: gitInfo(root),
1008
+ generated_at: new Date().toISOString(),
1009
+ checks: checks.map((c) => {
1010
+ const o = {
1011
+ id: c.id,
1012
+ status: STATUS_OUT[c.status],
1013
+ detail: String(c.detail || '').split('\n')[0],
1014
+ evidence: c.evidence || [],
1015
+ point: c.point,
1016
+ label: c.label,
1017
+ };
1018
+ if (c.status === 'na') o.na_reason = String(c.detail || 'not applicable');
1019
+ return o;
1020
+ }),
1021
+ };
1022
+ }
1023
+
1024
+ export function registerPluginDoctor(pluginCmd) {
1025
+ pluginCmd
1026
+ .command('doctor <name>')
1027
+ .option('--root <path>', 'quox-dashboard checkout to check (default: the repo containing cwd, else ~/quox-dashboard)')
1028
+ .option('--website-root <path>', 'quox-website checkout for the nav-link check (default: ~/quox-website; unreadable => unknown)')
1029
+ .option('--skills-root <path>', 'skills directory for the quoxpert-skill check (default: ~/.claude/skills; missing => unknown)')
1030
+ .option('--out <file>', 'also write the quox.external-verdict/v1 JSON verdict file here')
1031
+ .option('--service <names>', 'backend alias(es), comma-separated, when they differ from the plugin id (e.g. quox-nucleus -> nucleus,repobrain)')
1032
+ .description('Check a plugin against the dev-bundle DoD: 13-point chain + the five prohibitions. Machine evidence only; exit 0 green, 1 findings, 2 cannot evaluate.')
1033
+ .action((name, options, command) => {
1034
+ // --json is a program-level option in this CLI (bin/quox.js:76); read it
1035
+ // through the globals rather than declaring a shadow that never fills.
1036
+ // The acceptance test caught this: doctor --json emitted the human
1037
+ // render, which JSON.parse choked on.
1038
+ const wantJson = command.optsWithGlobals?.().json
1039
+ || command.optsWithGlobals?.().output === 'json'
1040
+ || options.json;
1041
+ if (!/^[a-z0-9-]+$/.test(name)) {
1042
+ process.stderr.write('plugin names are lowercase kebab-case\n');
1043
+ process.exitCode = 2;
1044
+ return;
1045
+ }
1046
+ const root = resolveRoot(options.root);
1047
+ const checks = buildChecks(name, {
1048
+ root,
1049
+ service: options.service,
1050
+ websiteRoot: path.resolve(options.websiteRoot || path.join(HOME, 'quox-website')),
1051
+ skillsRoot: path.resolve(options.skillsRoot || path.join(HOME, '.claude/skills')),
1052
+ });
1053
+ const red = checks.filter((c) => c.status === 'red').length;
1054
+ const amber = checks.filter((c) => c.status === 'amber').length;
1055
+ const na = checks.filter((c) => c.status === 'na').length;
1056
+ // AMBER and N/A are different questions and must not share a number.
1057
+ // Amber means "this tool could not determine, and someone must". N/A
1058
+ // means "there is nothing here to determine" — a CLI-only plugin has no
1059
+ // nav entry to find, a dashboard-only plugin has no compose service.
1060
+ //
1061
+ // 2026-08-19: gripe scored "0 red, 8 amber, 5 green" and read as the
1062
+ // worst plugin in the bundle. Every one of those eight was N/A. The
1063
+ // scoreboard was making the simplest, most honest member look broken,
1064
+ // which is the opposite of what a status tool is for, and exactly the
1065
+ // kind of thing that makes someone distrust the whole board.
1066
+ const verdict = red ? 'FINDINGS' : amber ? 'INCOMPLETE' : 'GREEN';
1067
+
1068
+ const contract = (wantJson || options.out) ? toContract(name, checks, root) : null;
1069
+ if (options.out) {
1070
+ fs.mkdirSync(path.dirname(path.resolve(options.out)), { recursive: true });
1071
+ fs.writeFileSync(options.out, `${JSON.stringify(contract, null, 2)}\n`);
1072
+ }
1073
+ if (wantJson) {
1074
+ process.stdout.write(`${JSON.stringify(contract, null, 2)}\n`);
1075
+ } else {
1076
+ const mark = { green: '✓', red: '✗', amber: '?', na: '–' };
1077
+ process.stdout.write(`\u{1F3C1} DONEENGINE: plugin doctor — ${name}: ${verdict}\n\n`);
1078
+ for (const c of checks) {
1079
+ process.stdout.write(` ${mark[c.status]} [${c.point.padEnd(4)}] ${c.label}\n ${c.detail}\n`);
1080
+ }
1081
+ process.stdout.write(`\n${red} red, ${amber} amber, ${na} n/a, ${checks.length - red - amber - na} green. `
1082
+ + 'Amber is not green: it means this tool could not determine, and someone must. '
1083
+ + 'N/A means there is nothing to determine (no nav entry for a CLI-only plugin, '
1084
+ + 'no compose service for a dashboard-only one) and needs no action.\n'
1085
+ + 'Reference: quox-dashboard/docs/DEVBUNDLE_DOD.md\n');
1086
+ }
1087
+ process.exitCode = red ? 1 : amber ? 2 : 0;
1088
+ });
1089
+ }
1090
+
1091
+ export default registerPluginDoctor;