quoxcli 0.3.1 → 0.4.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 (188) hide show
  1. package/lib/api/endpoints/actions.js +15 -0
  2. package/lib/api/endpoints/admin.js +20 -0
  3. package/lib/api/endpoints/blog.js +16 -0
  4. package/lib/api/endpoints/chat.js +7 -0
  5. package/lib/api/endpoints/contactsq.js +4 -0
  6. package/lib/api/endpoints/devServerRegistry.js +25 -0
  7. package/lib/api/endpoints/engagement.js +26 -0
  8. package/lib/api/endpoints/evidence.js +20 -0
  9. package/lib/api/endpoints/hat.js +14 -0
  10. package/lib/api/endpoints/index.js +12 -0
  11. package/lib/api/endpoints/loops.js +26 -0
  12. package/lib/api/endpoints/mon.js +173 -0
  13. package/lib/api/endpoints/objectives.js +6 -0
  14. package/lib/api/endpoints/quoxseo.js +6 -0
  15. package/lib/api/endpoints/server.js +21 -0
  16. package/lib/api/endpoints/setup.js +6 -0
  17. package/lib/api/endpoints/v1.js +34 -0
  18. package/lib/commands/actions.js +81 -11
  19. package/lib/commands/agent.js +11 -4
  20. package/lib/commands/agentAttach.js +46 -7
  21. package/lib/commands/approvals.js +5 -0
  22. package/lib/commands/ask.js +3 -1
  23. package/lib/commands/blog.js +110 -0
  24. package/lib/commands/chat.js +127 -28
  25. package/lib/commands/chatCompanion.js +27 -0
  26. package/lib/commands/chatState.js +45 -0
  27. package/lib/commands/claude.js +3 -32
  28. package/lib/commands/contacts.js +85 -4
  29. package/lib/commands/contactsSendAt.js +21 -0
  30. package/lib/commands/engagement.js +90 -0
  31. package/lib/commands/evidence.js +154 -0
  32. package/lib/commands/ext.js +23 -0
  33. package/lib/commands/fleet.js +25 -0
  34. package/lib/commands/hat.js +76 -0
  35. package/lib/commands/image.js +68 -0
  36. package/lib/commands/kuma.js +1050 -0
  37. package/lib/commands/kumaSla.js +174 -0
  38. package/lib/commands/licence.js +39 -1
  39. package/lib/commands/loop.js +291 -0
  40. package/lib/commands/objective.js +48 -1
  41. package/lib/commands/org.js +21 -4
  42. package/lib/commands/provider.js +58 -0
  43. package/lib/commands/quoxseo.js +31 -0
  44. package/lib/commands/research.js +79 -0
  45. package/lib/commands/rows.js +9 -0
  46. package/lib/commands/run.js +18 -1
  47. package/lib/commands/scope.js +2 -2
  48. package/lib/commands/server.js +113 -0
  49. package/lib/commands/shortcut.js +41 -0
  50. package/lib/commands/skills.js +20 -4
  51. package/lib/commands/vm.js +2 -2
  52. package/lib/commands/youtube.js +172 -0
  53. package/lib/core/actionItems.js +115 -0
  54. package/lib/core/agentAlias.js +2 -0
  55. package/lib/core/agentFamilies.js +7 -0
  56. package/lib/core/client.js +10 -2
  57. package/lib/core/commandRegistrars.js +9 -0
  58. package/lib/core/config.js +1 -1
  59. package/lib/core/findingReconcile.js +90 -0
  60. package/lib/core/global-flags.js +1 -1
  61. package/lib/core/governedSession.js +1 -3
  62. package/lib/core/liveSessionsReport.js +1 -1
  63. package/lib/core/loopGlyph.js +97 -0
  64. package/lib/core/offload.js +36 -7
  65. package/lib/core/output.js +7 -7
  66. package/lib/core/sessionHeartbeat.js +12 -8
  67. package/lib/core/shortcut.js +83 -0
  68. package/lib/core/version.js +1 -1
  69. package/lib/core/vm.js +5 -0
  70. package/lib/dragon/companionView.js +34 -0
  71. package/lib/dragon/frames.js +118 -0
  72. package/lib/dragon/jobs.js +123 -0
  73. package/lib/dragon/renderWorker.js +15 -0
  74. package/lib/dragon/select.js +72 -0
  75. package/lib/evidence/bundleExport.js +47 -0
  76. package/lib/evidence/bundleVerify.js +77 -0
  77. package/lib/quoxon/EXTENSIONS.md +23 -0
  78. package/lib/quoxon/api.js +192 -6
  79. package/lib/quoxon/app.js +655 -89
  80. package/lib/quoxon/approvalFacts.js +8 -6
  81. package/lib/quoxon/approvalSentence.js +44 -0
  82. package/lib/quoxon/approvalsView.js +63 -4
  83. package/lib/quoxon/commandShort.js +15 -3
  84. package/lib/quoxon/commands.js +75 -10
  85. package/lib/quoxon/countdownLine.js +10 -1
  86. package/lib/quoxon/deck.js +30 -0
  87. package/lib/quoxon/demo.autoplay.js +1 -1
  88. package/lib/quoxon/demo.js +3 -1
  89. package/lib/quoxon/dividerAocl.js +4 -3
  90. package/lib/quoxon/dividerRule.js +3 -3
  91. package/lib/quoxon/dragonCompanion.js +255 -0
  92. package/lib/quoxon/dragonModes.js +1 -0
  93. package/lib/quoxon/dragonMoments.js +82 -0
  94. package/lib/quoxon/dragonReads.js +68 -0
  95. package/lib/quoxon/dragonView.js +2 -2
  96. package/lib/quoxon/events.js +24 -3
  97. package/lib/quoxon/extensions.js +276 -0
  98. package/lib/quoxon/inboxView.js +79 -0
  99. package/lib/quoxon/installCli.js +79 -0
  100. package/lib/quoxon/keys.js +2 -2
  101. package/lib/quoxon/loopCard.js +456 -0
  102. package/lib/quoxon/loopCards.js +180 -0
  103. package/lib/quoxon/loopStream.js +87 -0
  104. package/lib/quoxon/main.js +12 -3
  105. package/lib/quoxon/mouse.js +12 -1
  106. package/lib/quoxon/objectiveOffer.js +82 -0
  107. package/lib/quoxon/objects/index.js +22 -4
  108. package/lib/quoxon/objects/model.js +33 -0
  109. package/lib/quoxon/objects/proof.js +12 -4
  110. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  111. package/lib/quoxon/onboarding/view.js +1 -1
  112. package/lib/quoxon/orgSwitch.js +27 -1
  113. package/lib/quoxon/planSteps.js +51 -0
  114. package/lib/quoxon/print.js +6 -3
  115. package/lib/quoxon/qdragonFly.js +7 -6
  116. package/lib/quoxon/qmotion.js +17 -0
  117. package/lib/quoxon/render.js +130 -33
  118. package/lib/quoxon/roster.js +26 -12
  119. package/lib/quoxon/rows.js +244 -1
  120. package/lib/quoxon/serverChip.js +42 -0
  121. package/lib/quoxon/session.js +10 -0
  122. package/lib/quoxon/slash/cliCommand.js +42 -1
  123. package/lib/quoxon/slash/cliLeaves.json +76 -1
  124. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  125. package/lib/quoxon/slash/palette.js +1 -1
  126. package/lib/quoxon/slash/qpeCmd.js +35 -0
  127. package/lib/quoxon/slash/restart.js +25 -1
  128. package/lib/quoxon/slash/resume.js +18 -1
  129. package/lib/quoxon/slash/todoCmd.js +122 -0
  130. package/lib/quoxon/suggestCommand.js +33 -2
  131. package/lib/quoxon/toolCards.js +30 -0
  132. package/lib/quoxon/whatsnew.jsonl +45 -0
  133. package/lib/quoxon/windowOrg.js +11 -1
  134. package/lib/server/devServers.js +137 -0
  135. package/package.json +2 -1
  136. package/vendor/dragonlab/SOURCE +4 -0
  137. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  138. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  139. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  140. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  141. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  142. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  143. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  144. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  145. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  146. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  147. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  148. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  149. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  150. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  151. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  152. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  153. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  154. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  155. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  156. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  157. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  158. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  159. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  160. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  161. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  162. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  163. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  164. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  165. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  166. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  167. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  168. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  169. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  170. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  171. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  172. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  173. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  174. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  175. package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
  176. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  177. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  178. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  179. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  180. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  181. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  182. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  183. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  184. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  185. package/vendor/dragonlab/lib/power-effects.js +294 -0
  186. package/vendor/dragonlab/lib/power-registry.js +74 -0
  187. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  188. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -5,39 +5,52 @@
5
5
  * Tracker answers could not be checked against CLI truth. These verbs call the
6
6
  * SAME governed tool the Action Tracker UI and the agent use (POST
7
7
  * /api/v1/tools/execute, tool_name action_list), so chat, UI and CLI read one
8
- * source. Writes (add/move/update/delete) stay in chat and the UI for now.
8
+ * source. `add` and `done` write through the collector route the dashboard
9
+ * uses (POST/PATCH /api/v1/actions) as the logged-in user, via core/actionItems.js,
10
+ * the same core the Quox TUI /todo uses. Move/update/delete stay in chat and the UI.
9
11
  */
10
12
 
11
- import { formatOutput, formatDetail } from '../core/output.js';
13
+ import { formatOutput, formatDetail, printSuccess } from '../core/output.js';
12
14
  import { QuoxError } from '../core/errors.js';
15
+ import { confirmDestructive } from '../core/confirm.js';
13
16
  import { executeGovernedTool } from './tool.js';
17
+ import { addActionItem, completeActionItem, moveActionItem, deleteActionItem, STATUS_COLUMNS, PRIORITIES } from '../core/actionItems.js';
14
18
 
15
19
  const STATUSES = ['todo', 'doing', 'done', 'all'];
20
+ const MAX_PAGES = 50;
16
21
 
17
22
  async function listItems(ctx, { status = 'all', limit = 100, scope } = {}) {
18
23
  if (!STATUSES.includes(status)) {
19
24
  throw new QuoxError(`--status must be one of ${STATUSES.join(', ')}`);
20
25
  }
21
- const input = { status, limit: Math.min(Math.max(Number(limit) || 20, 1), 100) };
22
- if (scope) input.scope_id = scope;
23
- const data = await executeGovernedTool(ctx, 'action_list', input);
24
- const result = data && data.result !== undefined ? data.result : data;
25
- if (!result || result.success === false) {
26
- throw new QuoxError(`action_list failed: ${(result && (result.error || result.message)) || 'no result'}`);
26
+ const pageSize = Math.min(Math.max(Number(limit) || 20, 1), 100);
27
+ // Page through everything: a silent cap at one page hid 4 of 24 items (owner, 2026-10-20).
28
+ const all = [];
29
+ for (let page = 1; page <= MAX_PAGES; page++) {
30
+ const input = { status, limit: pageSize };
31
+ if (scope) input.scope_id = scope;
32
+ if (page > 1) input.page = page;
33
+ const data = await executeGovernedTool(ctx, 'action_list', input);
34
+ const result = data && data.result !== undefined ? data.result : data;
35
+ if (!result || result.success === false) {
36
+ throw new QuoxError(`action_list failed: ${(result && (result.error || result.message)) || 'no result'}`);
37
+ }
38
+ all.push(...(Array.isArray(result.items) ? result.items : []));
39
+ if (!result.hasMore) return all;
27
40
  }
28
- return Array.isArray(result.items) ? result.items : [];
41
+ throw new QuoxError(`More than ${MAX_PAGES * pageSize} items; narrow with --status or --scope.`);
29
42
  }
30
43
 
31
44
  export function registerActionsCommands(program, ctx) {
32
45
  const actions = program
33
46
  .command('actions')
34
- .description('Action Tracker items (read-only; add and move items in chat or the dashboard)');
47
+ .description('Action Tracker items: list, get, add, done, move, delete (edit in chat or the dashboard)');
35
48
 
36
49
  actions
37
50
  .command('list')
38
51
  .description('List action items')
39
52
  .option('--status <status>', 'todo, doing, done or all', 'all')
40
- .option('--limit <n>', 'max items (1-100)', '100')
53
+ .option('--limit <n>', 'page size (1-100); every page is listed', '100')
41
54
  .option('--scope <scopeId>', 'only items in this team/department/division scope')
42
55
  .action(async (options) => {
43
56
  const items = await listItems({ ...ctx, program }, options);
@@ -58,4 +71,61 @@ export function registerActionsCommands(program, ctx) {
58
71
  if (!item) throw new QuoxError(`No action item ${id} among the latest 100 items.`);
59
72
  formatDetail(item);
60
73
  });
74
+
75
+ actions
76
+ .command('add')
77
+ .description('Add an Action Tracker item (todo) as the logged-in user')
78
+ .argument('<text...>', 'what needs doing')
79
+ .option('--due <date>', 'YYYY-MM-DD, today or tomorrow')
80
+ .option('--priority <priority>', `one of ${PRIORITIES.join(', ')}`)
81
+ .option('--description <text>', 'longer notes')
82
+ .action(async (words, options) => {
83
+ ctx.auth.requireAuth();
84
+ const item = await addActionItem(ctx.client, { title: words, due: options.due, priority: options.priority, description: options.description });
85
+ if (program.opts().output === 'json') return formatDetail(item);
86
+ printSuccess(`Added action item ${item.id}: ${item.title}${item.due_date ? ` (due ${String(item.due_date).slice(0, 10)})` : ''}`);
87
+ return undefined;
88
+ });
89
+
90
+ actions
91
+ .command('done')
92
+ .description('Mark an Action Tracker item done')
93
+ .argument('<id>', 'action item id (from `quox actions list`)')
94
+ .action(async (id) => {
95
+ ctx.auth.requireAuth();
96
+ const item = await completeActionItem(ctx.client, id);
97
+ if (program.opts().output === 'json') return formatDetail(item);
98
+ printSuccess(`Marked done ${item.id}: ${item.title}`);
99
+ return undefined;
100
+ });
101
+
102
+ actions
103
+ .command('move')
104
+ .description('Move an Action Tracker item to todo, doing or done')
105
+ .argument('<id>', 'action item id (from `quox actions list`)')
106
+ .argument('<status>', STATUS_COLUMNS.join(', '))
107
+ .action(async (id, status) => {
108
+ ctx.auth.requireAuth();
109
+ const item = await moveActionItem(ctx.client, id, status);
110
+ if (program.opts().output === 'json') return formatDetail(item);
111
+ printSuccess(`Moved ${item.id} to ${item.status}: ${item.title}`);
112
+ return undefined;
113
+ });
114
+
115
+ actions
116
+ .command('delete')
117
+ .description('Delete one of your Action Tracker items (asks first; --yes skips, required without a terminal)')
118
+ .argument('<id>', 'action item id (from `quox actions list`)')
119
+ .option('-y, --yes', 'do not ask')
120
+ .action(async (id, options) => {
121
+ ctx.auth.requireAuth();
122
+ if (!(await confirmDestructive(program, { force: options.yes === true }, `Delete action item ${id}?`))) {
123
+ console.log('Not deleted.');
124
+ return undefined;
125
+ }
126
+ const r = await deleteActionItem(ctx.client, id);
127
+ if (program.opts().output === 'json') return formatDetail(r);
128
+ printSuccess(`Deleted ${r.id}${r.title ? `: ${r.title}` : ''}`);
129
+ return undefined;
130
+ });
61
131
  }
@@ -8,6 +8,7 @@ import { resolveOrgId } from './helpers.js';
8
8
  import { resolveEndpoint } from '../api/endpoints.js';
9
9
  import { QuoxApiError } from '../core/errors.js';
10
10
  import { loadBundledAssistantsRoster } from '../core/agentAlias.js';
11
+ import { AGENT_FAMILY_IDS } from '../core/agentFamilies.js';
11
12
  import { registerAgentAttachCommands } from './agentAttach.js';
12
13
 
13
14
  // quox#1169: the prompt, domains and parent a user gives their own agent. Auth reads
@@ -64,6 +65,7 @@ export function registerAgentCommands(program, ctx) {
64
65
  .option('--level <level>', 'filter by level (0-4)')
65
66
  .option('--source <source>', 'registry | org | all', 'all')
66
67
  .option('--plugin <id>', 'only agents that require this plugin')
68
+ .option('--family <id>', `only agents in this family (${AGENT_FAMILY_IDS.join(', ')})`)
67
69
  .option('--search <text>', 'substring match on id, name or description')
68
70
  .action(async (options) => {
69
71
  auth.requireAuth();
@@ -71,6 +73,9 @@ export function registerAgentCommands(program, ctx) {
71
73
  if (!['registry', 'org', 'all'].includes(source)) {
72
74
  throw new Error(`--source must be registry, org or all (got "${options.source}")`);
73
75
  }
76
+ if (options.family !== undefined && !AGENT_FAMILY_IDS.includes(options.family)) {
77
+ throw new Error(`unknown family "${options.family}". Valid families: ${AGENT_FAMILY_IDS.join(', ')}`);
78
+ }
74
79
  const notes = [];
75
80
 
76
81
  // Registered specialists (server roster; bundled roster if the server lacks the route).
@@ -84,7 +89,7 @@ export function registerAgentCommands(program, ctx) {
84
89
  } catch (err) {
85
90
  notes.push(`note: server registry unavailable (${err.message}); showing the bundled roster, which may be out of date.`);
86
91
  registry = loadBundledAssistantsRoster().map(r => ({
87
- agent_id: r.id, name: r.name || r.id, level: null, requires_plugin: null, tool_count: null, source: 'registry',
92
+ agent_id: r.id, name: r.name || r.id, level: null, requires_plugin: null, family: r.family || null, tool_count: null, source: 'registry',
88
93
  }));
89
94
  }
90
95
  }
@@ -113,7 +118,7 @@ export function registerAgentCommands(program, ctx) {
113
118
  const key = o.agent_id || o.id;
114
119
  const reg = merged.get(key);
115
120
  merged.set(key, reg
116
- ? { ...reg, ...o, requires_plugin: reg.requires_plugin, description: reg.description ?? o.description, tool_count: o.tool_count ?? reg.tool_count, source: 'org+registry' }
121
+ ? { ...reg, ...o, requires_plugin: reg.requires_plugin, family: reg.family ?? null, description: reg.description ?? o.description, tool_count: o.tool_count ?? reg.tool_count, source: 'org+registry' }
117
122
  : o);
118
123
  }
119
124
 
@@ -121,6 +126,7 @@ export function registerAgentCommands(program, ctx) {
121
126
  if (options.level !== undefined) all = all.filter(r => String(r.level) === String(options.level));
122
127
  if (options.status) all = all.filter(r => r.status === options.status);
123
128
  if (options.plugin) all = all.filter(r => r.requires_plugin === options.plugin);
129
+ if (options.family) all = all.filter(r => r.family === options.family);
124
130
  if (options.search) {
125
131
  const q = options.search.toLowerCase();
126
132
  all = all.filter(r => [r.agent_id, r.name, r.description].some(v => String(v ?? '').toLowerCase().includes(q)));
@@ -130,10 +136,11 @@ export function registerAgentCommands(program, ctx) {
130
136
  for (const n of notes) console.error(n);
131
137
  const dash = (v) => (v === null || v === undefined || v === '' ? '-' : v);
132
138
  formatOutput({
133
- columns: ['agent_id', 'name', 'source', 'level', 'state', 'tool_count'],
134
- headers: ['Agent ID', 'Name', 'Source', 'Level', 'Status/Plugin', 'Tools'],
139
+ columns: ['agent_id', 'name', 'family', 'source', 'level', 'state', 'tool_count'],
140
+ headers: ['Agent ID', 'Name', 'Family', 'Source', 'Level', 'Status/Plugin', 'Tools'],
135
141
  rows: all.map(r => ({
136
142
  ...r,
143
+ family: dash(r.family),
137
144
  level: dash(r.level),
138
145
  state: r.status ? r.status : (r.requires_plugin ? `plugin:${r.requires_plugin}` : '-'),
139
146
  tool_count: dash(r.tool_count),
@@ -435,6 +435,38 @@ export function execResultFailed(data) {
435
435
  return negative(data.success) || negative(data.ok);
436
436
  }
437
437
 
438
+ /**
439
+ * A governed tool that was held for a human returns { success:false, pending:true, approvalId }. That is not a
440
+ * failure: the approval is filed and waiting. Returns the approval id, or null when the result is not pending.
441
+ */
442
+ export function execResultPending(data) {
443
+ if (!data || typeof data !== 'object') return null;
444
+ const src = data.pending === true ? data : (data.result && typeof data.result === 'object' && data.result.pending === true ? data.result : null);
445
+ if (!src) return null;
446
+ return src.approvalId || src.approval_id || (src._approval && src._approval.id) || 'unknown';
447
+ }
448
+
449
+ /**
450
+ * A governed tool held by the policy layer answers HTTP 403 with {pending:true, approvalId}: the approval IS filed.
451
+ * Returns that body when an API error carries one (so the caller renders awaiting approval), else null.
452
+ */
453
+ export function pendingBodyOfError(err) {
454
+ const body = err && err.body && typeof err.body === 'object' ? err.body : null;
455
+ return body && execResultPending(body) ? body : null;
456
+ }
457
+
458
+ /**
459
+ * Is this 401/403 really about the attach token? 401 is an unauthenticated bearer. A 403 counts only when the server
460
+ * says so (attach/lease/token in its code or message); any other 403 is the server's own refusal and is shown verbatim.
461
+ */
462
+ export function isAttachTokenError(err) {
463
+ const status = err && err.status;
464
+ if (status === 401) return true;
465
+ if (status !== 403) return false;
466
+ const text = [err && err.code, err && err.body && err.body.error, err && err.body && err.body.error_code, err && err.message].filter(Boolean).join(' ');
467
+ return /attach|lease|token/i.test(text);
468
+ }
469
+
438
470
  /** The first human-readable error string a failed tool result carries, or a default. */
439
471
  function execResultError(data) {
440
472
  return (data && (data.error || data.error_code || data.reason || data.message)) || 'tool reported failure';
@@ -1343,13 +1375,17 @@ export function registerAgentAttachCommands(agent, ctx, program) {
1343
1375
  });
1344
1376
  } catch (err) {
1345
1377
  const status = err instanceof QuoxApiError ? err.status : 'network error';
1346
- if (err instanceof QuoxApiError && (err.status === 401 || err.status === 403)) {
1378
+ const held = err instanceof QuoxApiError ? pendingBodyOfError(err) : null;
1379
+ if (held) {
1380
+ data = held; // the policy layer filed an approval: that is a result, not an error
1381
+ } else if (err instanceof QuoxApiError && isAttachTokenError(err)) {
1347
1382
  throw new Error(
1348
1383
  `POST ${ep.path} failed (${status}): the attach token for "${agentId}" is no longer valid `
1349
1384
  + `(expired or revoked lease). Re-attach with \`quox agent attach ${agentId}\`.`
1350
1385
  );
1386
+ } else {
1387
+ throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
1351
1388
  }
1352
- throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
1353
1389
  }
1354
1390
 
1355
1391
  // finding (autonomous loop): the glyph must reflect the TOOL result, not
@@ -1358,14 +1394,17 @@ export function registerAgentAttachCommands(agent, ctx, program) {
1358
1394
  // agent read failures as passes ([error-body-is-not-data] at the CLI
1359
1395
  // surface). Set a non-zero exit code too so a scripted / feedback-loop
1360
1396
  // caller can't mistake it for a pass.
1361
- const toolOk = !execResultFailed(data);
1397
+ const pendingId = execResultPending(data);
1398
+ const toolOk = pendingId ? true : !execResultFailed(data);
1362
1399
  // Skip the announce line under --json/-o json: a scripted caller parses
1363
1400
  // stdout as a single JSON document, and the glyph line would break that.
1364
1401
  if (program.opts().output !== 'json') {
1365
- printEvent(line('quox', `exec ${options.tool}`, {
1366
- result: toolOk ? agentId : `${agentId} · ${execResultError(data)}`,
1367
- status: toolOk ? 'ok' : 'fail',
1368
- }));
1402
+ printEvent(line('quox', `exec ${options.tool}`, pendingId
1403
+ ? { result: `${agentId} · awaiting approval ${pendingId}`, status: 'busy' }
1404
+ : {
1405
+ result: toolOk ? agentId : `${agentId} · ${execResultError(data)}`,
1406
+ status: toolOk ? 'ok' : 'fail',
1407
+ }));
1369
1408
  }
1370
1409
  formatDetail(data, { format: program.opts().output, title: `Tool result: ${options.tool}` });
1371
1410
  if (!toolOk) process.exitCode = 1;
@@ -34,6 +34,11 @@ export function registerApprovalsCommands(program, ctx) {
34
34
  }
35
35
  console.log(`Approval ${data.approval_id} tool: ${data.tool} status: ${data.status}`
36
36
  + (data.executed_at ? ` executed: ${data.executed_at}` : ''));
37
+ if (data.decided_by) {
38
+ const who = data.decided_by.name || data.decided_by.id;
39
+ const via = data.decided_via && data.decided_via.channel ? ` via ${data.decided_via.channel}` : '';
40
+ console.log(`decided by ${who} (${data.decided_by.actor_type || 'user'})${via} at ${data.decided_at || 'unknown time'}`);
41
+ }
37
42
  if (data.result === null || data.result === undefined) {
38
43
  if (data.status === 'pending') console.log('Still pending: a human has not decided yet.');
39
44
  else if (data.status === 'resuming') console.log('Approved, executing. Re-run shortly.');
@@ -96,6 +96,7 @@ export function registerAskCommands(program, { client, auth }) {
96
96
  .option('--paths <files...>', 'every test file the commit edits (absolute, worktree or repo-relative: all are normalised)')
97
97
  .option('--justify <text>', 'why it is safe, in plain words (required)')
98
98
  .option('--why <text>', 'detail: the findings, before and after lines')
99
+ .option('--recommend <y|n>', 'your advice for the card: y or n (shown as the recommendation)')
99
100
  .option('--session <name>', 'which session is asking (default: $QUOX_SESSION)')
100
101
  .option('--slot <name>', 'your slot')
101
102
  .option('--expires <dur>', 'how long it stays open')
@@ -105,6 +106,7 @@ export function registerAskCommands(program, { client, auth }) {
105
106
  // commander lets the parent `ask` swallow flags it shares with this verb (--justify, --why, --session...), so merge both
106
107
  const options = { ...ask.opts(), ...Object.fromEntries(Object.entries(subOptions).filter(([, v]) => v !== undefined)) };
107
108
  if (!String(options.justify || '').trim()) { err('say why it is safe: --justify "<plain words>"'); process.exitCode = 2; return; }
109
+ if (!options.recommend) err('tip: add --recommend y|n so the card shows your advice');
108
110
  const raw = [].concat(options.paths || []).flatMap((x) => String(x).split(',')).map((x) => x.trim()).filter(Boolean);
109
111
  let root;
110
112
  try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); } catch { root = process.cwd(); }
@@ -114,7 +116,7 @@ export function registerAskCommands(program, { client, auth }) {
114
116
  // 2026-10-19: the server truncates long titles, and the attestation names only paths found in title+body,
115
117
  // so a 7-file ask covered 3. Every path also goes in the body.
116
118
  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 });
119
+ 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, recommend: options.recommend });
118
120
  });
119
121
 
120
122
  ask
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `quox blog draft submit|list|show|mark-published` (BLOG-DRAFT).
3
+ *
4
+ * Submit saves the draft as a NotesQ note in the blog-drafts collection and files a hand-off to
5
+ * the blog publisher (a line in the publisher's slot plus an inbox item). It NEVER publishes:
6
+ * publishing is the publisher's governed pipeline after the owner's yes. `mark-published` is the
7
+ * publisher-side verb that records the URL and tells the originating conversation/objective.
8
+ */
9
+
10
+ import { readFileSync } from 'fs';
11
+ import { formatOutput, formatDetail, printSuccess, printInfo } from '../core/output.js';
12
+ import { line as glyphLine, printEvent } from '../core/quoxglyph.js';
13
+ import { resolveEndpoint } from '../api/endpoints.js';
14
+
15
+ const isJson = (program) => program.opts().output === 'json';
16
+ const printJson = (d) => console.log(JSON.stringify(d, null, 2));
17
+
18
+ async function readBody(options, ctx) {
19
+ if (options.file) return readFileSync(options.file, 'utf-8');
20
+ if (options.stdin) {
21
+ const input = ctx.stdin || process.stdin;
22
+ let buf = '';
23
+ for await (const chunk of input) buf += chunk;
24
+ return buf;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export function registerBlogCommands(program, ctx) {
30
+ const { client, auth } = ctx;
31
+ const blog = program.command('blog').description('Blog: hand drafts to the blog publisher (never publishes)');
32
+ const draft = blog.command('draft').description('Blog drafts: saved as revisioned NotesQ notes in the blog-drafts collection');
33
+
34
+ draft
35
+ .command('submit')
36
+ .description('Save a draft and hand it to the blog publisher (slot + inbox). Does NOT publish.')
37
+ .requiredOption('--title <title>', 'post title')
38
+ .option('--file <path>', 'read the post markdown from a file')
39
+ .option('--stdin', 'read the post markdown from stdin')
40
+ .option('--hero <image>', 'hero image path or URL')
41
+ .option('--tag <tag...>', 'tag (repeatable)')
42
+ .option('--conversation <id>', 'originating conversation id (so its publication is reported back)')
43
+ .option('--objective <id>', 'originating objective id')
44
+ .action(async (options) => {
45
+ auth.requireAuth();
46
+ const markdown = await readBody(options, ctx);
47
+ if (markdown === null) throw new Error('give the post body with --file <path> or --stdin');
48
+ if (!markdown.trim()) throw new Error('the draft body is empty; nothing to submit');
49
+ const body = { title: options.title, markdown };
50
+ if (options.hero) body.hero = options.hero;
51
+ if (options.tag && options.tag.length) body.tags = options.tag;
52
+ if (options.conversation) body.conversation_id = options.conversation;
53
+ if (options.objective) body.objective_id = options.objective;
54
+ const ep = resolveEndpoint('blog.drafts.submit');
55
+ const data = await client.post(ep.path, { service: ep.service, body });
56
+ if (isJson(program)) return printJson(data);
57
+ printEvent(glyphLine('quox', `blog draft submit "${options.title}"`, `note ${data.note_id}`));
58
+ printSuccess(`Draft saved as note ${data.note_id} and handed to the publisher slot ${data.slot} (hand-off ${data.handoff_id}).`);
59
+ if (data.link) console.log(`link: ${data.link}`);
60
+ printInfo('Not published: nothing goes live until the publisher publishes after the owner approves.');
61
+ });
62
+
63
+ draft
64
+ .command('list')
65
+ .description('List blog drafts')
66
+ .option('--status <status>', 'draft | published')
67
+ .action(async (options) => {
68
+ auth.requireAuth();
69
+ const ep = resolveEndpoint('blog.drafts.list');
70
+ const data = await client.get(ep.path, { service: ep.service, query: options.status ? { status: options.status } : {} });
71
+ if (isJson(program)) return printJson(data);
72
+ const rows = (data.drafts || []).map((d) => ({ id: d.id, title: d.title, status: d.status, url: d.url || '', updated: d.updated || '' }));
73
+ if (!rows.length) return printInfo('No blog drafts.');
74
+ formatOutput({ columns: ['id', 'title', 'status', 'url', 'updated'], rows });
75
+ });
76
+
77
+ draft
78
+ .command('show')
79
+ .description('Show one blog draft (body and hand-off facts)')
80
+ .argument('<id>', 'draft (note) id')
81
+ .action(async (id) => {
82
+ auth.requireAuth();
83
+ const ep = resolveEndpoint('blog.drafts.get', { id });
84
+ const data = await client.get(ep.path, { service: ep.service });
85
+ if (isJson(program)) return printJson(data);
86
+ const d = data.draft || {};
87
+ const { markdown, ...facts } = d;
88
+ formatDetail(facts);
89
+ console.log('');
90
+ console.log(markdown || '');
91
+ });
92
+
93
+ draft
94
+ .command('mark-published')
95
+ .description('Publisher side: record the published URL and tell the originating conversation/objective')
96
+ .argument('<id>', 'draft (note) id')
97
+ .requiredOption('--url <url>', 'the published https URL')
98
+ .action(async (id, options) => {
99
+ auth.requireAuth();
100
+ if (!/^https:\/\//i.test(options.url)) throw new Error('--url must be an https URL');
101
+ const ep = resolveEndpoint('blog.drafts.published', { id });
102
+ const data = await client.post(ep.path, { service: ep.service, body: { url: options.url } });
103
+ if (isJson(program)) return printJson(data);
104
+ printSuccess(data.already ? `Draft ${id} was already marked published at ${data.url}.` : `Draft ${id} marked published at ${data.url}.`);
105
+ if (data.notified) {
106
+ const n = data.notified;
107
+ printInfo(`Told: ${[n.conversation_id && `conversation ${n.conversation_id}`, n.objective_id && `objective ${n.objective_id}`, n.inbox_item_id && `inbox ${n.inbox_item_id}`].filter(Boolean).join(', ') || 'inbox only'}.`);
108
+ }
109
+ });
110
+ }