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,174 @@
1
+ /**
2
+ * quox kuma sla ... : signed, verifiable uptime reports (NQ-KUMA-7).
3
+ *
4
+ * quox kuma sla [--month YYYY-MM | --from <iso> --to <iso>] [--out <file>] [--json] generate + sign + store
5
+ * quox kuma sla list this org's stored reports
6
+ * quox kuma sla verify <id|file> [--public-key <base64>] recompute the hash, check the signature
7
+ *
8
+ * `verify <id>` asks the collector, which checks against its pinned WARD key (trust anchored).
9
+ * `verify <file>` is fully OFFLINE: it recomputes sha256 over the canonical report and checks the Ed25519
10
+ * signature with node:crypto. Against the key embedded in the file this proves integrity only (anyone can
11
+ * mint a keypair); pass --public-key <raw 32 byte base64 WARD key> to pin the signer yourself.
12
+ *
13
+ * Exit codes: 0 ok / verified, 1 error, 2 verification FAILED.
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
17
+ import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
18
+ import { resolveEndpoint } from '../api/endpoints.js';
19
+ import { line as glyphLine } from '../core/quoxglyph.js';
20
+
21
+ // Same canonical form as services/collector/lib/bundleSigner.js canonicalize (sorted keys, no whitespace).
22
+ export function canonicalize(val) {
23
+ if (val === null || val === undefined) return JSON.stringify(val);
24
+ if (typeof val !== 'object') return JSON.stringify(val);
25
+ if (Array.isArray(val)) return `[${val.map((v) => canonicalize(v)).join(',')}]`;
26
+ const keys = Object.keys(val).filter((k) => val[k] !== undefined).sort();
27
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(val[k])}`).join(',')}}`;
28
+ }
29
+
30
+ const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
31
+ const keyFromRaw = (b64) => {
32
+ try {
33
+ const raw = Buffer.from(String(b64 || ''), 'base64');
34
+ if (raw.length !== 32) return null;
35
+ return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
36
+ } catch { return null; }
37
+ };
38
+
39
+ /** Offline verification of an export bundle. Mirrors kumaSlaReports.verifyBundle. */
40
+ export function verifyBundleOffline(bundle, { publicKey } = {}) {
41
+ const out = { valid: false, hash_ok: false, binding_ok: false, signature_ok: false, key_source: 'none', trust_anchored: false };
42
+ if (!bundle || typeof bundle !== 'object' || !bundle.report || typeof bundle.report !== 'object') return { ...out, reason: 'malformed_bundle' };
43
+ const sig = bundle.signature;
44
+ if (!sig || !sig.signature || !sig.message) return { ...out, reason: 'missing_signature' };
45
+ const recomputed = createHash('sha256').update(canonicalize(bundle.report), 'utf-8').digest('hex');
46
+ out.recomputed_sha256 = recomputed;
47
+ out.hash_ok = recomputed === bundle.report_sha256;
48
+ if (!out.hash_ok) return { ...out, reason: 'report_hash_mismatch' };
49
+ const r = bundle.report;
50
+ const expected = r.period ? {
51
+ kind: 'quox.kuma.sla.report', report_id: bundle.report_id, org_id: r.org_id,
52
+ period_from: r.period.from, period_to: r.period.to, generated_at: r.generated_at, report_sha256: recomputed,
53
+ } : null;
54
+ out.binding_ok = !!expected && canonicalize(expected) === canonicalize(sig.message);
55
+ if (!out.binding_ok) return { ...out, reason: 'signature_not_bound_to_report' };
56
+ if (sig.sig_type !== 'ed25519') return { ...out, reason: `unverifiable_sig_type:${sig.sig_type}` };
57
+ let key = null; let source = 'none';
58
+ if (publicKey) { key = keyFromRaw(publicKey); source = 'pinned'; if (!key) return { ...out, reason: 'bad_public_key' }; }
59
+ else { key = keyFromRaw(sig.public_key); source = 'embedded'; }
60
+ if (!key) return { ...out, reason: 'no_verification_key' };
61
+ out.key_source = source;
62
+ try {
63
+ out.signature_ok = cryptoVerify(null, Buffer.from(canonicalize(sig.message), 'utf-8'), key, Buffer.from(String(sig.signature), 'base64'));
64
+ } catch (err) { return { ...out, reason: `verify_error:${err.message}` }; }
65
+ if (!out.signature_ok) return { ...out, reason: 'signature_mismatch' };
66
+ out.valid = true;
67
+ out.trust_anchored = source === 'pinned' && !sig.warning;
68
+ if (sig.warning) out.warning = sig.warning;
69
+ return out;
70
+ }
71
+
72
+ const dur = (s) => {
73
+ if (s === null || s === undefined) return '-';
74
+ if (s < 60) return `${s}s`;
75
+ if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
76
+ return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
77
+ };
78
+
79
+ export function registerKumaSla(kuma, { client, auth, jsonOut, guard, KumaFail, pad }) {
80
+ const slaFail = (verb, ep, err) => {
81
+ const status = err && (err.status ?? err.statusCode);
82
+ const msg = (err && err.message) || String(err);
83
+ const head = `quox kuma ${verb}: ${ep.method} ${ep.path} failed${status ? ` with HTTP ${status}` : ''}`;
84
+ if (status === 422) return new KumaFail(`${head}: Uptime Kuma is not configured for this org (add the uptime_kuma credential in the vault).`);
85
+ if (status === 404) return new KumaFail(`${head}: not found in this org.`);
86
+ return new KumaFail(`${head}: ${msg}`);
87
+ };
88
+
89
+ const sla = kuma
90
+ .command('sla')
91
+ .description('Signed, verifiable uptime (SLA) report from Kuma heartbeats; `sla verify <id|file>` checks one')
92
+ .option('--month <YYYY-MM>', 'calendar month (UTC), e.g. 2026-09')
93
+ .option('--from <iso>', 'range start (with --to)')
94
+ .option('--to <iso>', 'range end, exclusive')
95
+ .option('--out <file>', 'also write the signed bundle here for offline verification')
96
+ .addHelpText('after', '\nExit codes: 0 ok, 1 error, 2 `sla verify` found the report does NOT verify.')
97
+ .action(guard('sla', async (options) => {
98
+ auth.requireAuth();
99
+ if (!options.month && !(options.from && options.to)) throw new KumaFail('quox kuma sla: give --month YYYY-MM, or both --from and --to.');
100
+ const ep = resolveEndpoint('mon.kuma.sla.create');
101
+ const body = options.month ? { month: options.month } : { from: options.from, to: options.to };
102
+ let data;
103
+ try { data = await client.post(ep.path, { service: ep.service, body }); } catch (err) { throw slaFail('sla', ep, err); }
104
+ if (!data || data.success === false || data.error || !data.report || !data.signature) {
105
+ throw new KumaFail(`quox kuma sla: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no report'}`);
106
+ }
107
+ if (options.out) {
108
+ writeFileSync(options.out, `${JSON.stringify({ report_id: data.report_id, report: data.report, report_sha256: data.report_sha256, signature: data.signature }, null, 2)}\n`);
109
+ }
110
+ if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
111
+ const r = data.report; const ro = r.rollup;
112
+ console.log(glyphLine('kuma', 'sla', { result: `${r.period.label} · ${ro.uptime_pct === null ? 'no data' : `${ro.uptime_pct}%`}`, status: ro.uptime_pct === null ? 'warn' : 'ok' }));
113
+ console.log(`Report ${data.report_id} sha256 ${data.report_sha256}`);
114
+ console.log(`Period ${r.period.from} to ${r.period.effective_to}${r.period.partial ? ' (partial: the period has not ended)' : ''}`);
115
+ const rows = [['MONITOR', 'UPTIME', 'OUTAGES', 'DOWNTIME', 'LONGEST', 'MTTR', 'MAINT', 'GAPS']];
116
+ for (const m of r.monitors) {
117
+ rows.push([m.name || m.monitor_id, m.status === 'no_data' ? 'no data' : `${m.uptime_pct}%`, m.outages.count, dur(m.outages.total_downtime_seconds), dur(m.outages.longest_seconds), dur(m.outages.mttr_seconds), m.beats.maintenance, m.coverage.gaps.length]);
118
+ }
119
+ console.log(`\n${pad(rows).join('\n')}`);
120
+ console.log(`\nOrg: ${ro.uptime_pct === null ? 'no data' : `${ro.uptime_pct}%`} over ${ro.monitors_with_data}/${ro.monitors_total} monitors with data; ${ro.outages.count} ${ro.outages.count === 1 ? 'outage' : 'outages'}, ${dur(ro.outages.total_downtime_seconds)} down, MTTR ${dur(ro.outages.mttr_seconds)}.`);
121
+ if (ro.monitors_no_data) console.log(`${ro.monitors_no_data} ${ro.monitors_no_data === 1 ? 'monitor has' : 'monitors have'} no heartbeats in the period: reported as no data, not as 100%.`);
122
+ console.log(data.trust_anchored ? 'Signed with the WARD key and witnessed.' : 'WARNING: signed with an ephemeral key (no persistent WARD key on this collector): integrity only, not trust anchored.');
123
+ console.log(`Verify: quox kuma sla verify ${options.out || data.report_id}`);
124
+ }));
125
+
126
+ sla
127
+ .command('list')
128
+ .description('This org\'s stored SLA reports')
129
+ .action(guard('sla list', async () => {
130
+ auth.requireAuth();
131
+ const ep = resolveEndpoint('mon.kuma.sla.list');
132
+ let data;
133
+ try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw slaFail('sla list', ep, err); }
134
+ if (!data || data.success === false || data.error || !Array.isArray(data.reports)) {
135
+ throw new KumaFail(`quox kuma sla list: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no reports array'}`);
136
+ }
137
+ if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
138
+ console.log(glyphLine('kuma', 'sla list', { result: `${data.reports.length} ${data.reports.length === 1 ? 'report' : 'reports'}`, status: 'ok' }));
139
+ if (!data.reports.length) { console.log('No reports yet. Generate one: quox kuma sla --month YYYY-MM'); return; }
140
+ const rows = [['REPORT', 'PERIOD', 'CREATED', 'SHA256']];
141
+ for (const r of data.reports) rows.push([r.report_id, r.period, r.created_at, String(r.report_sha256).slice(0, 16)]);
142
+ console.log(pad(rows).join('\n'));
143
+ }));
144
+
145
+ sla
146
+ .command('verify')
147
+ .description('Recompute the report hash and check the signature (id: via the collector, pinned WARD key; file: fully offline)')
148
+ .argument('<id|file>', 'a report id from `quox kuma sla list`, or a bundle file written by `sla --out`')
149
+ .option('--public-key <base64>', 'offline: pin this raw 32 byte Ed25519 WARD public key instead of trusting the key in the file')
150
+ .action(guard('sla verify', async (target, options) => {
151
+ let result;
152
+ if (existsSync(target)) {
153
+ let bundle;
154
+ try { bundle = JSON.parse(readFileSync(target, 'utf-8')); } catch (err) { throw new KumaFail(`quox kuma sla verify: ${target} is not valid JSON: ${err.message}`); }
155
+ result = { report_id: bundle && bundle.report_id, mode: 'offline', ...verifyBundleOffline(bundle, { publicKey: options.publicKey }) };
156
+ } else {
157
+ auth.requireAuth();
158
+ const ep = resolveEndpoint('mon.kuma.sla.verify', { id: target });
159
+ let data;
160
+ try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw slaFail('sla verify', ep, err); }
161
+ if (!data || data.success === false || data.error || typeof data.valid !== 'boolean') {
162
+ throw new KumaFail(`quox kuma sla verify: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no verdict'}`);
163
+ }
164
+ result = { mode: 'collector', ...data };
165
+ }
166
+ if (jsonOut()) { console.log(JSON.stringify(result, null, 2)); } else {
167
+ console.log(glyphLine('kuma', 'sla verify', { result: result.valid ? `verified (${result.mode})` : `NOT verified: ${result.reason}`, status: result.valid ? 'ok' : 'error' }));
168
+ console.log(`hash ${result.hash_ok ? 'matches' : 'DOES NOT match'} binding ${result.binding_ok ? 'ok' : 'FAILED'} signature ${result.signature_ok ? 'valid' : 'INVALID'} key ${result.key_source}${result.trust_anchored ? ' (trust anchored)' : ' (not trust anchored)'}`);
169
+ if (result.valid && !result.trust_anchored) console.log('Integrity holds, but the signer was not checked against the WARD key. Pin it with --public-key, or verify by id through the collector.');
170
+ if (result.warning) console.log(`Warning: ${result.warning}`);
171
+ }
172
+ if (!result.valid) process.exitCode = 2;
173
+ }));
174
+ }
@@ -109,9 +109,21 @@ export function registerLicenceCommands(program, ctx, { owner = VENDOR_OWNER } =
109
109
  .description('List a plugin band holders; --release-dead frees non-live holders (dry run unless --yes)')
110
110
  .argument('<pluginId>', 'plugin id, e.g. notesq')
111
111
  .option('--release-dead', 'release holders that are not live orgs (deleted orgs, user: ids)')
112
+ .option('--release <orgId>', 'release a LIVE org seat (its install of the plugin is disabled); dry run unless --yes')
113
+ .option('--to <orgId>', 'with --release: hand the freed seat to this org in the same call')
112
114
  .option('--yes', 'actually release (otherwise dry run)')
113
115
  .action(async (pluginId, opts) => {
114
116
  auth.requireAuth();
117
+ if (opts.to && !opts.release) {
118
+ const e = new Error('--to only works together with --release <orgId>');
119
+ e.exitCode = 2;
120
+ throw e;
121
+ }
122
+ if (opts.release && opts.releaseDead) {
123
+ const e = new Error('use either --release or --release-dead, not both');
124
+ e.exitCode = 2;
125
+ throw e;
126
+ }
115
127
  const lep = resolveEndpoint('setup.licensing.seats.list', { pluginId });
116
128
  const list = await client.get(lep.path, { service: lep.service });
117
129
  let rel = null;
@@ -119,7 +131,15 @@ export function registerLicenceCommands(program, ctx, { owner = VENDOR_OWNER } =
119
131
  const rep = resolveEndpoint('setup.licensing.seats.releaseDead', { pluginId });
120
132
  rel = await client.post(rep.path, { service: rep.service, body: { dry_run: !opts.yes } });
121
133
  }
122
- if (json()) { console.log(JSON.stringify({ ...list, release: rel }, null, 2)); return; }
134
+ let live = null;
135
+ if (opts.release) {
136
+ const rp = resolveEndpoint('setup.licensing.seats.release', { pluginId });
137
+ live = await client.post(rp.path, {
138
+ service: rp.service,
139
+ body: { org_id: opts.release, ...(opts.to ? { to_org_id: opts.to } : {}), dry_run: !opts.yes },
140
+ });
141
+ }
142
+ if (json()) { console.log(JSON.stringify({ ...list, release: rel, live_release: live }, null, 2)); return; }
123
143
  printInfo(`${pluginId}: ${list.holders.length} holder(s), cap ${list.allowance ?? '(no band)'}`);
124
144
  formatOutput({
125
145
  columns: ['id', 'name', 'status'],
@@ -133,6 +153,24 @@ export function registerLicenceCommands(program, ctx, { owner = VENDOR_OWNER } =
133
153
  printSuccess(`Released ${rel.released.length} non-live holder(s): ${rel.released.join(', ') || '(none)'}`);
134
154
  }
135
155
  }
156
+ if (live) {
157
+ const who = `${live.org_name || live.org_id} (${live.org_id})`;
158
+ const loses = live.will_disable_install
159
+ ? `its install of ${pluginId} goes disabled and the plugin stops working there`
160
+ : `it loses access to ${pluginId} (no enabled install to disable)`;
161
+ if (live.dry_run) {
162
+ printInfo(`DRY RUN: would release the ${pluginId} seat held by ${who}: ${loses}.`);
163
+ if (live.would_assign_to) printInfo(`DRY RUN: the freed seat would go to ${live.would_assign_to}.`);
164
+ else printInfo(`Seats are first-come: add --to <orgId> to hand the freed seat to a specific org, otherwise the first org to use ${pluginId} takes it.`);
165
+ printInfo(`Re-run with --yes to release.`);
166
+ } else if (live.released) {
167
+ printSuccess(`Released the ${pluginId} seat held by ${who}: ${loses}.`);
168
+ if (live.assigned_to) printSuccess(`Seat now held by ${live.assigned_to}.`);
169
+ else if (opts.to) printWarning(`The seat was freed but ${opts.to} could not take it (see the band with: quox licence seats ${pluginId}).`);
170
+ } else {
171
+ printWarning(`Nothing released: ${who} no longer holds a ${pluginId} seat.`);
172
+ }
173
+ }
136
174
  });
137
175
 
138
176
  if (owner) owner.registerLicenceOwnerCommands(licence, { json, fetchImpl });
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Loop commands (QLOOP-LIVE): observe a QLoop loop as it runs.
3
+ *
4
+ * quox loop watch <id> [--spool|--quiet] [--since n] live, one QuoxGlyph line per event
5
+ * quox loop status <id> snapshot
6
+ * quox loop list running + recent loops (org)
7
+ *
8
+ * `watch` reconnects with ?since=<last event_seq> when the stream drops, and exits with the loop's
9
+ * outcome: 0 completed, 1 failed, 2 halted, 3 interrupted, 4 connection lost for good.
10
+ * Events come from GET /api/v1/loops/:id/events (design: ~/quoxon-work/qloop-live-design.md).
11
+ */
12
+
13
+ import { formatOutput, getOutputFormat } from '../core/output.js';
14
+ import { resolveEndpoint } from '../api/endpoints.js';
15
+ import { loopLine, renderEvent, exitCodeFor, elapsedLabel } from '../core/loopGlyph.js';
16
+ import { QuoxApiError, QuoxAuthError, QuoxUserError } from '../core/errors.js';
17
+
18
+ const MAX_SILENT_RETRIES = 10;
19
+ const BACKOFF_BASE_MS = 1000;
20
+ const BACKOFF_CAP_MS = 10000;
21
+
22
+ const sleepReal = (ms) => new Promise((r) => setTimeout(r, ms));
23
+
24
+ /** SQLite "YYYY-MM-DD HH:MM:SS" (UTC, no zone) or ISO to epoch ms; null if unreadable. */
25
+ function toMs(ts) {
26
+ if (!ts) return null;
27
+ const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts) ? `${ts.replace(' ', 'T')}Z` : ts;
28
+ const n = Date.parse(iso);
29
+ return Number.isNaN(n) ? null : n;
30
+ }
31
+
32
+ const SETTLE_MAX_MS = 90000;
33
+ const SETTLE_POLL_MS = 4000;
34
+
35
+ /**
36
+ * After loop.finished: wait (bounded) for the loop's receipts to be WITNESSED by WARD, asking the snapshot
37
+ * (which re-checks WARD and records what it finds), printing each receipt as it flips. Returns the finished
38
+ * event with the final witnessed count. A snapshot that cannot be read ends the wait and says so; it never
39
+ * reads as "0 witnessed".
40
+ */
41
+ async function settleReceipts({ client, id, finished, write, sleep, ctx, maxMs, signal, seen = new Set() }) {
42
+ const ep = resolveEndpoint('loops.get', { id: encodeURIComponent(id) });
43
+ let witnessed = finished.witnessed;
44
+ write(loopLine('receipts', `${witnessed}/${finished.receipts} witnessed, waiting for WARD to sign the covering tip`, 'busy'));
45
+ let waited = 0;
46
+ while (witnessed < finished.receipts && waited < maxMs && !(signal && signal.aborted)) {
47
+ await sleep(SETTLE_POLL_MS);
48
+ waited += SETTLE_POLL_MS;
49
+ let snap;
50
+ try { snap = await client.get(ep.path, { service: ep.service }); } catch (err) {
51
+ write(loopLine('receipts', `could not re-check WARD (${oneLineErr(err)}); \`quox loop status ${id}\` will`, 'warn'));
52
+ break;
53
+ }
54
+ const items = (snap && snap.receipts && snap.receipts.items) || [];
55
+ for (const r of items) {
56
+ if (r.status === 'WITNESSED' && !seen.has(r.envelope_id)) {
57
+ seen.add(r.envelope_id);
58
+ write(loopLine('receipt', `${String(r.envelope_id)} WITNESSED`, 'ok'));
59
+ }
60
+ }
61
+ witnessed = snap && snap.receipts ? snap.receipts.witnessed : witnessed;
62
+ }
63
+ if (witnessed < finished.receipts) write(loopLine('receipts', `${finished.receipts - witnessed} still pending after ${Math.round(waited / 1000)}s; they are witnessed when the next tip signs (quox loop status ${id})`, 'warn'));
64
+ return { ...finished, witnessed };
65
+ }
66
+
67
+ /**
68
+ * Follow one loop to its end. Returns the process exit code. Exported for tests.
69
+ * @param {object} p
70
+ * @param {object} p.client cli client (stream)
71
+ * @param {string} p.id
72
+ * @param {object} p.snapshot loops.get result (title, max_turns, started_at)
73
+ * @param {{spool?: boolean, quiet?: boolean, since?: number, json?: boolean, noSettle?: boolean, settleMs?: number}} p.opts
74
+ * @param {(line: string) => void} p.write
75
+ * @param {(ms: number) => Promise<void>} [p.sleep]
76
+ * @param {AbortSignal} [p.signal]
77
+ */
78
+ export async function watchLoop({ client, id, snapshot, opts = {}, write, sleep = sleepReal, signal }) {
79
+ const ep = resolveEndpoint('loops.events', { id: encodeURIComponent(id) });
80
+ let lastSeq = Number(opts.since) || 0;
81
+ let startedMs = toMs(snapshot && snapshot.started_at);
82
+ const ctx = { title: snapshot && snapshot.title, maxTurns: snapshot && snapshot.max_turns, startedMs, spool: !!opts.spool };
83
+ const showQuiet = new Set(['loop.started', 'loop.observe', 'loop.approval', 'loop.finished']);
84
+ let failures = 0;
85
+ let backoff = BACKOFF_BASE_MS;
86
+ const witnessedShown = new Set(); // receipts already printed from loop.ward events, so the settle phase does not repeat them
87
+
88
+ while (!signal || !signal.aborted) {
89
+ let gotEvent = false;
90
+ let ended = null;
91
+ try {
92
+ for await (const ev of client.stream(ep.path, { service: ep.service, query: { since: lastSeq }, signal })) {
93
+ // `event: end` frame (an old loop that never logged a finish): no type, a reason
94
+ if (ev && !ev.type && ev.reason) { ended = ev; break; }
95
+ if (!ev || typeof ev.type !== 'string' || !ev.type.startsWith('loop.')) continue;
96
+ gotEvent = true;
97
+ if (typeof ev.event_seq === 'number') {
98
+ if (ev.event_seq <= lastSeq) continue; // replay overlap after a reconnect
99
+ lastSeq = ev.event_seq;
100
+ }
101
+ if (ev.type === 'loop.ward' && ev.status === 'WITNESSED') witnessedShown.add(ev.envelope_id);
102
+ if (!ctx.startedMs && ev.type === 'loop.started') ctx.startedMs = Date.parse(ev.at);
103
+ if (opts.json) write(JSON.stringify(ev));
104
+ else if (ev.type !== 'loop.finished' && !(opts.quiet && !showQuiet.has(ev.type))) for (const l of renderEvent(ev, ctx)) write(l);
105
+ if (ev.type === 'loop.finished') {
106
+ // the loop is over; its receipts are WITNESSED when WARD's next signed tip covers them (about a minute),
107
+ // so say so and follow them in rather than printing a final count that is about to be stale
108
+ const last = (!opts.json && !opts.noSettle && ev.receipts > ev.witnessed)
109
+ ? await settleReceipts({ client, id, finished: ev, write, sleep, ctx, maxMs: opts.settleMs ?? SETTLE_MAX_MS, signal, seen: witnessedShown })
110
+ : ev;
111
+ if (!opts.json) for (const l of renderEvent(last, ctx).slice(-1)) write(l);
112
+ if (!opts.json && !opts.quiet) write(` quox loop status ${id} # receipts and proof`);
113
+ return exitCodeFor(ev.state);
114
+ }
115
+ }
116
+ } catch (err) {
117
+ // a refusal is not a dropped connection: 401/403/404 and friends end the watch now with the server's words
118
+ if (err instanceof QuoxAuthError || (err instanceof QuoxApiError && err.status >= 400 && err.status < 500 && err.status !== 429)) throw err;
119
+ if (signal && signal.aborted) return 130;
120
+ failures += 1;
121
+ if (failures > MAX_SILENT_RETRIES) {
122
+ write(loopLine('connection lost', `could not reach the loop stream after ${MAX_SILENT_RETRIES} tries; resume with: quox loop watch ${id} --since ${lastSeq}`, 'fail'));
123
+ return 4;
124
+ }
125
+ if (!opts.quiet && !opts.json) write(loopLine('reconnecting', `stream dropped (${oneLineErr(err)}); resuming after event ${lastSeq}`, 'warn'));
126
+ await sleep(backoff);
127
+ backoff = Math.min(BACKOFF_CAP_MS, backoff * 2);
128
+ continue;
129
+ }
130
+ if (ended) {
131
+ write(loopLine(ctx.title || 'loop', `ended without a recorded finish (${ended.status || 'unknown'}): ${ended.reason}`, 'warn'));
132
+ return ended.status === 'completed' ? 0 : 1;
133
+ }
134
+ // stream closed by the server (restart, proxy idle limit) before loop.finished: resume, quietly counting
135
+ if (gotEvent) { failures = 0; backoff = BACKOFF_BASE_MS; } else failures += 1;
136
+ if (failures > MAX_SILENT_RETRIES) {
137
+ write(loopLine('connection lost', `the loop stream keeps closing with nothing new; resume with: quox loop watch ${id} --since ${lastSeq}`, 'fail'));
138
+ return 4;
139
+ }
140
+ await sleep(gotEvent ? 250 : backoff);
141
+ if (!gotEvent) backoff = Math.min(BACKOFF_CAP_MS, backoff * 2);
142
+ }
143
+ return 130;
144
+ }
145
+
146
+ function oneLineErr(err) {
147
+ return String((err && err.message) || err).replace(/\s+/g, ' ').slice(0, 80);
148
+ }
149
+
150
+ export function statusLines(s, { diagnostics = false } = {}) {
151
+ const lines = [];
152
+ const turns = `turn ${s.turn}${s.max_turns ? `/${s.max_turns}` : ''}`;
153
+ lines.push(loopLine(s.title || s.objective_id, `${s.state}, ${turns}`, s.state === 'running' ? 'busy' : (s.state === 'completed' ? 'ok' : (s.state === 'completed_with_errors' || s.state === 'halted' || s.state === 'interrupted' ? 'warn' : (s.finished ? 'fail' : 'busy')))));
154
+ lines.push(` id ${s.objective_id}`);
155
+ lines.push(` host ${s.host || '-'} backend ${s.backend || '-'} scope ${(s.scope || []).join(',') || '-'}`);
156
+ if (s.until) lines.push(` until ${s.until}${s.every_seconds ? ` (a turn every ${s.every_seconds}s)` : ''}`);
157
+ else if (s.every_seconds) lines.push(` every ${s.every_seconds}s`);
158
+ if (s.workspace) lines.push(` workspace ${s.workspace}`);
159
+ const r = s.receipts || { total: 0, witnessed: 0 };
160
+ lines.push(` receipts ${r.witnessed}/${r.total} witnessed${s.ward_check_error ? ` (WARD could not be asked just now: ${s.ward_check_error})` : ''}`);
161
+ if (s.observe) lines.push(` observe ${JSON.stringify(s.observe)}`);
162
+ if (s.reason && s.finished) lines.push(` reason ${s.reason}`);
163
+ if (diagnostics && s.diagnostics) lines.push(` diagnostics ${s.diagnostics}`);
164
+ if ((s.last_turns || []).length) {
165
+ lines.push(' last turns');
166
+ for (const t of s.last_turns) {
167
+ const tools = (t.tools || []).map((x) => `${x.tool}${x.denied ? ' denied' : (x.ok ? '' : ' FAILED')}`).join(', ');
168
+ lines.push(` ${t.turn} ${t.summary}${tools ? ` [${tools}]` : ''} ${t.envelope_id ? `${String(t.envelope_id)} ${t.ward}` : ''}`.trimEnd());
169
+ }
170
+ }
171
+ if (s.started_at) {
172
+ const st = toMs(s.started_at);
173
+ const en = toMs(s.completed_at) || Date.now();
174
+ if (st) lines.push(` elapsed ${elapsedLabel(en - st)}`);
175
+ }
176
+ return lines;
177
+ }
178
+
179
+ export function registerLoopCommands(program, ctx) {
180
+ const { client, auth } = ctx;
181
+
182
+ const loop = program
183
+ .command('loop')
184
+ .description('Observe QLoop loops as they run (watch, status, list)');
185
+
186
+ loop
187
+ .command('watch')
188
+ .description('Follow a loop live: one QuoxGlyph line per event, reconnecting on drop. Exits 0 completed, 1 failed, 2 halted, 3 interrupted, 4 connection lost')
189
+ .argument('<id>', 'loop (objective) ID')
190
+ .option('--spool', 'also stream the loop\'s output text under each turn line')
191
+ .option('--quiet', 'only the headline lines: started, observe points (milestones, errors, approvals, stalls), the final line')
192
+ .option('--since <n>', 'resume after this event_seq (printed by a previous watch; default: from the start)')
193
+ .option('--no-wait-receipts', 'print the final line as soon as the loop ends instead of waiting (up to 90s) for its receipts to be witnessed')
194
+ .action(async (id, options) => {
195
+ auth.requireAuth();
196
+ if (options.spool && options.quiet) throw new QuoxUserError('--spool and --quiet cancel each other out; pick one.');
197
+ const since = options.since !== undefined ? Number(options.since) : 0;
198
+ if (!Number.isInteger(since) || since < 0) throw new QuoxUserError('--since needs a whole number event_seq (0 or more).');
199
+ const snap = resolveEndpoint('loops.get', { id: encodeURIComponent(id) });
200
+ const snapshot = await client.get(snap.path, { service: snap.service });
201
+ const ac = new AbortController();
202
+ const onSigint = () => ac.abort();
203
+ process.once('SIGINT', onSigint);
204
+ let code;
205
+ try {
206
+ code = await watchLoop({
207
+ client, id, snapshot,
208
+ opts: { spool: options.spool, quiet: options.quiet, since, json: getOutputFormat() === 'json', noSettle: options.waitReceipts === false },
209
+ write: (l) => console.log(l),
210
+ signal: ac.signal,
211
+ });
212
+ } finally {
213
+ process.removeListener('SIGINT', onSigint);
214
+ }
215
+ if (code === 130) console.log(` stopped watching; the loop keeps running. Resume: quox loop watch ${id}`);
216
+ if (code) process.exitCode = code;
217
+ });
218
+
219
+ loop
220
+ .command('status')
221
+ .description('Snapshot of a loop: state, turn n/max, host, backend, scope, last turns, receipts')
222
+ .argument('<id>', 'loop (objective) ID')
223
+ .option('--diagnostics', 'also show the raw diagnostics behind the finish reason (hidden by default)')
224
+ .action(async (id, options = {}) => {
225
+ auth.requireAuth();
226
+ const ep = resolveEndpoint('loops.get', { id: encodeURIComponent(id) });
227
+ const data = await client.get(ep.path, { service: ep.service });
228
+ if (getOutputFormat() === 'json') {
229
+ const out = options.diagnostics || !data || typeof data !== 'object' ? data : { ...data, diagnostics: undefined };
230
+ console.log(JSON.stringify(out, null, 2)); return;
231
+ }
232
+ for (const l of statusLines(data, { diagnostics: !!options.diagnostics })) console.log(l);
233
+ });
234
+
235
+ loop
236
+ .command('list')
237
+ .description('Running and recent loops in this org')
238
+ .option('--limit <n>', 'max results', '20')
239
+ .action(async (options) => {
240
+ auth.requireAuth();
241
+ const ep = resolveEndpoint('loops.list');
242
+ const data = await client.get(ep.path, { service: ep.service, query: { limit: options.limit } });
243
+ formatOutput({
244
+ columns: ['objective_id', 'title', 'state', 'turn', 'backend', 'created_at'],
245
+ headers: ['ID', 'Title', 'State', 'Turn', 'Backend', 'Created'],
246
+ rows: (data.loops || []).map((l) => ({ ...l, turn: l.max_turns ? `${l.turn}/${l.max_turns}` : String(l.turn) })),
247
+ }, { format: program.opts().output, emptyMessage: 'No loops in this org yet.' });
248
+ });
249
+
250
+ // SECURITY containment (2026-10-26): which orgs may run loops in-server (core-local) while a security review is open.
251
+ // Instance-admin only (the server enforces it; a non-admin gets a 403). Stored server-side in instance_config.
252
+ const coreLocal = loop
253
+ .command('core-local')
254
+ .description('Instance admin: which workspaces may run loops inside the server (limited while a security review is open)');
255
+
256
+ coreLocal
257
+ .command('list')
258
+ .description('Show the workspaces allowed to run loops inside the server')
259
+ .action(async () => {
260
+ auth.requireAuth();
261
+ const ep = resolveEndpoint('admin.instance.core_local_orgs.list');
262
+ const r = await client.get(ep.path, { service: ep.service });
263
+ if (getOutputFormat() === 'json') { console.log(JSON.stringify(r, null, 2)); return; }
264
+ if (!(r.orgs || []).length) { console.log('No workspace may run loops inside the server (loops need a dev server).'); return; }
265
+ for (const o of r.orgs) console.log(o);
266
+ });
267
+
268
+ coreLocal
269
+ .command('add')
270
+ .description('Allow a workspace to run loops inside the server (instance admin)')
271
+ .argument('<orgId>', 'workspace (org) id')
272
+ .action(async (orgId) => {
273
+ auth.requireAuth();
274
+ const ep = resolveEndpoint('admin.instance.core_local_orgs.add');
275
+ const r = await client.post(ep.path, { service: ep.service, body: { org_id: orgId } });
276
+ if (getOutputFormat() === 'json') { console.log(JSON.stringify(r, null, 2)); return; }
277
+ console.log(`Added ${orgId}. ${(r.orgs || []).length} workspace(s) may now run loops inside the server.`);
278
+ });
279
+
280
+ coreLocal
281
+ .command('remove')
282
+ .description('Stop a workspace running loops inside the server (instance admin)')
283
+ .argument('<orgId>', 'workspace (org) id')
284
+ .action(async (orgId) => {
285
+ auth.requireAuth();
286
+ const ep = resolveEndpoint('admin.instance.core_local_orgs.remove', { orgId: encodeURIComponent(orgId) });
287
+ const r = await client.delete(ep.path, { service: ep.service });
288
+ if (getOutputFormat() === 'json') { console.log(JSON.stringify(r, null, 2)); return; }
289
+ console.log(`Removed ${orgId}. ${(r.orgs || []).length} workspace(s) may still run loops inside the server.`);
290
+ });
291
+ }
@@ -12,9 +12,10 @@
12
12
  * live loop gate: budget, risk, interval).
13
13
  */
14
14
 
15
- import { formatOutput, formatDetail, printSuccess } from '../core/output.js';
15
+ import { formatOutput, formatDetail, printSuccess, printWarning } from '../core/output.js';
16
16
  import { parseJSON } from './helpers.js';
17
17
  import { resolveEndpoint } from '../api/endpoints.js';
18
+ import { QuoxUserError } from '../core/errors.js';
18
19
 
19
20
  /**
20
21
  * Format a timestamp as a short relative-time string ("5m ago").
@@ -75,6 +76,13 @@ export function registerObjectiveCommands(program, ctx) {
75
76
  .option('--loop-backend <backend>', 'claude|codex, loop mode only (default claude)')
76
77
  .option('--capability-scope <tools>', 'comma-separated tool names the loop may call (repeatable); empty scope escalates every tool', collectScope)
77
78
  .option('--system-prompt <text>', 'per-objective system prompt, carried verbatim to the run')
79
+ .option('--observe-every <n>', 'loop mode: emit a status line every N turns while it runs (watch with `quox loop watch <id>`)')
80
+ .option('--observe-milestones', 'loop mode: lift MILESTONE: lines from the loop output into headline updates')
81
+ .option('--until <text>', 'loop mode: the done criterion in plain words. The loop runs turn after turn until the model ends a turn with "DONE: <evidence>" (or --max-iterations / --max-duration-ms stops it)')
82
+ .option('--every-seconds <n>', 'loop mode: the runner waits N seconds (30-3600) between turns; the model is never asked to sleep')
83
+ .option('--workspace <name>', 'loop mode: name the durable workspace dir, so a follow-up loop can work in the same files (default: one per loop)')
84
+ .option('--done-file <path>', 'loop mode: DONE only counts once this file exists in the loop workspace; the runner checks it itself (repeatable)', (v, prev = []) => prev.concat(v))
85
+ .option('--done-command <cmd>', 'loop mode: DONE only counts once this command exits 0 in the loop workspace; must be one the loop scope lets it run (repeatable)', (v, prev = []) => prev.concat(v))
78
86
  .action(async (goal, options) => {
79
87
  auth.requireAuth();
80
88
  const body = { title: goal };
@@ -98,6 +106,28 @@ export function registerObjectiveCommands(program, ctx) {
98
106
  if (scope.length > 0) body.capability_scope = scope;
99
107
  }
100
108
  if (options.systemPrompt) body.system_prompt = options.systemPrompt;
109
+ if (options.until) body.until = options.until;
110
+ if (options.everySeconds !== undefined) {
111
+ const n = Number(options.everySeconds);
112
+ if (!Number.isInteger(n) || n < 30 || n > 3600) throw new QuoxUserError('--every-seconds needs a whole number of seconds between 30 and 3600.');
113
+ body.every_seconds = n;
114
+ }
115
+ if (options.workspace) body.workspace = options.workspace;
116
+ const doneChecks = [
117
+ ...(options.doneFile || []).map((path) => ({ kind: 'file_exists', path })),
118
+ ...(options.doneCommand || []).map((command) => ({ kind: 'command_ok', command })),
119
+ ];
120
+ if (doneChecks.length) body.done_check = doneChecks;
121
+ if (options.observeEvery !== undefined || options.observeMilestones) {
122
+ const observe = {};
123
+ if (options.observeEvery !== undefined) {
124
+ const n = Number(options.observeEvery);
125
+ if (!Number.isInteger(n) || n < 1) throw new QuoxUserError('--observe-every needs a whole number of turns (1 or more).');
126
+ observe.every_turns = n;
127
+ }
128
+ if (options.observeMilestones) observe.milestones = true;
129
+ body.observe = observe;
130
+ }
101
131
 
102
132
  const data = await client.post(resolveEndpoint('objectives.create').path, { service: resolveEndpoint('objectives.create').service, body });
103
133
  printSuccess(`Objective created: ${data.id}`);
@@ -155,6 +185,17 @@ export function registerObjectiveCommands(program, ctx) {
155
185
  printSuccess(`Objective ${id}: ${data.status}${data.message ? ` — ${data.message}` : ''}`);
156
186
  });
157
187
 
188
+ objective
189
+ .command('retry')
190
+ .description('Retry a failed or partially completed objective: failed and skipped steps are reset and the plan comes back for approval')
191
+ .argument('<id>', 'objective ID')
192
+ .action(async (id) => {
193
+ auth.requireAuth();
194
+ const ep = resolveEndpoint('objectives.retry', { id });
195
+ const data = await client.post(ep.path, { service: ep.service });
196
+ printSuccess(`Objective ${id}: ${data.steps_reset ?? 0} step(s) reset, plan is back for approval. Run \`quox objective approve ${id}\` to run them again.`);
197
+ });
198
+
158
199
  objective
159
200
  .command('checkpoints')
160
201
  .description('List pending HITL checkpoints across the org (approval queue)')
@@ -191,6 +232,12 @@ export function registerObjectiveCommands(program, ctx) {
191
232
  if (options.notes) body.notes = options.notes;
192
233
  const params = { id: objectiveId, checkpointId };
193
234
  const data = await client.post(resolveEndpoint('objectives.checkpoint-approve', params).path, { service: resolveEndpoint('objectives.checkpoint-approve', params).service, body });
235
+ // LOOPQ-HALT: "approved" is not "running". When the server says the loop did not start, say so in words.
236
+ if (data.started === false) {
237
+ printWarning(`Checkpoint ${checkpointId}: ${data.status}, but the objective did NOT start (objective ${data.objective_status || 'pending'}).`);
238
+ if (data.hint) printWarning(data.hint);
239
+ return;
240
+ }
194
241
  printSuccess(`Checkpoint ${checkpointId}: ${data.status}${data.objective_status ? ` (objective ${data.objective_status})` : ''}`);
195
242
  });
196
243