@kal-elsam/kairo-runtime 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +124 -17
  2. package/global-template/components/catalog.json +63 -6
  3. package/global-template/components/sdd-core/personas/teaching.md +42 -0
  4. package/global-template/components/sdd-core/skills/sdd-apply/SKILL.md +34 -0
  5. package/global-template/components/sdd-core/skills/sdd-apply/references/contract.md +4 -0
  6. package/global-template/components/sdd-core/skills/sdd-archive/SKILL.md +34 -0
  7. package/global-template/components/sdd-core/skills/sdd-archive/references/contract.md +4 -0
  8. package/global-template/components/sdd-core/skills/sdd-design/SKILL.md +34 -0
  9. package/global-template/components/sdd-core/skills/sdd-design/references/contract.md +4 -0
  10. package/global-template/components/sdd-core/skills/sdd-explore/SKILL.md +34 -0
  11. package/global-template/components/sdd-core/skills/sdd-explore/references/contract.md +4 -0
  12. package/global-template/components/sdd-core/skills/sdd-init/SKILL.md +34 -0
  13. package/global-template/components/sdd-core/skills/sdd-init/references/contract.md +4 -0
  14. package/global-template/components/sdd-core/skills/sdd-propose/SKILL.md +35 -0
  15. package/global-template/components/sdd-core/skills/sdd-propose/references/contract.md +4 -0
  16. package/global-template/components/sdd-core/skills/sdd-spec/SKILL.md +34 -0
  17. package/global-template/components/sdd-core/skills/sdd-spec/references/contract.md +4 -0
  18. package/global-template/components/sdd-core/skills/sdd-tasks/SKILL.md +34 -0
  19. package/global-template/components/sdd-core/skills/sdd-tasks/references/contract.md +4 -0
  20. package/global-template/components/sdd-core/skills/sdd-verify/SKILL.md +34 -0
  21. package/global-template/components/sdd-core/skills/sdd-verify/references/contract.md +4 -0
  22. package/package.json +1 -1
  23. package/scripts/cockpit-smoke.mjs +29 -8
  24. package/scripts/smoke-test.sh +9 -0
  25. package/src/cli.js +54 -5
  26. package/src/global/action-planner.js +17 -4
  27. package/src/global/component-authoring.js +1 -2
  28. package/src/global/component-ecosystem-checks.js +74 -15
  29. package/src/global/component-health.js +56 -0
  30. package/src/global/component-integration-cli.js +302 -0
  31. package/src/global/component-manifest.js +214 -0
  32. package/src/global/component-registry.js +19 -2
  33. package/src/global/component-resolve.js +51 -0
  34. package/src/global/components/sdd-core.js +6 -0
  35. package/src/global/control-plane-proposals.js +229 -0
  36. package/src/global/control-plane-snapshot.js +212 -0
  37. package/src/global/dashboard-guidance.js +27 -3
  38. package/src/global/diff.js +2 -5
  39. package/src/global/global-cli.js +16 -1
  40. package/src/global/global-doctor.js +75 -2
  41. package/src/global/global-installer.js +43 -6
  42. package/src/global/governance-actions.js +212 -0
  43. package/src/global/governance-repair.js +13 -0
  44. package/src/global/ink/cockpit-changes.js +124 -0
  45. package/src/global/ink/cockpit-control-center.js +106 -0
  46. package/src/global/ink/cockpit-controller.js +17 -0
  47. package/src/global/ink/cockpit-enter.js +27 -0
  48. package/src/global/ink/cockpit-focus.js +7 -1
  49. package/src/global/ink/cockpit-models.js +98 -46
  50. package/src/global/ink/cockpit-proposals.js +82 -0
  51. package/src/global/ink/cockpit-recovery.js +120 -0
  52. package/src/global/ink/cockpit-runs.js +47 -0
  53. package/src/global/ink/cockpit-scan.js +60 -0
  54. package/src/global/ink/cockpit-views.js +144 -74
  55. package/src/global/ink/orchestrator-app.js +156 -42
  56. package/src/global/ink/orchestrator-state.js +22 -1
  57. package/src/global/ink/use-orchestrator-data.js +239 -17
  58. package/src/global/integrations/engram-apply.js +105 -0
  59. package/src/global/integrations/engram-evidence.js +218 -0
  60. package/src/global/integrations/engram-exec.js +107 -0
  61. package/src/global/integrations/engram-plan.js +60 -0
  62. package/src/global/integrations/engram-provider.js +22 -0
  63. package/src/global/integrations/engram-receipts.js +116 -0
  64. package/src/global/integrations/engram-rollback.js +131 -0
  65. package/src/global/integrations/index.js +12 -0
  66. package/src/global/integrations/provider-contract.js +83 -0
  67. package/src/global/integrations/provider-registry.js +47 -0
  68. package/src/global/integrations/sdd-apply.js +190 -0
  69. package/src/global/integrations/sdd-destinations.js +95 -0
  70. package/src/global/integrations/sdd-evidence.js +89 -0
  71. package/src/global/integrations/sdd-fs-guard.js +183 -0
  72. package/src/global/integrations/sdd-lifecycle.js +84 -0
  73. package/src/global/integrations/sdd-persona.js +71 -0
  74. package/src/global/integrations/sdd-plan.js +87 -0
  75. package/src/global/integrations/sdd-provider.js +21 -0
  76. package/src/global/integrations/sdd-receipts.js +44 -0
  77. package/src/global/integrations/sdd-rollback.js +269 -0
  78. package/src/global/integrations/sdd-skill-files.js +61 -0
  79. package/src/global/integrations/sdd-state.js +175 -0
  80. package/src/global/integrations/sdd-verify.js +122 -0
  81. package/src/global/intelligence/backends/opencode-catalog.js +212 -0
  82. package/src/global/intelligence/backends/opencode-cli.js +247 -0
  83. package/src/global/intelligence/backends/opencode-evidence.js +129 -0
  84. package/src/global/intelligence/backends/opencode-http.js +152 -0
  85. package/src/global/intelligence/backends/opencode-invoke.js +83 -0
  86. package/src/global/intelligence/backends/opencode-providers.js +67 -0
  87. package/src/global/intelligence/backends/opencode-runtime.js +242 -0
  88. package/src/global/intelligence/context-compiler.js +95 -50
  89. package/src/global/intelligence/index.js +38 -1
  90. package/src/global/intelligence/orchestrate.js +4 -2
  91. package/src/global/intelligence/registry.js +62 -6
  92. package/src/global/intelligence/router.js +188 -70
  93. package/src/global/intelligence/transport-registry.js +147 -0
  94. package/src/global/intelligence/types.js +21 -2
  95. package/src/global/intelligence-cli.js +48 -17
  96. package/src/global/intelligence-session.js +37 -0
  97. package/src/global/json-output.js +1 -0
  98. package/src/global/load-component-catalog.js +10 -28
  99. package/src/global/load-workspace-component-catalog.js +22 -63
  100. package/src/global/state-migration.js +21 -1
  101. package/src/global/state.js +13 -8
  102. package/src/global/status.js +4 -17
  103. package/src/global/sync.js +2 -5
@@ -0,0 +1,175 @@
1
+ import { SDD_FILE_OUTCOMES } from "./sdd-evidence.js";
2
+ import { derivePersona, normalizePersonaAgentIds } from "./sdd-persona.js";
3
+
4
+ /** Empty v4 SDD block: persona off, no managed files, no receipt. */
5
+ export function defaultSddState() {
6
+ return {
7
+ persona: "off",
8
+ personaAgentIds: [],
9
+ agentIds: [],
10
+ files: [],
11
+ lastReceiptId: null,
12
+ updatedAt: null
13
+ };
14
+ }
15
+
16
+ /** Coerce any prior/absent shape into a valid v4 SDD block. */
17
+ export function normalizeSddState(raw) {
18
+ if (!raw || typeof raw !== "object") return defaultSddState();
19
+ const personaAgentIds = Object.hasOwn(raw, "personaAgentIds")
20
+ ? (Array.isArray(raw.personaAgentIds) ? normalizePersonaAgentIds(raw.personaAgentIds) : [])
21
+ : (raw.persona === "teaching" && Array.isArray(raw.agentIds)
22
+ ? normalizePersonaAgentIds(raw.agentIds) : []);
23
+ return {
24
+ persona: derivePersona(personaAgentIds),
25
+ personaAgentIds,
26
+ agentIds: Array.isArray(raw.agentIds) ? [...raw.agentIds] : [],
27
+ files: Array.isArray(raw.files) ? raw.files.map(normalizeSddFile) : [],
28
+ lastReceiptId: typeof raw.lastReceiptId === "string" ? raw.lastReceiptId : null,
29
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null
30
+ };
31
+ }
32
+
33
+ function normalizeSddFile(entry) {
34
+ return {
35
+ destinationPath: entry.destinationPath,
36
+ relativePath: entry.relativePath ?? "SKILL.md",
37
+ skillId: entry.skillId,
38
+ agentIds: Array.isArray(entry.agentIds) ? [...entry.agentIds] : [],
39
+ hash: entry.hash ?? null,
40
+ skillHash: entry.skillHash ?? null,
41
+ action: entry.action ?? null
42
+ };
43
+ }
44
+
45
+ function verifiedNoopHash(file) {
46
+ const disk = file.afterHash ?? file.diskHash ?? file.beforeHash ?? null;
47
+ if (disk == null || file.canonicalHash == null || disk !== file.canonicalHash) return null;
48
+ return disk;
49
+ }
50
+
51
+ /** Track only applied(+afterHash) or noop with verified hash equality. No legacy fallback. */
52
+ export function shouldTrackSddReceiptFile(file) {
53
+ if (!file || typeof file !== "object") return false;
54
+ if (file.outcome === SDD_FILE_OUTCOMES.APPLIED) return file.afterHash != null;
55
+ if (file.outcome === SDD_FILE_OUTCOMES.NOOP) return verifiedNoopHash(file) != null;
56
+ return false;
57
+ }
58
+
59
+ function trackingHash(file) {
60
+ if (file.outcome === SDD_FILE_OUTCOMES.APPLIED) return file.afterHash ?? null;
61
+ if (file.outcome === SDD_FILE_OUTCOMES.NOOP) return verifiedNoopHash(file);
62
+ return null;
63
+ }
64
+
65
+ function collectAgentIds(files) {
66
+ const ids = new Set();
67
+ for (const file of files) {
68
+ for (const id of file.agentIds ?? []) ids.add(id);
69
+ }
70
+ return [...ids].sort();
71
+ }
72
+
73
+ /** Legacy receipts without personaTransition derive consumers from teaching + files. */
74
+ function resolvePersonaAgentIds(receipt, current, files) {
75
+ if (receipt?.personaTransition) return normalizePersonaAgentIds(receipt.personaTransition.after);
76
+ if (!files.length || (receipt.persona ?? current.persona) !== "teaching") return [];
77
+ return current.personaAgentIds.length ? current.personaAgentIds : collectAgentIds(files);
78
+ }
79
+
80
+ /** True when rollback mutated disk via successful delete/restore (even if global ok=false). */
81
+ export function hasSuccessfulSddRollbackMutations(actions) {
82
+ return (actions ?? []).some((entry) =>
83
+ entry?.ok && !entry.noop && (entry.action === "delete" || entry.action === "restore" || entry.action === "persona")
84
+ );
85
+ }
86
+
87
+ /** Merge receipt into v4 SDD block for applied/verified files only. */
88
+ export function recordSddMaterialization(state, { receipt, now = () => new Date().toISOString() } = {}) {
89
+ const current = normalizeSddState(state?.sdd);
90
+ const managed = new Map(current.files.map((file) => [file.destinationPath, file]));
91
+
92
+ for (const file of receipt.files ?? []) {
93
+ if (!shouldTrackSddReceiptFile(file)) continue;
94
+ const hash = trackingHash(file);
95
+ if (hash == null) continue;
96
+ managed.set(file.destinationPath, {
97
+ destinationPath: file.destinationPath,
98
+ relativePath: file.relativePath ?? "SKILL.md",
99
+ skillId: file.skillId,
100
+ agentIds: [...(file.agentIds ?? [])],
101
+ hash,
102
+ skillHash: file.skillHash ?? null,
103
+ action: file.action
104
+ });
105
+ }
106
+
107
+ const files = [...managed.values()].sort((left, right) =>
108
+ left.destinationPath.localeCompare(right.destinationPath)
109
+ );
110
+ const personaAgentIds = resolvePersonaAgentIds(receipt, current, files);
111
+
112
+ return {
113
+ ...(state ?? {}),
114
+ sdd: {
115
+ persona: derivePersona(personaAgentIds),
116
+ personaAgentIds,
117
+ agentIds: collectAgentIds(files),
118
+ files,
119
+ lastReceiptId: receipt.id ?? current.lastReceiptId,
120
+ updatedAt: now()
121
+ }
122
+ };
123
+ }
124
+
125
+ /** Reconcile each successful delete/restore/persona; refresh agentIds/persona from remaining files. */
126
+ export function reconcileSddStateAfterRollback(state, {
127
+ receipt, actions, now = () => new Date().toISOString()
128
+ } = {}) {
129
+ const current = normalizeSddState(state?.sdd);
130
+ const managed = new Map(current.files.map((file) => [file.destinationPath, { ...file }]));
131
+ const receiptFiles = new Map((receipt?.files ?? []).map((file) => [file.destinationPath, file]));
132
+ const backups = new Map((receipt?.backups ?? []).map((entry) => [entry.path, entry]));
133
+
134
+ for (const action of actions ?? []) {
135
+ if (!action?.ok || action.dryRun || action.action === "persona" || action.action === "skip") continue;
136
+ if (action.action === "delete") {
137
+ managed.delete(action.path);
138
+ continue;
139
+ }
140
+ if (action.action !== "restore") continue;
141
+ const file = receiptFiles.get(action.path);
142
+ const beforeHash = backups.get(action.path)?.beforeHash ?? file?.beforeHash ?? null;
143
+ if (beforeHash == null || !file) continue;
144
+ managed.set(action.path, {
145
+ destinationPath: action.path,
146
+ relativePath: file.relativePath ?? "SKILL.md",
147
+ skillId: file.skillId,
148
+ agentIds: [...(file.agentIds ?? [])],
149
+ hash: beforeHash,
150
+ skillHash: file.skillHash ?? null,
151
+ action: file.action
152
+ });
153
+ }
154
+
155
+ const files = [...managed.values()].sort((left, right) =>
156
+ left.destinationPath.localeCompare(right.destinationPath)
157
+ );
158
+ const personaAction = (actions ?? []).find((entry) => entry.action === "persona" && entry.ok);
159
+ let personaAgentIds = current.personaAgentIds;
160
+ if (files.length === 0 && !personaAction) personaAgentIds = [];
161
+ else if (personaAction) personaAgentIds = normalizePersonaAgentIds(personaAction.before ?? []);
162
+
163
+ return {
164
+ ...(state ?? {}),
165
+ sdd: {
166
+ ...current,
167
+ files,
168
+ agentIds: collectAgentIds(files),
169
+ personaAgentIds,
170
+ persona: derivePersona(personaAgentIds),
171
+ lastReceiptId: receipt?.id ?? current.lastReceiptId,
172
+ updatedAt: now()
173
+ }
174
+ };
175
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { hashBuffer } from "../../hash.js";
5
+ import { resolveAdapter } from "../registry.js";
6
+ import { applySddConfigure } from "./sdd-apply.js";
7
+ import { classifySddVerifyHealth, SDD_HEALTH } from "./sdd-evidence.js";
8
+ import { harnessHomePaths } from "../paths.js";
9
+ import {
10
+ SDD_SKILL_IDS,
11
+ groupSddSkillDestinations,
12
+ resolveSddAgentSelection
13
+ } from "./sdd-destinations.js";
14
+ import {
15
+ classifyPersonaHealth,
16
+ derivePersona,
17
+ normalizePersonaAgentIds,
18
+ SDD_PERSONA_GATE
19
+ } from "./sdd-persona.js";
20
+ import { compareSkillPaths, loadCanonicalSddSkill } from "./sdd-skill-files.js";
21
+
22
+ /** Read-only verify: configured | missing | drifted | conflict (canonical vs disk). */
23
+ export async function verifySddConfigure({
24
+ requestedAgentIds = null,
25
+ detectedAgentIds = [],
26
+ homeDir,
27
+ packageRoot,
28
+ trackedFiles = {},
29
+ personaAgentIds = [],
30
+ exists = existsSync,
31
+ readFileImpl = readFile
32
+ } = {}) {
33
+ if (!homeDir) throw new Error("verifySddConfigure requires homeDir.");
34
+ if (!packageRoot) throw new Error("verifySddConfigure requires packageRoot.");
35
+
36
+ const agentIds = resolveSddAgentSelection({
37
+ requestedIds: requestedAgentIds,
38
+ detectedIds: detectedAgentIds
39
+ });
40
+ const groups = groupSddSkillDestinations(agentIds, homeDir);
41
+ const findings = [];
42
+
43
+ for (const skillId of SDD_SKILL_IDS) {
44
+ const { files, skillHash } = await loadCanonicalSddSkill(skillId, packageRoot);
45
+ for (const file of files) {
46
+ const canonicalHash = hashBuffer(file.bytes);
47
+ for (const group of groups) {
48
+ const destinationPath = join(group.root, skillId, ...file.relativePath.split("/"));
49
+ const fileExists = exists(destinationPath);
50
+ const diskHash = fileExists ? hashBuffer(await readFileImpl(destinationPath)) : null;
51
+ const trackedHash = trackedFiles[destinationPath] ?? null;
52
+ const health = classifySddVerifyHealth({
53
+ exists: fileExists, canonicalHash, diskHash, trackedHash
54
+ });
55
+ findings.push({
56
+ skillId, relativePath: file.relativePath, destinationPath,
57
+ agentIds: [...group.agentIds], kind: group.kind,
58
+ status: health.status, drift: health.drift, reason: health.reason,
59
+ canonicalHash, skillHash, diskHash, trackedHash
60
+ });
61
+ }
62
+ }
63
+ }
64
+
65
+ findings.sort((a, b) => compareSkillPaths(a.skillId, b.skillId)
66
+ || compareSkillPaths(a.relativePath, b.relativePath)
67
+ || compareSkillPaths(a.destinationPath, b.destinationPath));
68
+
69
+ const summary = { configured: 0, missing: 0, drifted: 0, conflict: 0 };
70
+ for (const entry of findings) summary[entry.status] += 1;
71
+
72
+ const consumers = normalizePersonaAgentIds(personaAgentIds);
73
+ const incompleteAgentIds = consumers.filter((id) => {
74
+ const mine = findings.filter((e) => e.agentIds.includes(id));
75
+ return !mine.length || mine.some((e) => e.status !== SDD_HEALTH.CONFIGURED);
76
+ });
77
+ let gatePresent = true;
78
+ for (const id of consumers) {
79
+ const p = join(homeDir, resolveAdapter(id).assets.configFile);
80
+ if (!exists(p) || !String(await readFileImpl(p, "utf8")).includes(SDD_PERSONA_GATE)) {
81
+ gatePresent = false;
82
+ break;
83
+ }
84
+ }
85
+ const personaHealth = classifyPersonaHealth({
86
+ personaAgentIds: consumers, gatePresent, incompleteAgentIds,
87
+ assetPresent: exists(join(harnessHomePaths(homeDir).root, "components/sdd-core/personas/teaching.md"))
88
+ });
89
+ return {
90
+ provider: "sdd-core", componentId: "sdd-core", agentIds, findings, summary,
91
+ status: summarizeSddHealth(summary),
92
+ ok: summary.missing === 0 && summary.drifted === 0 && summary.conflict === 0,
93
+ persona: {
94
+ ...personaHealth, persona: derivePersona(consumers), personaAgentIds: consumers, incompleteAgentIds
95
+ }
96
+ };
97
+ }
98
+
99
+ export function summarizeSddHealth(summary) {
100
+ if (summary.conflict > 0) return SDD_HEALTH.CONFLICT;
101
+ if (summary.missing > 0) return SDD_HEALTH.MISSING;
102
+ if (summary.drifted > 0) return SDD_HEALTH.DRIFTED;
103
+ return SDD_HEALTH.CONFIGURED;
104
+ }
105
+
106
+ /** Sync = verify then apply; conflicts block writes. */
107
+ export async function syncSddConfigure(options = {}) {
108
+ const verification = await verifySddConfigure(options);
109
+ if (verification.summary.conflict > 0) {
110
+ return {
111
+ ...verification,
112
+ synced: false,
113
+ blocked: true,
114
+ applied: false,
115
+ cancelled: false,
116
+ receipt: null,
117
+ reason: "Conflicts present; refusing sync overwrite."
118
+ };
119
+ }
120
+ const result = await applySddConfigure(options);
121
+ return { ...result, verification, synced: Boolean(result.applied), blocked: false };
122
+ }
@@ -0,0 +1,212 @@
1
+ import {
2
+ COST_CLASSES,
3
+ OPENCODE_API_KEY_ENV,
4
+ PRIVACY_CLASSES,
5
+ TRANSPORT_KINDS,
6
+ createModelDescriptor
7
+ } from "../types.js";
8
+ import {
9
+ isDirectTransport,
10
+ normalizeModelId,
11
+ resolveOpencodeTransport
12
+ } from "../transport-registry.js";
13
+ import { fetchJson } from "../http.js";
14
+ import { CAPABILITY_STATES } from "../../capability-states.js";
15
+ import {
16
+ BILLING_MODELS,
17
+ ENTITLEMENT_STATES,
18
+ buildDirectEvidenceBase,
19
+ classifyModelsProbeStatus,
20
+ collectOpencodeCliEvidence
21
+ } from "./opencode-evidence.js";
22
+
23
+ export function resolveOpencodeApiKey({ env = process.env, apiKey = null } = {}) {
24
+ const value = apiKey ?? env[OPENCODE_API_KEY_ENV] ?? null;
25
+ if (value == null) return null;
26
+ const trimmed = String(value).trim();
27
+ return trimmed.length > 0 ? trimmed : null;
28
+ }
29
+
30
+ export function redactSecret(message, secret) {
31
+ if (!message || !secret) return message ?? null;
32
+ return String(message).split(String(secret)).join("[REDACTED]");
33
+ }
34
+
35
+ export function zenCostClassForModel(modelId) {
36
+ const id = normalizeModelId(modelId) ?? "";
37
+ return id === "big-pickle" || id.endsWith("-free") ? COST_CLASSES.FREE : COST_CLASSES.PAID;
38
+ }
39
+
40
+ export function goCostClassForModel() {
41
+ return COST_CLASSES.PAID;
42
+ }
43
+
44
+ /** Catalog/detection for OpenCode Go/Zen. Invoke composition ships in a later PR. */
45
+ export function createOpencodeCatalogBackend({
46
+ id,
47
+ label,
48
+ product,
49
+ baseUrl,
50
+ defaultModelId,
51
+ costClassForModel,
52
+ env = process.env,
53
+ fetchImpl = globalThis.fetch,
54
+ apiKey = null,
55
+ collectCliEvidence = collectOpencodeCliEvidence
56
+ }) {
57
+ const resolvedKey = resolveOpencodeApiKey({ env, apiKey });
58
+
59
+ return {
60
+ id,
61
+ label,
62
+ local: false,
63
+ product,
64
+ baseUrl,
65
+
66
+ async detect() {
67
+ const cliEvidence = collectCliEvidence({ env });
68
+ if (!resolvedKey) return missingKeyDetection(id, label, product, cliEvidence);
69
+
70
+ const probe = await fetchJson(`${baseUrl}/models`, {
71
+ headers: bearerHeaders(resolvedKey),
72
+ timeoutMs: 5000,
73
+ fetchImpl
74
+ });
75
+ const evidence = buildDirectEvidenceBase({
76
+ product,
77
+ hasApiKey: true,
78
+ cliEvidence,
79
+ modelsHttpStatus: probe.status || null,
80
+ modelsOk: probe.ok
81
+ });
82
+
83
+ if (!probe.ok) {
84
+ const entitlement = classifyModelsProbeStatus(probe.status);
85
+ return {
86
+ id,
87
+ label,
88
+ state: CAPABILITY_STATES.ERROR,
89
+ detected: true,
90
+ available: false,
91
+ hasApiKey: true,
92
+ cloud: true,
93
+ ...evidence,
94
+ entitlement,
95
+ error: redactSecret(probe.error ?? `HTTP ${probe.status}`, resolvedKey),
96
+ recommendation: describeAuthFailure(probe.status, label, product)
97
+ };
98
+ }
99
+
100
+ return {
101
+ id,
102
+ label,
103
+ state: CAPABILITY_STATES.AUTHENTICATED,
104
+ detected: true,
105
+ available: true,
106
+ hasApiKey: true,
107
+ cloud: true,
108
+ ...evidence,
109
+ error: null,
110
+ recommendation: `${label} HTTP authenticated via /models. Entitlement remains ${ENTITLEMENT_STATES.UNVERIFIED}; cloud invoke needs --cloud-consent --yes.`
111
+ };
112
+ },
113
+
114
+ async listModels() {
115
+ if (!resolvedKey) return [];
116
+ const result = await fetchJson(`${baseUrl}/models`, {
117
+ headers: bearerHeaders(resolvedKey),
118
+ timeoutMs: 10000,
119
+ fetchImpl
120
+ });
121
+ if (!result.ok) return [];
122
+
123
+ const mapped = [];
124
+ for (const remoteId of extractModelIds(result.data)) {
125
+ const modelId = normalizeModelId(remoteId);
126
+ const transport = resolveOpencodeTransport(product, modelId);
127
+ if (!isDirectTransport(transport)) continue;
128
+ mapped.push(createModelDescriptor({
129
+ provider: id,
130
+ modelId,
131
+ local: false,
132
+ costClass: costClassForModel(modelId),
133
+ privacyClass: PRIVACY_CLASSES.CLOUD,
134
+ transport
135
+ }));
136
+ }
137
+ return mapped;
138
+ },
139
+
140
+ async capabilities() {
141
+ const detection = await this.detect();
142
+ return {
143
+ id,
144
+ local: false,
145
+ cloud: true,
146
+ requiresApiKey: true,
147
+ requiresConsent: true,
148
+ streaming: false,
149
+ tools: false,
150
+ transports: [TRANSPORT_KINDS.CHAT_COMPLETIONS, TRANSPORT_KINDS.RESPONSES],
151
+ billingModel: detection.billingModel,
152
+ entitlement: detection.entitlement,
153
+ configured: detection.configured,
154
+ authenticated: detection.authenticated,
155
+ state: detection.state,
156
+ hasApiKey: Boolean(resolvedKey),
157
+ defaultModelId
158
+ };
159
+ }
160
+ };
161
+ }
162
+
163
+ function missingKeyDetection(id, label, product, cliEvidence = null) {
164
+ const evidence = buildDirectEvidenceBase({
165
+ product,
166
+ hasApiKey: false,
167
+ cliEvidence,
168
+ modelsHttpStatus: null,
169
+ modelsOk: false
170
+ });
171
+ const viaCli = evidence.configured;
172
+ return {
173
+ id,
174
+ label,
175
+ state: CAPABILITY_STATES.UNKNOWN,
176
+ detected: viaCli,
177
+ available: false,
178
+ hasApiKey: false,
179
+ cloud: true,
180
+ ...evidence,
181
+ error: null,
182
+ recommendation: viaCli
183
+ ? `${label} appears in OpenCode CLI auth list (${ENTITLEMENT_STATES.UNVERIFIED} entitlement). Intelligence direct HTTP still needs ${OPENCODE_API_KEY_ENV}, or use --backend opencode for CLI runtime.`
184
+ : `Set ${OPENCODE_API_KEY_ENV} for direct ${label} HTTP, or install/authenticate OpenCode CLI for runtime. Kairo never reads auth.json or claims balance without evidence.`
185
+ };
186
+ }
187
+
188
+ function bearerHeaders(apiKey) {
189
+ return { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
190
+ }
191
+
192
+ function extractModelIds(payload) {
193
+ const rows = Array.isArray(payload?.data)
194
+ ? payload.data
195
+ : Array.isArray(payload?.models)
196
+ ? payload.models
197
+ : Array.isArray(payload) ? payload : [];
198
+ return rows
199
+ .map((entry) => (typeof entry === "string" ? entry : entry?.id ?? entry?.name))
200
+ .filter(Boolean);
201
+ }
202
+
203
+ function describeAuthFailure(status, label, product = "zen") {
204
+ if (status === 401 || status === 403) {
205
+ return `${label} rejected the API key (HTTP ${status}). Key is configured but not authenticated for HTTP.`;
206
+ }
207
+ if (status === 429) {
208
+ return `${label} returned HTTP 429 (${ENTITLEMENT_STATES.LIMIT_REACHED}). Go limits do not automatically spend Zen credits.`;
209
+ }
210
+ const billing = product === "go" ? BILLING_MODELS.GO_PLAN : BILLING_MODELS.ZEN_CREDITS;
211
+ return `${label} model discovery failed (${billing} catalog; entitlement ${ENTITLEMENT_STATES.UNKNOWN}).`;
212
+ }