@bongos/core 1.19.705 → 1.19.707

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,711 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/agent-invoke.js — ask an on-demand agent a question, on the record
3
+ // (task 1002491, goal 1000038 Phase 1, criterion `agents-historian-exemplar`).
4
+ //
5
+ // WHY. Phase 1 built four things that only meet each other at runtime: a registry
6
+ // reconciled from files (agents-sync), a pure validator, a spawn seam, and a
7
+ // ledger. Each has its own tests and none of them proves the chain. This is the
8
+ // one caller that runs all four in sequence against real data, so "the framework
9
+ // works" stops being an inference from four green suites.
10
+ //
11
+ // FIVE RULES, each of which is a way this could quietly become a lie:
12
+ //
13
+ // 1. THE REGISTRY IS THE AUTHORITY, NEVER THE FILE. This reads
14
+ // agents_definitions and nothing under .claude/agents/. A committed file is
15
+ // an INPUT to the registry (agents-sync owns that edge, and deliberately
16
+ // never arms anything); reading the file here would hand every committer a
17
+ // way to invoke a definition the instance never admitted — precisely the
18
+ // hole rule 2 of agents-sync closes one layer up.
19
+ //
20
+ // 2. A REFUSAL IS A LEDGER ROW. A disabled, flagged or wrong-triggered agent
21
+ // gets a no-go row with a reason, not a bare exit. Same argument as
22
+ // autonomy_runs and agents_runs' own no-go invariant: a gate that declines
23
+ // silently is indistinguishable from a gate that never ran, and "the agent
24
+ // didn't answer" then has two causes nobody can tell apart.
25
+ //
26
+ // 3. THE CITATION CONTRACT IS ENFORCED, NOT REQUESTED. Every citation is
27
+ // resolved against the working tree (or the learnings actually supplied)
28
+ // BEFORE the run is recorded as ok. An answer whose sources do not exist is
29
+ // recorded as an error and printed as a refusal. Asking a model to cite and
30
+ // then not checking produces something worse than an uncited answer: an
31
+ // uncheckable one that looks checked. The check runs INSIDE the spawn, so
32
+ // the ledger's verdict is the real verdict.
33
+ //
34
+ // WHAT IT IS NOT: a sandbox. This checks what the agent CLAIMS it read, not
35
+ // what it could read. The actual capability boundary is elsewhere and is not
36
+ // touched by this file — grader-subagent.js spawns with `--tools Read` and a
37
+ // fixed cwd, which is what makes a v1 agent observe-and-report (spawn.js
38
+ // constraint 3). Do not read a passing citation check as containment.
39
+ //
40
+ // 4. THE ANSWER IS NEVER STORED IN THE LEDGER. `output_ref` is a pointer, by
41
+ // the schema's own rule — model-generated prose rendered to whoever reads
42
+ // the ledger would make agents_runs a delivery path for whatever the agent
43
+ // read. With no --out the answer goes to the caller's terminal and the
44
+ // pointer is honestly null, rather than a fabricated one.
45
+ //
46
+ // 5. IDENTITY COMES FROM THE LIVE SESSION, never a flag. The caller is resolved
47
+ // through /me with their own CLI token (ADR 0016). There is no --as: a fire
48
+ // nobody is attributable for is exactly what the ledger exists to prevent.
49
+ //
50
+ // THERE IS NO RANK FLOOR ON TOP OF THAT, and that is a decision rather than
51
+ // an omission. Any authenticated builder may ask an armed agent a question,
52
+ // matching the read routes' posture (task 1002488): this is a read of the
53
+ // instance's own record by the people who build it. Two things bound it —
54
+ // WHICH agents exist at all is an operator decision (`enabled`), and a v1
55
+ // agent is hard-read-only. What is NOT bounded is spend: a fire costs real
56
+ // money (~$0.90 for a historian question at the time of writing), and the
57
+ // only thing standing between a builder and a loop of them is that they
58
+ // have to type it. THE FLOOR TO ADD, WHEN IT IS ADDED, BELONGS HERE — and
59
+ // it becomes necessary the moment an agent is either write-capable or
60
+ // cheap enough to fire from something other than a keyboard.
61
+ //
62
+ // WHERE IT RUNS. Wherever the database is, like agents-sync.js — the registry
63
+ // read and the ledger write are both pool-side. The model call is local, through
64
+ // the `grade` port's runSubagentCached, which is fixed read-only (`--tools Read`)
65
+ // and bills into the shared LLM cost cache. So the agent can open files in the
66
+ // checkout and can change none of them.
67
+ //
68
+ // THE HISTORIAN ARRIVES DISARMED, AND THAT IS A SPLIT, NOT AN OVERSIGHT. Out of
69
+ // the box `agent-invoke historian` refuses with `agent_refused` until an operator
70
+ // enables the row. Two separate things cause that, and the first cannot be argued
71
+ // away:
72
+ //
73
+ // - STRUCTURAL. agents_definitions_armed_needs_author_chk refuses to arm a row
74
+ // whose author_rank is NULL, and stampableAuthorRank derives that rank from
75
+ // the file's last committer resolved against `builders`. On any instance but
76
+ // the one the core is developed in, the core's committer is not a builder in
77
+ // that DB — so the rank is NULL and a BUILT-IN row cannot be armed at all.
78
+ // Fixing that means deciding what authority a core-authored definition
79
+ // carries, then writing a migration.
80
+ // - POLICY. agents-sync rule 4 separately refuses to let a commit arm anything
81
+ // (task 1002490), which is stricter than the original criteria (idea 1000527,
82
+ // goal 1000038 #6) — those only require a SCOPE-refused definition to import
83
+ // disarmed.
84
+ //
85
+ // Both are the owner's call, so they are blocker 1000145 and task 1003862 rather
86
+ // than a decision taken here. The refusal names the reason so nobody has to
87
+ // rediscover it.
88
+ //
89
+ // Run: node scripts/gds/agent-invoke.js <agent> "<question>" [--out <path>] [--json]
90
+
91
+ 'use strict';
92
+
93
+ const fs = require('node:fs');
94
+ const path = require('node:path');
95
+
96
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
97
+
98
+ // ---- pure core -------------------------------------------------------------
99
+
100
+ // THE AGENT HAS `Read` AND NOTHING ELSE — no Glob, no Grep. So without a list of
101
+ // doors it can only guess at filenames, and a guessed filename is the first step
102
+ // toward a fabricated citation. Two kinds of door are supplied.
103
+ //
104
+ // First, the directory MANIFESTS: the actual filenames under each corpus root.
105
+ // This repo names its records to be read as an index — `0016-trust-boundary-
106
+ // server-enforced-permissions.md`, `2026-09-11-c3b-session-rename.md` — so the
107
+ // listing IS a table of contents, at roughly 70 bytes per entry.
108
+ const CORPUS_DIRS = Object.freeze([
109
+ ['docs/adr', 'architecture decisions — the decision of record on anything one covers'],
110
+ ['docs/session-logs', 'one file per session: what was attempted, and what actually happened'],
111
+ ]);
112
+
113
+ // Second, the small prose indexes, WHEN THEY ARE THERE AND SMALL ENOUGH TO OPEN.
114
+ // Both conditions bite in practice and both were found by running this for real:
115
+ // docs/repo-map.md and docs/session-log-index.md are generated and gitignored, so
116
+ // a fresh checkout has neither; docs/adr/README.md is a tracked 500KB file that
117
+ // the Read tool refuses outright. A door that does not open is worse than no door
118
+ // — it costs a tool call and teaches the agent that the index list is unreliable.
119
+ // The ADR manifest above is what replaces that README here.
120
+ const INDEX_CANDIDATES = Object.freeze([
121
+ ['docs/session-log-index.md', 'every session log, newest first'],
122
+ ['docs/repo-map.md', 'the generated symbol skeleton — where a function lives'],
123
+ ['docs/file-map.md', 'the repo tree'],
124
+ ['CLAUDE.md', 'the methodology kernel the project runs on today'],
125
+ ]);
126
+
127
+ // The Read tool refuses a file past its own size ceiling; this sits under it so a
128
+ // listed index is one the agent can actually open.
129
+ const INDEX_MAX_BYTES = 200000;
130
+ // A manifest is a table of contents, not a corpus dump. Past this the listing
131
+ // stops and says so, rather than silently truncating into a prompt the agent
132
+ // reads as complete.
133
+ const MANIFEST_MAX_FILES = 600;
134
+
135
+ // How many learnings travel in the prompt, and how much of each. They are the one
136
+ // corpus the agent cannot reach with Read — they live in a table — so they are
137
+ // carried inline or not at all. Bounded because this is a paid prompt on every
138
+ // fire, and an unbounded table would make the cost of asking a question depend on
139
+ // how long the instance has been running.
140
+ const LEARNINGS_LIMIT = 120;
141
+ const LEARNING_BODY_MAX = 400;
142
+ const LEARNINGS_BLOCK_MAX = 24000;
143
+
144
+ // A citation to a learning, which has no path to resolve.
145
+ const LEARNING_REF_RE = /^learning:(\d+)$/;
146
+
147
+ // One spelling for a learning id on both sides of the check. Ids are bigints, so
148
+ // this strips leading zeros by string rather than through Number, which would
149
+ // lose precision above 2^53 — the table will not get there, but a silent
150
+ // precision cliff in an identity comparison is not worth the two saved
151
+ // characters.
152
+ function normalizeLearningId(id) {
153
+ const digits = String(id).replace(/^0+(?=\d)/, '');
154
+ return /^\d+$/.test(digits) ? digits : null;
155
+ }
156
+
157
+ function isNonEmptyString(v) {
158
+ return typeof v === 'string' && v.trim().length > 0;
159
+ }
160
+
161
+ // Strip the human affordances people (and models) append to a path citation: a
162
+ // heading anchor or a line hint. `docs/adr/0043-x.md#4` and `src/bongos/auth.js:12`
163
+ // both name a real file, and refusing them would train the agent to cite less
164
+ // precisely rather than more.
165
+ function citationPath(ref) {
166
+ return String(ref).split('#')[0].replace(/:\d+(?:-\d+)?$/, '').trim();
167
+ }
168
+
169
+ // The same tree-escape rule the scope validator applies, and for the same reason:
170
+ // a citation is a path this tool is about to stat, so `..`, an absolute path and a
171
+ // Windows drive letter are all refused before anything touches the filesystem.
172
+ function isInsideTree(p) {
173
+ if (!isNonEmptyString(p)) return false;
174
+ if (p.startsWith('/') || p.startsWith('\\')) return false;
175
+ if (/^[a-zA-Z]:/.test(p)) return false;
176
+ return !p.split(/[\\/]/).includes('..');
177
+ }
178
+
179
+ /**
180
+ * Resolve one citation. PURE — `exists` and `learningIds` are supplied, so the
181
+ * whole contract is testable with no filesystem and no database.
182
+ *
183
+ * @returns {{ok: boolean, ref: string, kind: string, reason: string|null}}
184
+ */
185
+ function resolveCitation(ref, { exists = () => false, learningIds = new Set() } = {}) {
186
+ if (!isNonEmptyString(ref)) {
187
+ return { ok: false, ref: String(ref), kind: 'unknown', reason: 'a citation must be a non-empty string' };
188
+ }
189
+ const trimmed = ref.trim();
190
+ const learning = LEARNING_REF_RE.exec(trimmed);
191
+ if (learning) {
192
+ // Only a learning that was actually PUT IN THIS PROMPT counts. An id the
193
+ // agent never saw is a guess at a primary key, which is the same failure as
194
+ // a guessed filename wearing a different prefix.
195
+ // Compared as a NUMBER, not as the string the model typed: `learning:007`
196
+ // names the same row as `learning:7`, and refusing the padded spelling would
197
+ // fail an answer for a formatting choice rather than for a fabrication.
198
+ return learningIds.has(normalizeLearningId(learning[1]))
199
+ ? { ok: true, ref: trimmed, kind: 'learning', reason: null }
200
+ : { ok: false, ref: trimmed, kind: 'learning', reason: 'no learning with that id was supplied to this run' };
201
+ }
202
+ const p = citationPath(trimmed);
203
+ if (!isInsideTree(p)) {
204
+ return { ok: false, ref: trimmed, kind: 'path', reason: 'a citation must be a repo-relative path inside the tree' };
205
+ }
206
+ return exists(p)
207
+ ? { ok: true, ref: trimmed, kind: 'path', reason: null }
208
+ : { ok: false, ref: trimmed, kind: 'path', reason: 'no such file in the working tree' };
209
+ }
210
+
211
+ /**
212
+ * Check a whole answer's citations. Rule 3 made concrete, and STRICT on purpose:
213
+ * one unresolvable citation fails the answer rather than being dropped from it.
214
+ * A part-fabricated bibliography is worse than none — the reader has no way to
215
+ * tell which half was checked, so the real ones lend their credibility to the
216
+ * invented ones.
217
+ */
218
+ function checkCitations(citations, ctx = {}) {
219
+ const list = Array.isArray(citations) ? citations : [];
220
+ const resolved = list.map((c) => resolveCitation(
221
+ c && typeof c === 'object' ? c.ref : c,
222
+ ctx,
223
+ ));
224
+ const invalid = resolved.filter((r) => !r.ok);
225
+ if (resolved.length === 0) {
226
+ return { ok: false, code: 'agent_answer_uncited', resolved, invalid, reason: 'the answer carried no citations' };
227
+ }
228
+ if (invalid.length > 0) {
229
+ return {
230
+ ok: false,
231
+ code: 'agent_citation_unresolvable',
232
+ resolved,
233
+ invalid,
234
+ reason: invalid.map((r) => `${r.ref} (${r.reason})`).join('; '),
235
+ };
236
+ }
237
+ return { ok: true, code: null, resolved, invalid, reason: null };
238
+ }
239
+
240
+ // The runner hands back the `claude -p --output-format json` ENVELOPE, not the
241
+ // assistant's reply: `{ type, result: "<what the agent actually said>",
242
+ // total_cost_usd, ... }`. Unwrap it first, or the JSON extractor below happily
243
+ // parses the envelope — which is a perfectly good object with no `answer` in it,
244
+ // so a working run reads as an unparseable one. That is exactly how this failed
245
+ // the first time it met a real model.
246
+ //
247
+ // Guarded on the absence of `answer` so a reply that IS the answer and happens to
248
+ // carry a `result` key is not mistaken for an envelope.
249
+ function unwrapEnvelope(raw) {
250
+ const text = typeof raw === 'string' ? raw : '';
251
+ try {
252
+ const env = JSON.parse(text);
253
+ if (env && typeof env === 'object' && !Array.isArray(env)
254
+ && typeof env.result === 'string' && !('answer' in env)) return env.result;
255
+ } catch (_) { /* not an envelope — the reply is already the reply */ }
256
+ return text;
257
+ }
258
+
259
+ // Pull the one JSON object out of a model reply. Deliberately local rather than
260
+ // borrowed from the grader: this is ten lines, and requiring the grading module
261
+ // for them would put a module→module-shaped dependency in the pure half of a
262
+ // script whose whole point is that its decisions are testable in isolation.
263
+ function extractJsonObject(raw) {
264
+ const text = typeof raw === 'string' ? raw : '';
265
+ // EVERY fenced block, then the whole text. A single non-greedy match would take
266
+ // the first fence, which is wrong the moment the reply opens with a fenced
267
+ // example or a snippet of the file it read — and the whole-text fallback is
268
+ // wrong whenever the answer's own markdown contains braces.
269
+ const fenced = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map((m) => m[1]);
270
+ const candidates = [...fenced, text].filter(Boolean);
271
+ const found = [];
272
+ for (const c of candidates) {
273
+ const start = c.indexOf('{');
274
+ const end = c.lastIndexOf('}');
275
+ if (start < 0 || end <= start) continue;
276
+ try {
277
+ const parsed = JSON.parse(c.slice(start, end + 1));
278
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) found.push(parsed);
279
+ } catch (_) { /* try the next candidate */ }
280
+ }
281
+ // The ANSWER-shaped object wins over merely the first one. A reply that quotes a
282
+ // JSON snippet out of a file it read — entirely normal for an agent whose corpus
283
+ // includes config and manifests — would otherwise hand back the quotation.
284
+ return found.find((o) => typeof o.answer === 'string') || found[0] || null;
285
+ }
286
+
287
+ /**
288
+ * Model reply → the answer shape, or null. Never throws.
289
+ */
290
+ function parseAnswer(raw) {
291
+ const obj = extractJsonObject(unwrapEnvelope(raw));
292
+ if (!obj) return null;
293
+ if (!isNonEmptyString(obj.answer)) return null;
294
+ const citations = Array.isArray(obj.citations) ? obj.citations : [];
295
+ return {
296
+ answer: obj.answer.trim(),
297
+ citations: citations
298
+ .map((c) => (typeof c === 'string' ? { ref: c, supports: null } : c))
299
+ .filter((c) => c && typeof c === 'object')
300
+ .map((c) => ({ ref: isNonEmptyString(c.ref) ? c.ref.trim() : '', supports: isNonEmptyString(c.supports) ? c.supports.trim() : null })),
301
+ confidence: ['high', 'medium', 'low'].includes(obj.confidence) ? obj.confidence : null,
302
+ };
303
+ }
304
+
305
+ // The learnings block, bounded. Returns the prompt text and the id set the
306
+ // citation check will accept — the two must be built together, or the agent can
307
+ // be blamed for citing something it was shown.
308
+ function renderLearnings(rows) {
309
+ const ids = new Set();
310
+ const lines = [];
311
+ let budget = LEARNINGS_BLOCK_MAX;
312
+ for (const r of Array.isArray(rows) ? rows : []) {
313
+ const id = normalizeLearningId(r && r.id);
314
+ if (id === null) continue;
315
+ const body = String((r && r.body_md) || '').replace(/\s+/g, ' ').trim().slice(0, LEARNING_BODY_MAX);
316
+ const line = `- learning:${id} — ${String((r && r.title) || '(untitled)').trim()}${body ? `: ${body}` : ''}`;
317
+ if (line.length > budget) break;
318
+ budget -= line.length;
319
+ ids.add(id);
320
+ lines.push(line);
321
+ }
322
+ return { text: lines.join('\n'), ids };
323
+ }
324
+
325
+ /**
326
+ * The demand half of the prompt: everything that is true of THIS invocation and
327
+ * nothing that is true of the agent. The persona is the other half, and
328
+ * spawn.composePrompt is what joins them — see rule 1 there.
329
+ *
330
+ * PURE: indexes and learnings are passed in.
331
+ */
332
+ function buildDemand({ question, indexes = [], manifests = [], learnings = { text: '', ids: new Set() } }) {
333
+ const parts = [];
334
+ parts.push('---');
335
+ parts.push('## You are answering one question, and every claim must be cited.');
336
+ parts.push('');
337
+ parts.push('Your working directory is the repository root. You have exactly one tool:');
338
+ parts.push('`Read`. There is no search — every door is listed below, and everything not');
339
+ parts.push('listed you reach by following a link out of something that is.');
340
+ for (const m of manifests) {
341
+ parts.push('');
342
+ parts.push(`### \`${m.dir}/\` — ${m.why}`);
343
+ parts.push(`${m.total} file(s)${m.truncated ? `, of which the first ${m.files.length} are listed` : ''}. Read one by its full path, e.g. \`${m.dir}/${m.files[0] || 'name.md'}\`.`);
344
+ parts.push('');
345
+ parts.push('```');
346
+ parts.push(m.files.join('\n'));
347
+ parts.push('```');
348
+ }
349
+ if (indexes.length > 0) {
350
+ parts.push('');
351
+ parts.push('### Other indexes present in this checkout');
352
+ for (const [p, why] of indexes) parts.push(`- \`${p}\` — ${why}`);
353
+ }
354
+ if (learnings.text) {
355
+ parts.push('');
356
+ parts.push('### Learnings');
357
+ parts.push('These live in a table, not a file, so they are reproduced here in full. Cite one');
358
+ parts.push('as `learning:<id>`. They are the only citations that are not paths.');
359
+ parts.push('');
360
+ parts.push(learnings.text);
361
+ }
362
+ parts.push('');
363
+ parts.push('### Rules');
364
+ parts.push('1. Read before you answer. Do not answer from memory of this codebase.');
365
+ parts.push('2. Every substantive claim carries a citation: a repo-relative path to a file');
366
+ parts.push(' you actually opened, or a `learning:<id>` from the block above.');
367
+ parts.push('3. Never cite something you did not open. **Every citation is checked against');
368
+ parts.push(' the working tree before this answer is accepted, and one that does not');
369
+ parts.push(' resolve fails the whole run** — a half-invented bibliography is worse than');
370
+ parts.push(' no answer, because the reader cannot tell which half was real.');
371
+ parts.push('4. If the record does not answer the question, say so and cite what you DID');
372
+ parts.push(' read. A cited "the record is silent" is a correct answer.');
373
+ parts.push('');
374
+ parts.push('### Question');
375
+ parts.push('');
376
+ parts.push(String(question || '').trim());
377
+ parts.push('');
378
+ parts.push('### Reply with exactly one JSON object and nothing else');
379
+ parts.push('');
380
+ parts.push('```json');
381
+ parts.push('{');
382
+ parts.push(' "answer": "<markdown — the answer itself>",');
383
+ parts.push(' "citations": [{ "ref": "docs/adr/0016-trust-boundary-server-enforced-permissions.md", "supports": "<what this source establishes>" }],');
384
+ parts.push(' "confidence": "high" | "medium" | "low"');
385
+ parts.push('}');
386
+ parts.push('```');
387
+ return parts.join('\n');
388
+ }
389
+
390
+ /**
391
+ * The doors, resolved against a real checkout. PURE over its two injected
392
+ * readers, so a test can describe a tree without building one.
393
+ *
394
+ * @param {object} io
395
+ * @param {(dir: string) => string[]} io.listDir markdown filenames in a corpus dir ([] if absent)
396
+ * @param {(p: string) => number} io.fileSize bytes, or -1 when the file is not there
397
+ */
398
+ function buildCorpus({ listDir, fileSize }) {
399
+ const manifests = [];
400
+ for (const [dir, why] of CORPUS_DIRS) {
401
+ const all = listDir(dir);
402
+ if (!all || all.length === 0) continue;
403
+ const files = all.slice(0, MANIFEST_MAX_FILES);
404
+ manifests.push({ dir, why, files, total: all.length, truncated: all.length > files.length });
405
+ }
406
+ const indexes = INDEX_CANDIDATES.filter(([p]) => {
407
+ const size = fileSize(p);
408
+ return size >= 0 && size <= INDEX_MAX_BYTES;
409
+ });
410
+ return { manifests, indexes };
411
+ }
412
+
413
+ function defaultListDir(dir) {
414
+ try {
415
+ return fs.readdirSync(path.join(REPO_ROOT, dir)).filter((f) => f.endsWith('.md')).sort();
416
+ } catch (_) { return []; }
417
+ }
418
+
419
+ function defaultFileSize(p) {
420
+ try { return fs.statSync(path.join(REPO_ROOT, p)).size; } catch (_) { return -1; }
421
+ }
422
+
423
+ /**
424
+ * argv -> what the facade needs, or an error. PURE, because the alternative is a
425
+ * command line nothing tests: the question is a FREE-FORM positional that must
426
+ * survive being typed unquoted, and `--out`'s value must not be swallowed into
427
+ * it.
428
+ *
429
+ * @returns {{name, question, outArg, asJson, error: (string|null)}}
430
+ */
431
+ function parseArgv(argv = []) {
432
+ const args = argv.map(String);
433
+ const asJson = args.includes('--json');
434
+ const outIdx = args.indexOf('--out');
435
+ const outArg = outIdx >= 0 ? (args[outIdx + 1] ?? null) : null;
436
+ const bad = { name: null, question: '', outArg, asJson };
437
+ if (outIdx >= 0 && (outArg === null || outArg.startsWith('--'))) {
438
+ return { ...bad, outArg: null, error: '--out needs a path.' };
439
+ }
440
+ const positional = args.filter((a, i) => !a.startsWith('--') && !(outIdx >= 0 && i === outIdx + 1));
441
+ const [name, ...rest] = positional;
442
+ // Everything after the name IS the question, rejoined — so an unquoted
443
+ // sentence works the way anyone typing it would expect.
444
+ const question = rest.join(' ').trim();
445
+ if (!name || !question) return { ...bad, error: 'usage' };
446
+ return { name, question, outArg, asJson, error: null };
447
+ }
448
+
449
+ /**
450
+ * The gate. Decides whether this definition may be invoked on demand, and gives
451
+ * the reason in the same breath — rule 2 needs the reason as much as the verdict,
452
+ * because the reason is the whole content of the ledger row a refusal writes.
453
+ *
454
+ * PURE: takes the registry row, returns `{ decision, reason }`.
455
+ */
456
+ function gateFor(row) {
457
+ if (!row) return { decision: 'no-go', reason: 'no such agent in the registry' };
458
+ if (row.trigger_type !== 'on-demand') {
459
+ return {
460
+ decision: 'no-go',
461
+ reason: `trigger_type is '${row.trigger_type}', not 'on-demand' — this agent is dispatched by an event, not asked a question`,
462
+ };
463
+ }
464
+ // Checked before `enabled` even though the schema's flagged_not_armed CHECK
465
+ // makes a flagged row necessarily disabled: the two states have the same
466
+ // verdict and completely different fixes, and the reason is what the caller
467
+ // acts on.
468
+ if (isNonEmptyString(row.scope_violation)) {
469
+ return { decision: 'no-go', reason: `disarmed by the scope wall: ${row.scope_violation}` };
470
+ }
471
+ if (row.enabled !== true) {
472
+ return { decision: 'no-go', reason: 'the definition is present in the registry but not enabled' };
473
+ }
474
+ return { decision: 'go', reason: null };
475
+ }
476
+
477
+ // ---- the chain -------------------------------------------------------------
478
+
479
+ const DEFINITION_COLUMNS = `
480
+ id, name, title, persona, trigger_type, trigger_spec, model_tier,
481
+ scope_modules, scope_paths, scope_violation, source, provenance,
482
+ author_rank, source_path, enabled
483
+ `;
484
+
485
+ async function loadDefinition(pool, name) {
486
+ const { rows } = await pool.query(
487
+ `SELECT ${DEFINITION_COLUMNS} FROM agents_definitions WHERE name = $1`,
488
+ [name],
489
+ );
490
+ return rows[0] || null;
491
+ }
492
+
493
+ // Best-effort: an instance with no learnings, or a learnings table this caller
494
+ // cannot read, gets a smaller corpus and a warning. Failing the invocation would
495
+ // trade a complete answer for no answer, and the citation contract still holds
496
+ // over whatever WAS supplied — an id that never arrived is refused by the same
497
+ // check either way.
498
+ async function loadLearnings(pool, { warn = () => {} } = {}) {
499
+ try {
500
+ const { rows } = await pool.query(
501
+ 'SELECT id, title, body_md FROM learnings ORDER BY created_at DESC LIMIT $1',
502
+ [LEARNINGS_LIMIT],
503
+ );
504
+ return rows;
505
+ } catch (err) {
506
+ warn(`learnings unavailable (${(err && err.message) || err}) — answering from the file corpus alone`);
507
+ return [];
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Registry -> validate -> spawn -> ledger, once. Every edge is injectable, so the
513
+ * whole chain runs in tests against a fake pool and a fake runner.
514
+ *
515
+ * @returns {{ok: boolean, code: string|null, runId: (number|null), answer: object|null, ...}}
516
+ */
517
+ async function invokeAgent({ name, question, requestedByBuilderId = null, requestedByLogin = null, outputRef = null }, deps = {}) {
518
+ const pool = deps.pool;
519
+ const exists = deps.exists || ((p) => fs.existsSync(path.join(REPO_ROOT, p)));
520
+ const corpus = deps.corpus || buildCorpus({
521
+ listDir: deps.listDir || defaultListDir,
522
+ fileSize: deps.fileSize || defaultFileSize,
523
+ });
524
+ const spawn = deps.spawnAgent || require('../../modules/agents/spawn.js').spawnAgent;
525
+ const warn = deps.warn || (() => {});
526
+
527
+ const row = await loadDefinition(pool, name);
528
+ // The one refusal that writes NO ledger row: agents_runs.agent_name is NOT NULL
529
+ // and its whole purpose is to record what a KNOWN agent did. A row for a name
530
+ // that was never a definition would be a log line pretending to be a ledger
531
+ // entry, and would let a typo loop inflate the table.
532
+ if (!row) return { ok: false, code: 'agent_not_found', runId: null, answer: null, row: null };
533
+
534
+ const gate = gateFor(row);
535
+ // task 1002489's ledger contract: a no-go still inserts (status 'skipped', no
536
+ // cost, no output). spawnAgent does that and returns without spawning.
537
+ if (gate.decision !== 'go') {
538
+ const skipped = await spawn({
539
+ definition: row,
540
+ trigger: { type: 'on-demand', ref: requestedByLogin ? `on-demand:${requestedByLogin}` : null },
541
+ gate,
542
+ requestedByBuilderId,
543
+ }, { pool, ...(deps.spawnDeps || {}) });
544
+ return { ok: false, code: 'agent_refused', reason: gate.reason, runId: skipped.runId, answer: null, row };
545
+ }
546
+
547
+ const learnings = renderLearnings(await loadLearnings(pool, { warn }));
548
+ const demand = buildDemand({ question, indexes: corpus.indexes, manifests: corpus.manifests, learnings });
549
+
550
+ // THE CITATION CHECK RUNS INSIDE THE SPAWN (rule 3). Wrapping the runner rather
551
+ // than inspecting the result afterwards is what makes the ledger honest: the
552
+ // verdict spawnAgent records IS the checked verdict, so an uncited answer is
553
+ // stored as an error with its cost, never as an `ok` run someone later has to
554
+ // re-judge from prose that was never kept.
555
+ let verdict = null;
556
+ const runner = async (args) => {
557
+ const res = await (deps.runSubagentCached || realRunner())(args);
558
+ const parsed = parseAnswer(res && res.stdout);
559
+ if (!parsed) {
560
+ verdict = { ok: false, code: 'agent_answer_unparsed', answer: null, citations: null, raw: (res && res.stdout) || '' };
561
+ return { ...res, ok: false, error_code: 'agent_answer_unparsed' };
562
+ }
563
+ const cites = checkCitations(parsed.citations, { exists, learningIds: learnings.ids });
564
+ verdict = { ok: cites.ok, code: cites.code, answer: parsed, citations: cites, raw: (res && res.stdout) || '' };
565
+ if (!cites.ok) return { ...res, ok: false, error_code: cites.code };
566
+ return res;
567
+ };
568
+
569
+ const fired = await spawn({
570
+ definition: row,
571
+ input: demand,
572
+ trigger: { type: 'on-demand', ref: requestedByLogin ? `on-demand:${requestedByLogin}` : null },
573
+ gate,
574
+ requestedByBuilderId,
575
+ }, { pool, runSubagentCached: runner, outputRef, opts: { cwd: REPO_ROOT }, ...(deps.spawnDeps || {}) });
576
+
577
+ if (!fired.ok) {
578
+ return {
579
+ ok: false,
580
+ // A spawn that never reached the model (no grade port, a ledger write that
581
+ // failed) has its own code; one that reached it and came back uncheckable
582
+ // carries the verdict's.
583
+ code: (verdict && verdict.code) || fired.error_code || 'agent_run_failed',
584
+ reason: verdict && verdict.citations ? verdict.citations.reason : (fired.error || null),
585
+ runId: fired.runId,
586
+ answer: verdict ? verdict.answer : null,
587
+ cost_usd: fired.cost_usd ?? null,
588
+ row,
589
+ };
590
+ }
591
+ return {
592
+ ok: true,
593
+ code: null,
594
+ runId: fired.runId,
595
+ answer: verdict.answer,
596
+ citations: verdict.citations.resolved,
597
+ model: fired.model,
598
+ cost_usd: fired.cost_usd ?? null,
599
+ duration_ms: fired.duration_ms ?? null,
600
+ row,
601
+ };
602
+ }
603
+
604
+ // Resolved lazily so the pure half of this file — and every test over it — never
605
+ // pulls in the grading module or its subprocess machinery.
606
+ function realRunner() {
607
+ const grader = require('../../modules/grading/grader.js');
608
+ return grader.runSubagentCached;
609
+ }
610
+
611
+ module.exports = {
612
+ invokeAgent,
613
+ parseArgv,
614
+ gateFor,
615
+ buildCorpus,
616
+ buildDemand,
617
+ parseAnswer,
618
+ resolveCitation,
619
+ checkCitations,
620
+ renderLearnings,
621
+ citationPath,
622
+ extractJsonObject,
623
+ unwrapEnvelope,
624
+ INDEX_CANDIDATES,
625
+ CORPUS_DIRS,
626
+ INDEX_MAX_BYTES,
627
+ MANIFEST_MAX_FILES,
628
+ LEARNINGS_LIMIT,
629
+ };
630
+
631
+ // ---- facade ----------------------------------------------------------------
632
+
633
+ function usage() {
634
+ console.error('Usage: node scripts/gds/agent-invoke.js <agent> "<question>" [--out <path>] [--json]');
635
+ console.error(' <agent> the agent name as it appears in the registry (GET /api/gds/agents)');
636
+ console.error(' --out <path> write the answer to a file and record it as the run\'s output_ref');
637
+ console.error(' --json print the whole result as JSON instead of prose');
638
+ }
639
+
640
+ async function main() {
641
+ const cliArgs = parseArgv(process.argv.slice(2));
642
+ if (cliArgs.error === 'usage') { usage(); return 2; }
643
+ if (cliArgs.error) { console.error(`agent-invoke: ${cliArgs.error}`); return 2; }
644
+ const { name, question, outArg, asJson } = cliArgs;
645
+
646
+ // Rule 5. requireSession exits with its own guidance when there is no session.
647
+ const cli = require('./cli-lib');
648
+ await cli.requireSession();
649
+ const me = await cli.apiCall('GET', '/api/gds/me');
650
+ const builder = (me && me.data && me.data.builder) || null;
651
+ if (!builder || !builder.id) {
652
+ console.error('agent-invoke: could not resolve who you are from /me — a fire has to be attributable.');
653
+ return 1;
654
+ }
655
+
656
+ const { pool } = require('../../src/bongos/pool');
657
+ const outputRef = outArg
658
+ ? path.relative(REPO_ROOT, path.resolve(process.cwd(), outArg)).split(path.sep).join('/')
659
+ : null;
660
+
661
+ const result = await invokeAgent({
662
+ name,
663
+ question,
664
+ requestedByBuilderId: builder.id,
665
+ requestedByLogin: builder.github_login || null,
666
+ outputRef,
667
+ }, { warn: (m) => console.error(`agent-invoke: ${m}`) });
668
+
669
+ if (result.ok && outArg) {
670
+ fs.writeFileSync(path.resolve(process.cwd(), outArg), `${result.answer.answer}\n`, 'utf8');
671
+ }
672
+
673
+ if (asJson) {
674
+ console.log(JSON.stringify(result.ok
675
+ ? { ok: true, run_id: result.runId, model: result.model, cost_usd: result.cost_usd, answer: result.answer }
676
+ : { ok: false, code: result.code, reason: result.reason || null, run_id: result.runId }, null, 2));
677
+ return result.ok ? 0 : 1;
678
+ }
679
+
680
+ if (!result.ok) {
681
+ console.error(`\n ${name}: ${result.code}`);
682
+ if (result.reason) console.error(` ${result.reason}`);
683
+ if (result.code === 'agent_not_found') {
684
+ console.error(' Nothing by that name is in the registry. `node scripts/gds/api.js GET /api/gds/agents` lists it.');
685
+ }
686
+ if (result.code === 'agent_refused' && /not enabled/.test(result.reason || '')) {
687
+ console.error(' Definitions land in the registry DISABLED by design: agents-sync reconciles');
688
+ console.error(' .claude/agents/*.md at deploy and never arms one, because a committed file');
689
+ console.error(' cannot grant authority (task 1002490 rule 4). Arming is an operator act.');
690
+ }
691
+ if (result.runId) console.error(` Recorded as agents_runs #${result.runId}.`);
692
+ return 1;
693
+ }
694
+
695
+ console.log(`\n${result.answer.answer}\n`);
696
+ console.log('Sources:');
697
+ for (const c of result.answer.citations) {
698
+ console.log(` - ${c.ref}${c.supports ? ` — ${c.supports}` : ''}`);
699
+ }
700
+ const cost = typeof result.cost_usd === 'number' ? `$${result.cost_usd.toFixed(4)}` : 'uncosted';
701
+ console.log(`\n ${name} · ${result.model} · ${cost} · confidence ${result.answer.confidence || 'unstated'} · agents_runs #${result.runId}`);
702
+ if (outArg) console.log(` answer written to ${outArg}`);
703
+ return 0;
704
+ }
705
+
706
+ if (require.main === module) {
707
+ main().then((code) => { process.exitCode = code; }).catch((err) => {
708
+ console.error(`agent-invoke: ${(err && err.message) || err}`);
709
+ process.exitCode = 1;
710
+ });
711
+ }