@moda-ai/cli 1.9.6 → 1.10.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.
@@ -2,7 +2,9 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { callControlAPI, callDataAPI } from './api-client.js';
4
4
  import { CliInputError } from './errors.js';
5
+ import { EXIT_DEGRADED, EXIT_OK } from './output.js';
5
6
  import { asNumber, asRecord, asString } from './transforms.js';
7
+ import { INTELLIGENCE_SCHEMA_VERSION, } from './intelligence-schema.js';
6
8
  const INVESTIGATION_SCHEMA_VERSION = 'production_investigation.v0.1';
7
9
  const OVERVIEW_SCHEMA_VERSION = 'production_overview.v0.1';
8
10
  const ASK_SCHEMA_VERSION = 'production_ask.v0.1';
@@ -36,18 +38,17 @@ export async function runProductionCommand(context) {
36
38
  console.log(renderAskHuman(result));
37
39
  }
38
40
  else {
39
- context.output.writeData(result, askAgentOptions(result));
41
+ // The agent/JSON `data` payload is the canonical moda.intelligence.v1
42
+ // value (carrying evidence grounding + per-claim verdicts). The
43
+ // moda.agent.v1 envelope still wraps it; only the inner `data` is v1.
44
+ context.output.writeData(result.intelligence, {
45
+ ...askAgentOptions(result),
46
+ status: result.degraded ? 'degraded' : 'success',
47
+ });
40
48
  }
41
- return { exitCode: 0 };
42
- }
43
- if (context.command === 'why-failed') {
44
- const runId = context.positional;
45
- if (!runId) {
46
- throw new CliInputError('<run_id> is required', 'Usage: moda why-failed <run_id>');
47
- }
48
- const investigation = await investigateProduction({ daysBack, cwd: context.cwd, runId });
49
- writeInvestigation(context, investigation);
50
- return { exitCode: 0 };
49
+ // Exit 3 on a degraded/local-fallback answer so a scripted agent can detect
50
+ // it from the exit code alone (in addition to `data.degraded` / status).
51
+ return { exitCode: result.degraded ? EXIT_DEGRADED : EXIT_OK };
51
52
  }
52
53
  if (context.command === 'failures') {
53
54
  const investigation = await investigateProduction({
@@ -63,7 +64,6 @@ export async function runProductionCommand(context) {
63
64
  const investigation = await investigateProduction({
64
65
  daysBack,
65
66
  cwd: context.cwd,
66
- runId: context.flags.run,
67
67
  conversationId: context.flags.conversation,
68
68
  toolName: context.flags.tool,
69
69
  loopId: context.flags.loop,
@@ -127,20 +127,16 @@ export async function investigateProduction(options) {
127
127
  ? `/tool-failures/${encodeURIComponent(options.toolName)}?days_back=${options.daysBack}&limit=5`
128
128
  : `/tool-failures?days_back=${options.daysBack}`;
129
129
  const frustrationsEndpoint = `/frustrations?days_back=${options.daysBack}&limit=5`;
130
- const runsEndpoint = options.runId
131
- ? `/runs/${encodeURIComponent(options.runId)}`
132
- : `/runs?time_range=${timeRangeForDays(options.daysBack)}&limit=20`;
133
130
  const loopsEndpoint = options.loopId
134
131
  ? `/loops/${encodeURIComponent(options.loopId)}`
135
132
  : '/loops/active?limit=20';
136
- const [overview, tools, frustrations, runs, loops] = await Promise.all([
133
+ const [overview, tools, frustrations, loops] = await Promise.all([
137
134
  safeData(overviewEndpoint),
138
135
  safeData(toolEndpoint),
139
136
  safeData(frustrationsEndpoint),
140
- safeData(runsEndpoint),
141
137
  safeData(loopsEndpoint),
142
138
  ]);
143
- for (const [key, result] of Object.entries({ overview, tools, frustrations, runs, loops })) {
139
+ for (const [key, result] of Object.entries({ overview, tools, frustrations, loops })) {
144
140
  sourceStatus[key] = result.status;
145
141
  if (result.warning)
146
142
  warnings.push(result.warning);
@@ -151,11 +147,9 @@ export async function investigateProduction(options) {
151
147
  findings.push(...toolFailureFindings(tools.data, options.daysBack, hints, evidenceRefs, options.toolName));
152
148
  if (!options.failureOnly) {
153
149
  findings.push(...frustrationFindings(frustrations.data, options.daysBack, evidenceRefs));
154
- findings.push(...runFindings(runs.data, evidenceRefs, options.runId));
155
150
  findings.push(...loopFindings(loops.data, evidenceRefs, options.loopId));
156
151
  }
157
152
  else {
158
- findings.push(...runFindings(runs.data, evidenceRefs, options.runId).filter((finding) => finding.kind === 'partial_run'));
159
153
  findings.push(...loopFindings(loops.data, evidenceRefs, options.loopId));
160
154
  }
161
155
  if (findings.length === 0) {
@@ -169,7 +163,6 @@ export async function investigateProduction(options) {
169
163
  generatedAt: new Date().toISOString(),
170
164
  daysBack: options.daysBack,
171
165
  scope: {
172
- ...(options.runId ? { runId: options.runId } : {}),
173
166
  ...(options.conversationId ? { conversationId: options.conversationId } : {}),
174
167
  ...(options.toolName ? { toolName: options.toolName } : {}),
175
168
  ...(options.loopId ? { loopId: options.loopId } : {}),
@@ -184,6 +177,10 @@ export async function investigateProduction(options) {
184
177
  };
185
178
  }
186
179
  export async function askModa(options) {
180
+ // Capture *why* the cloud path was not used so the caller (human or agent)
181
+ // can see it, instead of silently swallowing the error and masquerading the
182
+ // local fallback as a normal answer.
183
+ let fallbackReason = 'Moda Cloud ask endpoint returned no answer.';
187
184
  try {
188
185
  const response = await callControlAPI('/cli/ask', {
189
186
  method: 'POST',
@@ -193,14 +190,14 @@ export async function askModa(options) {
193
190
  profile: options.profile,
194
191
  harness: localHarnessSummary(options.cwd),
195
192
  }),
196
- });
193
+ }, options.profile ? { profile: options.profile } : {}, { timeoutMs: askTimeoutMs(), retries: 0 });
197
194
  const cloud = normalizeCloudAsk(response, options.question);
198
195
  if (cloud)
199
196
  return cloud;
197
+ fallbackReason = 'Moda Cloud ask endpoint returned an empty answer.';
200
198
  }
201
- catch {
202
- // The endpoint is intentionally future-facing. Until cloud intelligence is
203
- // deployed everywhere, the CLI keeps answering from bounded Data API evidence.
199
+ catch (error) {
200
+ fallbackReason = `Moda Cloud ask endpoint unavailable: ${error instanceof Error ? error.message : String(error)}`;
204
201
  }
205
202
  const investigation = await investigateProduction({
206
203
  daysBack: options.daysBack,
@@ -209,24 +206,56 @@ export async function askModa(options) {
209
206
  });
210
207
  const top = investigation.findings[0];
211
208
  const answer = top
212
- ? `Moda Cloud intelligence is not available yet, so I synthesized this from production evidence. The strongest signal is: ${top.title}. ${top.summary}`
213
- : 'Moda Cloud intelligence is not available yet, and the local Data API evidence did not contain a strong production signal.';
209
+ ? `Moda Cloud intelligence is not available, so I synthesized this from production evidence. The strongest signal is: ${top.title}. ${top.summary}`
210
+ : 'Moda Cloud intelligence is not available, and the local Data API evidence did not contain a strong production signal.';
211
+ const confidence = capFallbackConfidence(investigation.summary.confidence);
212
+ const evidenceRefs = investigation.evidenceRefs.slice(0, 12);
213
+ const findings = investigation.findings.slice(0, 5);
214
+ const nextCommands = investigation.nextCommands;
215
+ const warnings = [
216
+ 'Cloud ask endpoint unavailable; synthesized answer from local Data API evidence.',
217
+ fallbackReason,
218
+ ...investigation.warnings,
219
+ ];
214
220
  return {
215
221
  schemaVersion: ASK_SCHEMA_VERSION,
216
222
  generatedAt: new Date().toISOString(),
217
223
  source: 'local_fallback',
224
+ degraded: true,
225
+ fallbackReason,
218
226
  question: options.question,
219
227
  answer,
220
- confidence: capFallbackConfidence(investigation.summary.confidence),
221
- evidenceRefs: investigation.evidenceRefs.slice(0, 12),
222
- findings: investigation.findings.slice(0, 5),
223
- nextCommands: investigation.nextCommands,
224
- warnings: [
225
- 'Cloud ask endpoint unavailable; synthesized answer from local Data API evidence.',
226
- ...investigation.warnings,
227
- ],
228
+ confidence,
229
+ evidenceRefs,
230
+ findings,
231
+ nextCommands,
232
+ warnings,
233
+ // Degraded answers still carry a v1-shaped payload so every surface reads
234
+ // one contract; `source`/`degraded` flag the lower trust (preserving exit
235
+ // code 3 + status 'degraded').
236
+ intelligence: synthesizeIntelligenceV1({
237
+ question: options.question,
238
+ answer,
239
+ confidence,
240
+ evidenceRefs,
241
+ nextCommands,
242
+ warnings,
243
+ source: 'local_fallback',
244
+ degraded: true,
245
+ findings,
246
+ }),
228
247
  };
229
248
  }
249
+ /**
250
+ * Timeout (ms) for the cloud `ask` POST. The backend enforces its own ~240s run
251
+ * deadline under a 300s function limit, so the client waits long enough to
252
+ * receive a real answer rather than aborting a healthy run at 30s and silently
253
+ * falling back. Override with `MODA_ASK_TIMEOUT_MS`.
254
+ */
255
+ function askTimeoutMs() {
256
+ const raw = Number(process.env.MODA_ASK_TIMEOUT_MS);
257
+ return Number.isFinite(raw) && raw > 0 ? raw : 250_000;
258
+ }
230
259
  function writeInvestigation(context, investigation) {
231
260
  if (context.outputMode === 'human') {
232
261
  if (!context.quiet)
@@ -415,45 +444,6 @@ function frustrationFindings(data, daysBack, evidenceRefs) {
415
444
  }],
416
445
  }];
417
446
  }
418
- function runFindings(data, evidenceRefs, scopedRunId) {
419
- const rows = runRows(data);
420
- return rows.flatMap((run) => {
421
- const runId = run.id || scopedRunId;
422
- const outcome = run.outcome;
423
- if (!runId || !['failed', 'partial', 'abandoned', 'loop'].includes(outcome))
424
- return [];
425
- const evidenceId = `run:${runId}`;
426
- evidenceRefs.push({
427
- id: evidenceId,
428
- kind: 'run',
429
- label: `${run.agentName || 'Agent run'} ${outcome}`,
430
- endpoint: `/runs/${runId}`,
431
- runId,
432
- conversationId: run.conversationId,
433
- value: run.raw,
434
- });
435
- return [{
436
- id: `finding_run_${slug(runId)}`,
437
- kind: outcome === 'partial' ? 'partial_run' : 'tool_failure',
438
- title: `${run.agentName || 'Agent run'} ended as ${outcome}`,
439
- severity: outcome === 'failed' ? 'high' : 'medium',
440
- confidence: 'high',
441
- impactScore: outcome === 'failed' ? 88 : 76,
442
- rankReason: `Run outcome is ${outcome}${run.reason ? `: ${run.reason}` : ''}.`,
443
- summary: run.reason || 'Moda classified this run as incomplete or unhealthy.',
444
- evidenceRefIds: [evidenceId],
445
- likelyLocations: [unknownLocation('entrypoint', 'Map this run to a harness entrypoint after harness sync.')],
446
- recommendedActions: [{
447
- id: `action_run_${slug(runId)}`,
448
- title: 'Open the run detail.',
449
- rationale: 'Run detail includes receipts and missing effects.',
450
- command: `moda run ${runId}`,
451
- mutability: 'read',
452
- requiresApproval: false,
453
- }],
454
- }];
455
- });
456
- }
457
447
  function loopFindings(data, evidenceRefs, scopedLoopId) {
458
448
  const rows = loopRows(data);
459
449
  return rows.flatMap((loop) => {
@@ -547,10 +537,27 @@ function normalizeCloudAsk(response, question) {
547
537
  ? record.next_commands.filter((item) => typeof item === 'string')
548
538
  : ['moda investigate'];
549
539
  const confidence = normalizeConfidence(record?.confidence);
540
+ // Prefer the backend's canonical v1 payload when it ships one; otherwise
541
+ // synthesize a v1 projection from the legacy snake_case fields so older
542
+ // backends still produce a valid moda.intelligence.v1 `data`.
543
+ const intelligenceRecord = asRecord(record?.intelligence);
544
+ const intelligence = looksLikeIntelligenceV1(intelligenceRecord)
545
+ ? coerceIntelligenceV1(intelligenceRecord, { question, answer, confidence })
546
+ : synthesizeIntelligenceV1({
547
+ question,
548
+ answer,
549
+ confidence,
550
+ evidenceRefs,
551
+ nextCommands,
552
+ warnings: [],
553
+ source: 'cloud',
554
+ degraded: false,
555
+ });
550
556
  return {
551
557
  schemaVersion: ASK_SCHEMA_VERSION,
552
558
  generatedAt: new Date().toISOString(),
553
559
  source: 'cloud',
560
+ degraded: false,
554
561
  question,
555
562
  answer,
556
563
  confidence,
@@ -558,8 +565,260 @@ function normalizeCloudAsk(response, question) {
558
565
  findings: [],
559
566
  nextCommands,
560
567
  warnings: [],
568
+ intelligence,
569
+ };
570
+ }
571
+ /**
572
+ * Heuristic "is this the canonical v1 payload?" guard. We accept an object that
573
+ * either declares the matching `schema_version` or carries the core v1 fields
574
+ * (`answer_markdown` / `summary`). A present-but-wrong `schema_version` is
575
+ * rejected so a future breaking version falls back to synthesis rather than
576
+ * being mis-projected.
577
+ */
578
+ function looksLikeIntelligenceV1(record) {
579
+ if (!record)
580
+ return false;
581
+ const version = asString(record.schema_version);
582
+ if (version && version !== INTELLIGENCE_SCHEMA_VERSION)
583
+ return false;
584
+ return (version === INTELLIGENCE_SCHEMA_VERSION ||
585
+ typeof record.answer_markdown === 'string' ||
586
+ asRecord(record.summary) !== null);
587
+ }
588
+ /**
589
+ * Defensively project a backend `intelligence` object into a schema-valid
590
+ * {@link ModaIntelligenceV1}. Every field is coerced and defaulted so the CLI
591
+ * never emits a malformed payload even if the backend is partially shaped.
592
+ */
593
+ function coerceIntelligenceV1(record, context) {
594
+ const summaryRecord = asRecord(record.summary);
595
+ const summary = {
596
+ text: asString(summaryRecord?.text) ?? context.answer,
597
+ confidence: confidenceValue(summaryRecord?.confidence) ?? context.confidence,
598
+ };
599
+ const evidenceRefs = Array.isArray(record.evidence_refs)
600
+ ? record.evidence_refs.flatMap(coerceV1EvidenceRef)
601
+ : [];
602
+ const answerClaims = Array.isArray(record.answer_claims)
603
+ ? record.answer_claims.flatMap(coerceV1Claim)
604
+ : [];
605
+ const findings = Array.isArray(record.findings)
606
+ ? record.findings.flatMap(coerceV1Finding)
607
+ : [];
608
+ const nextCommands = Array.isArray(record.next_commands)
609
+ ? record.next_commands.flatMap(coerceV1NextCommand)
610
+ : [];
611
+ const warnings = stringArray(record.warnings);
612
+ const meta = asRecord(record.meta);
613
+ return {
614
+ schema_version: INTELLIGENCE_SCHEMA_VERSION,
615
+ generated_at: asString(record.generated_at) ?? new Date().toISOString(),
616
+ command: asString(record.command) ?? 'ask',
617
+ source: record.source === 'local_fallback' ? 'local_fallback' : 'cloud',
618
+ degraded: record.degraded === true,
619
+ question: asString(record.question) ?? context.question,
620
+ summary,
621
+ answer_markdown: asString(record.answer_markdown) ?? context.answer,
622
+ answer_claims: answerClaims,
623
+ findings,
624
+ evidence_refs: evidenceRefs,
625
+ next_commands: nextCommands,
626
+ warnings,
627
+ ...(meta ? { meta } : {}),
628
+ };
629
+ }
630
+ /**
631
+ * Build a v1 payload from legacy snake_case fields (or local-fallback evidence).
632
+ * `evidence_refs` default to `grounding: 'measured'` and `answer_claims` is
633
+ * empty because the legacy contract has no per-claim grounding verdicts.
634
+ */
635
+ function synthesizeIntelligenceV1(options) {
636
+ return {
637
+ schema_version: INTELLIGENCE_SCHEMA_VERSION,
638
+ generated_at: new Date().toISOString(),
639
+ command: 'ask',
640
+ source: options.source,
641
+ degraded: options.degraded,
642
+ question: options.question,
643
+ summary: { text: options.answer, confidence: options.confidence },
644
+ answer_markdown: options.answer,
645
+ answer_claims: [],
646
+ findings: (options.findings ?? []).map(productionFindingToV1),
647
+ evidence_refs: options.evidenceRefs.map(productionEvidenceToV1),
648
+ next_commands: options.nextCommands.map(commandStringToV1),
649
+ warnings: options.warnings,
650
+ };
651
+ }
652
+ function coerceV1EvidenceRef(item) {
653
+ const ref = asRecord(item);
654
+ if (!ref)
655
+ return [];
656
+ const label = asString(ref.label);
657
+ const anchor = coerceV1Anchor(asRecord(ref.anchor));
658
+ const snippet = asString(ref.snippet);
659
+ return [{
660
+ id: asString(ref.id) ?? `ask:evidence:${slug(label ?? 'ref')}`,
661
+ kind: asString(ref.kind) ?? 'ask',
662
+ label: label ?? asString(ref.id) ?? 'Cloud evidence',
663
+ ...(anchor ? { anchor } : {}),
664
+ ...(snippet ? { snippet } : {}),
665
+ grounding: provenanceValue(ref.grounding) ?? 'measured',
666
+ }];
667
+ }
668
+ function coerceV1Anchor(record) {
669
+ if (!record)
670
+ return undefined;
671
+ const anchor = {
672
+ ...(asString(record.conversation_id) ? { conversation_id: asString(record.conversation_id) } : {}),
673
+ ...(asNumber(record.message_index) !== null ? { message_index: asNumber(record.message_index) } : {}),
674
+ ...(asString(record.run_id) ? { run_id: asString(record.run_id) } : {}),
675
+ ...(asString(record.tool_name) ? { tool_name: asString(record.tool_name) } : {}),
676
+ };
677
+ return Object.keys(anchor).length > 0 ? anchor : undefined;
678
+ }
679
+ function coerceV1Claim(item) {
680
+ const claim = asRecord(item);
681
+ if (!claim)
682
+ return [];
683
+ const text = asString(claim.text);
684
+ if (!text)
685
+ return [];
686
+ const value = asNumber(claim.value);
687
+ const matchedPath = asString(claim.matched_path);
688
+ return [{
689
+ text,
690
+ ...(value !== null ? { value } : {}),
691
+ kind: claim.kind === 'percentage' || claim.kind === 'count' ? claim.kind : 'qualitative',
692
+ status: claim.status === 'grounded' || claim.status === 'ungrounded'
693
+ ? claim.status
694
+ : 'unverifiable',
695
+ evidence_ref_ids: stringArray(claim.evidence_ref_ids),
696
+ ...(matchedPath ? { matched_path: matchedPath } : {}),
697
+ }];
698
+ }
699
+ function coerceV1Finding(item) {
700
+ const finding = asRecord(item);
701
+ if (!finding)
702
+ return [];
703
+ const id = asString(finding.id);
704
+ const title = asString(finding.title);
705
+ if (!id || !title)
706
+ return [];
707
+ const severity = finding.severity;
708
+ const description = asString(finding.description);
709
+ return [{
710
+ id,
711
+ title,
712
+ ...(severity === 'critical' || severity === 'high' || severity === 'medium' ||
713
+ severity === 'low' || severity === 'info'
714
+ ? { severity }
715
+ : {}),
716
+ ...(description ? { description } : {}),
717
+ evidence_ref_ids: stringArray(finding.evidence_ref_ids),
718
+ recommended_actions: Array.isArray(finding.recommended_actions)
719
+ ? finding.recommended_actions.flatMap(coerceV1RecommendedAction)
720
+ : [],
721
+ }];
722
+ }
723
+ function coerceV1RecommendedAction(item) {
724
+ const action = asRecord(item);
725
+ if (!action)
726
+ return [];
727
+ const id = asString(action.id);
728
+ const title = asString(action.title);
729
+ if (!id || !title)
730
+ return [];
731
+ const rationale = asString(action.rationale);
732
+ const command = asString(action.command);
733
+ return [{
734
+ id,
735
+ title,
736
+ ...(rationale ? { rationale } : {}),
737
+ ...(command ? { command } : {}),
738
+ mutability: mutabilityValue(action.mutability) ?? 'unknown',
739
+ requires_approval: action.requires_approval === true,
740
+ }];
741
+ }
742
+ function coerceV1NextCommand(item) {
743
+ if (typeof item === 'string')
744
+ return [commandStringToV1(item)];
745
+ const record = asRecord(item);
746
+ if (!record)
747
+ return [];
748
+ const command = asString(record.command);
749
+ if (!command)
750
+ return [];
751
+ const purpose = asString(record.purpose);
752
+ return [{
753
+ command,
754
+ ...(purpose ? { purpose } : {}),
755
+ mutability: mutabilityValue(record.mutability) ?? mutabilityForCommand(command),
756
+ requires_approval: typeof record.requires_approval === 'boolean'
757
+ ? record.requires_approval
758
+ : requiresApprovalForCommand(command),
759
+ }];
760
+ }
761
+ function productionEvidenceToV1(ref) {
762
+ const anchor = {
763
+ ...(ref.conversationId ? { conversation_id: ref.conversationId } : {}),
764
+ ...(ref.runId ? { run_id: ref.runId } : {}),
765
+ ...(ref.toolName ? { tool_name: ref.toolName } : {}),
766
+ };
767
+ return {
768
+ id: ref.id,
769
+ kind: ref.kind,
770
+ label: ref.label,
771
+ ...(Object.keys(anchor).length > 0 ? { anchor } : {}),
772
+ grounding: 'measured',
773
+ };
774
+ }
775
+ function productionFindingToV1(finding) {
776
+ return {
777
+ id: finding.id,
778
+ title: finding.title,
779
+ severity: finding.severity,
780
+ description: `${finding.summary} ${finding.rankReason}`.trim(),
781
+ evidence_ref_ids: finding.evidenceRefIds,
782
+ recommended_actions: finding.recommendedActions.map((action) => ({
783
+ id: action.id,
784
+ title: action.title,
785
+ rationale: action.rationale,
786
+ command: action.command,
787
+ mutability: action.mutability,
788
+ requires_approval: action.requiresApproval,
789
+ })),
561
790
  };
562
791
  }
792
+ function commandStringToV1(command) {
793
+ return {
794
+ command,
795
+ purpose: purposeForCommand(command),
796
+ mutability: mutabilityForCommand(command),
797
+ requires_approval: requiresApprovalForCommand(command),
798
+ };
799
+ }
800
+ function mutabilityForCommand(command) {
801
+ return command.includes(' sync') ? 'sync' : 'read';
802
+ }
803
+ function requiresApprovalForCommand(command) {
804
+ return command.includes(' sync') || command.includes(' --yes');
805
+ }
806
+ function stringArray(value) {
807
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
808
+ }
809
+ function confidenceValue(value) {
810
+ return value === 'high' || value === 'medium' || value === 'low' || value === 'unknown'
811
+ ? value
812
+ : null;
813
+ }
814
+ function provenanceValue(value) {
815
+ return value === 'measured' || value === 'inferred' ? value : null;
816
+ }
817
+ function mutabilityValue(value) {
818
+ return value === 'read' || value === 'write' || value === 'sync' || value === 'unknown'
819
+ ? value
820
+ : null;
821
+ }
563
822
  function investigationAgentOptions(investigation) {
564
823
  return {
565
824
  summary: investigation.summary,
@@ -585,6 +844,17 @@ function overviewAgentOptions(briefing) {
585
844
  };
586
845
  }
587
846
  function askAgentOptions(result) {
847
+ const errors = result.degraded
848
+ ? [{
849
+ code: 'cloud_unavailable',
850
+ message: result.fallbackReason ??
851
+ 'Moda Cloud intelligence was unavailable; answer synthesized from local Data API evidence.',
852
+ // Non-blocking: an answer WAS produced, just from a degraded source. The
853
+ // agent should still treat it as lower-trust (see exit code 3 / status).
854
+ blocking: false,
855
+ suggested_commands: ['moda doctor --online'],
856
+ }]
857
+ : [];
588
858
  return {
589
859
  summary: { text: result.answer, confidence: result.confidence },
590
860
  findings: result.findings.map(agentFinding),
@@ -592,6 +862,7 @@ function askAgentOptions(result) {
592
862
  recommendedActions: result.findings.flatMap((finding) => finding.recommendedActions.map(agentAction)),
593
863
  nextCommands: result.nextCommands.map(nextCommand),
594
864
  warnings: result.warnings,
865
+ errors,
595
866
  };
596
867
  }
597
868
  function agentFinding(finding) {
@@ -707,17 +978,31 @@ function renderAskHuman(result) {
707
978
  const lines = [];
708
979
  lines.push('Moda ask');
709
980
  lines.push(`Question: ${result.question}`);
981
+ if (result.degraded) {
982
+ lines.push('');
983
+ lines.push('[degraded] Moda Cloud was unavailable — this answer is synthesized from local Data API evidence (lower trust).');
984
+ }
710
985
  lines.push('');
711
986
  lines.push(result.answer);
712
987
  lines.push('');
713
988
  lines.push(`Confidence: ${result.confidence}`);
989
+ lines.push(`Source: ${result.source}${result.degraded ? ' (degraded)' : ''}`);
714
990
  if (result.evidenceRefs.length > 0) {
715
991
  lines.push('');
716
992
  lines.push('Evidence');
993
+ // Subtle grounding marker: flag model-inferred evidence so a human reader
994
+ // can weight it. Ids/anchors stay hidden in the human projection.
995
+ const grounding = new Map(result.intelligence.evidence_refs.map((ref) => [ref.id, ref.grounding]));
717
996
  for (const ref of result.evidenceRefs.slice(0, 8)) {
718
- lines.push(` - ${ref.label}${ref.path ? ` (${ref.path})` : ''}`);
997
+ const marker = grounding.get(ref.id) === 'inferred' ? ' (inferred)' : '';
998
+ lines.push(` - ${ref.label}${ref.path ? ` (${ref.path})` : ''}${marker}`);
719
999
  }
720
1000
  }
1001
+ const unverifiedClaims = result.intelligence.answer_claims.filter((claim) => claim.status !== 'grounded').length;
1002
+ if (unverifiedClaims > 0) {
1003
+ lines.push('');
1004
+ lines.push(`Note: ${unverifiedClaims} claim(s) could not be grounded against tool data.`);
1005
+ }
721
1006
  if (result.nextCommands.length > 0) {
722
1007
  lines.push('');
723
1008
  lines.push('Next commands');
@@ -775,24 +1060,6 @@ function toolRows(data, scopedTool) {
775
1060
  }];
776
1061
  });
777
1062
  }
778
- function runRows(data) {
779
- const record = asRecord(data);
780
- const run = asRecord(record?.run);
781
- const rows = Array.isArray(record?.runs) ? record.runs : run ? [run] : [];
782
- return rows.flatMap((item) => {
783
- const row = asRecord(item);
784
- if (!row)
785
- return [];
786
- return [{
787
- id: asString(row.job_id) ?? asString(row.run_id) ?? '',
788
- outcome: asString(row.outcome) ?? 'unknown',
789
- agentName: asString(row.agent_name) ?? undefined,
790
- reason: asString(row.outcome_reason) ?? undefined,
791
- conversationId: asString(row.conversation_id) ?? undefined,
792
- raw: row,
793
- }];
794
- });
795
- }
796
1063
  function loopRows(data) {
797
1064
  const record = asRecord(data);
798
1065
  const rows = Array.isArray(record?.loops)
@@ -1056,17 +1323,6 @@ function numberFlag(flags, key, fallback) {
1056
1323
  return fallback;
1057
1324
  return Math.min(Math.floor(parsed), 90);
1058
1325
  }
1059
- function timeRangeForDays(days) {
1060
- if (days <= 1)
1061
- return '24h';
1062
- if (days <= 3)
1063
- return '3d';
1064
- if (days <= 7)
1065
- return '7d';
1066
- if (days <= 30)
1067
- return '30d';
1068
- return '90d';
1069
- }
1070
1326
  function normalizeConfidence(value) {
1071
1327
  if (value === 'high' || value === 'medium' || value === 'low' || value === 'unknown')
1072
1328
  return value;
@@ -1080,7 +1336,6 @@ function evidenceKind(value) {
1080
1336
  value === 'tool_failure' ||
1081
1337
  value === 'frustration' ||
1082
1338
  value === 'loop' ||
1083
- value === 'run' ||
1084
1339
  value === 'harness' ||
1085
1340
  value === 'ask')
1086
1341
  return value;
@@ -1102,8 +1357,6 @@ function purposeForCommand(command) {
1102
1357
  return 'Read the relevant conversation window.';
1103
1358
  if (command.startsWith('moda loop'))
1104
1359
  return 'Inspect loop detection details.';
1105
- if (command.startsWith('moda run'))
1106
- return 'Inspect one agent run and side-effect receipts.';
1107
1360
  if (command.startsWith('moda doctor'))
1108
1361
  return 'Validate Moda setup and data flow.';
1109
1362
  if (command.startsWith('moda overview'))