quoxcli 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +18 -3
  2. package/lib/api/endpoints/admin.js +21 -0
  3. package/lib/api/endpoints/calendarq.js +8 -0
  4. package/lib/api/endpoints/index.js +2 -0
  5. package/lib/api/endpoints/lens.js +34 -0
  6. package/lib/api/endpoints/lucid.js +2 -1
  7. package/lib/api/endpoints/mon.js +6 -7
  8. package/lib/api/endpoints/v1.js +57 -0
  9. package/lib/commands/admin.js +86 -0
  10. package/lib/commands/approvals.js +5 -1
  11. package/lib/commands/calendar.js +27 -0
  12. package/lib/commands/chat.js +1 -1
  13. package/lib/commands/fleet.js +164 -0
  14. package/lib/commands/fleetRecipe.js +174 -0
  15. package/lib/commands/lens.js +268 -0
  16. package/lib/commands/lucidGoals.js +36 -2
  17. package/lib/commands/mail.js +21 -2
  18. package/lib/commands/monitoring.js +5 -1
  19. package/lib/commands/plan.js +36 -1
  20. package/lib/commands/research.js +78 -0
  21. package/lib/commands/settings.js +7 -1
  22. package/lib/commands/wazuh.js +9 -0
  23. package/lib/commands/wazuhIntake.js +11 -3
  24. package/lib/core/commandRegistrars.js +1 -0
  25. package/lib/core/version.js +15 -2
  26. package/lib/dragon/select.js +2 -2
  27. package/lib/quoxon/app.js +1 -1
  28. package/lib/quoxon/commands.js +1 -1
  29. package/lib/quoxon/dragonCompanion.js +24 -12
  30. package/lib/quoxon/main.js +1 -1
  31. package/lib/quoxon/onboarding/view.js +1 -1
  32. package/lib/quoxon/slash/cliLeaves.json +19 -0
  33. package/lib/quoxon/whatsnew.jsonl +1 -0
  34. package/package.json +1 -1
  35. package/vendor/dragonlab/SOURCE +3 -3
  36. package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
  37. package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
  38. package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
  39. package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
  40. package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
  41. package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
  42. package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
  43. package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
  44. package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
  45. package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
  46. package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
  47. package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
  48. package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
  49. package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
  50. package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
  51. package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
  52. package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
  53. package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
  54. package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
  55. package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
  56. package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
  57. package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
  58. package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
  59. package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
  60. package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
  61. package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
  62. package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
  63. package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
  64. package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
  65. package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
  66. package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
  67. package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
  68. package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
  69. package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
  70. package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
  71. package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
  72. package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
  73. package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
  74. package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
  75. package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
  76. package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
  77. package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
  78. package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
  79. package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
  80. package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
  81. package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
  82. package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
  83. package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
@@ -5,9 +5,20 @@
5
5
  * Stored as the org setting RESEARCH_TRUSTED_DOMAINS through the SAME governed settings path as
6
6
  * `quox settings set` (PATCH /api/v1/platform/settings/flags/:key, human org admin, witnessed there).
7
7
  * Unset or empty means the built-in default list, which add/remove seed from so a first edit never drops it.
8
+ *
9
+ * quox research ask "<question>" [--save [--collection <name>]]
10
+ *
11
+ * Asks the research agent (registry id `perplexity`, the agent holding the governed web_search/web_read
12
+ * tools) through the SAME governed chat-turn route as `quox chat --agent perplexity --no-stream`
13
+ * (POST /chat/claude, Prefer: respond-async, polled at /chat/turns/:id). --save writes the answer and its
14
+ * sources as a NotesQ note through the same create route as `quox notes new`.
15
+ * Sources are the URLs the agent cited in its answer: the chat route returns tool names, not tool results,
16
+ * so a URL the agent read but did not cite is not shown, and none is ever invented.
8
17
  */
9
18
 
10
19
  import { resolveEndpoint } from '../api/endpoints.js';
20
+ import { QuoxError } from '../core/errors.js';
21
+ import { sendOneShot } from './chat.js';
11
22
 
12
23
  const KEY = 'RESEARCH_TRUSTED_DOMAINS';
13
24
 
@@ -41,6 +52,25 @@ async function write(client, list) {
41
52
  return client.patch(ep.path, { service: ep.service, body: { value: list.join(',') } });
42
53
  }
43
54
 
55
+ export const RESEARCH_AGENT = 'perplexity';
56
+
57
+ /** URLs cited in an answer (bare or markdown links), trailing punctuation trimmed, first-seen order, deduped. */
58
+ export function extractSources(text) {
59
+ const seen = new Set();
60
+ for (const m of String(text || '').matchAll(/https?:\/\/[^\s<>()\[\]"'`]+(?:\([^\s()]*\)[^\s<>()\[\]"'`]*)*/g)) {
61
+ const url = m[0].replace(/[.,;:!?*_]+$/, '');
62
+ if (url.length > 'https://'.length) seen.add(url);
63
+ }
64
+ return [...seen];
65
+ }
66
+
67
+ function noteMarkdown({ question, answer, sources, toolsUsed, agent }) {
68
+ const title = question.replace(/\s+/g, ' ').trim().slice(0, 120);
69
+ const src = sources.length ? sources.map((u) => `- ${u}`).join('\n') : '_No sources were returned for this answer: the agent cited no URL._';
70
+ return `# ${title}\n\n**Question:** ${question}\n\n${answer.trim()}\n\n## Sources\n\n_URLs cited in the answer. The research route does not report which pages were actually read; check the answer text._\n\n${src}\n\n---\n` +
71
+ `Asked with \`quox research ask\` (agent ${agent}) on ${new Date().toISOString()}. Tools called: ${toolsUsed.length ? toolsUsed.join(', ') : 'none'}.\n`;
72
+ }
73
+
44
74
  export function registerResearchCommands(program, ctx) {
45
75
  const { client } = ctx;
46
76
  const r = program.command('research').description('Governed web research settings');
@@ -76,4 +106,52 @@ export function registerResearchCommands(program, ctx) {
76
106
  await write(client, next);
77
107
  process.stdout.write(`removed ${dom} (${next.length} entries, org setting)\n`);
78
108
  });
109
+
110
+ r.command('ask <question...>')
111
+ .description('Ask the research agent a question; prints the answer and the sources it cited (--save keeps it as a NotesQ note)')
112
+ .option('--save', 'save the answer and sources as a NotesQ note')
113
+ .option('--collection <name>', 'NotesQ collection for --save', 'research')
114
+ .action(async (words, options) => {
115
+ if (ctx.auth && ctx.auth.requireAuth) ctx.auth.requireAuth();
116
+ const question = words.join(' ').trim();
117
+ if (!question) throw new QuoxError('Ask a question: quox research ask "<question>"', 2);
118
+ const g = program.opts();
119
+ const json = g.output === 'json' || g.json === true;
120
+
121
+ const { data, sessionId } = await sendOneShot(client, { message: question, agent: RESEARCH_AGENT });
122
+ const raw = data && (data.result ?? data.response ?? data.text);
123
+ const answer = typeof raw === 'string' ? raw : '';
124
+ if (!answer.trim()) throw new QuoxError(`The research agent returned no answer${data && data.error ? ` (${data.error})` : ''}.`, 1);
125
+ const toolsUsed = Array.isArray(data.tools_used) ? [...new Set(data.tools_used.map((t) => (typeof t === 'string' ? t : t && (t.name || t.tool))).filter(Boolean))] : [];
126
+ const agent = data.agent_used || RESEARCH_AGENT;
127
+ const sources = extractSources(answer);
128
+
129
+ let note = null;
130
+ if (options.save) {
131
+ const ep = resolveEndpoint('notesq.notes.create');
132
+ const saved = await client.post(ep.path, {
133
+ service: ep.service,
134
+ body: { scope: 'org', markdown: noteMarkdown({ question, answer, sources, toolsUsed, agent }), properties: { collection: options.collection, tags: ['research'] } },
135
+ });
136
+ note = saved && saved.note;
137
+ if (!note || !note.id) throw new QuoxError('NotesQ returned no note id: the answer was not saved.', 1);
138
+ }
139
+
140
+ if (json) {
141
+ console.log(JSON.stringify({ question, agent, answer, sources, tools_used: toolsUsed, conversation_id: sessionId || null, note }, null, 2));
142
+ return;
143
+ }
144
+ console.log(answer.trim());
145
+ console.log('');
146
+ if (sources.length) {
147
+ console.log(`Sources (${sources.length} URLs cited in the answer; the route does not report which pages were actually read):`);
148
+ sources.forEach((u, i) => console.log(` ${i + 1}. ${u}`));
149
+ } else {
150
+ console.log('Sources: none. The agent cited no URL in its answer.');
151
+ }
152
+ const ranSearch = toolsUsed.includes('web_search');
153
+ console.log(`Tools called: ${toolsUsed.length ? toolsUsed.join(', ') : 'none'}${ranSearch ? ' (called, not necessarily successful: read the answer)' : ' (web_search did not run on this turn)'}`);
154
+ if (agent !== RESEARCH_AGENT) console.log(`Answered by: ${agent}`);
155
+ if (note) console.log(`Saved note ${note.id} in collection ${options.collection}.`);
156
+ });
79
157
  }
@@ -12,6 +12,7 @@
12
12
  import { formatOutput, formatDetail } from '../core/output.js';
13
13
  import { line as glyphLine, printEvent } from '../core/quoxglyph.js';
14
14
  import { resolveEndpoint } from '../api/endpoints.js';
15
+ import { withStepUp } from '../core/stepUpPrompt.js';
15
16
 
16
17
  async function fetchFlags(client) {
17
18
  const ep = resolveEndpoint('v1.settings.list');
@@ -59,7 +60,12 @@ export function registerSettingsCommands(program, ctx) {
59
60
  .action(async (key, value) => {
60
61
  auth.requireAuth();
61
62
  const ep = resolveEndpoint('v1.settings.set', { key });
62
- const data = await client.patch(ep.path, { service: ep.service, body: { value } });
63
+ // VULCAN: weakening an approval control (STEP_UP_ENFORCE off) needs a step-up PIN; the server
64
+ // decides, and withStepUp asks for the PIN on a hidden prompt and retries once (scope override).
65
+ const data = await withStepUp(
66
+ (headers) => client.patch(ep.path, { service: ep.service, headers, body: { value } }),
67
+ { client, orgId: auth.getOrgId?.(program.opts().org) || null, scope: 'override', io: ctx.stepUpIO, what: `Changing ${key}` },
68
+ );
63
69
  if (program.opts().output === 'json') {
64
70
  console.log(JSON.stringify(data, null, 2));
65
71
  return;
@@ -380,6 +380,15 @@ export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
380
380
  head('coverage', s.covered === null || s.covered === undefined ? 'fleet unknown' : `${s.covered} of ${s.fleet_hosts} covered`,
381
381
  degraded || s.missing_agent > 0 || s.disconnected > 0 ? 'warn' : 'ok');
382
382
  console.log([n(s.fleet_hosts, 'fleet hosts'), `${s.wazuh_agents} Wazuh agents`, n(s.covered, 'covered'), n(s.missing_agent, 'missing agent'), `${s.disconnected} disconnected`, n(s.no_vuln_inventory, 'without vulnerability inventory')].join(', '));
383
+ // WAZ-11 (audit batch 5, N4): an unknown fleet-side number always says why, on stdout beside the summary (the JSON
384
+ // carries the reason as fleet_note / fleet_error; the human line used to print "unknown" with no reason).
385
+ if (s.fleet_hosts === null || s.fleet_hosts === undefined || s.covered === null || s.covered === undefined) {
386
+ const why = data.fleet_note
387
+ || (data.fleet_error === 'fleet_not_permitted' ? 'this organisation is not permitted to compare against the server fleet on this Quox instance (fleet_not_permitted)' : '')
388
+ || (data.fleet_error ? `the fleet list could not be read (${data.fleet_error})` : '')
389
+ || 'the collector gave no reason';
390
+ console.log(`Fleet hosts and covered are unknown: ${why}`);
391
+ }
383
392
  if (data.fleet_error) console.error(`Fleet list could not be read (${data.fleet_error}); fleet-side numbers are unknown, not zero.`);
384
393
  if (data.vuln_error) console.error(`Vulnerability inventory could not be read (${data.vuln_error}); that section is unknown, not empty.`);
385
394
  const section = (title, list, cols, row) => {
@@ -246,10 +246,11 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
246
246
  const printQuiet = () => {
247
247
  if (qUnknown) { console.log(`\nstill open in governance: unknown (${q.error || 'the collector could not read it'})`); return; }
248
248
  if (!qN) return;
249
- const qr = [['GOVERNANCE', 'STATUS', 'SEVERITY', 'ASSIGNED', 'WAZUH', 'CLOSED AS', 'CLOSED', 'AGENT', 'RULE']];
249
+ // WAZ-11 (audit batch 5, NEW-D a): VERDICT, ARGUS's triage verdict of the Wazuh incident ('-' = not triaged).
250
+ const qr = [['GOVERNANCE', 'STATUS', 'SEVERITY', 'ASSIGNED', 'WAZUH', 'CLOSED AS', 'CLOSED', 'AGENT', 'RULE', 'VERDICT']];
250
251
  for (const g of qRows) {
251
252
  qr.push([g.id, g.status, g.severity, g.assigned_to || '-', g.wazuh_incident_id || '-', g.wazuh_close_reason || '-', ago(g.wazuh_closed_at),
252
- g.agent_name || g.agent_id || '-', g.rule_id || '-']);
253
+ g.agent_name || g.agent_id || '-', g.rule_id || '-', g.verdict || '-']);
253
254
  }
254
255
  console.log(`\n${q.label || 'Wazuh went quiet; still open in governance'} (${qN}):`);
255
256
  console.log(pad(qr).join('\n'));
@@ -302,7 +303,14 @@ export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFa
302
303
  const dg = i.diagnosis;
303
304
  rows.push(['Diagnosis', dg ? `${dg.status} (${dg.trigger || dg.mode || 'manual'}) ${dg.summary || ''}`.trim() : `not run (quox wazuh incidents diagnose ${i.id})`]);
304
305
  const es = i.escalation;
305
- rows.push(['Escalated', es && es.finding_id ? `finding ${es.finding_id} in engagement ${es.engagement_id}${es.engagement_name ? ` (${es.engagement_name})` : ''}` : `no (quox wazuh incidents escalate ${i.id})`]);
306
+ // WAZ-11 (audit batch 5, N3): two different escalations, labelled apart. Escalated = sent to QuoxSecurity as a
307
+ // finding; Unowned = the governance incident's own flag (escalated because nobody claimed it).
308
+ rows.push(['Escalated', es && es.finding_id ? `finding ${es.finding_id} in engagement ${es.engagement_id}${es.engagement_name ? ` (${es.engagement_name})` : ''}: sent to QuoxSecurity (${es.finding_id})` : `not sent to QuoxSecurity (quox wazuh incidents escalate ${i.id})`]);
309
+ if (typeof i.governance_unowned_escalated === 'boolean') {
310
+ rows.push(['Unowned', i.governance_unowned_escalated
311
+ ? `unowned-escalated (nobody claimed ${i.governance_incident_id} in time: inbox item + LUCID ask; claim it: quox monitoring incidents claim ${i.governance_incident_id})`
312
+ : 'not unowned-escalated']);
313
+ }
306
314
  if (!i.open) rows.push(['Closed', `${i.closed_at}${i.closed_by ? ` by ${i.closed_by}` : ''}${i.close_reason ? `: ${i.close_reason}` : ''}`]);
307
315
  console.log(pad(rows).join('\n'));
308
316
  if (dg) printChecks(dg, pad);
@@ -115,6 +115,7 @@ export const COMMAND_REGISTRARS = [
115
115
  ['../commands/awareness.js', 'registerAwarenessCommands'],
116
116
  ['../commands/mind.js', 'registerMindCommands'],
117
117
  ['../commands/lucid.js', 'registerLucidCommands'],
118
+ ['../commands/lens.js', 'registerLensCommands'],
118
119
  ['../commands/hyperion.js', 'registerHyperionCommands'],
119
120
  ['../commands/tg.js', 'registerTgCommands'],
120
121
  ['../commands/dev.js', 'registerDevCommands'],
@@ -18,19 +18,32 @@ import { execFileSync } from 'node:child_process';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { dirname, resolve } from 'node:path';
20
20
 
21
- export const BASE_VERSION = '0.6.0';
21
+ export const BASE_VERSION = '0.6.1';
22
22
 
23
23
  // The CLI source root (…/cli). version.js lives at cli/lib/core/, so two
24
24
  // levels up is the tree whose HEAD identifies the running build.
25
25
  const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
26
26
 
27
- function git(args) {
27
+ function rawGit(args) {
28
28
  return execFileSync('git', ['-C', CLI_ROOT, ...args], {
29
29
  encoding: 'utf8',
30
30
  stdio: ['ignore', 'pipe', 'ignore'],
31
31
  }).trim();
32
32
  }
33
33
 
34
+ // An npm install can sit inside SOMEONE ELSE'S git repo (~/.nvm is a git
35
+ // clone), and `git -C` happily walks up to it. Git facts only count when the
36
+ // enclosing repo actually tracks this CLI's own files; otherwise behave as a
37
+ // plain-package install.
38
+ let ownRepo;
39
+ function git(args) {
40
+ if (ownRepo === undefined) {
41
+ try { rawGit(['ls-files', '--error-unmatch', 'lib/core/version.js']); ownRepo = true; } catch { ownRepo = false; }
42
+ }
43
+ if (!ownRepo) throw new Error('not a quox git worktree');
44
+ return rawGit(args);
45
+ }
46
+
34
47
  /**
35
48
  * Human version string: "0.1.0 (7bac4f67, session/vulcan)" — or bare
36
49
  * "0.1.0" when git facts are unavailable. Unlike the drift check this never
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Dragon companions (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): which one, whether to show it, and where its art lives.
2
+ * Dragon companions (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus, Janus, Archivist, Architect, Roady, Weaver): which one, whether to show it, and where its art lives.
3
3
  * Pure. The art, animation and activity reducer stay in quoxai/dragonlab (integration/companions-v1);
4
4
  * Quox only chooses and loads. See docs/dragons/COMPANION-UX.md for the decisions behind each rule.
5
5
  */
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
10
10
  export const VENDORED_DRAGONLAB = fileURLToPath(new URL('../../vendor/dragonlab', import.meta.url));
11
11
 
12
12
  /** Registry agent ids (and display names) to approved dragon. Others, including quox/commanderq, get none. */
13
- export const AGENT_DRAGON = Object.freeze({ lucid: 'lucid', hyperion: 'hyperion', atlas: 'hyperion', seer: 'seer', sentinel: 'sentinel', warden: 'sentinel', security_lead: 'drago', drago: 'drago', cipher: 'cipher', vulcan: 'vulcan', forge: 'vulcan', quoxperticus: 'quoxperticus' });
13
+ export const AGENT_DRAGON = Object.freeze({ lucid: 'lucid', hyperion: 'hyperion', atlas: 'hyperion', seer: 'seer', sentinel: 'sentinel', warden: 'sentinel', security_lead: 'drago', drago: 'drago', cipher: 'cipher', vulcan: 'vulcan', forge: 'vulcan', quoxperticus: 'quoxperticus', janus: 'janus', archivist: 'archivist', architect: 'architect', roady: 'roady', weaver: 'weaver' });
14
14
  export const COMPANION_MODES = ['on', 'greeting', 'off'];
15
15
  /** One-line change: the owner decides this after his Mac check (docs/dragons/COMPANION-UX.md section 4). */
16
16
  export const DEFAULT_COMPANION_MODE = 'on';
package/lib/quoxon/app.js CHANGED
@@ -691,7 +691,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
691
691
  return () => clearInterval(id);
692
692
  }, []);
693
693
  // ---- QUOXON-MESH: slot chat (slotChat.js) ----------------------------------------------------------------
694
- // Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): attach state + real stream events; art and reducer live in dragonlab.
694
+ // Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus, Janus, Archivist, Architect, Roady, Weaver): attach state + real stream events; art and reducer live in dragonlab.
695
695
  const DC = React.useRef(null); DC.current ??= createCompanionController({ settings: () => api.settings?.() || {}, isTTY: Boolean(stdout?.isTTY), typing: () => B.current.text !== '', jobs: createDragonJobs({ reads: api.dragonReads || {}, context: () => ({ envelopeId: receiptMentions([...S.current.history, ...S.current.blocks])[0]?.value || null }) }) }); // each dragon's one job: a real read (dragon/jobs.js)
696
696
  const dispatchEv = (a) => { DC.current.tap(a); dispatch(markDelegationDragon(a, a?.ev?.tool === 'delegate_to_agent' && delegationDragonOn(api.settings?.() || {}, env))); }; // dragons on: CommanderQ's hand-off gets a static dragon
697
697
  // QLOOP-LIVE: live loop cards in the chat stream (loopCards.js). A card is held live (commitLimit) until its loop finishes.
@@ -63,7 +63,7 @@ const ARGS = {
63
63
  broadcast: [{ after: [], choices: [{ value: '--slots', desc: 'only these slots, comma separated' }, { value: '--priority', desc: 'urgent shows red' }, { value: '--ack', desc: 'ask recipients to /ack' }, { value: '--active', desc: 'only sessions listening now; dormant are skipped' }, { value: '--dry-run', desc: 'with --active: list who would be reached, send nothing' }] }],
64
64
  send: [{ after: [], src: 'slots' }, { after: ['*'], src: 'files', of: 'partial' }],
65
65
  dragon: [
66
- { after: [], choices: ['explain', 'ask', 'sleep', 'fly', 'hero', 'fire', 'classic', { value: 'companion', desc: 'the dragon beside an attached Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan or Quoxperticus' }] },
66
+ { after: [], choices: ['explain', 'ask', 'sleep', 'fly', 'hero', 'fire', 'classic', { value: 'companion', desc: 'the dragon beside an attached Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus, Janus, Archivist, Architect, Roady or Weaver' }] },
67
67
  { after: ['companion'], choices: [{ value: 'on', desc: 'on attach and key moments, then leaves' }, { value: 'greeting', desc: 'only on attach, then leaves' }, { value: 'off', desc: 'never on its own (/dragon shows it)' }, { value: 'status', desc: 'what is set and whether the art loads' }] }
68
68
  ],
69
69
  ext: [
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Dragon companion for Quoxon (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus). The controller owns attach state and feeds REAL stream
2
+ * Dragon companion for Quoxon (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus, Janus, Archivist, Architect, Roady, Weaver). The controller owns attach state and feeds REAL stream
3
3
  * events to dragonlab's pure view (integration/companions-v1/view.mjs: the same reducer and renderer the CLI
4
4
  * uses). No art, state logic or timers are duplicated here; this file only decides, translates and lays out.
5
5
  * Decisions: docs/dragons/COMPANION-UX.md. The reducer never overwrites pending approvals or failures, but the host only SHOWS the dragon during a dwell (attach, key moment, invoke).
@@ -226,20 +226,33 @@ export function createCompanionController({ settings = () => ({}), env = process
226
226
  const mood = job?.mood === 'concerned' && !state.approvals.length ? 'concerned' : null;
227
227
  try { v = view.view(state, t, { cols, rows: big ? rows : 1, mood }); } catch { return null; }
228
228
  if (v.hidden) return null;
229
- // One caption per trigger: "Attached." only on an attach; an invoke says who is here; an approval always says so.
230
- let caption = v.caption;
231
- const summonedCaption = summonCaption;
232
- if (state.approvals.length) caption = 'Waiting for your approval.';
233
- else if (job?.caption && !state.issue) caption = job.caption; // the dragon's job: a real read (jobs.js); an approval or a recorded failure still comes first
234
- else if (state.phase === 'connecting') caption = trigger === 'attach' ? 'Attached.' : trigger === 'invoke' ? `${view.name} is here.` : v.caption;
235
- if (summonedCaption && now() < showUntil) caption = summonedCaption; // a domain moment states its own fact
236
- if (!big) return { kind: 'label', text: `${view.name}: ${caption}` };
229
+ // Owner, 2026-11-10: no caption by default. A caption only names a real event: an approval waiting, a failed step, a stream that
230
+ // ended with the outcome unknown, a job read that found something wrong, or a domain moment's own fact. Idle, arrival, generic
231
+ // work, power flourishes, mottos and calm job reads say nothing (the reducer's own captions are never shown).
232
+ const summonedCaption = summonCaption && now() < showUntil ? summonCaption : null; // a domain moment states its own fact
233
+ const caption = summonedCaption || eventCaption(state, job);
234
+ if (!big) return caption ? { kind: 'label', text: `${view.name}: ${caption}` } : null;
237
235
  if (!v.frame) { waitingFrame = true; return null; } // the first frame is still being drawn off the main thread: draw nothing rather than a gap
238
236
  return { kind: 'art', frame: v.frame, caption, name: view.name };
239
237
  },
240
238
  };
241
239
  }
242
240
 
241
+ /**
242
+ * The only captions the companion shows: each names a real host event. -> string | null. Pure.
243
+ * Allowlist, in priority order: an approval waiting, a failed step (an error, a failed tool or turn, a denied approval), a stream that
244
+ * ended without a verified outcome, a job read that found something wrong. Anything else (idle, arrival, working, thinking, power
245
+ * flourishes, mottos, a calm job read) is null.
246
+ */
247
+ export function eventCaption(state, job = null) {
248
+ if (!state) return null;
249
+ if (state.approvals?.length) return 'Waiting for your approval.';
250
+ if (state.issue || state.phase === 'error') return 'That step failed.';
251
+ if (state.phase === 'disconnected') return 'Stream ended, outcome unknown.';
252
+ if (job?.mood === 'concerned' && job.caption) return job.caption;
253
+ return null;
254
+ }
255
+
243
256
  /** "/dragon companion <arg>" -> { text, tone, save?, refresh? }. Pure. */
244
257
  export function companionCommand(arg, { settings = {}, plan = null, artProblem = null } = {}) {
245
258
  // `invoke: true` = the host also plays the attached agent's dragon for a dwell (a status check is a call for the dragon too).
@@ -249,7 +262,7 @@ export function companionCommand(arg, { settings = {}, plan = null, artProblem =
249
262
  const mode = cur || plan?.mode || 'on';
250
263
  const now = plan?.enabled ? `showing ${plan.id}` : `not showing (${plan?.why || 'no agent attached'})`;
251
264
  if (artProblem && mode !== 'off') return { text: `Dragon companion: ${mode}, but the art can't be loaded so no dragon will show (${artProblem}). /dragon companion on|greeting|off.`, tone: 'warn' };
252
- return { text: `Dragon companion: ${mode}, ${now}. /dragon companion on|greeting|off. Only Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan and Quoxperticus have dragons.`, invoke: true };
265
+ return { text: `Dragon companion: ${mode}, ${now}. /dragon companion on|greeting|off. Only Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus, Janus, Archivist, Architect, Roady and Weaver have dragons.`, invoke: true };
253
266
  }
254
267
  const m = normaliseMode(w);
255
268
  if (!m) return { text: `Usage: /dragon companion ${COMPANION_MODES.join('|')}|status`, tone: 'warn' };
@@ -270,8 +283,7 @@ export function DragonCompanionLive({ ctl, cols, rows }) {
270
283
  return h(Box, { flexDirection: 'row', marginTop: 1 },
271
284
  h(Box, { flexDirection: 'column', flexShrink: 0 }, ...snap.frame.map((r, k) => h(Text, { key: k, wrap: 'truncate' }, ...cellNodes(r, `dc${k}-`)))),
272
285
  h(Box, { flexDirection: 'column', paddingLeft: 2 },
273
- h(Text, { bold: true }, snap.name.toUpperCase()),
274
- h(Text, { wrap: 'wrap' }, snap.caption)));
286
+ ...(snap.caption ? [h(Text, { key: 'n', bold: true }, snap.name.toUpperCase()), h(Text, { key: 'c', wrap: 'wrap' }, snap.caption)] : [])));
275
287
  }
276
288
 
277
289
  /**
@@ -41,7 +41,7 @@ export async function main(argv = process.argv.slice(2)) {
41
41
  if (opts.errors.length) { early?.release(); process.stderr.write(`quox: ${opts.errors.join('; ')}\n${USAGE}\n`); process.exitCode = 1; return; }
42
42
  const cwd = process.cwd().replace(process.env.HOME || '\0', '~');
43
43
  const { resolveCliVersion } = await import('../core/version.js');
44
- let version = '0.6.0';
44
+ let version = '0.6.1';
45
45
  try { version = resolveCliVersion?.() || version; } catch { /* keep default */ }
46
46
  if (opts.print) return mainPrint(opts);
47
47
  let replay = null;
@@ -102,7 +102,7 @@ const Hint = ({ text, cols }) => h(Box, { paddingLeft: 2 }, h(Text, { color: c('
102
102
 
103
103
  // ---- the view ---------------------------------------------------------------------------------
104
104
  export function GettingStarted({
105
- columns, version = '0.6.0', cwd = '', welcome = false, animateIntro, earlyInput = null,
105
+ columns, version = '0.6.1', cwd = '', welcome = false, animateIntro, earlyInput = null,
106
106
  journeyPath, dryRun = false, runner = null, preflightDeps, runChecks = runPreflight, findLocal = probeLocalQuox, findOllama = detectOllama,
107
107
  requestCredential, broker = null, finisher = null, onExit = () => {}, now = () => Date.now()
108
108
  }) {
@@ -23,6 +23,9 @@
23
23
  "admin retention cleanup":"write",
24
24
  "admin retention config":"write",
25
25
  "admin retention stats":"read",
26
+ "admin scratch-orgs list":"read",
27
+ "admin scratch-orgs mark":"never",
28
+ "admin scratch-orgs unmark":"never",
26
29
  "admin service list":"read",
27
30
  "admin stats":"read",
28
31
  "admin users verify-email":"write",
@@ -142,6 +145,7 @@
142
145
  "build status":"read",
143
146
  "build targets":"read",
144
147
  "calendar agenda":"read",
148
+ "calendar connections add":"never",
145
149
  "calendar coverage":"read",
146
150
  "calendar layers":"read",
147
151
  "calendar propose":"write",
@@ -338,7 +342,15 @@
338
342
  "fleet gpu":"read",
339
343
  "fleet host-update":"write",
340
344
  "fleet hosts":"read",
345
+ "fleet hw events":"read",
346
+ "fleet hw identify":"write",
347
+ "fleet hw refresh":"read",
341
348
  "fleet ping":"write",
349
+ "fleet recipe add":"write",
350
+ "fleet recipe list":"read",
351
+ "fleet recipe remove":"write",
352
+ "fleet recipe run":"write",
353
+ "fleet recipe show":"read",
342
354
  "fleet rehome-agent":"write",
343
355
  "fleet status":"read",
344
356
  "fleet summary":"read",
@@ -430,6 +442,10 @@
430
442
  "kuma status-incident publish":"write",
431
443
  "kuma status-incident resolve":"write",
432
444
  "kuma uptime":"write",
445
+ "lens apply":"write",
446
+ "lens list":"read",
447
+ "lens panel":"write",
448
+ "lens show":"read",
433
449
  "licence catalogue-sign":"never",
434
450
  "licence refresh":"write",
435
451
  "licence reissue":"never",
@@ -475,6 +491,8 @@
475
491
  "lucid goals show":"read",
476
492
  "lucid herald set":"write",
477
493
  "lucid herald test":"write",
494
+ "lucid insights accept":"write",
495
+ "lucid insights reject":"write",
478
496
  "lucid insights show":"read",
479
497
  "lucid insights status":"read",
480
498
  "lucid journal":"read",
@@ -760,6 +778,7 @@
760
778
  "quoxchat widgets":"read",
761
779
  "reach agent":"read",
762
780
  "reach unused":"read",
781
+ "research ask":"write",
763
782
  "research domains add":"write",
764
783
  "research domains list":"read",
765
784
  "research domains remove":"write",
@@ -174,3 +174,4 @@
174
174
  {"id":"quoxon-2026-11-07-offload-lease-warn","date":"2026-11-07T13:20:00Z","since":"0.4.0","title":"Offload warns before a worker lease runs out","shell":"quox offload leases","text":"quox offload status and the new quox offload leases list every offload worker with EXPIRED, EXPIRING or OK, and every offload command warns when a lease ends within 12 hours, naming the command a human runs to renew it."}
175
175
  {"id":"quoxon-2026-11-07-select-dot-whole-result","date":"2026-11-07T13:50:00Z","since":"0.4.0","title":"quox tool exec --select . returns the whole result","text":"A dot means the whole result, as in jq, so --select . (and count(.)) no longer fails with \"bad path\". Every other --select path works as before."}
176
176
  {"id":"quoxon-2026-11-08-walk-revalidate","date":"2026-11-08T04:55:00Z","since":"0.4.0","title":"A stopped walkthrough only lists what is still waiting","text":"When a walkthrough stops, the TUI asks the server again before listing what is still waiting, so questions answered from another window drop off the list. A held list is re-checked the same way before its first card. If the server cannot be reached, the list says it may be out of date."}
177
+ {"id": "quoxon-2026-11-10-dragons-quiet-and-four-new", "date": "2026-11-10T16:00:00Z", "since": "0.4.0", "sha": "4a4a9b326", "title": "Dragons stay quiet, and four new companions", "text": "A companion dragon only shows a caption when something real happens: an approval waiting, a failed step, a status check that found a problem. Arriving, idling and working show no label. Janus has the original look back, and Archivist, Architect, Roady and Weaver now have their own dragons.", "try": "/attach agent:janus"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quoxcli",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Quox: the conversational front door to your self-hosted AI agent control plane",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
1
  repo: quoxai/dragonlab
2
- commit: a4dda5ef12c417689768664022b8de1245794c56
3
- branch: integration/companions-vulcan-quoxperticus
4
- files: 66
2
+ commit: ff114e97025be305b350e29de0d98220b66f77ce
3
+ branch: design/janus-v5
4
+ files: 112
@@ -0,0 +1,67 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
2
+ <title>Architect — ivory keeper of the Grand Design</title>
3
+ <g id="far-wing">
4
+ <path id="far-membrane" fill="#514763" d="M 349 305 C 379 203 436 103 533 53 C 499 117 499 174 546 210 Q 483 184 453 239 Q 409 225 383 293 Z"/>
5
+ <path id="far-vault" fill="none" stroke="#b6a9c6" stroke-width="8" d="M 349 305 C 379 203 436 103 533 53 M 349 305 Q 444 164 533 53 M 349 305 Q 457 182 546 210"/>
6
+ </g>
7
+ <g id="near-wing">
8
+ <path id="vaulted-membrane" fill="#6d647c" d="M 305 305 C 260 206 205 100 92 62 C 139 142 111 211 57 248 Q 128 218 162 273 Q 214 237 251 309 L 292 331 Z"/>
9
+ <path id="wing-lit-panel" fill="#968a9f" d="M 305 305 Q 230 141 92 62 Q 189 162 176 237 Q 224 229 251 309 Z"/>
10
+ <path id="vault-ribs" fill="none" stroke="#e5dcc8" stroke-width="9" d="M 305 305 C 260 206 205 100 92 62 M 305 305 Q 218 182 92 62 M 305 305 Q 197 155 57 248 M 305 305 Q 224 201 162 273"/>
11
+ <path id="wing-tip" fill="#f3e8d0" d="M 92 62 Q 71 46 62 25 Q 69 61 103 84 Z"/>
12
+ <path id="wing-violet-vein" fill="none" stroke="#a57ee1" stroke-width="4" d="M 295 304 Q 215 192 115 128"/>
13
+ </g>
14
+ <g id="tail">
15
+ <path id="long-tail" fill="#8c8490" d="M 306 371 C 225 397 227 446 145 440 C 75 436 53 384 88 349 C 111 326 150 342 145 371 Q 136 388 121 382 Q 141 363 121 356 C 91 347 84 387 110 402 C 166 440 212 379 256 351 Z"/>
16
+ <path id="tail-ivory-ridge" fill="none" stroke="#d7cfbd" stroke-width="7" d="M 287 374 C 219 406 212 430 154 428 C 106 428 77 397 92 367"/>
17
+ <path id="tail-tip" fill="#c5acee" d="M 125 382 Q 146 381 146 359 Q 163 379 147 395 Z"/>
18
+ </g>
19
+ <g id="body">
20
+ <path id="torso" fill="#8e8790" d="M 309 273 Q 351 258 379 289 Q 416 336 389 397 Q 348 430 285 409 Q 254 354 278 313 Z"/>
21
+ <path id="breast" fill="#e8dfcd" d="M 340 285 Q 376 295 379 335 L 361 389 Q 331 408 307 383 L 303 333 Z"/>
22
+ <path id="breast-scales" fill="none" stroke="#a8a1a4" stroke-width="5" d="M 312 329 Q 340 343 376 329 M 307 353 Q 335 369 370 353 M 313 377 Q 335 389 360 375"/>
23
+ <path id="far-leg" fill="#625e6b" d="M 367 351 Q 404 344 405 379 L 401 413 L 446 423 Q 459 430 445 440 L 369 440 L 352 414 Z"/>
24
+ <path id="near-haunch" fill="#a49ca2" d="M 292 343 Q 332 332 341 367 Q 340 392 320 409 L 344 430 L 332 442 L 258 441 Q 245 421 261 402 Q 253 363 292 343 Z"/>
25
+ <path id="feet-claws" fill="#f4e9d2" d="M 270 425 L 258 444 L 280 436 L 293 424 L 289 443 L 310 436 L 323 424 L 321 442 L 344 436 M 389 425 L 377 443 L 398 437 L 413 425 L 411 443 L 431 437 L 443 426 L 442 440 L 458 433"/>
26
+ <path id="shoulder" fill="#ded3c0" d="M 297 276 Q 314 261 331 284 L 332 313 Q 306 327 292 304 Z"/>
27
+ </g>
28
+ <g id="neck">
29
+ <path id="swan-neck" fill="#c8c0b3" d="M 371 169 Q 401 190 380 228 C 363 258 333 261 345 304 L 304 321 Q 277 270 313 237 C 347 210 351 200 339 180 Z"/>
30
+ <path id="throat" fill="#f2e9d5" d="M 374 187 Q 387 208 366 232 C 344 256 322 267 337 301 L 318 304 C 302 268 329 247 346 229 Q 370 207 360 190 Z"/>
31
+ <path id="graphite-crest" fill="#575365" d="M 344 193 L 322 203 L 337 216 L 309 224 L 317 239 L 291 247 L 298 265 L 278 280 L 295 291 L 307 264 L 338 233 L 359 200 Z"/>
32
+ </g>
33
+ <g id="head">
34
+ <path id="far-horn" fill="#c3b9ad" d="M 370 127 C 340 111 310 87 304 57 Q 331 86 389 103 Z"/>
35
+ <path id="near-horn" fill="#f2e6cc" d="M 405 121 C 371 92 340 61 345 29 Q 359 66 425 97 Z"/>
36
+ <path id="horn-bands" fill="none" stroke="#686171" stroke-width="6" d="M 354 65 L 366 57 M 372 85 L 386 75 M 329 89 L 339 81"/>
37
+ <path id="skull" fill="#e9dfca" d="M 346 129 Q 359 105 390 112 Q 425 104 443 139 L 441 173 Q 428 204 392 207 L 353 184 Q 337 158 346 129 Z"/>
38
+ <path id="ear" fill="#bdb2bd" d="M 351 139 L 313 120 Q 316 143 327 153 L 310 166 Q 340 181 359 164 Z"/>
39
+ <path id="long-muzzle" fill="#f4ead7" d="M 413 164 Q 445 153 475 173 Q 490 183 478 194 L 433 210 Q 403 207 398 190 Z"/>
40
+ <path id="mouth-opening" fill="#282633" d="M 412 193 Q 445 200 479 186 L 469 205 Q 440 222 417 208 Z"/>
41
+ <path id="lower-jaw" fill="#c8bdad" d="M 414 207 Q 440 220 469 204 Q 455 228 429 225 L 404 208 Z"/>
42
+ <path id="fang" fill="#fff4dd" d="M 434 200 L 445 199 L 441 210 Z"/>
43
+ <path id="nostril" fill="#383340" d="M 465 177 Q 474 173 474 182 L 464 184 Z"/>
44
+ <path id="brow-ridge" fill="none" stroke="#9f949b" stroke-width="5" d="M 356 133 Q 372 123 386 133 M 395 130 Q 414 121 430 134"/>
45
+ </g>
46
+ <g id="arms">
47
+ <path id="far-arm" fill="#a59ba4" d="M 354 292 Q 377 277 398 301 L 427 331 L 478 335 L 501 326 L 516 334 L 503 351 L 421 355 L 382 329 Z"/>
48
+ <path id="raised-arm" fill="#dfd4c2" d="M 328 289 Q 338 269 359 280 L 391 300 Q 421 298 447 268 L 470 244 L 487 247 Q 478 268 460 282 L 420 319 Q 393 336 368 316 Z"/>
49
+ <path id="drawing-finger" fill="#f5e9d2" d="M 466 247 Q 480 225 502 225 L 527 233 L 523 242 L 499 238 L 485 251 L 502 253 L 499 264 L 478 263 Z"/>
50
+ <path id="lower-fingers" fill="#f1e5d1" d="M 477 335 L 494 317 L 501 321 L 493 335 L 513 325 L 521 332 L 506 346 L 483 349 Z"/>
51
+ <path id="armour-cuffs" fill="none" stroke="#686071" stroke-width="9" d="M 440 275 L 455 290 M 466 334 L 466 352"/>
52
+ </g>
53
+ <g id="design">
54
+ <path id="seed" fill="#e6caff" d="M 548 226 L 555 233 L 548 240 L 541 233 Z"/>
55
+ <path id="draft-plane" fill="none" stroke="#9165c5" stroke-width="5" d="M 541 354 L 614 325 L 692 354 L 616 390 Z M 577 369 L 650 339 M 577 340 L 652 372"/>
56
+ <path id="grand-arch" fill="none" stroke="#d0a4ff" stroke-width="6" d="M 563 349 L 563 301 Q 590 252 617 301 L 617 372 M 617 301 L 657 283 Q 683 292 683 320 L 683 349 M 563 301 L 603 283 Q 630 234 657 283"/>
57
+ <path id="design-ribs" fill="none" stroke="#a07bd1" stroke-width="4" d="M 590 277 L 630 259 M 590 277 L 590 358 M 617 331 L 683 309 M 563 325 L 617 348"/>
58
+ </g>
59
+ <g id="eyes">
60
+ <path id="far-eye-socket" fill="#080d19" d="M 349 151 C 349 136.08831175 359.745166 124 373 124 C 386.254834 124 397 136.08831175 397 151 C 397 165.91168825 386.254834 178 373 178 C 359.745166 178 349 165.91168825 349 151 Z"/>
61
+ <path id="far-eye-white" fill="#ffffff" d="M 358.38 151 C 358.38 139.954305 364.9256 131 373.0 131 C 381.0744 131 387.62 139.954305 387.62 151 C 387.62 162.045695 381.0744 171 373.0 171 C 364.9256 171 358.38 162.045695 358.38 151 Z"/>
62
+ <path id="far-pupil" fill="#282633" d="M 373 154 C 373 150.13400675 375.23857625 147 378 147 C 380.76142375 147 383 150.13400675 383 154 C 383 157.86599325 380.76142375 161 378 161 C 375.23857625 161 373 157.86599325 373 154 Z"/>
63
+ <path id="near-eye-socket" fill="#080d19" d="M 382 150 C 382 132.87917275 394.98374225 119 411 119 C 427.01625775 119 440 132.87917275 440 150 C 440 167.12082725 427.01625775 181 411 181 C 394.98374225 181 382 167.12082725 382 150 Z"/>
64
+ <path id="near-eye-white" fill="#ffffff" d="M 392.08 150 C 392.08 136.745166 400.5508 126 411.0 126 C 421.4492 126 429.92 136.745166 429.92 150 C 429.92 163.254834 421.4492 174 411.0 174 C 400.5508 174 392.08 163.254834 392.08 150 Z"/>
65
+ <path id="near-pupil" fill="#282633" d="M 411 153 C 411 149.13400675 413.23857625 146 416 146 C 418.76142375 146 421 149.13400675 421 153 C 421 156.86599325 418.76142375 160 416 160 C 413.23857625 160 411 156.86599325 411 153 Z"/>
66
+ </g>
67
+ </svg>
@@ -0,0 +1,83 @@
1
+ import {renderSvgRig} from '../../../factory/svg-rig-contour.mjs';
2
+ export const ARCHITECT_CLIPS=Object.freeze([
3
+ {id:'idle',title:'Considering possibilities',duration:12000},
4
+ {id:'attach',title:'A first thought',duration:3000},
5
+ {id:'design',title:'Grand Design',duration:12000},
6
+ {id:'rethink',title:'Fold a possibility',duration:10000}
7
+ ]);
8
+ export const STORY_DURATION=15000;
9
+ const ease=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x);};
10
+ const pulse=(t,a,b)=>t<=a||t>=b?0:Math.sin(Math.PI*(t-a)/(b-a))**2;
11
+ const mix=(a,b,u)=>a.map((c,i)=>Math.round(c+(b[i]-c)*u));
12
+
13
+ export function poseRig(rig,scene,ms,{deck=false}={}){
14
+ if(scene==='design'&&ms>=12000)return poseRig(rig,'idle',ms-12000,{deck});
15
+ if(scene==='attach'&&ms>=3000)return poseRig(rig,'idle',ms-3000,{deck});
16
+
17
+ if(scene==='rethink'){
18
+ if(ms>=10000)return poseRig(rig,'idle',ms-10000,{deck});
19
+ if(ms>=8000)return poseRig(rig,'design',10000+ms-8000,{deck});
20
+ const base=poseRig(rig,'design',5200+4800*ease((ms-7400)/600),{deck});
21
+ const fold=pulse(ms,900,7400),angle=Math.PI*fold;
22
+ const paths=base.paths.filter(p=>p.id!=='design-ribs').map(p=>{
23
+ if(p.id!=='grand-arch')return p;
24
+ return {...p,rgb:mix(p.rgb,[246,220,255],fold),polygons:p.polygons.map((poly,i)=>i<2?poly:poly.map(([x,y])=>[617+(x-617)*Math.cos(angle),y+8*Math.sin(angle)]))};
25
+ });
26
+ return {...base,paths};
27
+ }
28
+ const active=scene==='design',t=active?ms:ms%12000;
29
+ const attention=active?pulse(t,0,11500):scene==='attach'?pulse(t,0,3000):.22*pulse(t,1500,7500);
30
+ const lift=active?ease((t-1800)/2600)*(1-ease((t-9500)/2500)):0;
31
+ const plane=ease((t-700)/1700)*(1-ease((t-10500)/1500));
32
+ const height=ease((t-2400)/2800)*(1-ease((t-10000)/1600));
33
+ const turn=.65*pulse(t,5200,8000);
34
+ const paths=[];
35
+ for(const p of rig.paths){
36
+ if(p.group==='design')continue;
37
+ const q={...p,polygons:p.polygons.map(poly=>poly.map(([x,y])=>{
38
+ if(p.group==='head'||p.group==='eyes')return [x+3*attention,y+5*attention];
39
+ if(p.group==='neck'){const w=Math.max(0,Math.min(1,(305-y)/105));return [x+3*attention*w,y+5*attention*w];}
40
+ if(p.group==='arms'){const w=Math.max(0,Math.min(1,(x-360)/170));return [x+7*attention*w,y+(y<300?-19*lift:6*lift)*w];}
41
+ if(p.group==='tail'){const w=Math.max(0,(260-x)/210);return [x,y-3*attention*w*w];}
42
+ return [x,y];
43
+ }))};
44
+ paths.push(q);
45
+ }
46
+ const seed=rig.paths.find(p=>p.id==='seed');
47
+ const seedTravel=active?plane:0;
48
+ paths.push({...seed,polygons:seed.polygons.map(poly=>poly.map(([x,y])=>[x+68*seedTravel,y+121*seedTravel]))});
49
+ if(active&&plane>.002){
50
+ const draft=rig.paths.find(p=>p.id==='draft-plane');
51
+ paths.push({...draft,polygons:draft.polygons.map(poly=>poly.map(([x,y])=>[616+(x-616)*plane,354+(y-354)*plane]))});
52
+ if(height>.002)for(const id of deck?['grand-arch','design-ribs']:['grand-arch']){
53
+ const p=rig.paths.find(p=>p.id===id);
54
+ const light=pulse(t,id==='grand-arch'?7900:8500,id==='grand-arch'?9900:10500);
55
+ paths.push({...p,rgb:mix(p.rgb,[255,243,225],light),polygons:p.polygons.map(poly=>poly.map(([x,y])=>{
56
+ const base=354+.12*(x-616),yy=base+(y-base)*height;
57
+ return [616+(x-616)*Math.cos(turn)+(yy-330)*Math.sin(turn)*.25,yy];
58
+ }))});
59
+ }
60
+ if(t>=8000&&t<10000){
61
+ // Light travels along the completed design, never outside its reserved space.
62
+ const u=(t-8000)/2000,x=563+120*u,y=354-74*Math.sin(Math.PI*u);
63
+ paths.push({id:'travelling-light',group:'effect',rgb:[255,244,230],stroke:0,erase:false,polygons:[[[x-4,y],[x,y-5],[x+4,y],[x,y+5]]]});
64
+ }
65
+ }
66
+ const map=new Map(paths.map(p=>[p.id,p]));
67
+ return {...rig,paths,eyes:rig.eyes.map(e=>({...e,socket:map.get(e.socket.id),white:map.get(e.white.id),pupil:map.get(e.pupil.id)}))};
68
+ }
69
+ export function renderArchitect(rig,scene='idle',ms=0,options={}){
70
+ if(!ARCHITECT_CLIPS.some(c=>c.id===scene))return {ok:false,code:'UNKNOWN_CLIP'};
71
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
72
+ if(!options.still&&scene==='rethink'&&ms>=10000)return renderArchitect(rig,'idle',ms-10000,options);
73
+ if(!options.still&&scene==='design'&&ms>=12000)return renderArchitect(rig,'idle',ms-12000,options);
74
+ if(!options.still&&scene==='attach'&&ms>=3000)return renderArchitect(rig,'idle',ms-3000,options);
75
+ const t=options.still?({idle:0,attach:1500,design:6500,rethink:4200}[scene]):ms;
76
+ const caption=scene==='rethink'?(t<900?'Hold the design.':t<4200?'Fold a possibility through itself.':t<7400?'Consider it from the other side.':t<8000?'The shape of an idea.':'Fold the thought away.'):scene==='design'?(t<700?'A single possibility.':t<2400?'Stretch a plane of light.':t<5200?'Lift the Grand Design.':t<8000?'Turn it. Consider every angle.':t<10000?'Illuminate the connections.':'Fold the thought away.'):scene==='attach'?'An idea takes shape.':'Imagine. Shape. Illuminate.';
77
+ return {...renderSvgRig(poseRig(rig,scene,t,options),options),clip:scene,scene,caption};
78
+ }
79
+ export function renderStory(rig,ms,options={}){
80
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
81
+ if(options.still)return renderArchitect(rig,'design',6500,options);
82
+ return ms<3000?renderArchitect(rig,'attach',ms,options):renderArchitect(rig,'design',ms-3000,options);
83
+ }