quoxcli 0.2.0 → 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 (327) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/admin.js +26 -0
  5. package/lib/api/endpoints/agent-schedules.js +26 -0
  6. package/lib/api/endpoints/agents.js +7 -0
  7. package/lib/api/endpoints/assistants.js +12 -0
  8. package/lib/api/endpoints/away.js +15 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/contactsq.js +2 -0
  11. package/lib/api/endpoints/conversations.js +14 -0
  12. package/lib/api/endpoints/executions.js +13 -0
  13. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  14. package/lib/api/endpoints/files.js +14 -0
  15. package/lib/api/endpoints/hat.js +30 -0
  16. package/lib/api/endpoints/inbox.js +20 -0
  17. package/lib/api/endpoints/index.js +14 -0
  18. package/lib/api/endpoints/memory.js +19 -0
  19. package/lib/api/endpoints/mind.js +13 -0
  20. package/lib/api/endpoints/mon.js +6 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/plugins.js +18 -0
  23. package/lib/api/endpoints/presence.js +33 -0
  24. package/lib/api/endpoints/quoxplan.js +7 -0
  25. package/lib/api/endpoints/quoxseo.js +6 -0
  26. package/lib/api/endpoints/router.js +17 -0
  27. package/lib/api/endpoints/safety.js +7 -0
  28. package/lib/api/endpoints/skills.js +20 -0
  29. package/lib/api/endpoints/stepup.js +20 -0
  30. package/lib/api/endpoints/teams.js +2 -2
  31. package/lib/api/endpoints/triggers.js +26 -0
  32. package/lib/api/endpoints/v1.js +86 -0
  33. package/lib/api/endpoints/ward.js +7 -0
  34. package/lib/api/endpoints/workflows.js +7 -0
  35. package/lib/api/endpoints/x.js +8 -0
  36. package/lib/commands/actions.js +61 -0
  37. package/lib/commands/admin.js +16 -0
  38. package/lib/commands/agent.js +163 -27
  39. package/lib/commands/agentAttach.js +94 -6
  40. package/lib/commands/approvals.js +73 -1
  41. package/lib/commands/aside.js +24 -0
  42. package/lib/commands/ask.js +144 -0
  43. package/lib/commands/assistant.js +59 -4
  44. package/lib/commands/auth.js +8 -2
  45. package/lib/commands/backup.js +8 -4
  46. package/lib/commands/brain.js +105 -14
  47. package/lib/commands/build.js +19 -5
  48. package/lib/commands/chat.js +57 -2
  49. package/lib/commands/claude.js +34 -8
  50. package/lib/commands/contacts.js +49 -0
  51. package/lib/commands/conversation.js +35 -4
  52. package/lib/commands/dashboard.js +43 -0
  53. package/lib/commands/department.js +28 -7
  54. package/lib/commands/devResident.js +15 -3
  55. package/lib/commands/devSupervisor.js +13 -1
  56. package/lib/commands/discord.js +75 -39
  57. package/lib/commands/done.js +98 -28
  58. package/lib/commands/engagement.js +17 -6
  59. package/lib/commands/fetchAllowlist.js +71 -0
  60. package/lib/commands/file.js +38 -1
  61. package/lib/commands/fleet.js +311 -4
  62. package/lib/commands/governance.js +12 -3
  63. package/lib/commands/gripe.js +44 -13
  64. package/lib/commands/hat.js +178 -9
  65. package/lib/commands/helpers.js +18 -1
  66. package/lib/commands/hyperion.js +58 -0
  67. package/lib/commands/inbox.js +74 -23
  68. package/lib/commands/licence.js +14 -155
  69. package/lib/commands/lucid.js +10 -4
  70. package/lib/commands/memory.js +85 -8
  71. package/lib/commands/mind.js +71 -0
  72. package/lib/commands/mon.js +69 -2
  73. package/lib/commands/monitoring.js +1 -1
  74. package/lib/commands/motion.js +2 -2
  75. package/lib/commands/nucleus.js +48 -17
  76. package/lib/commands/objective.js +11 -0
  77. package/lib/commands/offload.js +89 -0
  78. package/lib/commands/onboard.js +2 -1
  79. package/lib/commands/org.js +11 -1
  80. package/lib/commands/peek.js +28 -0
  81. package/lib/commands/plan.js +38 -0
  82. package/lib/commands/plugin.js +110 -0
  83. package/lib/commands/presence.js +89 -0
  84. package/lib/commands/provider.js +159 -0
  85. package/lib/commands/qlarity.js +19 -3
  86. package/lib/commands/quoxchat.js +13 -4
  87. package/lib/commands/quoxseo.js +38 -0
  88. package/lib/commands/reach.js +2 -1
  89. package/lib/commands/router.js +77 -0
  90. package/lib/commands/rows.js +358 -0
  91. package/lib/commands/run.js +64 -9
  92. package/lib/commands/server.js +292 -0
  93. package/lib/commands/setup.js +33 -7
  94. package/lib/commands/skills.js +48 -5
  95. package/lib/commands/slot.js +260 -7
  96. package/lib/commands/status.js +1 -1
  97. package/lib/commands/team.js +3 -1
  98. package/lib/commands/tips.js +23 -0
  99. package/lib/commands/tool.js +133 -45
  100. package/lib/commands/trace.js +12 -7
  101. package/lib/commands/trigger.js +95 -0
  102. package/lib/commands/update.js +46 -0
  103. package/lib/commands/vault.js +31 -8
  104. package/lib/commands/vm.js +237 -0
  105. package/lib/commands/voice.js +34 -3
  106. package/lib/commands/volt.js +45 -3
  107. package/lib/commands/walkthrough.js +32 -0
  108. package/lib/commands/watch.js +73 -21
  109. package/lib/commands/welcome.js +14 -0
  110. package/lib/commands/workflow.js +189 -32
  111. package/lib/commands/x.js +13 -4
  112. package/lib/core/actingAs.js +2 -2
  113. package/lib/core/activeSummary.js +186 -0
  114. package/lib/core/aside.js +54 -0
  115. package/lib/core/askDecision.js +60 -0
  116. package/lib/core/auth.js +164 -13
  117. package/lib/core/callerSource.js +1 -1
  118. package/lib/core/cliBuild.js +62 -0
  119. package/lib/core/client.js +144 -35
  120. package/lib/core/commandRegistrars.js +121 -0
  121. package/lib/core/config.js +142 -24
  122. package/lib/core/customHats.js +168 -0
  123. package/lib/core/doneEngineFetch.js +8 -0
  124. package/lib/core/errors.js +329 -20
  125. package/lib/core/global-flags.js +105 -5
  126. package/lib/core/hyperion.js +102 -0
  127. package/lib/core/inboxResolution.js +65 -0
  128. package/lib/core/lazyCommands.js +4 -1
  129. package/lib/core/offload.js +504 -0
  130. package/lib/core/org-override.js +1 -1
  131. package/lib/core/orgResolve.js +70 -0
  132. package/lib/core/output.js +4 -4
  133. package/lib/core/peek.js +126 -0
  134. package/lib/core/profileUsers.js +51 -0
  135. package/lib/core/promptCancel.js +18 -0
  136. package/lib/core/repoPath.js +11 -0
  137. package/lib/core/residentSeed.js +3 -1
  138. package/lib/core/rootOptions.js +39 -0
  139. package/lib/core/router.js +168 -0
  140. package/lib/core/selfUpdate.js +188 -0
  141. package/lib/core/sessionsOverview.js +64 -0
  142. package/lib/core/slotKeeper.js +248 -3
  143. package/lib/core/slotWake.js +120 -0
  144. package/lib/core/supervisorEngine.js +71 -2
  145. package/lib/core/version.js +1 -1
  146. package/lib/core/vm.js +361 -0
  147. package/lib/core/vmTui.js +86 -0
  148. package/lib/core/walkthrough.js +35 -0
  149. package/lib/hyperion/deck.js +29 -0
  150. package/lib/lucid/LucidCard.jsx +68 -0
  151. package/lib/lucid/LucidIntro.jsx +45 -58
  152. package/lib/lucid/cards.js +86 -0
  153. package/lib/lucid/markdown.js +81 -0
  154. package/lib/lucid/motion.js +56 -0
  155. package/lib/quoxon/activity.js +14 -2
  156. package/lib/quoxon/aocl.js +117 -0
  157. package/lib/quoxon/api.js +504 -25
  158. package/lib/quoxon/app.js +3000 -230
  159. package/lib/quoxon/approvalFacts.js +94 -0
  160. package/lib/quoxon/approvalsView.js +325 -0
  161. package/lib/quoxon/asideView.js +40 -0
  162. package/lib/quoxon/ask.js +121 -0
  163. package/lib/quoxon/away.js +52 -0
  164. package/lib/quoxon/bang.js +224 -0
  165. package/lib/quoxon/bulkRisk.js +17 -0
  166. package/lib/quoxon/capabilities.js +13 -6
  167. package/lib/quoxon/cells.js +53 -0
  168. package/lib/quoxon/commandShort.js +112 -0
  169. package/lib/quoxon/commands.js +363 -38
  170. package/lib/quoxon/connect.js +210 -23
  171. package/lib/quoxon/connectCard.js +19 -2
  172. package/lib/quoxon/consent.js +86 -0
  173. package/lib/quoxon/copy.js +4 -3
  174. package/lib/quoxon/countdownLine.js +11 -0
  175. package/lib/quoxon/demo.autoplay.js +29 -0
  176. package/lib/quoxon/demo.fixtures.js +1 -1
  177. package/lib/quoxon/demo.js +44 -3
  178. package/lib/quoxon/devObserver.js +20 -0
  179. package/lib/quoxon/dividerAocl.js +191 -0
  180. package/lib/quoxon/dividerRule.js +57 -0
  181. package/lib/quoxon/dragonModes.js +150 -0
  182. package/lib/quoxon/dragonView.js +41 -0
  183. package/lib/quoxon/earlyInput.js +40 -0
  184. package/lib/quoxon/echo.js +15 -0
  185. package/lib/quoxon/egg.js +2 -2
  186. package/lib/quoxon/events.js +244 -25
  187. package/lib/quoxon/feedback.js +50 -0
  188. package/lib/quoxon/gallery.js +21 -7
  189. package/lib/quoxon/handoffOffer.js +78 -0
  190. package/lib/quoxon/hatSwitch.js +37 -0
  191. package/lib/quoxon/headless.js +161 -0
  192. package/lib/quoxon/hostUrls.js +41 -0
  193. package/lib/quoxon/indicator.js +6 -2
  194. package/lib/quoxon/intent.js +12 -1
  195. package/lib/quoxon/keys.js +49 -6
  196. package/lib/quoxon/launcher.js +103 -0
  197. package/lib/quoxon/main.js +92 -160
  198. package/lib/quoxon/markdown.js +45 -21
  199. package/lib/quoxon/markdownSettle.js +19 -0
  200. package/lib/quoxon/matrixDomains.json +46 -0
  201. package/lib/quoxon/mentions.js +90 -0
  202. package/lib/quoxon/mind.js +103 -0
  203. package/lib/quoxon/mode.js +46 -0
  204. package/lib/quoxon/model.js +132 -26
  205. package/lib/quoxon/modelPrefs.js +345 -0
  206. package/lib/quoxon/motion/clock.js +3 -2
  207. package/lib/quoxon/motion/level.js +91 -0
  208. package/lib/quoxon/mouse.js +211 -0
  209. package/lib/quoxon/noai.js +57 -0
  210. package/lib/quoxon/objects/away.js +72 -0
  211. package/lib/quoxon/objects/fetch.js +17 -5
  212. package/lib/quoxon/objects/fleet.js +149 -0
  213. package/lib/quoxon/objects/health.js +186 -0
  214. package/lib/quoxon/objects/index.js +24 -3
  215. package/lib/quoxon/objects/mirror.js +137 -0
  216. package/lib/quoxon/objects/peek.js +17 -0
  217. package/lib/quoxon/objects/prove.js +130 -0
  218. package/lib/quoxon/objects/replay.js +125 -0
  219. package/lib/quoxon/objects/sitrep.js +58 -0
  220. package/lib/quoxon/objects/view.js +11 -0
  221. package/lib/quoxon/onboarding/copy.js +32 -4
  222. package/lib/quoxon/onboarding/credential.js +17 -4
  223. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  224. package/lib/quoxon/onboarding/failureCause.js +33 -0
  225. package/lib/quoxon/onboarding/finish.js +15 -0
  226. package/lib/quoxon/onboarding/index.js +34 -7
  227. package/lib/quoxon/onboarding/installRunner.js +64 -5
  228. package/lib/quoxon/onboarding/preflight.js +17 -3
  229. package/lib/quoxon/onboarding/probe.js +65 -0
  230. package/lib/quoxon/onboarding/view.js +76 -19
  231. package/lib/quoxon/orgLabel.js +10 -0
  232. package/lib/quoxon/orgSwitch.js +33 -0
  233. package/lib/quoxon/ownBroadcast.js +22 -0
  234. package/lib/quoxon/palette.js +43 -0
  235. package/lib/quoxon/print.js +9 -5
  236. package/lib/quoxon/profileSwitch.js +53 -0
  237. package/lib/quoxon/promptHistory.js +41 -0
  238. package/lib/quoxon/providers.js +48 -0
  239. package/lib/quoxon/qart.js +20 -51
  240. package/lib/quoxon/qdragon.js +410 -0
  241. package/lib/quoxon/qdragonFly.js +61 -0
  242. package/lib/quoxon/qdragonScenes.js +282 -0
  243. package/lib/quoxon/qeyes.js +213 -0
  244. package/lib/quoxon/qmotion.js +58 -37
  245. package/lib/quoxon/render.js +638 -119
  246. package/lib/quoxon/resend.js +72 -0
  247. package/lib/quoxon/roster.js +95 -9
  248. package/lib/quoxon/route.js +6 -5
  249. package/lib/quoxon/rows.js +711 -0
  250. package/lib/quoxon/rowsStore.js +80 -0
  251. package/lib/quoxon/screenContext.js +150 -0
  252. package/lib/quoxon/screenFrames.js +254 -0
  253. package/lib/quoxon/session.js +172 -2
  254. package/lib/quoxon/sessionDead.js +17 -0
  255. package/lib/quoxon/signin.js +125 -0
  256. package/lib/quoxon/slash/agents.js +112 -0
  257. package/lib/quoxon/slash/argSources.js +65 -0
  258. package/lib/quoxon/slash/args.js +65 -0
  259. package/lib/quoxon/slash/automate.js +89 -0
  260. package/lib/quoxon/slash/broadcast.js +140 -0
  261. package/lib/quoxon/slash/broadcastRun.js +108 -0
  262. package/lib/quoxon/slash/card.js +30 -0
  263. package/lib/quoxon/slash/challenge.js +114 -0
  264. package/lib/quoxon/slash/cliCommand.js +53 -0
  265. package/lib/quoxon/slash/cliLeaves.json +948 -0
  266. package/lib/quoxon/slash/compact.js +1 -1
  267. package/lib/quoxon/slash/confirm.js +30 -0
  268. package/lib/quoxon/slash/crystallize.js +69 -0
  269. package/lib/quoxon/slash/dashboard.js +136 -0
  270. package/lib/quoxon/slash/draft.js +54 -0
  271. package/lib/quoxon/slash/hatWalk.js +206 -0
  272. package/lib/quoxon/slash/inverses.js +77 -0
  273. package/lib/quoxon/slash/killer.js +24 -0
  274. package/lib/quoxon/slash/killerApi.js +16 -0
  275. package/lib/quoxon/slash/ledger.js +13 -8
  276. package/lib/quoxon/slash/listView.js +3 -1
  277. package/lib/quoxon/slash/modelCmd.js +261 -0
  278. package/lib/quoxon/slash/palette.js +21 -7
  279. package/lib/quoxon/slash/paletteController.js +117 -7
  280. package/lib/quoxon/slash/panels.js +21 -6
  281. package/lib/quoxon/slash/picker.js +20 -2
  282. package/lib/quoxon/slash/restart.js +71 -13
  283. package/lib/quoxon/slash/rowCmd.js +157 -0
  284. package/lib/quoxon/slash/rowWalk.js +175 -0
  285. package/lib/quoxon/slash/settings.js +16 -6
  286. package/lib/quoxon/slash/spawn.js +177 -0
  287. package/lib/quoxon/slash/stepUp.js +274 -0
  288. package/lib/quoxon/slash/stepUpCard.js +60 -0
  289. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  290. package/lib/quoxon/slash/undoApi.js +38 -0
  291. package/lib/quoxon/slash/undoCmd.js +49 -0
  292. package/lib/quoxon/slash/undoWish.js +15 -0
  293. package/lib/quoxon/slash/watch.js +164 -0
  294. package/lib/quoxon/slash/wish.js +23 -0
  295. package/lib/quoxon/slash/wishCmd.js +32 -0
  296. package/lib/quoxon/slotChat.js +300 -0
  297. package/lib/quoxon/slotFormat.js +195 -0
  298. package/lib/quoxon/slotRun.js +131 -0
  299. package/lib/quoxon/sound/mapping.js +2 -1
  300. package/lib/quoxon/stray.js +22 -0
  301. package/lib/quoxon/suggestCommand.js +59 -0
  302. package/lib/quoxon/term.js +46 -11
  303. package/lib/quoxon/textBuffer.js +176 -2
  304. package/lib/quoxon/theme.js +58 -25
  305. package/lib/quoxon/tips.js +433 -0
  306. package/lib/quoxon/toolError.js +41 -0
  307. package/lib/quoxon/transcript.js +42 -4
  308. package/lib/quoxon/web/qface.js +18 -10
  309. package/lib/quoxon/welcome.js +1 -1
  310. package/lib/quoxon/whatsnew.js +136 -0
  311. package/lib/quoxon/whatsnew.jsonl +89 -0
  312. package/lib/quoxon/windowOrg.js +113 -0
  313. package/lib/server/diag.js +129 -0
  314. package/lib/server/doctor.js +71 -0
  315. package/lib/server/install.js +149 -0
  316. package/lib/server/pluginEnable.js +161 -0
  317. package/lib/server/skew.js +56 -0
  318. package/lib/server/snapshot.js +131 -0
  319. package/lib/server/status.js +128 -0
  320. package/lib/server/sys.js +65 -0
  321. package/lib/server/uninstall.js +63 -0
  322. package/lib/server/update.js +234 -0
  323. package/lib/toolOutputShape.js +315 -0
  324. package/lib/tui/views/HyperionDeck.jsx +79 -0
  325. package/lib/tui/views/LucidDeck.jsx +209 -217
  326. package/package.json +4 -1
  327. package/lib/core/websiteSession.js +0 -148
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `quox ask`: a session puts a fork in front of a human through the Approvals inbox (one inbox item, kind decision).
3
+ * Who is asking, what, why, the options, the recommendation and the expiry travel with it, so ONE HITL session
4
+ * can answer for twenty terminals. Not an approval of an action: no step-up PIN unless --high-risk.
5
+ */
6
+ import { readFileSync, existsSync } from 'node:fs';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { resolveEndpoint } from '../api/endpoints.js';
11
+ import { resolveOrgId } from './helpers.js';
12
+ import { readPresence, approvalPresenceFields } from './presence.js';
13
+ import { toRepoRelative, LOOSENING_HINT } from '../core/repoPath.js';
14
+ import { parseOptions, parseDuration, YN_OPTIONS, waitForDecision, EXIT_NO_ANSWER } from '../core/askDecision.js';
15
+
16
+ function heldSlotFor(session) {
17
+ try {
18
+ const all = JSON.parse(readFileSync(join(homedir(), '.quox', 'held-slots.json'), 'utf8'));
19
+ const want = String(session || '').toLowerCase();
20
+ return Object.keys(all).find((k) => String(all[k]?.session || '').toLowerCase() === want) || null;
21
+ } catch { return null; }
22
+ }
23
+
24
+ export function registerAskCommands(program, { client, auth }) {
25
+ const err = (s) => process.stderr.write(`${s}\n`);
26
+
27
+ async function waitAndPrint(orgId, id, { timeoutSec = null } = {}) {
28
+ const r = await waitForDecision({
29
+ poll: (w) => { const ep = resolveEndpoint('inbox.decision.get', { orgId, id }); return client.get(ep.path, { service: ep.service, query: { wait: w }, timeout: (w + 15) * 1000, retries: 0 }); },
30
+ log: err,
31
+ maxWaitMs: timeoutSec != null ? timeoutSec * 1000 : null,
32
+ });
33
+ if (r.stdout) console.log(r.stdout);
34
+ process.exitCode = r.code;
35
+ return r;
36
+ }
37
+
38
+ const ask = program
39
+ .command('ask')
40
+ .description('Ask a human a question through the Approvals inbox: who asks, what, why, the options. Use this instead of asking in your own terminal')
41
+ .option('--session <name>', 'which session is asking (default: $QUOX_SESSION)')
42
+ .option('--title <text>', 'what you are asking, one line')
43
+ .option('--why <text>', 'a simple why: what the human needs to know to decide')
44
+ .option('--justify <text>', 'for a loosening (a check made easier to pass): why it is safe, in plain words; the card shows it on its JUSTIFY line and defaults to No without one')
45
+ .option('--options <list>', 'the choices: "a: Ship now|b: Wait for review|c: Drop it"')
46
+ .option('--yn', 'shorthand for a yes/no question (options y and n)')
47
+ .option('--default <key>', 'the option you would take if nobody answers (shown, never applied for you)')
48
+ .option('--recommend <key>', 'the option you recommend')
49
+ .option('--expires <dur>', 'how long it stays open: 30m, 2h, 1d (default 24h, max 30d)')
50
+ .option('--slot <name>', 'your slot, so the answer is also posted there (default: found from held slots)')
51
+ .option('--agent <name>', 'the agent asking, when it is an agent rather than a session')
52
+ .option('--high-risk', 'the answer needs the human step-up PIN')
53
+ .option('--wait', 'block until answered; prints the chosen option key (and any note) on stdout, exits 2 on expiry or deny')
54
+ .option('--timeout <dur>', 'with --wait: stop waiting after this long (the question stays open)')
55
+ .action((options) => fileAsk(options));
56
+
57
+ async function fileAsk(options) {
58
+ auth.requireAuth();
59
+ const orgId = resolveOrgId(auth, program);
60
+ const session = options.session || process.env.QUOX_SESSION;
61
+ if (!session) throw new Error('say which session is asking: --session <NAME>');
62
+ if (!options.title) throw new Error('say what you are asking: --title "<what>"');
63
+ if (options.yn && options.options) throw new Error('use --yn or --options, not both');
64
+ if (!options.yn && !options.options) throw new Error('give the choices: --options "a: Ship now|b: Wait" (or --yn)');
65
+ // QUOXON 2026-10-17: a high-risk ask (a loosening, anything the owner must PIN) carries a plain justification
66
+ // 2026-10-19: three PIN approvals were wasted on loosening asks filed without --high-risk or with unusable paths
67
+ if (!options.highRisk && /^\s*approve\s+(?:loosening|test)/i.test(options.title)) { err(`A test-edit approval must go through the loosening verb, which sets --high-risk and repo-relative paths for you:\n ${LOOSENING_HINT}`); process.exitCode = 2; return; }
68
+ if (options.highRisk && !String(options.justify || '').trim()) { err('High-risk asks need --justify "<2-3 plain lines the owner can judge>"'); process.exitCode = 2; return; }
69
+ const opts = options.yn ? YN_OPTIONS : parseOptions(options.options);
70
+ const body = {
71
+ session, title: options.title, why: options.why, options: opts,
72
+ ...(options.justify ? { justification: options.justify } : {}),
73
+ default: options.default, recommend: options.recommend,
74
+ ...(options.expires ? { expires_in_seconds: parseDuration(options.expires) } : {}),
75
+ high_risk: Boolean(options.highRisk),
76
+ slot: options.slot || heldSlotFor(session) || undefined,
77
+ profile: program.opts().profile || process.env.QUOX_PROFILE || undefined,
78
+ agent: options.agent,
79
+ };
80
+ const ep = resolveEndpoint('inbox.decision.create', { orgId });
81
+ const data = await client.post(ep.path, { service: ep.service, body });
82
+ // PRESENCE (owner 2026-10-16): say now whether the human is away, so this session can work on
83
+ // something else instead of waiting. The question is filed and queued exactly as before.
84
+ const presence = approvalPresenceFields(await readPresence(client, orgId));
85
+ const d = { ...data.decision, ...presence };
86
+ if (program.opts().output === 'json' && !options.wait) { console.log(JSON.stringify(d, null, 2)); return; }
87
+ err(`asked: ${d.id} (${session}: ${d.title}); it shows in /approvals${d.expires_at ? `, open until ${d.expires_at}` : ''}`);
88
+ if (presence.owner_away) err(`${presence.owner_away_notice}. Expect a wait: work on something else meanwhile.`);
89
+ if (!options.wait) { console.log(d.id); return; }
90
+ await waitAndPrint(orgId, d.id, { timeoutSec: options.timeout ? parseDuration(options.timeout) : null });
91
+ }
92
+
93
+ ask
94
+ .command('loosening')
95
+ .description('Ask a human to approve edits to test files (a check made easier to pass). Files a yes/no, high-risk (PIN) ask with repo-relative paths')
96
+ .option('--paths <files...>', 'every test file the commit edits (absolute, worktree or repo-relative: all are normalised)')
97
+ .option('--justify <text>', 'why it is safe, in plain words (required)')
98
+ .option('--why <text>', 'detail: the findings, before and after lines')
99
+ .option('--session <name>', 'which session is asking (default: $QUOX_SESSION)')
100
+ .option('--slot <name>', 'your slot')
101
+ .option('--expires <dur>', 'how long it stays open')
102
+ .option('--wait', 'block until answered')
103
+ .option('--timeout <dur>', 'with --wait: stop waiting after this long')
104
+ .action(async (subOptions) => {
105
+ // commander lets the parent `ask` swallow flags it shares with this verb (--justify, --why, --session...), so merge both
106
+ const options = { ...ask.opts(), ...Object.fromEntries(Object.entries(subOptions).filter(([, v]) => v !== undefined)) };
107
+ if (!String(options.justify || '').trim()) { err('say why it is safe: --justify "<plain words>"'); process.exitCode = 2; return; }
108
+ const raw = [].concat(options.paths || []).flatMap((x) => String(x).split(',')).map((x) => x.trim()).filter(Boolean);
109
+ let root;
110
+ try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); } catch { root = process.cwd(); }
111
+ const paths = [...new Set(raw.map(toRepoRelative))];
112
+ const missing = paths.filter((p) => !p || !existsSync(join(root, p)));
113
+ if (!paths.length || missing.length) { err(`not found in this repo (${root}): ${missing.join(', ') || '(no paths)'}. Run from the worktree and name files that exist.`); process.exitCode = 2; return; }
114
+ // 2026-10-19: the server truncates long titles, and the attestation names only paths found in title+body,
115
+ // so a 7-file ask covered 3. Every path also goes in the body.
116
+ const files = `Files (${paths.length}):\n${paths.map((p) => `- ${p}`).join('\n')}`;
117
+ await fileAsk({ ...options, title: `Approve test edits: ${paths.join(' ')}`, why: options.why ? `${files}\n\n${options.why}` : files, yn: true, highRisk: true, justify: options.justify });
118
+ });
119
+
120
+ ask
121
+ .command('status')
122
+ .description('Show where a question stands: pending, answered, denied or expired, with the answer')
123
+ .argument('<id>', 'the id `quox ask` printed')
124
+ .action(async (id) => {
125
+ auth.requireAuth();
126
+ const orgId = resolveOrgId(auth, program);
127
+ const ep = resolveEndpoint('inbox.decision.get', { orgId, id });
128
+ const d = (await client.get(ep.path, { service: ep.service })).decision;
129
+ if (program.opts().output === 'json') { console.log(JSON.stringify(d, null, 2)); return; }
130
+ console.log(`${d.state}${d.answer?.option ? ` ${d.answer.option}` : ''}${d.answer?.note ? ` (${d.answer.note})` : ''}`);
131
+ if (d.state === 'expired' || d.state === 'denied') process.exitCode = EXIT_NO_ANSWER;
132
+ });
133
+
134
+ ask
135
+ .command('wait')
136
+ .description('Block until a question is answered; prints the chosen option key (and note), exits 2 on expiry or deny')
137
+ .argument('<id>', 'the id `quox ask` printed')
138
+ .option('--timeout <dur>', 'stop waiting after this long (the question stays open)')
139
+ .action(async (id, options) => {
140
+ auth.requireAuth();
141
+ const orgId = resolveOrgId(auth, program);
142
+ await waitAndPrint(orgId, id, { timeoutSec: options.timeout ? parseDuration(options.timeout) : null });
143
+ });
144
+ }
@@ -5,6 +5,10 @@
5
5
  import { formatOutput, formatDetail, printSuccess } from '../core/output.js';
6
6
  import { resolveOrgId } from './helpers.js';
7
7
  import { resolveEndpoint } from '../api/endpoints.js';
8
+ import { QuoxError } from '../core/errors.js';
9
+
10
+ // #1049: auth routes/assistants.js DEPLOYMENT_MODES (the server is the authority).
11
+ const DEPLOY_MODES = ['private', 'shared', 'public'];
8
12
 
9
13
  export function registerAssistantCommands(program, ctx) {
10
14
  const { client, auth } = ctx;
@@ -84,20 +88,71 @@ export function registerAssistantCommands(program, ctx) {
84
88
  printSuccess('Assistant deleted.');
85
89
  });
86
90
 
91
+ assistant
92
+ .command('publish')
93
+ .description('Publish a draft assistant (draft to active), so it can be deployed')
94
+ .argument('<assistantId>', 'assistant ID')
95
+ .action(async (assistantId) => {
96
+ auth.requireAuth();
97
+ const orgId = resolveOrgId(auth, program);
98
+ // #1028: create makes a draft and deploy refuses anything not active; this is the step between.
99
+ const ep = resolveEndpoint('assistants.publish', { orgId, assistantId });
100
+ await client.post(ep.path, { service: ep.service });
101
+ printSuccess('Assistant published.');
102
+ });
103
+
87
104
  assistant
88
105
  .command('deploy')
89
- .description('Deploy an assistant')
106
+ .description('Deploy an active assistant to a scope (scope ids: quox org divisions list, quox department list, quox team list)')
90
107
  .argument('<assistantId>', 'assistant ID')
91
- .option('--target <target>', 'deployment target')
108
+ .option('--scope <scopeId>', 'scope ID to deploy to (required)')
109
+ .option('--target <scopeId>', 'alias for --scope')
110
+ .option('--mode <mode>', `${DEPLOY_MODES.join(', ')} (default shared)`)
92
111
  .action(async (assistantId, options) => {
93
112
  auth.requireAuth();
94
113
  const orgId = resolveOrgId(auth, program);
95
- const body = {};
96
- if (options.target) body.target = options.target;
114
+ // #998: the auth route requires scope_id; the CLI used to send { target }.
115
+ const scopeId = options.scope || options.target;
116
+ if (!scopeId) throw new QuoxError('assistant deploy needs --scope <scopeId>.', 1);
117
+ // #1049: the server's modes (auth routes/assistants.js DEPLOYMENT_MODES); refuse anything else here.
118
+ if (options.mode && !DEPLOY_MODES.includes(options.mode)) {
119
+ throw new QuoxError(`--mode must be one of: ${DEPLOY_MODES.join(', ')} (got "${options.mode}").`, 1);
120
+ }
121
+ const body = { scope_id: scopeId };
122
+ if (options.mode) body.mode = options.mode;
97
123
  await client.post(resolveEndpoint('assistants.deploy', { orgId, assistantId }).path, { service: resolveEndpoint('assistants.deploy', { orgId, assistantId }).service, body });
98
124
  printSuccess('Assistant deployed.');
99
125
  });
100
126
 
127
+ assistant
128
+ .command('undeploy')
129
+ .description('Remove a deployment of an assistant (by --deployment id, or the active one at --scope)')
130
+ .argument('<assistantId>', 'assistant ID')
131
+ .option('--deployment <deploymentId>', 'deployment ID to remove (see quox assistant get)')
132
+ .option('--scope <scopeId>', 'remove the active deployment at this scope')
133
+ .action(async (assistantId, options) => {
134
+ auth.requireAuth();
135
+ const orgId = resolveOrgId(auth, program);
136
+ // #1049: a deployed assistant could not be removed from the CLI.
137
+ let deploymentId = options.deployment;
138
+ if (!deploymentId) {
139
+ if (!options.scope) throw new QuoxError('assistant undeploy needs --deployment <id> or --scope <scopeId>.', 1);
140
+ const g = resolveEndpoint('assistants.get', { orgId, assistantId });
141
+ const data = await client.get(g.path, { service: g.service });
142
+ const deps = (data && data.assistant && data.assistant.deployments) || (data && data.deployments) || [];
143
+ const live = deps.filter((d) => d.status === 'active');
144
+ const hit = live.find((d) => d.scope_id === options.scope);
145
+ if (!hit) {
146
+ const where = live.length ? live.map((d) => `${d.scope_id} (${d.id})`).join(', ') : 'nowhere';
147
+ throw new QuoxError(`No active deployment of ${assistantId} at scope ${options.scope}. It is deployed at: ${where}.`, 1);
148
+ }
149
+ deploymentId = hit.id;
150
+ }
151
+ const ep = resolveEndpoint('assistants.undeploy', { orgId, assistantId, deploymentId });
152
+ await client.delete(ep.path, { service: ep.service });
153
+ printSuccess(`Deployment ${deploymentId} removed.`);
154
+ });
155
+
101
156
  assistant
102
157
  .command('fork')
103
158
  .description('Fork an assistant')
@@ -199,7 +199,9 @@ export function registerAuthCommands(program, ctx) {
199
199
  .action(async () => {
200
200
  auth.requireAuth();
201
201
  const data = await client.get(resolveEndpoint('auth.me.list').path, { service: resolveEndpoint('auth.me.list').service });
202
- const authData = config.getAuth();
202
+ // A QUOX_TOKEN_FILE principal (the W4 builder uid) has no stored profile:
203
+ // render from the server's /me answer rather than crash on null.
204
+ const authData = config.getAuth() || {};
203
205
 
204
206
  // quox#463: resident logins carry org_id but no org_name (the
205
207
  // bootstrap has only the id), and this rendered a real membership as
@@ -245,8 +247,12 @@ export function registerAuthCommands(program, ctx) {
245
247
  org: orgDisplay,
246
248
  role: authData.org_role || 'n/a',
247
249
  mfa: data.user?.mfa_enabled ? 'enabled' : 'disabled',
250
+ // Z2H-3c: expires_at is the ACCESS token. With a refresh token the sign-in outlives it, so do not
251
+ // present it as the session ending (a fresh owner read "expires in 60m" right after logging in).
248
252
  session: authData.expires_at
249
- ? `expires in ${Math.max(0, Math.round((authData.expires_at - Date.now()) / 60000))}m`
253
+ ? (authData.refresh_token
254
+ ? `active, auto-renews (token refresh in ${Math.max(0, Math.round((authData.expires_at - Date.now()) / 60000))}m)`
255
+ : `expires in ${Math.max(0, Math.round((authData.expires_at - Date.now()) / 60000))}m`)
250
256
  : 'unknown',
251
257
  profile: config.getActiveProfileName(),
252
258
  };
@@ -59,14 +59,18 @@ export function registerBackupCommands(program, ctx) {
59
59
  backup
60
60
  .command('create')
61
61
  .description('Create a backup')
62
- .option('--label <label>', 'backup label')
63
- .option('--type <type>', 'backup type: full, incremental', 'full')
62
+ .option('--label <label>', 'backup label (stored as the backup name)')
63
+ .option('--type <type>', 'backup type: full, quick, selective', 'full')
64
64
  .option('--components <list>', 'comma-separated components to include')
65
65
  .action(async (options) => {
66
66
  auth.requireAuth();
67
67
  const orgId = resolveOrgId(auth, program);
68
- const body = { type: options.type };
69
- if (options.label) body.label = options.label;
68
+ // #1017: the server reads backup_type and name (services/auth/routes/backups.js); type/label were dropped,
69
+ // and a type outside the backups.backup_type CHECK would only fail later inside the server.
70
+ const TYPES = ['full', 'quick', 'selective'];
71
+ if (!TYPES.includes(options.type)) throw new Error(`--type must be one of: ${TYPES.join(', ')}`);
72
+ const body = { backup_type: options.type };
73
+ if (options.label) body.name = options.label;
70
74
  if (options.components) body.components = options.components.split(',').map(c => c.trim());
71
75
 
72
76
  const ora = (await import('ora')).default;
@@ -51,6 +51,18 @@ export function titleOverlap(a, b) {
51
51
  for (const w of x) if (y.has(w)) hit++;
52
52
  return hit / Math.min(x.size, y.size);
53
53
  }
54
+ const NEGATIVE_WORDS = new Set(['disable', 'disabled', 'disabling', 'off', 'deny', 'denied', 'block', 'blocked', 'stop', 'stopped', 'remove', 'removed', 'drop', 'dropped', 'abandon', 'abandoned', 'reject', 'rejected', 'exclude', 'excluded', 'not', 'never', 'no', 'dont', 'cannot', 'cant', 'wont', 'without', 'revoke', 'revoked']);
55
+ /** Odd count of negating words = negative polarity ("not", "stop", "disable", "reject", ...). */
56
+ function negativeParity(t) {
57
+ const toks = String(t || '').toLowerCase().replace(/['\u2019]/g, '').split(/[^a-z0-9]+/).filter(Boolean);
58
+ let n = 0;
59
+ for (const w of toks) if (NEGATIVE_WORDS.has(w)) n++;
60
+ return n % 2;
61
+ }
62
+ /** True when two decision titles read as opposites (enable/disable, on/off, stop X, no longer X...). */
63
+ export function oppositePolarity(a, b) {
64
+ return negativeParity(a) !== negativeParity(b);
65
+ }
54
66
  export const DECIDE_DUPE_THRESHOLD = 0.6;
55
67
  export const DISTILL_CAP = 50;
56
68
 
@@ -136,6 +148,24 @@ export function scoreRetrievalEval(judgements, searches, k = 8) {
136
148
  export function registerBrainCommands(program, ctx) {
137
149
  const { client, auth } = ctx;
138
150
 
151
+ // quox#934: read/history/related go through the NotesQ API, which only knows
152
+ // editable NotesQ notes. A feed note (git_commit, email, ...) that `brain note`
153
+ // resolves came back as a bare 404. On a NotesQ 404, ask the brain itself;
154
+ // rethrow the original error when the brain does not know the note either.
155
+ const brainNoteOn404 = async (noteId, err) => {
156
+ if (!(err instanceof QuoxApiError) || err.status !== 404) throw err;
157
+ let note;
158
+ try {
159
+ const ep = resolveEndpoint('brain.note.get', { id: noteId });
160
+ note = await client.get(ep.path, { service: ep.service });
161
+ } catch {
162
+ throw err;
163
+ }
164
+ if (!note || !note.id) throw err;
165
+ return note;
166
+ };
167
+ const feedReason = (n, what) => `${n.id} is a ${n.kind || 'feed'} note${n.source_type ? ` from the ${n.source_type} source` : ''}, not an editable NotesQ note, so it has no ${what}.`;
168
+
139
169
  const brain = program
140
170
  .command('brain')
141
171
  .description('Query Quox Qortex (vaults, notes, semantic search)')
@@ -164,10 +194,16 @@ export function registerBrainCommands(program, ctx) {
164
194
  console.log(JSON.stringify(data, null, 2));
165
195
  return;
166
196
  }
197
+ // quox#853: a keyword-only fallback fooled both the product and a
198
+ // verifier ("0 hits" read as "does not exist"). Say it in words.
199
+ const degradedNote = data.retrieval === 'fts' && data.degraded
200
+ ? '⚠ Semantic search was unavailable for this query, so this is keyword-only: a missing or zero result does not prove the note is absent. Retry shortly.'
201
+ : '';
167
202
  if (insufficient) {
168
203
  const legLabel = data.retrieval ? ` · ${data.retrieval}` : '';
169
204
  console.log(glyphLine('brain2', `search "${query.slice(0, 40)}"`, { result: `0 relevant hits${legLabel}`, ms: data.tookMs ?? '?' }));
170
205
  console.log('⚠ Qortex judged the matches for this query insufficient (no relevant notes). The weak hits are withheld; use -o json to see them with the relevance details.');
206
+ if (degradedNote) console.log(degradedNote);
171
207
  return;
172
208
  }
173
209
  // BRAIN2-AUDIT: sessions must SEE what Qortex recalled, not just a hit
@@ -176,12 +212,14 @@ export function registerBrainCommands(program, ctx) {
176
212
  const results = data.results || [];
177
213
  const legLabel = data.retrieval ? ` · ${data.retrieval}` : '';
178
214
  console.log(glyphLine('brain2', `search "${query.slice(0, 40)}"`, { result: `${results.length} hits${legLabel}`, ms: data.tookMs ?? '?' }));
215
+ if (degradedNote) console.log(degradedNote);
179
216
  if (results.length === 0) {
180
217
  console.log(glyphLine('brain2', 'recall', { result: 'nothing on record for this query' }));
181
218
  return;
182
219
  }
183
220
  for (const r of results) {
184
- console.log(`${brandName('🌌 QORTEX')} recall │ "${recallSnippet(r)}" · ${recallSource(r)} · ${r.title || 'untitled'} · note ${r.noteId || '?'}`);
221
+ const miss = Array.isArray(r.missingEntities) && r.missingEntities.length ? ` · does not mention ${r.missingEntities.join(', ')}` : '';
222
+ console.log(`${brandName('🌌 QORTEX')} recall │ "${recallSnippet(r)}" · ${recallSource(r)} · ${r.title || 'untitled'} · note ${r.noteId || '?'}${miss}`);
185
223
  }
186
224
  return;
187
225
  }
@@ -191,6 +229,7 @@ export function registerBrainCommands(program, ctx) {
191
229
  if (program.opts().output !== 'json') {
192
230
  const legLabel = data.retrieval ? ` · ${data.retrieval}` : '';
193
231
  console.log(glyphLine('brain2', `search "${query.slice(0, 40)}"`, { result: `${(data.results || []).length} hits${legLabel}`, ms: data.tookMs ?? '?' }));
232
+ if (degradedNote) console.log(degradedNote);
194
233
  }
195
234
  formatOutput({
196
235
  columns: ['title', 'kind', 'snippet', 'score'],
@@ -300,7 +339,7 @@ export function registerBrainCommands(program, ctx) {
300
339
  if (!['private', 'org'].includes(options.scope)) throw new QuoxError('--scope must be private or org.', 2);
301
340
  const prov = decisionProvenance();
302
341
  const title = `Decision: ${what}`.slice(0, 200);
303
- const markdown = decisionMarkdown({ what, why: options.why, rejected: options.rejected, prov });
342
+ let markdown = decisionMarkdown({ what, why: options.why, rejected: options.rejected, prov });
304
343
  const tags = ['decision', ...String(options.tags || '').split(',').map((t) => t.trim()).filter(Boolean)];
305
344
  const json = program.opts().output === 'json';
306
345
  if (options.dryRun) {
@@ -311,7 +350,15 @@ export function registerBrainCommands(program, ctx) {
311
350
  const sp = resolveEndpoint('brain.search.list');
312
351
  const found = await client.post(sp.path, { service: sp.service, body: { query: what, limit: 5 } });
313
352
  const insufficient = found.insufficient === true || found.relevance?.insufficient === true;
314
- const dupe = insufficient ? null : (found.results || []).find((r) => titleOverlap(what, String(r.title || '').replace(/^Decision:\s*/i, '')) >= DECIDE_DUPE_THRESHOLD);
353
+ const bare = (r) => String(r.title || '').replace(/^Decision:\s*/i, '');
354
+ const matches = insufficient ? [] : (found.results || []).filter((r) => titleOverlap(what, bare(r)) >= DECIDE_DUPE_THRESHOLD);
355
+ // An opposite-polarity match is a reversal of the earlier decision, never a duplicate of it.
356
+ const dupe = matches.find((r) => !oppositePolarity(what, bare(r)));
357
+ const reversed = dupe ? null : matches[0];
358
+ if (reversed) {
359
+ const ref = reversed.noteId || reversed.title;
360
+ if (ref) markdown = decisionMarkdown({ what, why: options.why, rejected: options.rejected, prov: { ...prov, supersedes: ref } });
361
+ }
315
362
  if (dupe && !options.force) {
316
363
  if (json) console.log(JSON.stringify({ saved: false, reason: 'existing_match', match: { noteId: dupe.noteId, title: dupe.title, kind: dupe.kind } }, null, 2));
317
364
  else {
@@ -324,9 +371,10 @@ export function registerBrainCommands(program, ctx) {
324
371
  }
325
372
  const ep = resolveEndpoint('brain.capture.create');
326
373
  const data = await client.post(ep.path, { service: ep.service, body: { title, text: markdown.replace(/^# .*\n\n/, ''), tags, scope: options.scope === 'org' ? 'org' : 'user' } });
327
- if (json) { console.log(JSON.stringify(data, null, 2)); return; }
374
+ if (json) { console.log(JSON.stringify(reversed ? { ...data, reverses: { noteId: reversed.noteId, title: reversed.title } } : data, null, 2)); return; }
328
375
  const verb = data.status === 'created' ? 'saved' : data.status === 'unchanged' ? 'already saved' : 'updated';
329
376
  console.log(glyphLine('brain2', 'decide', { result: `${verb} · note ${String(data.noteId || '?').slice(0, 8)}` }));
377
+ if (reversed) console.log(` reverses ${reversed.title || 'untitled'} (note ${reversed.noteId || '?'})`);
330
378
  });
331
379
 
332
380
  brain
@@ -443,14 +491,14 @@ export function registerBrainCommands(program, ctx) {
443
491
  formatDetail(data, { format });
444
492
  return;
445
493
  }
446
- const verdict = (r) => (r.contradicted ? 'contradicted' : r.supported === true ? 'supported' : r.supported === false ? 'unsupported' : 'not measured');
494
+ const verdict = (r) => (r.fabricated || r.support_method === 'fabricated' ? 'fabricated' : r.support_method === 'unchecked' ? 'unchecked' : r.contradicted ? 'contradicted' : r.supported === true ? 'supported' : r.support_method === 'overlap' ? 'overlap' : r.supported === false ? 'unsupported' : 'not measured');
447
495
  formatOutput({
448
496
  columns: ['session', 'handle', 'note', 'span', 'rev', 'verdict', 'at'],
449
497
  headers: ['Session', 'Handle', 'Note', 'Span', 'Rev', 'Verdict', 'At'],
450
498
  rows: (data.records || []).map((r) => ({
451
499
  session: r.session || '-',
452
500
  handle: r.handle || '-',
453
- note: r.note_id,
501
+ note: r.note_id || '-',
454
502
  span: r.char_start != null && r.char_end != null ? `${r.char_start}-${r.char_end}` : '-',
455
503
  rev: r.note_hash ? String(r.note_hash).slice(0, 8) : '-',
456
504
  verdict: verdict(r),
@@ -509,7 +557,8 @@ export function registerBrainCommands(program, ctx) {
509
557
  { metric: 'answers after brain search', week: cw.answers ?? 0, total: ct.answers ?? 0 },
510
558
  { metric: 'answers citing a note', week: `${cw.grounded ?? 0} (${pct(cw.grounded ?? 0, cw.answers ?? 0)})`, total: `${ct.grounded ?? 0} (${pct(ct.grounded ?? 0, ct.answers ?? 0)})` },
511
559
  ...((cw.supportMeasured ?? 0) + (ct.supportMeasured ?? 0) > 0
512
- ? [{ metric: 'answers with supported citations (lexical)', week: `${cw.supportedAnswers ?? 0} (${pct(cw.supportedAnswers ?? 0, cw.supportMeasured ?? 0)})`, total: `${ct.supportedAnswers ?? 0} (${pct(ct.supportedAnswers ?? 0, ct.supportMeasured ?? 0)})` }]
560
+ ? [{ metric: 'answers with supported citations (entailment)', week: `${cw.supportedAnswers ?? 0} (${pct(cw.supportedAnswers ?? 0, cw.supportMeasured ?? 0)})`, total: `${ct.supportedAnswers ?? 0} (${pct(ct.supportedAnswers ?? 0, ct.supportMeasured ?? 0)})` },
561
+ { metric: 'answers with overlap only (not checked for meaning)', week: `${cw.overlapAnswers ?? 0}`, total: `${ct.overlapAnswers ?? 0}` }]
513
562
  : []),
514
563
  { metric: 'notes cited / retrieved', week: `${cw.citedNotes ?? 0}/${cw.retrievedNotes ?? 0}`, total: `${ct.citedNotes ?? 0}/${ct.retrievedNotes ?? 0}` },
515
564
  ],
@@ -559,7 +608,13 @@ export function registerBrainCommands(program, ctx) {
559
608
  .action(async (noteId, options) => {
560
609
  auth.requireAuth();
561
610
  const ep = resolveEndpoint('notesq.notes.get', { id: noteId });
562
- const data = await client.get(ep.path, { service: ep.service });
611
+ let data;
612
+ try {
613
+ data = await client.get(ep.path, { service: ep.service });
614
+ } catch (e) {
615
+ const bn = await brainNoteOn404(noteId, e);
616
+ data = { note: bn, source: 'brain', editable: false };
617
+ }
563
618
  const n = data.note || data;
564
619
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
565
620
  if (options.raw) { console.log(n.body ?? n.markdown ?? ''); return; }
@@ -602,11 +657,22 @@ export function registerBrainCommands(program, ctx) {
602
657
  .description('Delete a note (recorded as a revision, restorable in NotesQ). Requires --yes.')
603
658
  .argument('<noteId>', 'note id')
604
659
  .option('--yes', 'confirm deletion')
660
+ .option('--expected-hash <hash>', "the note's content_hash you read (see `quox brain read`); a stale value is a conflict (409) and nothing is deleted")
605
661
  .action(async (noteId, options) => {
606
662
  auth.requireAuth();
607
663
  if (!options.yes) throw new QuoxError(`Refusing to delete ${noteId} without --yes.`, 2);
664
+ if (!options.expectedHash) throw new QuoxError(`Refusing to delete ${noteId} without --expected-hash (the content_hash from \`quox brain read ${noteId}\`), so a concurrent edit is never deleted.`, 2);
608
665
  const ep = resolveEndpoint('notesq.notes.delete', { id: noteId });
609
- const data = await client.delete(ep.path, { service: ep.service });
666
+ let data;
667
+ try {
668
+ data = await client.delete(ep.path, { service: ep.service, query: { expected_hash: options.expectedHash } });
669
+ } catch (e) {
670
+ if (e instanceof QuoxApiError && e.status === 409) {
671
+ const h = e.body && (e.body.currentHash || e.body.current_hash);
672
+ throw new QuoxError(`Conflict (409): note ${noteId} changed since hash ${options.expectedHash} (current_hash ${h || 'unknown'}). Nothing was deleted. Re-run \`quox brain read ${noteId}\` and retry.`, 1);
673
+ }
674
+ throw e;
675
+ }
610
676
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
611
677
  printSuccess(`Deleted note ${noteId}.`);
612
678
  });
@@ -621,7 +687,14 @@ export function registerBrainCommands(program, ctx) {
621
687
  .action(async (noteId, options) => {
622
688
  auth.requireAuth();
623
689
  const ep = resolveEndpoint('notesq.notes.related', { id: noteId });
624
- const data = await client.get(ep.path, { service: ep.service, query: { limit: Math.min(Math.max(parseInt(options.limit, 10) || 8, 1), 20) } });
690
+ let data;
691
+ try {
692
+ data = await client.get(ep.path, { service: ep.service, query: { limit: Math.min(Math.max(parseInt(options.limit, 10) || 8, 1), 20) } });
693
+ } catch (e) {
694
+ const bn = await brainNoteOn404(noteId, e);
695
+ data = { related: [], editable: false, reason: `${feedReason(bn, 'similarity links')} Use \`quox brain neighbours ${noteId}\` for its graph links.` };
696
+ if (program.opts().output !== 'json') console.log(data.reason);
697
+ }
625
698
  const related = data.related || [];
626
699
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
627
700
  console.log(glyphLine('brain2', `related ${noteId.slice(0, 8)}`, { result: `${related.length} notes` }));
@@ -657,11 +730,13 @@ export function registerBrainCommands(program, ctx) {
657
730
  .description('Token-budgeted pack of the most relevant cited passages on a topic (what the Cortex brain_context tool returns)')
658
731
  .argument('<query>', 'topic or task')
659
732
  .option('--budget <n>', 'approximate token budget (200-4000)', '1200')
733
+ .option('--since <date>', 'only notes dated on or after this ISO date/datetime (e.g. 2026-10-05)')
734
+ .option('--until <date>', 'only notes dated on or before this ISO date/datetime (a date alone means the whole day)')
660
735
  .action(async (query, options) => {
661
736
  auth.requireAuth();
662
737
  const tokenBudget = Math.min(Math.max(parseInt(options.budget, 10) || 1200, 200), 4000);
663
738
  const ep = resolveEndpoint('brain.context.pack');
664
- const data = await client.post(ep.path, { service: ep.service, body: { query, tokenBudget } });
739
+ const data = await client.post(ep.path, { service: ep.service, body: { query, tokenBudget, ...(options.since ? { since: options.since } : {}), ...(options.until ? { until: options.until } : {}) } });
665
740
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
666
741
  const passages = data.passages || [];
667
742
  const insufficient = data.insufficient === true || data.relevance?.insufficient === true || passages.length === 0;
@@ -693,7 +768,7 @@ export function registerBrainCommands(program, ctx) {
693
768
  if (!data || data.success === false) {
694
769
  throw new QuoxError(`brain_check failed: ${(data && (data.error || data.output)) || 'unknown error'}`, 1);
695
770
  }
696
- const mark = { supported: '✔', contradicted: '✖', unknown: '◐' }[data.verdict] || '';
771
+ const mark = { supported: '✔', contradicted: '✖', overlap: '◐', unknown: '◐' }[data.verdict] || '';
697
772
  console.log(glyphLine('brain2', `check "${claim.slice(0, 40)}"`, { result: `${data.verdict} ${mark}`.trim() }));
698
773
  const ev = data.evidence || [];
699
774
  for (const e of ev) {
@@ -749,7 +824,14 @@ export function registerBrainCommands(program, ctx) {
749
824
  return;
750
825
  }
751
826
  const ep = resolveEndpoint('notesq.notes.revisions', { id: noteId });
752
- const data = await client.get(ep.path, { service: ep.service });
827
+ let data;
828
+ try {
829
+ data = await client.get(ep.path, { service: ep.service });
830
+ } catch (e) {
831
+ const bn = await brainNoteOn404(noteId, e);
832
+ data = { revisions: [], editable: false, reason: feedReason(bn, 'revision history') };
833
+ if (program.opts().output !== 'json') { console.log(data.reason); return; }
834
+ }
753
835
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
754
836
  formatOutput({ columns: ['rev', 'action', 'author_kind', 'created_at', 'content_hash'], headers: ['Rev', 'Action', 'Author', 'When', 'Hash'], rows: data.revisions || [] }, { format: program.opts().output });
755
837
  });
@@ -770,7 +852,16 @@ export function registerBrainCommands(program, ctx) {
770
852
  throw new QuoxError(`Conflict: note ${noteId} changed since hash ${options.expectedHash} (current_hash ${current || 'unknown'}). Nothing was restored. Re-run \`quox brain read ${noteId}\` and try again.`, 1);
771
853
  }
772
854
  const ep = resolveEndpoint('notesq.notes.restore', { id: noteId });
773
- const data = await client.post(ep.path, { service: ep.service, body: { rev } });
855
+ let data;
856
+ try {
857
+ data = await client.post(ep.path, { service: ep.service, body: { rev, expected_hash: options.expectedHash } });
858
+ } catch (e) {
859
+ if (e instanceof QuoxApiError && e.status === 409) {
860
+ const h = e.body && (e.body.currentHash || e.body.current_hash);
861
+ throw new QuoxError(`Conflict (409): note ${noteId} changed since hash ${options.expectedHash} (current_hash ${h || 'unknown'}). Nothing was restored. Re-run \`quox brain read ${noteId}\` and try again.`, 1);
862
+ }
863
+ throw e;
864
+ }
774
865
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
775
866
  printSuccess(data.unchanged ? `Note ${noteId} already matches revision ${rev}.` : `Restored note ${noteId} to revision ${rev} (new rev ${data.revision?.rev ?? '?'}).`);
776
867
  });
@@ -33,10 +33,26 @@ export function registerBuildCommands(program, ctx) {
33
33
  // own parsed opts.
34
34
  const groupOpt = (options, key) => options[key] ?? build.opts()[key];
35
35
 
36
+ // An org with exactly one dev server needs no --host: use it and say so. With several, name them;
37
+ // with none, say so (proof cases code/reach-build-status / -targets exited 1 on a one-server org).
38
+ async function resolveHost(options) {
39
+ const given = groupOpt(options, 'host');
40
+ if (given) return given;
41
+ const listEp = resolveEndpoint('v1.dev.servers.list');
42
+ const list = await client.get(listEp.path, { service: listEp.service });
43
+ const ids = (Array.isArray(list?.servers) ? list.servers : []).map((s) => s.host_id).filter(Boolean);
44
+ if (ids.length === 1) {
45
+ printWarning(`No --host given; using the only dev server: ${ids[0]}`);
46
+ return ids[0];
47
+ }
48
+ if (ids.length === 0) throw new Error('No dev server is registered for this org (register one with `quox dev enable <hostId>`).');
49
+ throw new Error(`--host <hostId> is required: this org has ${ids.length} dev servers (${ids.join(', ')}).`);
50
+ }
51
+
36
52
  async function dispatch(target, options) {
37
53
  auth.requireAuth();
38
54
  options = {
39
- host: groupOpt(options, 'host'),
55
+ host: await resolveHost(options),
40
56
  stream: groupOpt(options, 'stream'),
41
57
  phase: groupOpt(options, 'phase'),
42
58
  msg: groupOpt(options, 'msg'),
@@ -76,8 +92,7 @@ export function registerBuildCommands(program, ctx) {
76
92
  .option('--host <hostId>', 'dev server host_id')
77
93
  .action(async (options) => {
78
94
  auth.requireAuth();
79
- const host = groupOpt(options, 'host');
80
- if (!host) throw new Error('--host <hostId> is required');
95
+ const host = await resolveHost(options);
81
96
  const endpoint = resolveEndpoint('v1.dev.build.status', { hostId: host });
82
97
  const data = await client.get(endpoint.path, { service: endpoint.service });
83
98
  formatDetail(data, { format: program.opts().output, title: `Build status: ${host}` });
@@ -89,8 +104,7 @@ export function registerBuildCommands(program, ctx) {
89
104
  .option('--host <hostId>', 'dev server host_id')
90
105
  .action(async (options) => {
91
106
  auth.requireAuth();
92
- const host = groupOpt(options, 'host');
93
- if (!host) throw new Error('--host <hostId> is required');
107
+ const host = await resolveHost(options);
94
108
  const endpoint = resolveEndpoint('v1.dev.build.targets', { hostId: host });
95
109
  const data = await client.get(endpoint.path, { service: endpoint.service });
96
110
  formatOutput(data.targets || [], {