agentic-relay 3.8.5 → 4.1.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.
package/CLAUDE.md CHANGED
@@ -6,7 +6,7 @@ Expert "solutions-engineer" agents for real third-party tools (APIs, SDKs, libra
6
6
 
7
7
  On any non-trivial build that touches a third-party tool, **load the `agent-relay` skill before doing Agent Relay work.** This file is a map, not the manual — the skill holds the real procedure. Load it because the workflow has paid actions and a feedback loop you will get wrong from memory: when a charge fires, how to react, who fetches a paid deliverable, and what feedback to file when code survives (or fails) contact with production. Don't reconstruct that from this file.
8
8
 
9
- Lightweight exception: a one-off lookup ("find me X", "how do I install X") doesn't need the full breadth→build→depth ceremony. A single inline consult is fine — but still settle payments by the rules below.
9
+ Lightweight exception: a one-off lookup ("find me X", "how do I install X") doesn't need the full breadth→build→depth ceremony. A single inline session (`session start` → a send or two → `session end`) is fine — but still settle payments by the rules below.
10
10
 
11
11
  ## Commands
12
12
 
package/README.md CHANGED
@@ -134,12 +134,22 @@ gotchas}], capability_id, ad_unit_id }` (+ any extra keys) — see `schema/deliv
134
134
  | `agentrelay session end <id>` | Close + summary. |
135
135
  | `agentrelay feedback <id>` | `--score <0-100> --text "<s>"`. |
136
136
  | `agentrelay cache ls\|show <slug>\|put <slug>\|clear` | Persist/review distillations (`.agent-relay/`). |
137
- | `agentrelay consult <slug>` / `consult-batch` | **Fallback** code-driven template loop (fixed shape, generic). `--want`, `--context`. |
137
+ | `agentrelay session pay <id>` / `fetch` / `budget` | Settle an in-policy charge, fetch a paid deliverable, read/set the spend policy. |
138
+ | `agentrelay doctor` | Diagnose the install: Node version, API key + connectivity, npm prefix writability, `~/.npm` ownership, global bin + PATH. `--json` for machines. |
138
139
 
139
140
  Output is a `{ ok, ...payload, errors: [] }` envelope (`--json` auto-on when piped). Key resolution:
140
141
  `--api-key` → `$AGENT_RELAY_API_KEY` → `~/.config/agent-relay/credentials.json` (with a read-only fallback to the
141
142
  legacy `~/.config/penguin/credentials.json`). The key is never printed.
142
143
 
144
+ **Staying current:** interactive runs print a one-line stderr notice when a newer version is on npm
145
+ (checked in the background at most once per 24h; never blocks a command, never fires when piped, in CI,
146
+ or under `npx`). Opt out with `AGENT_RELAY_NO_UPDATE_CHECK=1`. Deliverable zips are extracted by a
147
+ built-in pure-JS extractor — no system `unzip` needed, so `fetch` works on Windows too.
148
+
149
+ **Future work (not in this release):** standalone binaries, a Homebrew tap, winget/scoop manifests, and
150
+ code signing. The package uses no npm lifecycle scripts (no postinstall), so npm v12's
151
+ scripts-blocked-by-default change does not affect installs.
152
+
143
153
  ## What is Agent Relay?
144
154
 
145
155
  AI-native search for software, tools, services, and business solutions. Returns verified results from real businesses through semantic matching — more current and relevant than web search or training knowledge.
package/bin/cli.mjs CHANGED
@@ -15,16 +15,19 @@
15
15
 
16
16
  const CLI_COMMANDS = new Set([
17
17
  "search",
18
- "consult",
19
- "consult-batch",
20
18
  "session",
21
19
  "feedback",
22
20
  "cache",
23
21
  "budget",
24
22
  "connect-wallet",
25
23
  "fetch",
24
+ "doctor",
26
25
  "version",
27
26
  "help",
27
+ // Removed commands — still routed to the CLI core so they fail with a clear
28
+ // error (exit 2) instead of falling through to the interactive installer.
29
+ "consult",
30
+ "consult-batch",
28
31
  ]);
29
32
 
30
33
  const sub = process.argv[2];
package/bin/install.mjs CHANGED
@@ -29,13 +29,26 @@
29
29
  * deeplink Print URL the agent registers (tome://, raycast://) — user clicks Install
30
30
  */
31
31
 
32
- import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from "fs";
32
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
33
33
  import { homedir } from "os";
34
34
  import { join, dirname } from "path";
35
35
  import { fileURLToPath } from "url";
36
36
  import { createInterface } from "readline/promises";
37
37
  import { stdin, stdout } from "process";
38
38
  import { execSync } from "child_process";
39
+ import { parseInstallArgs } from "./lib/install-args.mjs";
40
+ import {
41
+ resolveCommandPath,
42
+ isEphemeralBin,
43
+ npmGlobalPrefix,
44
+ npmGlobalBinDir,
45
+ globalBinShimPath,
46
+ globalPrefixWritable,
47
+ npmCacheOwnedByOther,
48
+ dirOnPath,
49
+ USER_PREFIX_RECIPE,
50
+ CACHE_CHOWN_RECIPE,
51
+ } from "./lib/sysenv.mjs";
39
52
 
40
53
  const __dirname = dirname(fileURLToPath(import.meta.url));
41
54
  const HOME = homedir();
@@ -266,39 +279,8 @@ function escapeRegex(str) {
266
279
  // ═══════════════════════════════════════════════════════════════════════
267
280
 
268
281
  function parseArgs() {
269
- const args = process.argv.slice(2);
270
- const flags = {
271
- targets: null,
272
- all: args.includes("--all"),
273
- project: args.includes("--project"),
274
- apiKey: null,
275
- librechat: null,
276
- // Legacy single-flag shortcuts (kept for backward compat)
277
- legacyClaude: args.includes("--claude"),
278
- legacyCodex: args.includes("--codex"),
279
- legacyCursor: args.includes("--cursor"),
280
- };
281
-
282
- for (const arg of args) {
283
- if (arg.startsWith("--target=")) {
284
- flags.targets = arg
285
- .slice("--target=".length)
286
- .split(",")
287
- .map((s) => s.trim())
288
- .filter(Boolean);
289
- } else if (arg.startsWith("--api-key=")) {
290
- flags.apiKey = arg.slice("--api-key=".length);
291
- } else if (arg.startsWith("--librechat=")) {
292
- flags.librechat = arg.slice("--librechat=".length);
293
- }
294
- }
295
-
296
- // Support `--api-key VALUE` (space-separated) too
297
- const keyIdx = args.indexOf("--api-key");
298
- if (keyIdx !== -1 && args[keyIdx + 1] && !args[keyIdx + 1].startsWith("--")) {
299
- flags.apiKey = args[keyIdx + 1];
300
- }
301
-
282
+ const { flags, warnings } = parseInstallArgs(process.argv.slice(2));
283
+ for (const w of warnings) warn(w);
302
284
  return flags;
303
285
  }
304
286
 
@@ -623,45 +605,10 @@ function getExistingKey() {
623
605
  * Prefers a registry install (`agentic-relay@<version>`) over the local path
624
606
  * (the npx cache dir is ephemeral; npm would link/copy from it). Falls back to
625
607
  * the path for unpublished checkouts. Surfaces npm's actual error (EACCES
626
- * etc.) instead of swallowing it, and verifies the command resolves afterwards
627
- * so a PATH mismatch is reported, not discovered later as `command not found`.
608
+ * etc.) instead of swallowing it, and verifies the global bin file landed so a
609
+ * PATH mismatch is reported, not discovered later as `command not found`.
610
+ * System probes live in lib/sysenv.mjs (shared with `agentrelay doctor`).
628
611
  */
629
- function resolveCommandPath(cmd) {
630
- try {
631
- const probe = process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`;
632
- const out = execSync(probe, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
633
- return out.split("\n")[0].trim() || null;
634
- } catch {
635
- return null;
636
- }
637
- }
638
-
639
- /** True when a resolved bin path is an ephemeral npx/npm-exec shim (or this package's own copy), not a real install. */
640
- function isEphemeralBin(binPath, pkgRoot) {
641
- if (!binPath) return false;
642
- let real = binPath;
643
- try {
644
- real = realpathSync(binPath);
645
- } catch {
646
- // dangling symlink — definitely not a usable install
647
- return true;
648
- }
649
- const sep = process.platform === "win32" ? "\\" : "/";
650
- return (
651
- real.includes(`${sep}_npx${sep}`) ||
652
- binPath.includes(`${sep}_npx${sep}`) ||
653
- real.startsWith(realpathSafe(pkgRoot))
654
- );
655
- }
656
-
657
- function realpathSafe(p) {
658
- try {
659
- return realpathSync(p);
660
- } catch {
661
- return p;
662
- }
663
- }
664
-
665
612
  function linkGlobalCli() {
666
613
  const pkgRoot = join(__dirname, "..");
667
614
  let version = null;
@@ -686,6 +633,21 @@ function linkGlobalCli() {
686
633
  }
687
634
  }
688
635
 
636
+ // Preflight: detect the two environment breakages PROACTIVELY instead of
637
+ // letting `npm install -g` fail with a buried stderr.
638
+ const prefix = npmGlobalPrefix();
639
+ if (npmCacheOwnedByOther()) {
640
+ warn("your ~/.npm cache contains files owned by another user (an old `sudo npm`).");
641
+ warn(`This breaks npx AND npm i -g. Fix it once, then re-run: ${CACHE_CHOWN_RECIPE}`);
642
+ return;
643
+ }
644
+ if (prefix && !globalPrefixWritable(prefix)) {
645
+ warn(`npm's global prefix (${prefix}) isn't writable — npm i -g would fail with EACCES.`);
646
+ warn("Use a user-level prefix (the official npm fix):");
647
+ for (const line of USER_PREFIX_RECIPE) warn(` ${line}`);
648
+ return;
649
+ }
650
+
689
651
  const attempts = version ? [`agentic-relay@${version}`, `"${pkgRoot}"`] : [`"${pkgRoot}"`];
690
652
  let lastErr = null;
691
653
  let installedFrom = null;
@@ -702,31 +664,24 @@ function linkGlobalCli() {
702
664
  const stderr = String((lastErr && lastErr.stderr) || "");
703
665
  const detail =
704
666
  stderr.split("\n").find((l) => /EACCES|EEXIST|E404|ERR!|error/i.test(l)) || "";
705
- warn(
706
- `could not auto-link the global CLI${detail ? ` — ${detail.trim()}` : ""}.` +
707
- ` Run once: npm i -g agentic-relay` +
708
- (/EACCES/.test(stderr) ? " (npm's global prefix isn't writable — fix permissions or use a user-level prefix)" : ""),
709
- );
667
+ warn(`could not auto-link the global CLI${detail ? ` — ${detail.trim()}` : ""}. Run once: npm i -g agentic-relay`);
668
+ if (/EACCES/.test(stderr)) {
669
+ warn("EACCES use a user-level prefix (the official npm fix):");
670
+ for (const line of USER_PREFIX_RECIPE) warn(` ${line}`);
671
+ }
710
672
  return;
711
673
  }
712
674
 
713
675
  // Verify the global bin actually landed. Don't probe the bare command here:
714
676
  // in this process it resolves to the ephemeral npx shim (which shadows the
715
677
  // global install), so check the file in npm's global prefix directly.
716
- let binDir = null;
717
- let globalBin = null;
718
- try {
719
- const prefix = execSync("npm prefix -g", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
720
- binDir = process.platform === "win32" ? prefix : join(prefix, "bin");
721
- const cand = join(binDir, process.platform === "win32" ? "agentrelay.cmd" : "agentrelay");
722
- if (existsSync(cand)) globalBin = cand;
723
- } catch {
724
- // can't resolve the prefix — fall through to the warn
725
- }
726
- if (globalBin) {
678
+ const binDir = npmGlobalBinDir(prefix);
679
+ const shim = globalBinShimPath(prefix, "agentrelay");
680
+ if (shim && existsSync(shim)) {
727
681
  success(`linked global \`agentrelay\` CLI (${installedFrom})`);
728
- const onPath = (process.env.PATH || "").split(process.platform === "win32" ? ";" : ":").includes(binDir);
729
- if (!onPath) warn(`note: ${binDir} doesn't appear to be on your PATH — add it to use \`agentrelay\` directly`);
682
+ if (!dirOnPath(binDir)) {
683
+ warn(`note: ${binDir} doesn't appear to be on your PATH — add it to use \`agentrelay\` directly`);
684
+ }
730
685
  } else {
731
686
  warn(`installed, but no global \`agentrelay\` found in ${binDir || "npm's global bin directory"} — run once: npm i -g agentic-relay`);
732
687
  }
@@ -739,6 +694,13 @@ async function setupApiKeyInteractive() {
739
694
  return true;
740
695
  }
741
696
 
697
+ // Scripted/CI runs have no TTY — don't hang on a prompt nobody will answer.
698
+ if (!stdin.isTTY) {
699
+ warn("no API key configured and stdin is not a terminal — skipping the prompt.");
700
+ warn("Pass --api-key=<am_live_...> or write ~/.config/agent-relay/credentials.json");
701
+ return false;
702
+ }
703
+
742
704
  console.log("");
743
705
  log("API key setup");
744
706
  console.log("");
package/bin/lib/cache.mjs CHANGED
@@ -69,24 +69,18 @@ export class Cache {
69
69
  return join(this.searchDir, `${h}.json`);
70
70
  }
71
71
 
72
- readSearch(query) {
73
- if (!this.enabled) return null;
74
- const entry = this._readJson(this._searchPath(query));
75
- if (!entry) return null;
76
- if (Date.now() > entry.expires) return null;
77
- return entry.capabilities || null;
78
- }
79
-
80
- // The buyer-side spend policy snapshot the search API returns alongside the
81
- // menu. Cached with the search so a cache hit still hands the agent a policy
82
- // to decide auto-pay-vs-bubble against (see SKILL.md Pay). Null on older
83
- // cache files written before this field existed.
84
- readSearchPaymentContext(query) {
72
+ /**
73
+ * Whole unexpired search entry — `{ts, capabilities, payment_context}` — or
74
+ * null. Returning the entry (not just capabilities) lets the caller surface
75
+ * how stale the cached payment_context snapshot is (see SKILL.md Pay).
76
+ */
77
+ readSearchEntry(query) {
85
78
  if (!this.enabled) return null;
86
79
  const entry = this._readJson(this._searchPath(query));
87
80
  if (!entry) return null;
88
81
  if (Date.now() > entry.expires) return null;
89
- return entry.payment_context || null;
82
+ if (!entry.capabilities) return null;
83
+ return entry;
90
84
  }
91
85
 
92
86
  writeSearch(query, capabilities, paymentContext = null) {
@@ -2,12 +2,10 @@
2
2
  * Command implementations for the agentrelay CLI. Each returns an envelope payload
3
3
  * (the entry adds `ok`/`errors` and chooses the exit code).
4
4
  *
5
- * Two modes (see SKILL.md):
6
- * - `session start|send|end` thin api_v1 wrappers; the caller (a host
7
- * subagent, or the main agent in direct mode) drives the conversation and
8
- * reads the agent's RAW turns. This is the primary, LLM-driven path.
9
- * - `consult` / `consult-batch` — the code-driven TEMPLATE fallback for
10
- * no-LLM/scripted use; returns a distilled result, transcript stays out.
5
+ * `session start|send|pay|end` are thin api_v1 wrappers; the caller (a host
6
+ * subagent, or the main agent in direct mode) drives the conversation and
7
+ * reads the agent's RAW turns. Compact specs get banked in the on-disk cache
8
+ * (`cache put`/`ls`/`show`) so transcripts stay out of the main context.
11
9
  */
12
10
 
13
11
  import { readFileSync } from "fs";
@@ -23,7 +21,6 @@ import {
23
21
  } from "./api.mjs";
24
22
  import { slugify } from "./config.mjs";
25
23
  import { Cache } from "./cache.mjs";
26
- import { consultOne } from "./driver.mjs";
27
24
  import { cmdFetch } from "./fetch.mjs";
28
25
 
29
26
  /** Normalize one upstream capability into the spec §4 menu shape. */
@@ -61,9 +58,14 @@ export async function cmdSearch(query, opts) {
61
58
  // The buyer-side spend policy snapshot the search API returns alongside the
62
59
  // menu — the agent decides auto-pay-vs-bubble against it (SKILL.md Pay). Pass
63
60
  // it through here so it survives to the CLI's JSON output and gets cached.
64
- let paymentContext = opts.refresh ? null : cache.readSearchPaymentContext(query);
61
+ // On a cache hit the snapshot can be up to the search TTL (1h) stale, so the
62
+ // payload carries from_cache/cached_at for the caller's pay decision.
63
+ const cachedEntry = opts.refresh ? null : cache.readSearchEntry(query);
64
+ let paymentContext = cachedEntry ? (cachedEntry.payment_context ?? null) : null;
65
+ let capabilities = cachedEntry ? cachedEntry.capabilities : null;
66
+ const fromCache = Boolean(cachedEntry);
67
+ const cachedAt = cachedEntry ? (cachedEntry.ts ?? null) : null;
65
68
 
66
- let capabilities = opts.refresh ? null : cache.readSearch(query);
67
69
  if (!capabilities) {
68
70
  const data = await apiSearch(query, {
69
71
  apiKey: opts.apiKey,
@@ -74,7 +76,7 @@ export async function cmdSearch(query, opts) {
74
76
  paymentContext = data.payment_context ?? null;
75
77
  capabilities = dedupeSlugs((data.capabilities || []).map(normalizeCapability));
76
78
  cache.writeSearch(query, capabilities, paymentContext);
77
- // Seed the index so consult can resolve ids without re-searching.
79
+ // Seed the index so session start can resolve ids without re-searching.
78
80
  for (const c of capabilities) {
79
81
  if (c.capability_id && c.ad_unit_id) {
80
82
  cache.updateIndex(c.slug, {
@@ -93,7 +95,14 @@ export async function cmdSearch(query, opts) {
93
95
  }
94
96
  capabilities = capabilities.slice(0, maxResults);
95
97
 
96
- return { query, count: capabilities.length, capabilities, payment_context: paymentContext };
98
+ return {
99
+ query,
100
+ count: capabilities.length,
101
+ from_cache: fromCache,
102
+ cached_at: cachedAt,
103
+ capabilities,
104
+ payment_context: paymentContext,
105
+ };
97
106
  }
98
107
 
99
108
  /**
@@ -153,116 +162,6 @@ export async function resolveCapability(slugOrId, opts) {
153
162
  };
154
163
  }
155
164
 
156
- async function consultResolved(resolved, want, opts) {
157
- const cache = new Cache(opts.cacheDir, { enabled: !opts.noCache });
158
-
159
- // Cache hit: return the stored spec unless --refresh.
160
- if (!opts.refresh) {
161
- const cached = cache.readSpec(resolved.slug);
162
- if (cached) return { ...cached, cached: true };
163
- }
164
-
165
- const { deliverable, sessionId, turns } = await consultOne(resolved, {
166
- want,
167
- context: opts.context,
168
- deliverNow: opts.deliverNow,
169
- raw: opts.raw,
170
- maxTurns: opts.maxTurns,
171
- timeoutMs: opts.timeoutMs,
172
- apiKey: opts.apiKey,
173
- });
174
- deliverable.turns = turns;
175
- cache.writeSpec(resolved.slug, deliverable);
176
- cache.updateIndex(resolved.slug, {
177
- last_session_id: sessionId,
178
- capability_id: resolved.capabilityId,
179
- ad_unit_id: resolved.adUnitId,
180
- clearing_price_cents: resolved.clearingPriceCents,
181
- name: resolved.name,
182
- });
183
- return deliverable;
184
- }
185
-
186
- /**
187
- * `consult`/`consult-batch` only run the code-driven TEMPLATE driver. The
188
- * LLM-driven path is a host subagent driving the `session` primitives — there's
189
- * no CLI-internal LLM. Reject `--driver llm` with a pointer rather than
190
- * silently degrading.
191
- */
192
- function assertTemplateDriver(opts) {
193
- const d = opts.driver || "template";
194
- if (d !== "template") {
195
- const e = new Error(
196
- `--driver "${d}" is not available. consult runs the template driver only; ` +
197
- "for LLM-driven conversation, drive `agentrelay session start|send|end` from a subagent (see SKILL.md).",
198
- );
199
- e.usage = true;
200
- throw e;
201
- }
202
- }
203
-
204
- export async function cmdConsult(slugOrId, opts) {
205
- assertTemplateDriver(opts);
206
- if (!opts.want) {
207
- const e = new Error("--want is required");
208
- e.usage = true;
209
- throw e;
210
- }
211
- const resolved = await resolveCapability(slugOrId, opts);
212
- const deliverable = await consultResolved(resolved, opts.want, opts);
213
- return { deliverable };
214
- }
215
-
216
- function loadWantFile(path) {
217
- try {
218
- return JSON.parse(readFileSync(path, "utf-8"));
219
- } catch (err) {
220
- throw new Error(`could not read --want-file ${path}: ${err.message}`);
221
- }
222
- }
223
-
224
- export async function cmdConsultBatch(opts) {
225
- assertTemplateDriver(opts);
226
- if (!opts.agents || opts.agents.length === 0) {
227
- const e = new Error("--agents <a,b,c> is required");
228
- e.usage = true;
229
- throw e;
230
- }
231
- const wantMap = opts.wantFile ? loadWantFile(opts.wantFile) : null;
232
- if (!wantMap && !opts.want) {
233
- const e = new Error("provide --want <str> or --want-file <map.json>");
234
- e.usage = true;
235
- throw e;
236
- }
237
-
238
- const { pool } = await import("./pool.mjs");
239
- const t0 = Date.now();
240
-
241
- const results = await pool(opts.agents, opts.concurrency || 5, async (slug) => {
242
- const want = (wantMap && wantMap[slug]) || opts.want;
243
- if (!want) throw new Error(`no want for "${slug}" (missing from --want-file)`);
244
- const resolved = await resolveCapability(slug, opts);
245
- return consultResolved(resolved, want, opts);
246
- });
247
-
248
- const deliverables = [];
249
- const failures = [];
250
- results.forEach((r, i) => {
251
- if (r.ok) deliverables.push(r.value);
252
- else failures.push({ slug: opts.agents[i], reason: String(r.error.message || r.error) });
253
- });
254
-
255
- const totals = {
256
- consulted: opts.agents.length,
257
- ok: deliverables.length,
258
- failed: failures.length,
259
- wall_seconds: Math.round((Date.now() - t0) / 1000),
260
- tokens_estimate: deliverables.reduce((s, d) => s + (d.tokens_estimate || 0), 0),
261
- };
262
-
263
- return { deliverables, failures, totals };
264
- }
265
-
266
165
  // ── session primitives (thin api_v1 wrappers — the LLM-driven path) ─────────
267
166
  function parseJsonFlag(s, name) {
268
167
  if (!s) return undefined;
@@ -296,17 +195,20 @@ export function shapeSessionTurn(r) {
296
195
 
297
196
  /**
298
197
  * Map a `delivery` object (from a paid session turn) to cmdFetch args. Tolerant of
299
- * minor key variants so it survives small upstream shape differences.
198
+ * minor key variants so it survives small upstream shape differences. The dest
199
+ * defaults to ./<delivery.slug>/ (slugified — a hostile slug can't traverse);
200
+ * an explicit dest always wins.
300
201
  */
301
202
  export function deliveryToFetchArgs(d, { dest = null } = {}) {
302
203
  if (!d || typeof d !== "object") return null;
303
204
  const url = d.download_url || d.url || null;
304
205
  if (!url) return null;
206
+ const slug = d.slug ? slugify(String(d.slug)) : null;
305
207
  return {
306
208
  url,
307
209
  checksum: d.checksum_sha256 || d.checksum || null,
308
210
  filename: d.filename || null,
309
- dest,
211
+ dest: dest || (slug ? `./${slug}` : null),
310
212
  };
311
213
  }
312
214
 
@@ -421,13 +323,13 @@ export async function cmdFetchSession(sessionId, opts) {
421
323
  }
422
324
 
423
325
  export async function cmdFeedback(sessionId, opts) {
424
- if (!sessionId || opts.score == null) {
326
+ if (!sessionId || opts.score == null || !opts.text || !String(opts.text).trim()) {
425
327
  const e = new Error("usage: agentrelay feedback <session_id> --score <0-100> --text <str>");
426
328
  e.usage = true;
427
329
  throw e;
428
330
  }
429
331
  return apiFeedback(
430
- { sessionId, feedbackText: opts.text || "", score: Number(opts.score) },
332
+ { sessionId, feedbackText: String(opts.text), score: Number(opts.score) },
431
333
  { apiKey: opts.apiKey, timeoutMs: opts.timeoutMs },
432
334
  );
433
335
  }
@@ -532,11 +434,24 @@ export function cmdCache(sub, slug, opts) {
532
434
  }
533
435
  deliverable.slug = deliverable.slug || slug;
534
436
  deliverable.ts = deliverable.ts || new Date().toISOString();
437
+ // Soft contract check (schema/deliverable.json spine). Warn-only: a spec
438
+ // with gaps is still worth banking, and concurrent breadth subagents must
439
+ // never be blocked by validation.
440
+ const warnings = [];
441
+ if (!deliverable.session_id || !String(deliverable.session_id).trim()) {
442
+ warnings.push("no session_id — build-outcome feedback can't be filed against this consult later");
443
+ }
444
+ if (!deliverable.summary || !String(deliverable.summary).trim()) {
445
+ warnings.push("no summary — `cache ls` triage will show a blank line for this spec");
446
+ }
447
+ if (!Array.isArray(deliverable.features) || deliverable.features.length === 0) {
448
+ warnings.push("no features[] — build-time has no surface map; depth sessions will have to rediscover it");
449
+ }
535
450
  // Write ONLY the per-slug spec file — no shared index.json mutation — so N
536
451
  // breadth subagents can `cache put` concurrently without clobbering each
537
452
  // other. `cache ls` reads the spec files directly.
538
453
  cache.writeSpec(slug, deliverable);
539
- return { stored: true, slug, cache_dir: opts.cacheDir };
454
+ return { stored: true, slug, cache_dir: opts.cacheDir, warnings };
540
455
  }
541
456
  if (sub === "clear") {
542
457
  cache.clear();