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
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `quox youtube` — YouTube plugin verbs, YT-O-6 (tiktok/linkedin CLI parity).
3
+ *
4
+ * quox youtube upload "<title>" --file <path-in-media-root> [--description t] [--privacy private|unlisted|public] [--thumbnail <image-path>]
5
+ * quox youtube thumbnail <videoId> --image <path-in-media-root>
6
+ * quox youtube videos [--limit <n>]
7
+ * quox youtube channel
8
+ * quox youtube analytics [--metrics a,b,c] [--video <id>]
9
+ * quox youtube comment <commentId> "<text>"
10
+ *
11
+ * The write verbs (upload, thumbnail, comment) execute the youtube connector
12
+ * tool through the governed collector endpoint (POST /api/v1/tools/execute ->
13
+ * connector bridge). The bridge gates them on requiresApproval: it files an
14
+ * owner approval card and returns { pending:true, status:'awaiting_approval',
15
+ * approvalId, inbox_item_id }. The YouTube API write only happens once an
16
+ * approver says yes (dashboard inbox or `quox inbox respond <id> --action approve`).
17
+ *
18
+ * Honest limit on --thumbnail: YouTube assigns the video id only when the
19
+ * upload actually executes, i.e. AFTER approval. While the upload card is
20
+ * pending there is no id to attach a thumbnail to, so the chained
21
+ * set_thumbnail step runs only if the upload came back executed with a
22
+ * video id; otherwise the CLI says so and names the follow-up verb.
23
+ */
24
+
25
+ import { formatDetail, printSuccess, printInfo, printWarning } from '../core/output.js';
26
+ import { resolveEndpoint } from '../api/endpoints.js';
27
+ import { resolveOrgId } from './helpers.js';
28
+
29
+ const PRIVACY = ['private', 'unlisted', 'public'];
30
+
31
+ async function executeYouTubeTool({ client, auth, program }, toolName, input) {
32
+ auth.requireAuth();
33
+ const orgId = resolveOrgId(auth, program);
34
+ const ep = resolveEndpoint('v1.tools.execute');
35
+ const body = { tool_name: toolName, input, org_id: orgId };
36
+ const data = await client.post(ep.path, { service: ep.service, body });
37
+ if (data && data.success === false && !data.pending) {
38
+ throw new Error(`${toolName} via POST ${ep.path} returned success:false: ${data.error || 'unknown error'}`);
39
+ }
40
+ return data;
41
+ }
42
+
43
+ function printAwaitingApproval(action, data) {
44
+ const itemRef = data.inbox_item_id || data.approvalId || 'unknown';
45
+ printSuccess(`${action} filed for owner approval - inbox item ${itemRef} (awaiting approval). Nothing is sent to YouTube until an approver says yes.`);
46
+ if (data.inbox_item_id) {
47
+ printInfo(`Review and approve: quox inbox get ${data.inbox_item_id} (or: quox inbox respond ${data.inbox_item_id} --action approve)`);
48
+ }
49
+ }
50
+
51
+ export function registerYouTubeCommands(program, ctx) {
52
+ const { client, auth } = ctx;
53
+ const run = (tool, input) => executeYouTubeTool({ client, auth, program }, tool, input);
54
+ const asJson = () => program.opts().output === 'json';
55
+
56
+ const youtube = program
57
+ .command('youtube')
58
+ .description('YouTube uploads, thumbnails, comments and reads: writes are governed and HITL-approved via HITL Inbox, on your own vaulted Google credential');
59
+
60
+ youtube
61
+ .command('upload')
62
+ .description('Upload a video from a file under the org media root. Files an owner approval card and returns immediately; never uploads directly.')
63
+ .argument('<title>', 'video title (max 100 characters)')
64
+ .requiredOption('--file <path>', 'video file path, relative to the org media root')
65
+ .option('--description <text>', 'video description')
66
+ .option('--privacy <status>', `privacy status: ${PRIVACY.join('|')} (default private)`, 'private')
67
+ .option('--thumbnail <path>', 'thumbnail image path (media root); chained set_thumbnail, only possible once the upload has a video id')
68
+ .action(async (title, options) => {
69
+ const privacy = options.privacy || 'private';
70
+ if (!PRIVACY.includes(privacy)) {
71
+ throw new Error(`--privacy must be one of ${PRIVACY.join(', ')} (got ${privacy})`);
72
+ }
73
+ const input = { title, file_path: options.file, privacy_status: privacy };
74
+ if (options.description) input.description = options.description;
75
+
76
+ const data = await run('youtube_upload_video', input);
77
+ const videoId = data?.result?.video_id || data?.video_id || null;
78
+ let thumb = null;
79
+ if (options.thumbnail && videoId && !data.pending) {
80
+ thumb = await run('youtube_set_thumbnail', { video_id: videoId, image_path: options.thumbnail });
81
+ }
82
+
83
+ if (asJson()) {
84
+ formatDetail(thumb ? { upload: data, thumbnail: thumb } : data, { format: 'json' });
85
+ return;
86
+ }
87
+ if (data.pending) {
88
+ printAwaitingApproval('Upload', data);
89
+ if (options.thumbnail) {
90
+ printWarning(`Thumbnail not filed: the video id does not exist until the upload is approved and runs. After approval run: quox youtube thumbnail <videoId> --image ${options.thumbnail} (that files a second, separate approval).`);
91
+ }
92
+ return;
93
+ }
94
+ printSuccess(`Upload executed${videoId ? ` - video ${videoId}` : ''}.`);
95
+ if (thumb) {
96
+ if (thumb.pending) printAwaitingApproval('Thumbnail', thumb);
97
+ else printSuccess('Thumbnail set.');
98
+ } else if (options.thumbnail) {
99
+ printWarning(`Upload returned no video id, so the thumbnail was not set. Run: quox youtube thumbnail <videoId> --image ${options.thumbnail}`);
100
+ }
101
+ });
102
+
103
+ youtube
104
+ .command('thumbnail')
105
+ .description('Set a custom thumbnail on an existing video from an image under the org media root. Files an owner approval card; never sets it directly.')
106
+ .argument('<videoId>', 'YouTube video id')
107
+ .requiredOption('--image <path>', 'image path (.jpg/.jpeg/.png, max 2MB), relative to the org media root')
108
+ .action(async (videoId, options) => {
109
+ const data = await run('youtube_set_thumbnail', { video_id: videoId, image_path: options.image });
110
+ if (asJson()) { formatDetail(data, { format: 'json' }); return; }
111
+ printAwaitingApproval('Thumbnail', data);
112
+ });
113
+
114
+ youtube
115
+ .command('videos')
116
+ .description('List the connected channel\'s recent videos (read-only, no approval needed).')
117
+ .option('--limit <n>', 'max videos to return (max 50)', '20')
118
+ .action(async (options) => {
119
+ const count = Math.min(parseInt(options.limit, 10) || 20, 50);
120
+ const data = await run('youtube_list_videos', { count });
121
+ if (asJson()) { formatDetail(data, { format: 'json' }); return; }
122
+ const videos = data.result?.videos || [];
123
+ if (videos.length === 0) { printInfo('No videos returned.'); return; }
124
+ for (const v of videos) {
125
+ console.log(`${v.video_id} ${v.title || '(no title)'} [${v.privacy_status || 'unknown'}]`);
126
+ console.log(` views: ${v.view_count ?? 0} likes: ${v.like_count ?? 0} comments: ${v.comment_count ?? 0}`);
127
+ }
128
+ });
129
+
130
+ youtube
131
+ .command('channel')
132
+ .description('Show the connected YouTube channel (also the cheapest live credential check).')
133
+ .action(async () => {
134
+ const data = await run('youtube_get_channel', {});
135
+ if (asJson()) { formatDetail(data, { format: 'json' }); return; }
136
+ const ch = data.result || {};
137
+ if (!ch.channel_id && !ch.title) { printInfo('No channel returned.'); return; }
138
+ console.log(`${ch.title || 'unknown'} (${ch.channel_id || 'unknown'})`);
139
+ console.log(` subscribers: ${ch.subscriber_count ?? 'unknown'} videos: ${ch.video_count ?? 'unknown'} views: ${ch.view_count ?? 'unknown'}`);
140
+ });
141
+
142
+ youtube
143
+ .command('analytics')
144
+ .description('Channel (or single video) analytics totals for the last 28 days.')
145
+ .option('--metrics <list>', 'comma-separated YouTube Analytics metric names (default views,estimatedMinutesWatched,averageViewDuration,likes,subscribersGained)')
146
+ .option('--video <id>', 'restrict to one video id')
147
+ .action(async (options) => {
148
+ const input = {};
149
+ if (options.metrics) input.metrics = options.metrics;
150
+ if (options.video) input.video_id = options.video;
151
+ const data = await run('youtube_get_analytics', input);
152
+ if (asJson()) { formatDetail(data, { format: 'json' }); return; }
153
+ const r = data.result || {};
154
+ const metrics = r.metrics || {};
155
+ const keys = Object.keys(metrics);
156
+ if (keys.length === 0) { printInfo('No analytics returned.'); return; }
157
+ console.log(`${r.start_date || '?'} to ${r.end_date || '?'}${r.video_id ? ` video ${r.video_id}` : ''}`);
158
+ for (const k of keys) console.log(` ${k}: ${metrics[k]}`);
159
+ if (r.missing_metrics?.length) printWarning(`Not returned by YouTube: ${r.missing_metrics.join(', ')}`);
160
+ });
161
+
162
+ youtube
163
+ .command('comment')
164
+ .description('Reply to a comment thread. Files an owner approval card and returns immediately; never posts directly.')
165
+ .argument('<commentId>', 'comment thread id to reply to (the id of the top-level comment)')
166
+ .argument('<text>', 'reply text')
167
+ .action(async (parentId, text) => {
168
+ const data = await run('youtube_reply_comment', { parent_id: parentId, text });
169
+ if (asJson()) { formatDetail(data, { format: 'json' }); return; }
170
+ printAwaitingApproval('Comment reply', data);
171
+ });
172
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Action Tracker item writes and open-item reads, shared by `quox actions
3
+ * add|done` and the Quox TUI /todo and /todos. One core, so chat, CLI and TUI
4
+ * create the same item through the same collector route
5
+ * (POST /api/v1/actions, PATCH /api/v1/actions/:id), as the logged-in user.
6
+ */
7
+
8
+ import { resolveEndpoint } from '../api/endpoints.js';
9
+ import { QuoxError } from './errors.js';
10
+
11
+ export const PRIORITIES = ['low', 'medium', 'high', 'urgent'];
12
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
13
+
14
+ const pad = (n) => String(n).padStart(2, '0');
15
+ const dayOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
16
+
17
+ /** "today", "tomorrow" or YYYY-MM-DD -> YYYY-MM-DD. Anything else is a usage error, never guessed. */
18
+ export function parseDue(value, now = new Date()) {
19
+ if (value === undefined || value === null || value === '') return null;
20
+ const v = String(value).trim().toLowerCase();
21
+ if (v === 'today') return dayOf(now);
22
+ if (v === 'tomorrow') return dayOf(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1));
23
+ if (ISO_DAY.test(v) && !Number.isNaN(Date.parse(`${v}T00:00:00Z`))) return v;
24
+ throw new QuoxError(`--due must be YYYY-MM-DD, today or tomorrow (got "${value}")`, 2);
25
+ }
26
+
27
+ /** Words typed after the verb -> a title. Empty or blank is a usage error. */
28
+ export function titleFromWords(words) {
29
+ const title = (Array.isArray(words) ? words.join(' ') : String(words || '')).trim();
30
+ if (!title) throw new QuoxError('Usage: quox actions add <text...> [--due <date>] [--priority low|medium|high|urgent]', 2);
31
+ return title;
32
+ }
33
+
34
+ /** Throw unless the response carries an item with an id: an error body is never a created item. */
35
+ function itemOf(res, what) {
36
+ const item = res && res.item;
37
+ if (!item || !item.id || res.success === false) {
38
+ throw new QuoxError(`${what} failed: ${(res && (res.error || res.message)) || 'the server returned no item'}`);
39
+ }
40
+ return item;
41
+ }
42
+
43
+ /** Create one Action Tracker item (status todo) as the logged-in user. -> the item. */
44
+ export async function addActionItem(client, { title, due, priority, description } = {}) {
45
+ const t = titleFromWords(title);
46
+ if (priority && !PRIORITIES.includes(priority)) {
47
+ throw new QuoxError(`--priority must be one of ${PRIORITIES.join(', ')}`, 2);
48
+ }
49
+ const body = { title: t };
50
+ const dueDate = parseDue(due);
51
+ if (dueDate) body.due_date = dueDate;
52
+ if (priority) body.priority = priority;
53
+ if (description) body.description = description;
54
+ const ep = resolveEndpoint('actions.create');
55
+ return itemOf(await client.post(ep.path, { service: ep.service, body }), 'Adding the action item');
56
+ }
57
+
58
+ export const STATUS_COLUMNS = ['todo', 'doing', 'done'];
59
+
60
+ /** Move one item to todo, doing or done. -> the item. */
61
+ export async function moveActionItem(client, id, status) {
62
+ const clean = String(id || '').trim();
63
+ if (!clean) throw new QuoxError('Usage: quox actions move <id> <todo|doing|done>', 2);
64
+ if (!STATUS_COLUMNS.includes(status)) throw new QuoxError(`status must be one of ${STATUS_COLUMNS.join(', ')}`, 2);
65
+ const ep = resolveEndpoint('actions.update', { id: clean });
66
+ return itemOf(await client.patch(ep.path, { service: ep.service, body: { status } }), 'Moving the action item');
67
+ }
68
+
69
+ /** Mark one item done. -> the item. */
70
+ export async function completeActionItem(client, id) {
71
+ if (!String(id || '').trim()) throw new QuoxError('Usage: quox actions done <id>', 2);
72
+ return moveActionItem(client, id, 'done');
73
+ }
74
+
75
+ /** Every item in one column (or all), paged to the end; never a silent cap. -> [{id,title,status,priority,due_date}]. */
76
+ export async function listActionItems(client, status = 'all', { pageSize = 100, maxPages = 50 } = {}) {
77
+ const ep = resolveEndpoint('actions.list');
78
+ const out = [];
79
+ for (let page = 1; page <= maxPages; page++) {
80
+ const query = { limit: pageSize, page };
81
+ if (status !== 'all') query.status = status;
82
+ const res = await client.get(ep.path, { service: ep.service, query, retries: 0 });
83
+ if (!res || !Array.isArray(res.items)) {
84
+ throw new QuoxError(`Listing action items failed: ${(res && (res.error || res.message)) || 'unexpected response'}`);
85
+ }
86
+ out.push(...res.items);
87
+ if (!(res.pagination && res.pagination.has_more)) return out;
88
+ }
89
+ throw new QuoxError(`More than ${maxPages * pageSize} action items; narrow with quox actions list --status.`);
90
+ }
91
+
92
+ /** The board by column. -> { backlog (status todo), doing, done } each a full list. */
93
+ export async function listBoard(client) {
94
+ const backlog = await listActionItems(client, 'todo');
95
+ const doing = await listActionItems(client, 'doing');
96
+ const done = await listActionItems(client, 'done');
97
+ return { backlog, doing, done };
98
+ }
99
+
100
+ /** Open items (todo then doing), all of them. */
101
+ export async function listOpenActionItems(client) {
102
+ return [...(await listActionItems(client, 'todo')), ...(await listActionItems(client, 'doing'))];
103
+ }
104
+
105
+ /** Delete one item (server soft-deletes; creator or admin only). Checks the status before trusting the body. -> { id, title }. */
106
+ export async function deleteActionItem(client, id) {
107
+ const clean = String(id || '').trim();
108
+ if (!clean) throw new QuoxError('Usage: quox actions delete <id> --yes', 2);
109
+ const ep = resolveEndpoint('actions.delete', { id: clean });
110
+ const res = await client.delete(ep.path, { service: ep.service });
111
+ if (!res || res.success === false || res.deleted !== true) {
112
+ throw new QuoxError(`Deleting the action item failed: ${(res && (res.error || res.message)) || 'the server did not confirm the delete'}`);
113
+ }
114
+ return { id: clean, title: res.title || '' };
115
+ }
@@ -58,6 +58,8 @@ export function loadBundledAssistantsRoster(dir = DEFAULT_ASSISTANTS_DIR) {
58
58
  id: raw.id,
59
59
  name: raw.identity?.name || null,
60
60
  shortName: raw.identity?.shortName || null,
61
+ // AGENT-TAXONOMY: present only when the file declares one.
62
+ ...(raw.family ? { family: raw.family } : {}),
61
63
  });
62
64
  }
63
65
  } catch {
@@ -0,0 +1,7 @@
1
+ // Agent family ids, mirrored from src/config/agentFamilies.js (the authority).
2
+ // The published CLI does not ship src/config, so it keeps this copy;
3
+ // cli/test/core/agentFamilies.test.js fails if the two drift.
4
+ export const AGENT_FAMILY_IDS = [
5
+ 'command', 'devops', 'monitoring', 'secops', 'development',
6
+ 'assistantq', 'knowledge', 'marketing', 'voice', 'governance',
7
+ ];
@@ -115,6 +115,14 @@ export function createClient(config, auth) {
115
115
  // never tried again for the rest of its life). A cooldown keeps the
116
116
  // don't-hammer-auth property without the death sentence.
117
117
  const REFRESH_RETRY_COOLDOWN_MS = 60 * 1000;
118
+ // quox#1375: a profile with nothing to refresh with (the collector's quox_cli hop writes only the caller's access
119
+ // token) must surface the route's own 401, not "Session expired... No refresh token", which hid the real cause.
120
+ // A token-file principal re-mints its own file and a resident re-auths by signed challenge, so both still refresh.
121
+ const canRefresh = () => {
122
+ if (process.env.QUOX_TOKEN_FILE) return true;
123
+ const a = typeof config.getAuth === 'function' ? config.getAuth() : null;
124
+ return !!(a && (a.refresh_token || a.kind === 'resident'));
125
+ };
118
126
  let refreshFailedAt = 0;
119
127
  const correlationId = generateCorrelationId();
120
128
 
@@ -291,7 +299,7 @@ export function createClient(config, auth) {
291
299
  }
292
300
 
293
301
  const refreshCoolingDown = refreshFailedAt && (Date.now() - refreshFailedAt < REFRESH_RETRY_COOLDOWN_MS);
294
- if (res.status === 401 && !noAuth && !actingAs && !refreshedThisRequest && !refreshCoolingDown && looksExpired) {
302
+ if (res.status === 401 && !noAuth && !actingAs && !refreshedThisRequest && !refreshCoolingDown && looksExpired && canRefresh()) {
295
303
  refreshedThisRequest = true;
296
304
  try {
297
305
  if (!refreshing) {
@@ -575,7 +583,7 @@ export function createClient(config, auth) {
575
583
  try { looksExpired = (await res.clone().json())?.error === 'invalid_token'; } catch { /* non-JSON body */ }
576
584
  }
577
585
  const refreshCoolingDown = refreshFailedAt && (Date.now() - refreshFailedAt < REFRESH_RETRY_COOLDOWN_MS);
578
- if (looksExpired && !refreshCoolingDown) {
586
+ if (looksExpired && !refreshCoolingDown && canRefresh()) {
579
587
  try {
580
588
  if (!refreshing) {
581
589
  refreshing = auth.refresh(config.getServiceUrl('auth')).finally(() => { refreshing = null; });
@@ -12,6 +12,7 @@ export const COMMAND_REGISTRARS = [
12
12
  ['../commands/doctor.js', 'registerDoctorCommand'],
13
13
  ['../commands/server.js', 'registerServerCommands'],
14
14
  ['../commands/update.js', 'registerUpdateCommand'],
15
+ ['../commands/shortcut.js', 'registerShortcutCommand'],
15
16
  ['../commands/open.js', 'registerOpenCommand'],
16
17
  ['../commands/peek.js', 'registerPeekCommand'],
17
18
  ['../commands/aside.js', 'registerAsideCommand'],
@@ -26,6 +27,7 @@ export const COMMAND_REGISTRARS = [
26
27
  ['../commands/tool.js', 'registerToolCommands'],
27
28
  ['../commands/workflow.js', 'registerWorkflowCommands'],
28
29
  ['../commands/objective.js', 'registerObjectiveCommands'],
30
+ ['../commands/loop.js', 'registerLoopCommands'],
29
31
  ['../commands/assistant.js', 'registerAssistantCommands'],
30
32
  ['../commands/fleet.js', 'registerFleetCommands'],
31
33
  ['../commands/chat.js', 'registerChatCommands'],
@@ -41,10 +43,12 @@ export const COMMAND_REGISTRARS = [
41
43
  ['../commands/mail.js', 'registerMailCommands'],
42
44
  ['../commands/calendar.js', 'registerCalendarCommands'],
43
45
  ['../commands/notes.js', 'registerNotesCommands'],
46
+ ['../commands/blog.js', 'registerBlogCommands'],
44
47
  ['../commands/contacts.js', 'registerContactsCommands'],
45
48
  ['../commands/licence.js', 'registerLicenceCommands'],
46
49
  ['../commands/run.js', 'registerRunCommands'],
47
50
  ['../commands/file.js', 'registerFileCommands'],
51
+ ['../commands/evidence.js', 'registerEvidenceCommands'],
48
52
  ['../commands/admin.js', 'registerAdminCommands'],
49
53
  ['../commands/aee.js', 'registerAeeCommands'],
50
54
  ['../commands/integration.js', 'registerIntegrationCommands'],
@@ -80,10 +84,12 @@ export const COMMAND_REGISTRARS = [
80
84
  ['../commands/capabilities.js', 'registerCapabilitiesCommands'],
81
85
  ['../commands/provider.js', 'registerProviderCommands'],
82
86
  ['../commands/tips.js', 'registerTipsCommands'],
87
+ ['../commands/ext.js', 'registerExtCommands'],
83
88
  ['../commands/quoxchat.js', 'registerQuoxchatCommands'],
84
89
  ['../commands/budget.js', 'registerBudgetCommands'],
85
90
  ['../commands/trace.js', 'registerTraceCommands'],
86
91
  ['../commands/mon.js', 'registerMonCommands'],
92
+ ['../commands/kuma.js', 'registerKumaCommands'],
87
93
  ['../commands/quoxbox.js', 'registerQuoxboxCommands'],
88
94
  ['../commands/plan.js', 'registerPlanCommands'],
89
95
  ['../commands/engagement.js', 'registerEngagementCommands'],
@@ -91,6 +97,7 @@ export const COMMAND_REGISTRARS = [
91
97
  ['../commands/discord.js', 'registerDiscordCommands'],
92
98
  ['../commands/qlarity.js', 'registerQlarityCommands'],
93
99
  ['../commands/fetchAllowlist.js', 'registerFetchAllowlistCommands'],
100
+ ['../commands/research.js', 'registerResearchCommands'],
94
101
  ['../commands/reach.js', 'registerReachCommands'],
95
102
  ['../commands/inventory.js', 'registerInventoryCommands'],
96
103
  ['../commands/policy.js', 'registerPolicyCommands'],
@@ -115,6 +122,8 @@ export const COMMAND_REGISTRARS = [
115
122
  ['../commands/facebook.js', 'registerFacebookCommands'],
116
123
  ['../commands/instagram.js', 'registerInstagramCommands'],
117
124
  ['../commands/tiktok.js', 'registerTikTokCommands'],
125
+ ['../commands/youtube.js', 'registerYouTubeCommands'],
126
+ ['../commands/image.js', 'registerImageCommands'],
118
127
  ['../commands/offload.js', 'registerOffloadCommands'],
119
128
  ];
120
129
 
@@ -220,7 +220,7 @@ export function deriveServiceUrl(baseUrl, port, service, opts = {}) {
220
220
  // only correct source for the flag at ctx-build time.
221
221
  // Commands whose remaining argv belongs to another program (`quox claude -p
222
222
  // "prompt"` is claude's --print, not quox's --profile).
223
- const WRAPPED_PROGRAM_COMMANDS = new Set(['claude', 'codex', 'gemini', 'wrap']);
223
+ const WRAPPED_PROGRAM_COMMANDS = new Set(['claude', 'codex', 'wrap']);
224
224
 
225
225
  export function resolveProfileFlagFromArgv(argv) {
226
226
  for (let i = 0; i < argv.length; i++) {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * SITREP-FINDINGS: gather the evidence that a security finding was fixed, on the host where
3
+ * git and gh live, for `quox engagement reconcile`. The collector makes the decision
4
+ * (securityFindings.reconcileFinding); this module only finds facts and never guesses:
5
+ * - issue: the stored issue_ref, the gripe_url Quox filed, a --link, or quox#N / #N in the
6
+ * title (quox#N in the description). Its state comes from `gh issue view`; unreadable = left out.
7
+ * - fix: commits on origin/main whose message cites the finding id or the linked issue
8
+ * number, or a --link commit. landed = an ancestor of origin/main; deployed = an ancestor of
9
+ * the build tree's HEAD (the tree quox-build last built from). A commit not on main is no fix.
10
+ */
11
+
12
+ const GH_ISSUE_URL = /github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)/;
13
+
14
+ export function issueRefsFor(finding, link = {}, issueRepo = 'quoxai/quox') {
15
+ const out = [];
16
+ const add = (r) => { if (r && !out.includes(r)) out.push(r); };
17
+ if (finding.issue_ref) add(finding.issue_ref);
18
+ const m = GH_ISSUE_URL.exec(finding.gripe_url || '');
19
+ if (m) add(`${m[1]}/${m[2]}#${m[3]}`);
20
+ if (link.issue) add(normIssue(link.issue, issueRepo));
21
+ const t = /(?:quox)?#(\d{1,6})\b/.exec(String(finding.title || ''));
22
+ if (t) add(`${issueRepo}#${t[1]}`);
23
+ const d = /\bquox#(\d{1,6})\b/.exec(String(finding.description || ''));
24
+ if (d) add(`${issueRepo}#${d[1]}`);
25
+ return out;
26
+ }
27
+
28
+ export function normIssue(ref, issueRepo = 'quoxai/quox') {
29
+ const s = String(ref).trim();
30
+ const m = GH_ISSUE_URL.exec(s);
31
+ if (m) return `${m[1]}/${m[2]}#${m[3]}`;
32
+ if (/^[\w.-]+\/[\w.-]+#\d+$/.test(s)) return s;
33
+ const n = /^(?:quox)?#?(\d+)$/.exec(s);
34
+ return n ? `${issueRepo}#${n[1]}` : null;
35
+ }
36
+
37
+ function issueState(ref, run) {
38
+ const [repo, n] = ref.split('#');
39
+ const r = run('gh', ['issue', 'view', n, '-R', repo, '--json', 'state']);
40
+ if (!r.ok) return null;
41
+ try {
42
+ const st = String(JSON.parse(r.out).state || '').toLowerCase();
43
+ return st === 'open' || st === 'closed' ? st : null;
44
+ } catch { return null; }
45
+ }
46
+
47
+ function commitsCiting(term, regex, { repoDir, run }) {
48
+ const r = run('git', ['-C', repoDir, 'log', 'origin/main', '--format=%H', ...(regex ? ['-E'] : ['--fixed-strings']), `--grep=${term}`]);
49
+ if (!r.ok) return [];
50
+ return r.out.split('\n').map((x) => x.trim()).filter((x) => /^[0-9a-f]{40}$/i.test(x));
51
+ }
52
+
53
+ export function gatherEvidence(finding, link = {}, { repoDir, buildDir, issueRepo = 'quoxai/quox', run }) {
54
+ const ev = {};
55
+ const refs = issueRefsFor(finding, link, issueRepo);
56
+ if (refs.length) {
57
+ ev.issue_ref = refs[0];
58
+ const st = issueState(refs[0], run);
59
+ if (st) ev.issue_state = st;
60
+ }
61
+
62
+ const shas = [];
63
+ const add = (s) => { if (s && !shas.includes(s)) shas.push(s); };
64
+ if (link.commit) {
65
+ const full = run('git', ['-C', repoDir, 'rev-parse', '--verify', `${link.commit}^{commit}`]);
66
+ add(full.ok && /^[0-9a-f]{40}$/i.test(full.out.trim()) ? full.out.trim() : link.commit);
67
+ }
68
+ if (finding.fix_commit) add(finding.fix_commit);
69
+ for (const s of commitsCiting(finding.id, false, { repoDir, run })) add(s);
70
+ if (ev.issue_ref && ev.issue_ref.startsWith(`${issueRepo}#`)) {
71
+ const n = ev.issue_ref.split('#')[1];
72
+ for (const s of commitsCiting(`#${n}([^0-9]|$)`, true, { repoDir, run })) add(s);
73
+ }
74
+ if (!shas.length) return ev;
75
+
76
+ const head = run('git', ['-C', buildDir, 'rev-parse', 'HEAD']);
77
+ const buildHead = head.ok ? head.out.trim() : null;
78
+ const isAnc = (dir, sha, tip) => run('git', ['-C', dir, 'merge-base', '--is-ancestor', sha, tip]).ok;
79
+ const landed = shas.filter((s) => isAnc(repoDir, s, 'origin/main'));
80
+ const deployed = buildHead ? landed.filter((s) => isAnc(buildDir, s, buildHead)) : [];
81
+ if (deployed.length) { ev.fix_commit = deployed[0]; ev.fix_state = 'deployed'; } else if (landed.length) { ev.fix_commit = landed[0]; ev.fix_state = 'landed'; }
82
+ return ev;
83
+ }
84
+
85
+ export function evidenceChanged(finding, ev) {
86
+ for (const k of ['issue_ref', 'issue_state', 'fix_commit', 'fix_state']) {
87
+ if (ev[k] != null && ev[k] !== (finding[k] ?? null)) return true;
88
+ }
89
+ return false;
90
+ }
@@ -87,7 +87,7 @@ const HONOURS_GLOBAL_DRY_RUN = Symbol.for('quox.forwardsRawArgs');
87
87
  /**
88
88
  * Mark a leaf that honours a --dry-run WITHOUT declaring its own option,
89
89
  * because it forwards every raw argv token after its verb word to a wrapped
90
- * tool (claude, codex, gemini, wrap). Such a leaf honours the flag only when
90
+ * tool (claude, codex, wrap). Such a leaf honours the flag only when
91
91
  * it is typed AFTER the verb (it then reaches the wrapped tool); typed before
92
92
  * the verb it reaches nothing, so it is refused like any other.
93
93
  */
@@ -37,8 +37,6 @@ export function autoName(now = new Date()) {
37
37
  export const PROGRAMS = {
38
38
  claude: { tag: '', bin: 'claude', resumeHint: (n) => `quox claude --resume ${n}` },
39
39
  codex: { tag: 'codex-', bin: 'codex', resumeHint: (n) => `quox codex resume ${n}` },
40
- // gemini resumes by index or "latest" (--resume latest | --resume N), not by name.
41
- gemini: { tag: 'gemini-', bin: 'gemini', resumeHint: () => 'quox gemini --resume latest' },
42
40
  tui: { tag: 'tui-', bin: 'quox', resumeHint: (n) => `quox tui --governed --name ${n}` },
43
41
  };
44
42
 
@@ -335,7 +333,7 @@ export function runLs(over) {
335
333
  return 1;
336
334
  }
337
335
  if (!s.length) {
338
- d.write('No Quox sessions running. Start one with: quox claude (or quox codex, quox gemini, quox wrap <program>)\n');
336
+ d.write('No Quox sessions running. Start one with: quox claude (or quox codex, quox wrap <program>)\n');
339
337
  return 0;
340
338
  }
341
339
  const pad = (v, n) => String(v).padEnd(n);
@@ -10,7 +10,7 @@ import { spawnSync } from 'node:child_process';
10
10
 
11
11
  export const TMUX_FORMAT = '#{session_name}\t#{session_created}\t#{session_attached}\t#{pane_current_command}\t#{pane_pid}\t#{session_activity}';
12
12
 
13
- const AGENT_PROGRAMS = new Set(['claude', 'codex', 'gemini']);
13
+ const AGENT_PROGRAMS = new Set(['claude', 'codex']);
14
14
 
15
15
  /** Parse `tmux list-panes -a -F TMUX_FORMAT` output; one entry per session (first pane wins). */
16
16
  export function parseTmuxPanes(out) {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * QLOOP-LIVE: render loop events as QuoxGlyph lines (`quox loop watch`).
3
+ * ∞ QLOOP │ turn 3/10 │ wrote report.md · 41s ✔
4
+ *
5
+ * Pure: event in, string out, so the watch loop, the tests and anything else that wants the same
6
+ * voice (the TUI card's observe lines) share one renderer. The ∞ icon is claimed here by extending
7
+ * the exported registry at import time (the vendored quoxglyph.js is source-locked to the package).
8
+ */
9
+
10
+ import { ICONS, STATUS, brandName } from './quoxglyph.js';
11
+
12
+ ICONS.qloop = '∞ QLOOP';
13
+
14
+ const R = '\x1b[0m', DIM = '\x1b[2m';
15
+ const colourOn = () => brandName('x y') !== 'x y';
16
+
17
+ function oneLine(s, n) {
18
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
19
+ return [...t].length > n ? `${[...t].slice(0, n - 1).join('')}…` : t;
20
+ }
21
+
22
+ /** "41s", "1m12s", "2h03m" */
23
+ export function elapsedLabel(ms) {
24
+ const s = Math.max(0, Math.round(ms / 1000));
25
+ if (s < 60) return `${s}s`;
26
+ if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
27
+ return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
28
+ }
29
+
30
+ /** One QLOOP line: icon+name, verb, optional result, optional status glyph. Result is NOT budget-truncated here (callers cap it). */
31
+ export function loopLine(verb, result, status) {
32
+ const col = colourOn();
33
+ const sep = col ? `${DIM}│${R}` : '│';
34
+ let out = `${brandName(ICONS.qloop)} ${sep} ${verb}`;
35
+ if (result) out += ` ${sep} ${result}`;
36
+ const s = status && STATUS[status];
37
+ if (s) out += ' ' + (col && s.rgb ? `\x1b[38;2;${s.rgb[0]};${s.rgb[1]};${s.rgb[2]}m${s.glyph}${R}` : s.glyph);
38
+ return out;
39
+ }
40
+
41
+ const OBSERVE_STATUS = { interval: 'busy', milestone: 'ok', error: 'fail', approval: 'warn', stall: 'warn' };
42
+
43
+ /**
44
+ * @param {object} e a loop event (design section 1)
45
+ * @param {object} ctx { title, maxTurns, startedMs, spool }
46
+ * @returns {string[]} lines to print (may be empty)
47
+ */
48
+ export function renderEvent(e, ctx = {}) {
49
+ const max = ctx.maxTurns ? `/${ctx.maxTurns}` : '';
50
+ const since = ctx.startedMs ? ` · ${elapsedLabel(Date.parse(e.at) - ctx.startedMs)}` : '';
51
+ switch (e.type) {
52
+ case 'loop.started': {
53
+ const scope = Array.isArray(e.scope) && e.scope.length ? e.scope.join(',') : 'none';
54
+ return [loopLine('started', `host ${e.host} · ${e.backend} · scope ${oneLine(scope, 60)}${e.max_turns ? ` · ${e.max_turns} turns max` : ''}`, 'busy')];
55
+ }
56
+ case 'loop.turn.started':
57
+ return ctx.spool ? [loopLine(`turn ${e.turn}${max}`, 'working', 'busy')] : [];
58
+ case 'loop.turn.output':
59
+ return ctx.spool ? String(e.chunk).replace(/\n+$/, '').split('\n').map((l) => ` │ ${l}`) : [];
60
+ case 'loop.turn.tool': {
61
+ if (e.note) return [loopLine(`turn ${e.turn}`, `${e.tool} no result (${oneLine(e.note, 60)})${since}`, 'warn')];
62
+ if (e.denied) return [loopLine(`turn ${e.turn}`, `${e.tool} denied: ${oneLine(e.denied, 60)}${since}`, 'warn')];
63
+ return [loopLine(`turn ${e.turn}`, `${e.tool} ${e.ok ? 'ok' : 'failed'}${e.ms ? ` ${e.ms}ms` : ''}${since}`, e.ok ? 'ok' : 'fail')];
64
+ }
65
+ case 'loop.turn.completed':
66
+ return [loopLine(`turn ${e.turn}${max}`, `${oneLine(e.summary, 70)}${since}`, 'ok')];
67
+ case 'loop.observe':
68
+ if (e.kind === 'milestone') return [loopLine('milestone', oneLine(e.text, 100), 'ok')];
69
+ return [loopLine(e.kind === 'interval' ? 'status' : e.kind, oneLine(e.text, 110), OBSERVE_STATUS[e.kind] || 'busy')];
70
+ case 'loop.ward':
71
+ if (e.status === 'pending') return []; // announced, not yet witnessed: nothing to say yet
72
+ return [loopLine('receipt', `${String(e.envelope_id).slice(0, 12)} ${e.status}`, e.status === 'WITNESSED' ? 'ok' : 'warn')];
73
+ case 'loop.approval':
74
+ return [loopLine('approval', `${oneLine(e.what, 90)} (${e.checkpoint_id})`, 'warn')];
75
+ case 'loop.finished': {
76
+ // Only a clean completion is a tick. completed_with_errors and a halt / interruption warn; a failure fails. The reason is
77
+ // whole (a wrapped line beats a cut sentence) and loses the raw component prefix the spawner puts first.
78
+ const status = e.state === 'completed' ? 'ok' : e.state === 'failed' ? 'fail' : 'warn';
79
+ const wit = e.receipts != null ? `, ${e.witnessed}/${e.receipts} witnessed` : '';
80
+ const reason = String(e.reason || '').replace(/\s+/g, ' ').trim().replace(/^[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*:\s+/, '');
81
+ const why = e.state !== 'completed' && reason ? `: ${reason}` : '';
82
+ return [loopLine(oneLine(ctx.title || 'loop', 48), `${e.state}, ${e.turns} turn${e.turns === 1 ? '' : 's'}${wit}${why}`, status)];
83
+ }
84
+ default:
85
+ return [];
86
+ }
87
+ }
88
+
89
+ /** Exit code for a finished loop: 0 completed (also with errors), 1 failed, 2 halted by a human, 3 interrupted. */
90
+ export function exitCodeFor(state) {
91
+ switch (state) {
92
+ case 'completed': case 'completed_with_errors': return 0;
93
+ case 'halted': return 2;
94
+ case 'interrupted': return 3;
95
+ default: return 1;
96
+ }
97
+ }