@empirical-gauzza/cli 1.0.37 → 1.0.38

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.
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ // Resolving "which memory did the user mean" from a --match query, shared by every memory
4
+ // subcommand that accepts --match. It lives in its own module so attachmentCommands.js can use
5
+ // it without a require cycle back through memoryCommands.js; memoryCommands.js re-exports the
6
+ // three names so existing importers and tests are unaffected.
7
+
8
+ const { warn } = require('./ui');
9
+ const { httpJson } = require('./http');
10
+ const { parseOption } = require('./env');
11
+ const { buildApiAuthHeaders } = require('./auth');
12
+
13
+ async function resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters) {
14
+ const queryUrl = new URL('/api/v1/memories/query', baseUrl).toString();
15
+ const body = { query: match, topK: 5 };
16
+ if (filters && Object.keys(filters).length) body.filters = filters;
17
+ let response;
18
+ try {
19
+ response = await httpJson(queryUrl, 'POST', body, buildApiAuthHeaders(accessToken));
20
+ } catch (error) {
21
+ return { found: false, reason: 'error', message: error.message };
22
+ }
23
+
24
+ const results = (response && response.data && Array.isArray(response.data.results))
25
+ ? response.data.results
26
+ : (Array.isArray(response) ? response : []);
27
+
28
+ if (!results.length) {
29
+ return { found: false, reason: 'no_results' };
30
+ }
31
+
32
+ const scored = results.map((r) => ({
33
+ id: r.id,
34
+ summary: r.summary,
35
+ score: r.rankScore != null ? r.rankScore : (r.similarityScore != null ? r.similarityScore : 0)
36
+ }));
37
+
38
+ scored.sort((a, b) => b.score - a.score);
39
+
40
+ // A caller-supplied filter (e.g. --tags) that narrows the server-side result set to exactly
41
+ // one memory is already a stronger disambiguation signal than the semantic score - trust it
42
+ // outright rather than falling through to the score check below. This matters because Atlas
43
+ // vector search has a real async indexing lag: a memory created moments ago can come back
44
+ // with score 0 (unindexed, not "not this one") even when a tag filter already proves it is
45
+ // the only candidate. Fixes a real bug found 2026-08-25 where delete --match failed on a
46
+ // memory `query` found instantly, because delete had no way to trust a filtered, unambiguous
47
+ // result over a stale/zero semantic score.
48
+ if (filters && Object.keys(filters).length && results.length === 1) {
49
+ const only = scored[0];
50
+ return { found: true, memory: { id: only.id, summary: only.summary, score: only.score } };
51
+ }
52
+
53
+ const top = scored[0];
54
+ const second = scored[1];
55
+
56
+ if (top.score < minScore) {
57
+ return { found: false, reason: 'low_score', candidates: scored.slice(0, 3) };
58
+ }
59
+
60
+ if (second && (top.score - second.score) <= 0.05) {
61
+ return { found: false, reason: 'ambiguous', candidates: scored.slice(0, 3) };
62
+ }
63
+
64
+ return { found: true, memory: { id: top.id, summary: top.summary, score: top.score } };
65
+ }
66
+
67
+ // --match resolution depends on Atlas vector search, which has a real async indexing lag:
68
+ // a memory created moments ago can be invisible to $vectorSearch even though it is already
69
+ // findable by tag. Letting --match commands pass --tags narrows the candidate pool directly
70
+ // instead of hoping semantic ranking surfaces the right one - the fix for a real bug found
71
+ // 2026-08-25 where delete --match returned unrelated score-0 candidates for a memory that
72
+ // `query` found instantly. Deliberately --tags (plural) only, not --category: `delete` already
73
+ // uses singular --tag/--category to mean "bulk delete everything in this category", so adding
74
+ // --category here would silently reroute a --match delete into a bulk delete instead of
75
+ // narrowing it - --tags never collides with that bulk-mode gate.
76
+ function parseMatchFilters(args) {
77
+ const tagsRaw = parseOption(args, '--tags', '');
78
+ const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
79
+ const filters = {};
80
+ if (tags.length) filters.tags = tags;
81
+ return filters;
82
+ }
83
+
84
+ function printMemoryResolveError(result) {
85
+ if (result.reason === 'no_results') {
86
+ warn(JSON.stringify({ error: true, code: 'no_results', message: 'No memories matched the query.' }));
87
+ } else if (result.reason === 'low_score') {
88
+ warn(JSON.stringify({
89
+ error: true,
90
+ code: 'low_score',
91
+ message: 'Top result score was below --min-score threshold.',
92
+ candidates: result.candidates
93
+ }));
94
+ } else if (result.reason === 'ambiguous') {
95
+ warn(JSON.stringify({
96
+ error: true,
97
+ code: 'ambiguous',
98
+ message: 'Multiple memories matched with similar scores. Use --memory-id to target one directly.',
99
+ candidates: result.candidates
100
+ }));
101
+ } else if (result.reason === 'error') {
102
+ warn(JSON.stringify({ error: true, code: 'query_error', message: result.message }));
103
+ } else {
104
+ warn(JSON.stringify({ error: true, code: 'resolve_failed', message: 'Could not resolve memory.' }));
105
+ }
106
+ }
107
+
108
+ module.exports = {
109
+ resolveMemoryByQuery,
110
+ parseMatchFilters,
111
+ printMemoryResolveError
112
+ };
@@ -55,9 +55,20 @@ function recoveryHints(error, sessionId, step = '') {
55
55
  switch (error && error.code) {
56
56
  case 'GUIDED_SETUP_VOICE_NOT_WAITING':
57
57
  case 'GUIDED_SETUP_VOICE_STALE':
58
+ case 'GUIDED_SETUP_WORKSPACE_NOT_WAITING':
58
59
  return [`See the current question and its key with: ${resume}`];
59
60
  case 'GUIDED_SETUP_VOICE_BUSY':
61
+ case 'GUIDED_SETUP_WORKSPACE_BUSY':
60
62
  return [`Wait a moment, then pick it up with: ${resume}`];
63
+ // The workspace interview's own refusals. The server's message already carries the price of a new
64
+ // interview (it is the effective one), so these lines name no number. An abandoned interview is
65
+ // not refunded, which is said before the person is pointed at starting again.
66
+ case 'GUIDED_SETUP_WORKSPACE_SESSION_LIMIT':
67
+ return [`If a draft was already shown, you can read it with ${resume} and confirm it; the limit can be reached before there is one. Starting a new interview is charged again, and stopping this one with empirical setup abandon ${sid} does not return what it cost.`];
68
+ case 'GUIDED_SETUP_WORKSPACE_GENERATION_FAILED':
69
+ return [`Your answers are saved. Run ${resume} to see where the setup is, then answer again.`];
70
+ case 'GUIDED_SETUP_WORKSPACE_UNAVAILABLE':
71
+ return ['Nothing was started or charged. Run the command again in a few minutes.'];
61
72
  case 'EMPIRICAL_TOKENS_ATTEMPT_CLOSED':
62
73
  return ['This attempt is already closed, so it cannot be continued. Start a new one with: empirical setup run <setupId>'];
63
74
  case 'EMPIRICAL_TOKEN_KEY_OWNER_MISMATCH':
@@ -120,7 +131,23 @@ function refundLine(error) {
120
131
  return null;
121
132
  }
122
133
 
134
+ // The server words its errors for every client, and one clause tells an AGENT to call a tool. At a terminal the
135
+ // same step is a command, so that clause (and only that clause) becomes the real command.
136
+ function cliConfirmWording(text, sessionId) {
137
+ return String(text === undefined || text === null ? '' : text).replace(
138
+ /\b([Cc])all confirm_guided_setup with this session's sessionId and the draftHash shown with the draft/g,
139
+ (_m, c) => `${c === 'C' ? 'Run' : 'run'} empirical setup confirm ${sessionId || '<sessionId>'} <draftHash>`
140
+ );
141
+ }
142
+ function forTerminal(error, sessionId) {
143
+ if (!error) return error;
144
+ if (typeof error.message === 'string') error.message = cliConfirmWording(error.message, sessionId);
145
+ if (typeof error.serverMessage === 'string') error.serverMessage = cliConfirmWording(error.serverMessage, sessionId);
146
+ return error;
147
+ }
148
+
123
149
  function failWith(error, pathSuffix, body) {
150
+ forTerminal(error, sessionIdFromPath(pathSuffix));
124
151
  printApiError(error);
125
152
  for (const line of recoveryHints(error, sessionIdFromPath(pathSuffix), stepOf(pathSuffix, body))) warn(line);
126
153
  const refund = refundLine(error);
@@ -219,7 +246,9 @@ function formatQuestionLines(question, progress = {}) {
219
246
  // records whatever is typed, so the end-early hint belongs to the follow-up loop alone.
220
247
  if (p.meterLine && question.kind === 'text' && /^g\d+$/.test(key) && !question.focus) lines.push(loopHint(question, p));
221
248
  }
222
- if (question.kind === 'pick_and_edit') lines.push('Reply with the numbers that fit, like 2,5, and optionally a | and your own changes.');
249
+ // The workspace interview words its own examples (built from the options on offer, and in the 'add Name'
250
+ // form its parser reads); this Writing Voice line would show "2,5" for a list that may have two options.
251
+ if (question.kind === 'pick_and_edit' && !isWorkspaceQuestion(question)) lines.push('Reply with the numbers that fit, like 2,5, and optionally a | and your own changes.');
223
252
  if (question.kind === 'pick_one') lines.push('Reply with one number.');
224
253
  if (question.kind === 'yes_no') lines.push('Reply yes or no.');
225
254
  if (staleTopic) {
@@ -238,7 +267,46 @@ function workspacePlace(name) {
238
267
  return /\bworkspace$/i.test(n) ? n : `${n} workspace`;
239
268
  }
240
269
 
270
+ // A workspace draft is a list of plain sentences (no hub or rules), the people to be invited, and
271
+ // anything that stops it being confirmed. The Writing Voice branch below would print "Hub: undefined".
272
+ function isWorkspaceDraft(question) {
273
+ return Boolean(question && question.draft && Array.isArray(question.draft.lines));
274
+ }
275
+
276
+ function formatWorkspaceDraftLines(question) {
277
+ const t = sanitizeForTerminal;
278
+ const confirm = question.confirm || {};
279
+ const lines = [];
280
+ for (const l of [...question.draft.lines, ...(Array.isArray(question.draft.statusSummary) ? question.draft.statusSummary : [])]) lines.push(`- ${t(l)}`);
281
+ if (Array.isArray(confirm.invites)) {
282
+ lines.push(confirm.invites.length
283
+ ? `Invites: ${confirm.invites.map((i) => `${t(i.email)} (${t(i.role)})`).join(', ')}`
284
+ : 'Invites: nobody yet.');
285
+ }
286
+ if (confirm.inviteNote) lines.push(t(confirm.inviteNote));
287
+ if (confirm.canConfirm === false) {
288
+ lines.push('This draft cannot be confirmed as it is:');
289
+ for (const b of Array.isArray(confirm.blockers) ? confirm.blockers : []) lines.push(`- ${t(b)}`);
290
+ lines.push('Type a change to fix it. Nothing has been created or changed.');
291
+ } else if (question.prompt) {
292
+ // The server's own review wording: what the person can do next (next, confirm, a change, a rename).
293
+ lines.push(workspaceReviewPrompt(question.prompt));
294
+ }
295
+ return lines;
296
+ }
297
+
298
+ // The server words the review for every client, and its one tool-call clause (how an AGENT confirms) means
299
+ // nothing at a terminal, where the person types the word that is asked for below. Everything else is the
300
+ // server's own text, unchanged, so every example in it works as typed.
301
+ function workspaceReviewPrompt(prompt) {
302
+ return sanitizeForTerminal(prompt)
303
+ .replace(/\s*\(call confirm_guided_setup[^)]*\)/, '')
304
+ .replace(/To (?:create the workspace|save these changes), call confirm_guided_setup[^.]*\.\s*/, '')
305
+ .trim();
306
+ }
307
+
241
308
  function formatDraftLines(question, progress = {}) {
309
+ if (isWorkspaceDraft(question)) return formatWorkspaceDraftLines(question);
242
310
  const t = sanitizeForTerminal;
243
311
  const draft = question.draft || {};
244
312
  const confirm = question.confirm || {};
@@ -302,6 +370,10 @@ function printCurrentQuestion(result) {
302
370
  }
303
371
  printDraft(question, result.progress);
304
372
  const confirm = question.confirm || {};
373
+ if (isWorkspaceDraft(question) && confirm.canConfirm === false) {
374
+ warn(`Ask for a change with: empirical setup answer ${sid} ${key} <the change you want>`);
375
+ return;
376
+ }
305
377
  if (confirm.stale) {
306
378
  warn(`Rebuild it with: empirical setup answer ${sid} ${key} rebuild`);
307
379
  } else if (confirm.draftHash) {
@@ -378,6 +450,14 @@ async function offerVoicePluginInstall({ prompter, env = process.env, install =
378
450
  // offer: { prompter } when a person at a terminal ran the interview; absent for scripted runs.
379
451
  async function reportConfirmed(result, offer) {
380
452
  if (result && result.message) log(sanitizeForTerminal(result.message));
453
+ // A confirmed workspace interview has no install job: it names the workspace and what to do next.
454
+ if (result && result.workspaceId && !(result && result.installId)) {
455
+ const id = sanitizeForTerminal(result.workspaceId);
456
+ log(`Workspace id: ${id}`);
457
+ log(`Switch to it with: ${WORKSPACE_USE_LINE(id)}`);
458
+ log(`Change its shape later with: ${WORKSPACE_SHAPE_LINE(id)}`);
459
+ return;
460
+ }
381
461
  const installId = result && result.installId;
382
462
  if (!installId) return;
383
463
  const status = await fetchInstallStatus(installId);
@@ -419,11 +499,26 @@ async function reportConfirmed(result, offer) {
419
499
  // Spec 7.3: a priced setup returns a cost preview first. Acceptance is sent only from the explicit
420
500
  // --accept-cost flag; a first call without it stops at the cost and starts nothing. A person at a
421
501
  // terminal is asked instead, and a non-interactive run stops.
422
- async function startWithCost(setupId, { acceptCost, prompter }) {
423
- const first = await callApi('POST', '/start', acceptCost ? { setupId, acceptEmpiricalTokenCost: true } : { setupId });
502
+ //
503
+ // start carries any extra start fields. For the workspace interview that is targetWorkspaceId, the one
504
+ // explicit way to ask for a re-shape (no field means a new workspace). Its start is also where a setup
505
+ // that is switched off for the person answers 404, which is explained instead of left as a bare error.
506
+ async function startWithCost(setupId, { acceptCost, prompter, start = {} }) {
507
+ const first = await startCall({ setupId, ...start, ...(acceptCost ? { acceptEmpiricalTokenCost: true } : {}) });
424
508
  if (!isCostPreview(first)) return reportTokensUsed(first);
425
509
  await confirmCostOrExit(first, { prompter, warn, sanitize: sanitizeForTerminal });
426
- return reportTokensUsed(await callApi('POST', '/start', { setupId, acceptEmpiricalTokenCost: true }));
510
+ return reportTokensUsed(await startCall({ setupId, ...start, acceptEmpiricalTokenCost: true }));
511
+ }
512
+
513
+ async function startCall(body) {
514
+ const { data, error } = await tryApi('POST', '/start', body);
515
+ if (!error) return data;
516
+ if (body.setupId === WORKSPACE_SETUP_ID && error.statusCode === 404) {
517
+ printApiError(error);
518
+ warn('The workspace interview is not switched on for your account, so nothing was started or charged.');
519
+ process.exit(1);
520
+ }
521
+ return failWith(error, '/start', body);
427
522
  }
428
523
 
429
524
  // After a paid start, say what it cost, but only when the response carries the amount. stderr, so
@@ -434,20 +529,35 @@ function reportTokensUsed(result) {
434
529
  return result;
435
530
  }
436
531
 
437
- async function runNonInteractive(setupId, answersFilePath, acceptCost) {
532
+ async function runNonInteractive(setupId, answersFilePath, acceptCost, start) {
438
533
  const raw = fs.readFileSync(path.resolve(answersFilePath), 'utf8');
439
534
  const answersByQuestionKey = JSON.parse(raw);
440
535
 
441
- let current = await startWithCost(setupId, { acceptCost, prompter: null });
536
+ let current = await startWithCost(setupId, { acceptCost, prompter: null, start });
537
+ hintAbandon(current);
442
538
  let confirmed = false;
539
+ const reviewTurns = {};
443
540
 
444
541
  while (current.question && current.status !== 'completed' && current.status !== 'finished_early') {
445
542
  const question = current.question;
446
543
  const questionKey = question.questionKey;
544
+ if (question.kind === 'confirm' && isFirstWorkspaceReview(question) && Object.prototype.hasOwnProperty.call(answersByQuestionKey, questionKey) && answersByQuestionKey[questionKey] !== 'confirm') {
545
+ // The first review of a workspace: the file's value is the answer, so a script can reach the invites.
546
+ // A list is taken in order (a rename, then next); a single string is sent once.
547
+ const wanted = answersByQuestionKey[questionKey];
548
+ const queue = Array.isArray(wanted) ? wanted : [wanted];
549
+ const turn = (reviewTurns[questionKey] = (reviewTurns[questionKey] || 0) + 1) - 1;
550
+ if (turn < queue.length) {
551
+ current = await callApi('POST', `/${current.sessionId}/continue`, answerBody(question, queue[turn]));
552
+ continue;
553
+ }
554
+ }
447
555
  if (question.kind === 'confirm') {
448
556
  // Confirming is its own step: only an explicit "confirm" for q5_confirm installs, never a stale draft.
449
557
  const stale = Boolean(question.confirm && question.confirm.stale);
450
- if (answersByQuestionKey[questionKey] === 'confirm' && !stale) {
558
+ // A workspace draft with blockers has no hash to confirm; the file's "confirm" is not sent for it.
559
+ const confirmable = !(question.confirm && question.confirm.canConfirm === false);
560
+ if (answersByQuestionKey[questionKey] === 'confirm' && !stale && confirmable) {
451
561
  current = await callApi('POST', `/${current.sessionId}/confirm`, { draftHash: question.confirm.draftHash });
452
562
  confirmed = true;
453
563
  } else if (stale) {
@@ -517,34 +627,44 @@ async function sendInteractiveAnswer(current, answer) {
517
627
  if (!error) return data;
518
628
  if (error.statusCode !== 400 && error.statusCode !== 409) failWith(error, pathSuffix, body);
519
629
  if (error.code === 'GUIDED_SETUP_CONFIRM_REQUIRED') warn('To install this draft, type yes.');
520
- else warn(sanitizeForTerminal(error.serverMessage || error.message));
630
+ else warn(sanitizeForTerminal(cliConfirmWording(error.serverMessage || error.message, current.sessionId)));
521
631
  return callApi('POST', pathSuffix, {});
522
632
  }
523
633
 
524
- async function runInteractive(setupId, acceptCost) {
634
+ async function runInteractive(setupId, acceptCost, start) {
525
635
  const prompter = createPrompter(process.stderr);
526
636
  let abortMessage = null;
527
637
  let finalResult = null;
528
638
  let confirmed = false;
529
639
  try {
530
- let current = await startWithCost(setupId, { acceptCost, prompter });
640
+ let current = await startWithCost(setupId, { acceptCost, prompter, start });
531
641
  if (!current) return;
642
+ hintAbandon(current);
532
643
  let emptyReplies = 0;
533
644
  while (current.question && current.status !== 'completed' && current.status !== 'finished_early') {
534
645
  const question = current.question;
535
646
  if (question.kind === 'confirm') {
536
647
  printDraft(question, current.progress);
537
- const reply = (await prompter.askText('Install this? (type yes to install, or type a change you want)')).trim();
648
+ const reply = (await prompter.askText(confirmPromptFor(question))).trim();
538
649
  if (!reply) {
539
650
  const sessionId = current.sessionId;
540
651
  abortMessage = () => printNothingInstalled(sessionId, question.confirm && question.confirm.draftHash);
541
652
  break;
542
653
  }
543
- if (/^(y|yes)$/i.test(reply)) {
654
+ const firstReview = isFirstWorkspaceReview(question);
655
+ if (firstReview && /^(y|yes)$/i.test(reply)) {
656
+ warn('Yes is not one of the choices here. Type next to choose who to invite, or confirm to finish without inviting anyone.');
657
+ continue;
658
+ }
659
+ if (firstReview ? /^confirm$/i.test(reply) : /^(y|yes|confirm)$/i.test(reply)) {
544
660
  if (question.confirm && question.confirm.stale) {
545
661
  warn('This draft is out of date. Type rebuild to write a new one first.');
546
662
  continue;
547
663
  }
664
+ if (isWorkspaceDraft(question) && (question.confirm.canConfirm === false || !question.confirm.draftHash)) {
665
+ warn('This draft cannot be confirmed as it is. Type a change to fix what is listed above.');
666
+ continue;
667
+ }
548
668
  current = await callApi('POST', `/${current.sessionId}/confirm`, { draftHash: question.confirm.draftHash });
549
669
  confirmed = true;
550
670
  break;
@@ -589,6 +709,50 @@ async function runInteractive(setupId, acceptCost) {
589
709
  printJson(finalResult);
590
710
  }
591
711
 
712
+ const WORKSPACE_SETUP_ID = 'setup_workspace';
713
+ // The commands printed after a workspace is made, built from the real id so they work as typed.
714
+ const WORKSPACE_USE_LINE = (id) => `empirical workspace use ${id}`;
715
+ const WORKSPACE_SHAPE_LINE = (id) => `empirical workspace shape --workspace-id ${id}`;
716
+
717
+ // The question at the review step. The workspace interview creates or changes a workspace, not an install.
718
+ // The first review (questionKey draft) is BEFORE the invitations, so a bare yes is not a choice there: the
719
+ // person picks next (to choose who to invite) or confirm (finish with nobody invited). The final review
720
+ // (after the invitations) is the one a yes confirms.
721
+ function isFirstWorkspaceReview(question) {
722
+ return isWorkspaceDraft(question) && question.questionKey === 'draft';
723
+ }
724
+ function confirmPromptFor(question) {
725
+ if (!isWorkspaceDraft(question)) return 'Install this? (type yes to install, or type a change you want)';
726
+ const reshape = question.confirm && question.confirm.mode === 'reshape';
727
+ if (question.confirm && question.confirm.canConfirm === false) return 'What next? (type a change you want)';
728
+ if (isFirstWorkspaceReview(question)) return `What next? (type next to choose who to invite, confirm to ${reshape ? 'save these changes' : 'create the workspace'} without inviting anyone, or type a change you want)`;
729
+ return `${reshape ? 'Apply this to the workspace?' : 'Create this workspace?'} (type yes to confirm, or type a change you want)`;
730
+ }
731
+
732
+ // Printed once after a workspace interview starts. An abandoned interview is NOT refunded, and the
733
+ // line says so, so nobody stops one expecting their Empirical Tokens back.
734
+ function hintAbandon(result) {
735
+ if (!result || !result.sessionId || !result.question) return;
736
+ if (!isWorkspaceQuestion(result.question)) return;
737
+ const sid = sanitizeForTerminal(result.sessionId);
738
+ warn(`If you want to stop this interview, run: empirical setup abandon ${sid} (what it cost is not returned).`);
739
+ }
740
+
741
+ // Workspace questions are the only ones with these keys; the Writing Voice engine has none of them.
742
+ function isWorkspaceQuestion(question) {
743
+ const key = String((question && question.questionKey) || '');
744
+ return key === 'purpose' || key === 'types' || key === 'nesting' || key === 'policy' || key === 'invites'
745
+ || key === 'draft' || /^(statuses|fields):/.test(key);
746
+ }
747
+
748
+ // The workspace interview from the workspace commands. targetWorkspaceId present means re-shape that
749
+ // workspace, absent means create a new one; nothing else decides it.
750
+ async function runWorkspaceInterview({ targetWorkspaceId, answersFile = '', acceptCost = false } = {}) {
751
+ const start = targetWorkspaceId ? { targetWorkspaceId } : {};
752
+ if (answersFile) return runNonInteractive(WORKSPACE_SETUP_ID, answersFile, acceptCost, start);
753
+ return runInteractive(WORKSPACE_SETUP_ID, acceptCost, start);
754
+ }
755
+
592
756
  async function runRun(args) {
593
757
  const setupId = args[0] && !args[0].startsWith('--') ? args[0] : '';
594
758
  if (!setupId) {
@@ -627,6 +791,18 @@ async function runFinish(args) {
627
791
  printJson(result);
628
792
  }
629
793
 
794
+ async function runAbandon(args) {
795
+ const [sessionId] = args;
796
+ if (!sessionId) {
797
+ warn('Usage: empirical setup abandon <sessionId>');
798
+ process.exit(1);
799
+ }
800
+ const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/abandon`, {});
801
+ log(result && result.status === 'abandoned'
802
+ ? 'The interview was stopped. Nothing was created or changed, and what it cost was not returned.'
803
+ : 'That interview was already finished or stopped. Nothing was changed, and no Empirical Tokens were returned.');
804
+ }
805
+
630
806
  async function runResume(args) {
631
807
  const [sessionId] = args;
632
808
  if (!sessionId) {
@@ -656,11 +832,12 @@ async function runSetupCommand(args) {
656
832
  if (sub === 'finish') return runFinish(rest);
657
833
  if (sub === 'resume') return runResume(rest);
658
834
  if (sub === 'confirm') return runConfirm(rest);
659
- warn('Usage: empirical setup <list|run|answer|finish|resume|confirm> ...');
835
+ if (sub === 'abandon') return runAbandon(rest);
836
+ warn('Usage: empirical setup <list|run|answer|finish|resume|confirm|abandon> ...');
660
837
  process.exit(1);
661
838
  }
662
839
 
663
840
  module.exports = {
664
- refundLine, runSetupCommand, formatQuestionLines, formatDraftLines, sanitizeForTerminal,
841
+ refundLine, cliConfirmWording, runSetupCommand, runWorkspaceInterview, WORKSPACE_SETUP_ID, recoveryHints, formatQuestionLines, formatDraftLines, sanitizeForTerminal,
665
842
  offerVoicePluginInstall, VOICE_PLUGIN_COMMANDS, VOICE_STANDING_INSTRUCTION
666
843
  };
@@ -1,12 +1,14 @@
1
1
  'use strict';
2
2
 
3
- const { log, ask, uiSuccess } = require('./ui');
3
+ const { log, warn, ask, askYesNo, uiSuccess } = require('./ui');
4
4
  const { httpJson } = require('./http');
5
5
  const { getEnv, parseFlag, parseOption } = require('./env');
6
6
  const { ensureHeadlessToken, resolveAccessToken, buildApiAuthHeaders } = require('./auth');
7
7
  const { readStoredOAuthClient } = require('./oauthClient');
8
8
  const { deriveBaseUrlFromMcpUrl } = require('./constants');
9
9
  const { withCliAnalyticsContext } = require('./memoryCommands');
10
+ const { runWorkspaceInterview, sanitizeForTerminal } = require('./setupCommands');
11
+ const { isInteractiveTerminal } = require('./empiricalTokensFormat');
10
12
  const {
11
13
  writeSelectedWorkspace,
12
14
  clearSelectedWorkspace,
@@ -18,9 +20,38 @@ const {
18
20
  WORKSPACE_ENV_VAR
19
21
  } = require('./workspaceContext');
20
22
 
23
+ const WORKSPACE_CREATE_INTERVIEW_USAGE = 'Usage: empirical workspace create --interview [--answers <file.json>] [--accept-cost]. The interview asks what the workspace is for, so it takes no name.';
24
+ const WORKSPACE_SHAPE_USAGE = 'Usage: empirical workspace shape [--workspace-id <id>] [--answers <file.json>] [--accept-cost]. Pass --workspace-id, or at a terminal bind this directory to the workspace first with `empirical workspace use <id>` and the command will ask you to confirm it.';
25
+ const WORKSPACE_SHAPE_EXPLICIT_ID = 'A re-shape is charged, so a script (--answers or --accept-cost) or a run with no terminal must name the workspace itself. Pass --workspace-id <id>; the workspace this directory is bound to is not used here.';
26
+
27
+ // Which workspace `workspace shape` re-shapes. An explicit --workspace-id is used as given. The ambient
28
+ // binding (env, project or global) is a convenience for a person at a terminal ONLY: it is shown by NAME
29
+ // and source and needs a yes that names the workspace, and it is never used by a script or with
30
+ // --accept-cost, so a stale binding cannot aim a paid re-shape at the wrong workspace unseen.
31
+ // Everything said here goes to stderr (warn), so a scripted run keeps stdout for its result.
32
+ async function resolveShapeTarget({ requested, local, answersFile, acceptCost, interactive, fetchName, askYesNo: confirm, say = warn }) {
33
+ if (requested) {
34
+ say(`Re-shaping workspace ${sanitizeForTerminal(requested)}.`);
35
+ return requested;
36
+ }
37
+ if (!local || !local.workspaceId) throw new Error(WORKSPACE_SHAPE_USAGE);
38
+ if (answersFile || acceptCost || !interactive) throw new Error(WORKSPACE_SHAPE_EXPLICIT_ID);
39
+ let name = '';
40
+ try { name = String((await fetchName(local.workspaceId)) || ''); } catch (_error) { name = ''; }
41
+ // The name can come from an AI-generated purpose, and everything below reaches the terminal: sanitised.
42
+ name = sanitizeForTerminal(name || String(local.label || ''));
43
+ const id = sanitizeForTerminal(local.workspaceId);
44
+ const source = sanitizeForTerminal(describeSelectionSource(local).trim().replace(/^\(|\)$/g, '')) || 'a local binding';
45
+ const shown = name ? `"${name}" (${id})` : id;
46
+ say(`No --workspace-id was given. This directory is bound to ${shown}: ${source}.`);
47
+ const yes = await confirm(`Re-shape ${name ? `"${name}"` : `workspace ${id}`}? This starts an interview that is charged.`, false, process.stderr);
48
+ if (!yes) throw new Error('Nothing was started. Pass --workspace-id <id> to name the workspace.');
49
+ return local.workspaceId;
50
+ }
51
+
21
52
  function isWorkspaceCommand(args) {
22
53
  if (args[0] !== 'workspace') return false;
23
- if (['list', 'current', 'select', 'use', 'unbind', 'create', 'delete'].includes(args[1])) return true;
54
+ if (['list', 'current', 'select', 'use', 'unbind', 'create', 'delete', 'shape'].includes(args[1])) return true;
24
55
  return args[1] === 'invite' && ['create', 'list', 'revoke'].includes(args[2]);
25
56
  }
26
57
 
@@ -40,7 +71,7 @@ function workspaceOptions(payload) {
40
71
  }
41
72
 
42
73
  function positionalArgs(args, startIndex) {
43
- const valueOptions = new Set(['--description', '--role', '--expires-in-days', '--max-uses']);
74
+ const valueOptions = new Set(['--description', '--role', '--expires-in-days', '--max-uses', '--answers', '--workspace-id']);
44
75
  const positional = [];
45
76
  for (let index = startIndex; index < args.length; index += 1) {
46
77
  if (valueOptions.has(args[index])) {
@@ -276,9 +307,41 @@ async function runWorkspaceCommand(args) {
276
307
  return;
277
308
  }
278
309
 
310
+ if (action === 'shape') {
311
+ // Re-shapes a team workspace you manage through the interview. resolveShapeTarget decides which: an
312
+ // explicit --workspace-id, or (at a terminal only, after a named yes) the bound workspace.
313
+ const requested = parseOption(args, '--workspace-id', '').trim();
314
+ const answersFile = parseOption(args, '--answers', '');
315
+ const acceptCost = parseFlag(args, '--accept-cost');
316
+ const targetWorkspaceId = await resolveShapeTarget({
317
+ requested,
318
+ local: requested ? null : resolveLocalWorkspaceSelection(),
319
+ answersFile,
320
+ acceptCost,
321
+ interactive: isInteractiveTerminal(),
322
+ fetchName: async (id) => {
323
+ const payload = await httpJson(teamBrainsUrl, 'GET', null, authHeaders);
324
+ const hit = getTeamBrains(payload).find((b) => b.workspaceId === id);
325
+ return hit ? hit.name : '';
326
+ },
327
+ askYesNo
328
+ });
329
+ return runWorkspaceInterview({ targetWorkspaceId, answersFile, acceptCost });
330
+ }
331
+
332
+ if (action === 'create' && parseFlag(args, '--interview')) {
333
+ // Creating through the interview takes no name (it asks what the workspace is for), and it never
334
+ // re-shapes: with no --workspace-id it always makes a new workspace.
335
+ if (positionalArgs(args, 2).length || parseOption(args, '--workspace-id', '')) throw new Error(WORKSPACE_CREATE_INTERVIEW_USAGE);
336
+ return runWorkspaceInterview({
337
+ answersFile: parseOption(args, '--answers', ''),
338
+ acceptCost: parseFlag(args, '--accept-cost')
339
+ });
340
+ }
341
+
279
342
  if (action === 'create') {
280
343
  const name = positionalArgs(args, 2).join(' ').trim();
281
- if (!name) throw new Error('Usage: empirical workspace create <name> [--description <text>]');
344
+ if (!name) throw new Error('Usage: empirical workspace create <name> [--description <text>] or empirical workspace create --interview');
282
345
  const description = parseOption(args, '--description', '');
283
346
  const created = await httpJson(
284
347
  teamBrainsUrl,
@@ -394,5 +457,9 @@ module.exports = {
394
457
  isWorkspaceCommand,
395
458
  getWorkspaceCommandAuth,
396
459
  resolveWorkspaceTarget,
397
- runWorkspaceCommand
460
+ runWorkspaceCommand,
461
+ WORKSPACE_CREATE_INTERVIEW_USAGE,
462
+ WORKSPACE_SHAPE_USAGE,
463
+ WORKSPACE_SHAPE_EXPLICIT_ID,
464
+ resolveShapeTarget
398
465
  };