@bongos/core 1.19.357 → 1.19.359

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,301 @@
1
+ -- agents_001_definitions.sql — the agent registry + the fire ledger.
2
+ --
3
+ -- Two tables for the agents module (goal 1000038, Phase 1):
4
+ --
5
+ -- agents_definitions — the REGISTRY. One row per declarative agent definition,
6
+ -- whether it came from a committed `.claude/agents/*.md` file (source='file',
7
+ -- reconciled at deploy by agents-sync.js) or was authored in the instance
8
+ -- against the DB (source='db'). Files-are-source: the sync upserts file rows
9
+ -- and must never clobber a db row, so `source` is load-bearing, not a label.
10
+ --
11
+ -- agents_runs — the LEDGER. One row per fire: what triggered it, what the gate
12
+ -- decided, what the spawn cost, and where the output went. Every fire is
13
+ -- recorded — ESPECIALLY a refusal, for the same reason autonomy_runs records
14
+ -- a no-go: a gate that silently declines is indistinguishable from a gate
15
+ -- that never ran.
16
+ --
17
+ -- NAMESPACE NOTE (why `agents_definitions`, not `agent_definitions`). Module
18
+ -- objects must carry the module key's own prefix, and this module's key is
19
+ -- `agents` — so the wall is `agents_`, and a singular `agent_definitions` fails
20
+ -- the fitness gate (`scripts/gds/fitness.js` Check 11) rather than being a style
21
+ -- quibble. The plural prefix is the price of the namespace; downstream tasks
22
+ -- (1002488 read routes, 1002489 spawn adapter, 1002490 agents-sync) read
23
+ -- `agents_definitions` / `agents_runs`.
24
+ --
25
+ -- FAIL-CLOSED ENUMS. `trigger_type`, `model_tier`, `source`, `provenance` and the
26
+ -- run's `status` are text + CHECK rather than Postgres enum TYPEs: the constraint
27
+ -- pins today's WIRED vocabulary closed (an unwired trigger name is refused, never
28
+ -- silently accepted — the same rule task 1002487's EVENT_CATALOG enforces in JS),
29
+ -- and widening later is a one-line constraint change in a subsequent agents_*
30
+ -- migration instead of an enum-type migration.
31
+ --
32
+ -- A DEFINITION IS NEVER ARMED BY ACCIDENT. Two invariants below are the DB half
33
+ -- of rules the code also enforces, and they exist because "enabled" is the field
34
+ -- that turns a piece of text into something that spends money and reads the repo:
35
+ -- • a definition that tripped the scope wall CANNOT be enabled — the sync
36
+ -- imports it disabled + flagged (task 1002490), and this constraint means a
37
+ -- later stray UPDATE cannot quietly arm it;
38
+ -- • an enabled definition MUST carry an author_rank — authority comes from the
39
+ -- author's LIVE DB rank (ADR 0016), never from anything written in the file,
40
+ -- so a definition with no known author has no authority to run.
41
+ --
42
+ -- STRUCTURE ONLY, NEVER AGENT OUTPUT. `agents_runs.output_ref` is a POINTER to
43
+ -- where a run's output landed — never the output itself. An agent's output is
44
+ -- model-generated text that is later rendered to whoever reads the ledger, so
45
+ -- storing it inline would make this table a delivery path for whatever the agent
46
+ -- read. Same rule, same reason as `security_tool_scans.findings_summary`.
47
+ --
48
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
49
+ -- object is agents_-prefixed, there is no down-migration, and the filename is
50
+ -- agents_-prefixed (ADR 0108 §5) so its stem never collides across repos.
51
+ -- `scripts/migrate.sh` discovers + applies this only when the agents module is
52
+ -- enabled — and the module ships `default:false` (task 1002485), so on a vanilla
53
+ -- instance this file is correctly skipped. Idempotent: safe to re-run.
54
+
55
+ BEGIN;
56
+
57
+ -- ---------------------------------------------------------------------------
58
+ -- agents_definitions — the registry
59
+ -- ---------------------------------------------------------------------------
60
+ CREATE TABLE IF NOT EXISTS agents_definitions (
61
+ id bigserial PRIMARY KEY,
62
+
63
+ -- The agent key. For a file-sourced definition this is the `.claude/agents/`
64
+ -- filename stem, which is why the format check matches the module-key shape:
65
+ -- it has to be safe as a filename, a URL path segment, and a CLI argument.
66
+ name text NOT NULL,
67
+ title text,
68
+ -- The role/system prompt. REQUIRED — task 1002487's validator rejects a
69
+ -- definition with no persona, and a NOT NULL here means a row that skipped the
70
+ -- validator still cannot exist without one.
71
+ persona text NOT NULL,
72
+
73
+ -- What fires it. 'event' consumes the wired event catalog; 'on-demand' is the
74
+ -- historian-exemplar shape (a builder asks, the agent answers). trigger_spec
75
+ -- carries the type's parameters — for 'event', at minimum {"event": "..."}.
76
+ trigger_type text NOT NULL,
77
+ trigger_spec jsonb NOT NULL DEFAULT '{}'::jsonb,
78
+
79
+ -- Which subagent tier this spawns at. Resolved to a concrete model id at fire
80
+ -- time from the branding pack's `models` block (task 1002489) — the tier is
81
+ -- stored, never the model id, so an instance re-pointing its tiers moves every
82
+ -- agent with it instead of leaving a pinned model behind.
83
+ -- 'default' → models.subagentDefault 'routine' → models.subagentRoutine
84
+ -- Deliberately NO 'main' tier: an agent is a subagent, never the main session.
85
+ model_tier text NOT NULL DEFAULT 'default',
86
+
87
+ -- The declared scope wall. scope_modules names module keys the agent may work
88
+ -- within; scope_paths narrows further. The wall is re-evaluated SERVER-SIDE at
89
+ -- sync (task 1002490) — a definition scoped onto protected paths lands with
90
+ -- scope_violation set, which the constraint below makes un-armable.
91
+ scope_modules text[] NOT NULL DEFAULT '{}',
92
+ scope_paths text[] NOT NULL DEFAULT '{}',
93
+ scope_violation text,
94
+
95
+ -- Where this row came from, and whether the sync owns it. 'file' rows are
96
+ -- rewritten from the committed definition on every deploy; 'db' rows are
97
+ -- instance-authored and must SURVIVE a deploy untouched (task 1002490).
98
+ source text NOT NULL,
99
+ -- 'built-in' (ships with the core) vs 'instance' (authored by this instance).
100
+ -- Distinct from `source`: a built-in agent is file-sourced, but an instance can
101
+ -- also commit its own file. Provenance answers "whose agent is this", source
102
+ -- answers "who may overwrite the row".
103
+ provenance text NOT NULL,
104
+
105
+ -- The author's LIVE DB rank at the moment the row was written (ADR 0016).
106
+ -- Never read from the definition file — a file cannot grant authority.
107
+ author_rank text,
108
+ author_builder_id bigint REFERENCES builders(id) ON DELETE SET NULL,
109
+
110
+ -- File provenance, for source='file'. source_sha256 is over the definition's
111
+ -- bytes so the sync can tell "unchanged" from "edited" without re-parsing, and
112
+ -- so a row can be traced back to the exact committed text that produced it.
113
+ source_path text,
114
+ source_sha256 text,
115
+ last_synced_at timestamptz,
116
+
117
+ -- Shipped != enabled, at the row level as well as the module level. A newly
118
+ -- synced definition lands OFF; arming it is a separate, deliberate act.
119
+ enabled boolean NOT NULL DEFAULT false,
120
+
121
+ created_at timestamptz NOT NULL DEFAULT now(),
122
+ updated_at timestamptz NOT NULL DEFAULT now(),
123
+
124
+ -- One row per agent name. This is what makes the sync an UPSERT rather than an
125
+ -- append, and what stops two definitions from answering to the same name.
126
+ CONSTRAINT agents_definitions_name_uniq UNIQUE (name),
127
+ -- Filename/CLI/URL-safe, and stable: the same shape a module key must take.
128
+ CONSTRAINT agents_definitions_name_fmt_chk
129
+ CHECK (name ~ '^[a-z][a-z0-9-]*$' AND length(name) <= 64),
130
+ -- v1 wires exactly these two trigger types. Anything else is refused here, not
131
+ -- silently stored and quietly never fired.
132
+ CONSTRAINT agents_definitions_trigger_type_chk
133
+ CHECK (trigger_type IN ('event', 'on-demand')),
134
+ CONSTRAINT agents_definitions_trigger_spec_shape_chk
135
+ CHECK (jsonb_typeof(trigger_spec) = 'object'),
136
+ -- An event-triggered definition with no event name is a definition that can
137
+ -- never fire; the catalog membership check itself lives in the validator, which
138
+ -- can name the offending event in its error.
139
+ CONSTRAINT agents_definitions_event_needs_name_chk
140
+ CHECK (trigger_type <> 'event'
141
+ OR (trigger_spec ? 'event' AND length(trigger_spec ->> 'event') > 0)),
142
+ CONSTRAINT agents_definitions_model_tier_chk
143
+ CHECK (model_tier IN ('default', 'routine')),
144
+ CONSTRAINT agents_definitions_source_chk
145
+ CHECK (source IN ('file', 'db')),
146
+ CONSTRAINT agents_definitions_provenance_chk
147
+ CHECK (provenance IN ('built-in', 'instance')),
148
+ CONSTRAINT agents_definitions_author_rank_chk
149
+ CHECK (author_rank IS NULL OR author_rank IN ('xenos', 'thetes', 'metic', 'archon')),
150
+ -- A file-sourced row must say WHICH file, or the next sync cannot reconcile it.
151
+ CONSTRAINT agents_definitions_file_needs_path_chk
152
+ CHECK (source <> 'file' OR source_path IS NOT NULL),
153
+ -- The two arming invariants (see the header). A flagged definition stays
154
+ -- disabled; an anonymous definition has no rank to act with, so it stays off.
155
+ CONSTRAINT agents_definitions_flagged_not_armed_chk
156
+ CHECK (scope_violation IS NULL OR enabled = false),
157
+ CONSTRAINT agents_definitions_armed_needs_author_chk
158
+ CHECK (enabled = false OR author_rank IS NOT NULL),
159
+ -- Defense in depth with the validator's own caps: a persona is a prompt sent to
160
+ -- a paid model on every fire, so an unbounded one is both a cost and a blast
161
+ -- radius. trigger_spec is parameters, never payload.
162
+ CONSTRAINT agents_definitions_persona_size_chk
163
+ CHECK (length(persona) <= 32768),
164
+ CONSTRAINT agents_definitions_trigger_spec_size_chk
165
+ CHECK (length(trigger_spec::text) <= 8192)
166
+ );
167
+
168
+ -- "Which definitions fire on event X?" — the dispatch lookup, and the only read
169
+ -- on the hot path. Partial: a disabled definition is never dispatched, so its
170
+ -- rows are dead weight in this index.
171
+ CREATE INDEX IF NOT EXISTS agents_definitions_enabled_trigger_idx
172
+ ON agents_definitions (trigger_type, (trigger_spec ->> 'event'))
173
+ WHERE enabled;
174
+
175
+ -- "What does the sync own?" — agents-sync.js walks file rows to reconcile them
176
+ -- and must never touch the db rows beside them (task 1002490).
177
+ CREATE INDEX IF NOT EXISTS agents_definitions_source_idx
178
+ ON agents_definitions (source, name);
179
+
180
+ -- "What tripped the scope wall?" — the review queue for flagged imports. Small
181
+ -- by construction, so partial.
182
+ CREATE INDEX IF NOT EXISTS agents_definitions_flagged_idx
183
+ ON agents_definitions (updated_at DESC)
184
+ WHERE scope_violation IS NOT NULL;
185
+
186
+ -- ---------------------------------------------------------------------------
187
+ -- agents_runs — one row per fire
188
+ -- ---------------------------------------------------------------------------
189
+ CREATE TABLE IF NOT EXISTS agents_runs (
190
+ id bigserial PRIMARY KEY,
191
+
192
+ -- The definition that fired. ON DELETE SET NULL, not CASCADE: the ledger is the
193
+ -- record of what this instance actually spent and ran, and deleting a
194
+ -- definition must not erase its history. agent_name is denormalized for exactly
195
+ -- that case — the run stays attributable after the row it pointed at is gone.
196
+ definition_id bigint REFERENCES agents_definitions(id) ON DELETE SET NULL,
197
+ agent_name text NOT NULL,
198
+
199
+ -- What fired it, as a stable reference rather than prose: the event and its
200
+ -- subject ('task.shipped:1002486'), or the on-demand caller. Kept as text so a
201
+ -- trigger can reference a record type this module does not own.
202
+ trigger_ref text,
203
+ trigger_type text NOT NULL,
204
+
205
+ -- The gate's decision for this fire, recorded whether or not anything spawned.
206
+ -- Same vocabulary as autonomy_runs ('go' | 'no-go') so the two ledgers read the
207
+ -- same way.
208
+ gate_decision text NOT NULL,
209
+ gate_reason text,
210
+
211
+ -- What became of the run. 'skipped' is the terminal state of a no-go (nothing
212
+ -- spawned); 'pending' covers a run in flight so a crashed spawn is visible as
213
+ -- unfinished rather than vanishing.
214
+ status text NOT NULL DEFAULT 'pending',
215
+ error_code text,
216
+
217
+ -- The CONCRETE model the tier resolved to at fire time, so an old row is never
218
+ -- re-read as though it ran on today's tier mapping.
219
+ model text,
220
+ -- What the fire actually cost. Spawns route through the grade port, which bills
221
+ -- into the LLM cost cache (task 1002489); this column is the per-agent view of
222
+ -- that spend, which is what an instance's ~$/agent/month cap reads.
223
+ cost_usd numeric(10, 4),
224
+ duration_ms integer,
225
+
226
+ -- A POINTER to where the output landed — never the output. See the header.
227
+ output_ref text,
228
+
229
+ requested_by_builder_id bigint REFERENCES builders(id) ON DELETE SET NULL,
230
+ created_at timestamptz NOT NULL DEFAULT now(),
231
+ updated_at timestamptz NOT NULL DEFAULT now(),
232
+
233
+ CONSTRAINT agents_runs_gate_decision_chk
234
+ CHECK (gate_decision IN ('go', 'no-go')),
235
+ CONSTRAINT agents_runs_status_chk
236
+ CHECK (status IN ('pending', 'ok', 'error', 'skipped')),
237
+ CONSTRAINT agents_runs_trigger_type_chk
238
+ CHECK (trigger_type IN ('event', 'on-demand')),
239
+ -- A refusal must never be readable as a run that happened. A no-go spawned
240
+ -- nothing, so it is terminal-skipped and carries no output — the mirror of
241
+ -- security_tool_scans' verdict/completion invariant.
242
+ CONSTRAINT agents_runs_nogo_is_skipped_chk
243
+ CHECK (gate_decision = 'go' OR (status = 'skipped' AND output_ref IS NULL)),
244
+ -- ...and it cannot have cost anything, because nothing ran.
245
+ CONSTRAINT agents_runs_nogo_free_chk
246
+ CHECK (gate_decision = 'go' OR cost_usd IS NULL OR cost_usd = 0),
247
+ -- A failed run must say why. Silent failure is the state this ledger exists to
248
+ -- make impossible.
249
+ CONSTRAINT agents_runs_error_needs_code_chk
250
+ CHECK (status <> 'error' OR error_code IS NOT NULL),
251
+ CONSTRAINT agents_runs_cost_nonneg_chk
252
+ CHECK (cost_usd IS NULL OR cost_usd >= 0),
253
+ CONSTRAINT agents_runs_duration_nonneg_chk
254
+ CHECK (duration_ms IS NULL OR duration_ms >= 0),
255
+ CONSTRAINT agents_runs_output_ref_len_chk
256
+ CHECK (output_ref IS NULL OR length(output_ref) <= 2048)
257
+ );
258
+
259
+ -- "What has this agent done lately?" — the per-agent history, and the read behind
260
+ -- a per-agent spend rollup.
261
+ CREATE INDEX IF NOT EXISTS agents_runs_agent_idx
262
+ ON agents_runs (agent_name, created_at DESC);
263
+
264
+ -- "What did this trigger set off?" — walks back from a shipped task (or any other
265
+ -- subject) to the agents that fired on it.
266
+ CREATE INDEX IF NOT EXISTS agents_runs_trigger_ref_idx
267
+ ON agents_runs (trigger_ref, created_at DESC)
268
+ WHERE trigger_ref IS NOT NULL;
269
+
270
+ -- "What did agents cost this month?" — the spend-cap read. Partial: a no-go row
271
+ -- contributes nothing to spend and would only dilute the scan.
272
+ CREATE INDEX IF NOT EXISTS agents_runs_spend_idx
273
+ ON agents_runs (created_at DESC)
274
+ WHERE cost_usd IS NOT NULL;
275
+
276
+ -- ---------------------------------------------------------------------------
277
+ -- updated_at triggers
278
+ -- ---------------------------------------------------------------------------
279
+ -- Both tables ARE mutated after insert (the sync rewrites definitions; a run goes
280
+ -- pending → ok/error and gains its cost + output_ref), so updated_at needs a real
281
+ -- trigger — without one it silently equals created_at forever, and "when did this
282
+ -- definition last change" becomes unanswerable. Idempotent: OR REPLACE + DROP IF
283
+ -- EXISTS on the trigger.
284
+ CREATE OR REPLACE FUNCTION agents_touch_updated_at() RETURNS trigger AS $$
285
+ BEGIN
286
+ NEW.updated_at = now();
287
+ RETURN NEW;
288
+ END;
289
+ $$ LANGUAGE plpgsql;
290
+
291
+ DROP TRIGGER IF EXISTS agents_definitions_updated_at ON agents_definitions;
292
+ CREATE TRIGGER agents_definitions_updated_at
293
+ BEFORE UPDATE ON agents_definitions
294
+ FOR EACH ROW EXECUTE FUNCTION agents_touch_updated_at();
295
+
296
+ DROP TRIGGER IF EXISTS agents_runs_updated_at ON agents_runs;
297
+ CREATE TRIGGER agents_runs_updated_at
298
+ BEFORE UPDATE ON agents_runs
299
+ FOR EACH ROW EXECUTE FUNCTION agents_touch_updated_at();
300
+
301
+ COMMIT;
@@ -0,0 +1,11 @@
1
+ {
2
+ "key": "agents",
3
+ "title": "Agents (declarative agent registry)",
4
+ "description": "Declarative agent definitions (.claude/agents/*.md) reconciled at deploy into a DB-backed registry (agents_definitions) plus a per-fire ledger (agents_runs) — BONGOS-V1 goal 1000038, Phase 1. Files-are-source: agents-sync upserts file-sourced definitions and never clobbers an instance-authored db row, and stamps author_rank from the author's LIVE DB rank because a committed file cannot grant authority (ADR 0016). Spawns consume the EXISTING `grade` port (runSubagentCached), which is fixed read-only, so v1 agents are observe-and-report only — no write principals — and every fire bills through the shared LLM cost cache. default:false: shipped is not enabled, and an instance opts in per ADR 0083.",
5
+ "coreVersion": "^1.19.0",
6
+ "default": false,
7
+ "maintenance": { "status": "core-maintained" },
8
+ "contributes": { "migrations": true },
9
+ "provides": [],
10
+ "consumes": ["grade"]
11
+ }
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ // modules/agents/spawn.js — the spawn seam (task 1002489, goal 1000038 Phase 1).
4
+ //
5
+ // One fire = resolve the tier to a concrete model, ask the `grade` port to run the
6
+ // subagent, and write exactly one agents_runs row saying what happened. Nothing
7
+ // else. The gate decides WHETHER to fire; this decides HOW and records THAT.
8
+ //
9
+ // THREE CONSTRAINTS THAT ARE NOT NEGOTIABLE HERE, each from the goal's own
10
+ // adversarial critique — a future reader will be tempted by all three:
11
+ //
12
+ // 1. CONSUME grade.runSubagentCached. Not a new `grade.runSubagent` port (the
13
+ // critique explicitly dropped it), and not a bespoke child_process.spawn. The
14
+ // port is also what bills every fire into the shared LLM cost cache, so an
15
+ // agent's spend lands in the same ledger as everything else instead of being
16
+ // invisible. A bespoke spawn would be free-looking and wrong.
17
+ //
18
+ // 2. REACH IT THROUGH THE DOORWAY, never `require('../grading/…')`. A
19
+ // module→module require is a hard fitness failure (ADR 0083), and the
20
+ // resolve-by-name seam is what lets an instance run `agents` with `grading`
21
+ // disabled — it degrades to a named refusal instead of a boot crash.
22
+ //
23
+ // 3. V1 AGENTS ARE READ-ONLY, and not by policy — by plumbing. grader.js's
24
+ // runSubagent hardcodes `--tools Read` + bypassPermissions and parametrizes
25
+ // only model/cwd/timeout. There is no allowlist to widen from here. A
26
+ // write-capable agent needs a parametrized-capability spawn path in `grading`
27
+ // first (a PROTECTED_GLOBS edit, Archon-gated), which is deferred to a later
28
+ // goal. Anything in this file that appears to grant a capability is a bug.
29
+ //
30
+ // THE LEDGER IS WRITTEN EVEN WHEN NOTHING RUNS. A no-go is a row: gate_decision
31
+ // 'no-go', status 'skipped', no output, no cost — the schema enforces all three.
32
+ // The reason is the same one behind every other loud-failure surface in this repo:
33
+ // a refusal that leaves no trace is indistinguishable from a fire that never
34
+ // happened, and "the agent didn't run" then has two causes nobody can tell apart.
35
+
36
+ const api = require('../../src/module-api');
37
+
38
+ // tier → concrete model id. The mapping is BRANDING, not a constant here: an
39
+ // instance retunes its own tiers, and the DB constraint already limits the
40
+ // vocabulary to these two.
41
+ //
42
+ // An unknown tier resolves to the DEFAULT tier, never to nothing and never to the
43
+ // cheap one. A definition whose tier is somehow outside the vocabulary is a bug in
44
+ // whatever wrote it; silently downgrading its model would make that bug present as
45
+ // mysteriously poor agent output much later, which is far harder to trace than an
46
+ // unexpected line in the cost ledger.
47
+ function resolveModel(tier, brandingModels) {
48
+ const m = brandingModels || {};
49
+ const fallback = m.subagentDefault || m.main || null;
50
+ if (tier === 'routine') return m.subagentRoutine || fallback;
51
+ return fallback;
52
+ }
53
+
54
+ // The insert shape for one fire, decided BEFORE anything spawns. Pure, so the
55
+ // schema's invariants can be tested without a database.
56
+ function plannedRow({ definition = {}, trigger = {}, gate = {}, model = null, requestedByBuilderId = null } = {}) {
57
+ const go = gate.decision === 'go';
58
+ return {
59
+ definition_id: definition.id ?? null,
60
+ agent_name: definition.name || null,
61
+ trigger_ref: trigger.ref ?? null,
62
+ trigger_type: trigger.type === 'on-demand' ? 'on-demand' : 'event',
63
+ gate_decision: go ? 'go' : 'no-go',
64
+ gate_reason: gate.reason ?? null,
65
+ // A no-go is TERMINAL. 'pending' would leave a refusal looking like a run
66
+ // still in flight, and the crash-visibility argument for 'pending' only
67
+ // applies to something that actually started.
68
+ status: go ? 'pending' : 'skipped',
69
+ // Recorded even on a no-go: it is what the fire WOULD have cost, and the
70
+ // schema permits it. It is the concrete id, never the tier — an old row must
71
+ // never be re-read through today's tier mapping.
72
+ model: go ? model : null,
73
+ requested_by_builder_id: requestedByBuilderId ?? null,
74
+ };
75
+ }
76
+
77
+ // How a finished run is recorded. Pure for the same reason.
78
+ function completedRow({ ok, errorCode = null, costUsd = null, durationMs = null, outputRef = null } = {}) {
79
+ if (ok) {
80
+ return { status: 'ok', error_code: null, cost_usd: costUsd, duration_ms: durationMs, output_ref: outputRef };
81
+ }
82
+ return {
83
+ status: 'error',
84
+ // The schema REFUSES an error row with no code, so this can never be null —
85
+ // 'agent_run_failed' is the honest fallback when the thrower named nothing.
86
+ error_code: errorCode || 'agent_run_failed',
87
+ cost_usd: costUsd,
88
+ duration_ms: durationMs,
89
+ // A failed run's partial output is not a result. The pointer stays null.
90
+ output_ref: null,
91
+ };
92
+ }
93
+
94
+ async function insertRun(pool, row) {
95
+ const cols = Object.keys(row);
96
+ const vals = cols.map((_, i) => `$${i + 1}`);
97
+ const r = await pool.query(
98
+ `INSERT INTO agents_runs (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING id`,
99
+ cols.map((c) => row[c]),
100
+ );
101
+ return r.rows[0].id;
102
+ }
103
+
104
+ async function finishRun(pool, id, patch) {
105
+ const cols = Object.keys(patch);
106
+ const set = cols.map((c, i) => `${c} = $${i + 2}`).join(', ');
107
+ await pool.query(
108
+ `UPDATE agents_runs SET ${set}, updated_at = now() WHERE id = $1`,
109
+ [id, ...cols.map((c) => patch[c])],
110
+ );
111
+ }
112
+
113
+ // Fire one agent. Never throws: a spawn failure is a ledger row, not an exception
114
+ // into whatever triggered it — an event fabric must not be taken down by one
115
+ // agent's bad day.
116
+ async function spawnAgent({
117
+ definition, trigger = {}, gate = { decision: 'go' }, requestedByBuilderId = null, cache = null,
118
+ } = {}, deps = {}) {
119
+ const pool = deps.pool || api.pool;
120
+ const now = deps.now || (() => Date.now());
121
+ const brandingModels = deps.brandingModels || (api.branding && api.branding().models) || {};
122
+ const model = resolveModel(definition && definition.model_tier, brandingModels);
123
+
124
+ const row = plannedRow({ definition, trigger, gate, model, requestedByBuilderId });
125
+ let runId = null;
126
+ try {
127
+ runId = await insertRun(pool, row);
128
+ } catch (e) {
129
+ // The ledger write itself failing is the one case with nowhere to record it.
130
+ // Say so and refuse to spawn: an unledgered fire is exactly what this table
131
+ // exists to prevent, and spending money we cannot account for is worse than
132
+ // not firing.
133
+ return { ok: false, ran: false, runId: null, error_code: 'ledger_write_failed', error: (e && e.message) || String(e) };
134
+ }
135
+ if (row.gate_decision !== 'go') {
136
+ return { ok: true, ran: false, runId, skipped: true, gate_reason: row.gate_reason || null };
137
+ }
138
+
139
+ // Resolved by NAME through the doorway — see constraint 2. resolveOptional so an
140
+ // instance running `agents` without `grading` gets a named ledger row rather than
141
+ // a crash at require time.
142
+ const grade = typeof api.resolveOptional === 'function' ? api.resolveOptional('grade', null) : null;
143
+ const runner = deps.runSubagentCached
144
+ || (grade && typeof grade.runSubagentCached === 'function' ? grade.runSubagentCached : null);
145
+ if (!runner) {
146
+ await finishRun(pool, runId, completedRow({ ok: false, errorCode: 'grade_port_unavailable' }));
147
+ return { ok: false, ran: false, runId, error_code: 'grade_port_unavailable' };
148
+ }
149
+
150
+ const started = now();
151
+ try {
152
+ const res = await runner({
153
+ prompt: (definition && definition.persona) || '',
154
+ // model/cwd/timeout are the ONLY knobs the port takes. No tools, no
155
+ // permissions — see constraint 3.
156
+ opts: { model, ...(deps.opts || {}) },
157
+ cache,
158
+ });
159
+ const durationMs = Math.max(0, now() - started);
160
+ const costUsd = (res && typeof res.cost_usd === 'number' && res.cost_usd >= 0) ? res.cost_usd : null;
161
+ const ok = !res || res.ok !== false;
162
+ const patch = completedRow({
163
+ ok,
164
+ errorCode: ok ? null : (res && res.error_code) || 'agent_run_failed',
165
+ costUsd,
166
+ durationMs,
167
+ outputRef: ok ? (deps.outputRef || (res && res.output_ref) || null) : null,
168
+ });
169
+ await finishRun(pool, runId, patch);
170
+ return { ok, ran: true, runId, model, cost_usd: costUsd, duration_ms: durationMs };
171
+ } catch (e) {
172
+ const durationMs = Math.max(0, now() - started);
173
+ await finishRun(pool, runId, completedRow({
174
+ ok: false, errorCode: (e && e.code) || 'agent_spawn_threw', durationMs,
175
+ })).catch(() => { /* the throw below is the report of last resort */ });
176
+ return { ok: false, ran: true, runId, error_code: (e && e.code) || 'agent_spawn_threw', error: (e && e.message) || String(e) };
177
+ }
178
+ }
179
+
180
+ module.exports = { spawnAgent, resolveModel, plannedRow, completedRow };
@@ -121,7 +121,34 @@ function helpRequestsNeed(ctx) {
121
121
 
122
122
  // The registry. To add a future input the system needs from a builder, append a
123
123
  // pure (ctx) => need|null function here — every renderer picks it up for free.
124
- const NEEDS = [artKeyNeed, helpRequestsNeed];
124
+ // Open task RECOMMENDATIONS waiting on this builder (task 1003173 / C03, goal
125
+ // 1000075; criterion collab-pass-a-task). Sibling of helpRequestsNeed above, and
126
+ // here for the same reason C01's grader round put that one here: a recommendation
127
+ // nobody surfaces is merely stored, and the criterion's whole argument is that a
128
+ // recommendation must be a visible, traceable judgement rather than a task
129
+ // quietly changing hands.
130
+ //
131
+ // 'info', NOT a demand, and the message says so. A recommendation grants the
132
+ // recipient nothing and obliges them to nothing — they still claim it themselves
133
+ // if they want it, and declining costs nothing (ADR 0187 §3). Wording that
134
+ // pressured the reader would BE the penalty the decision forbids.
135
+ function taskRecommendationsNeed(ctx) {
136
+ const n = Number(ctx.openTaskRecommendations) || 0;
137
+ if (n <= 0) return null;
138
+ const subject = n === 1 ? 'A task' : `${n} tasks`;
139
+ const verb = n === 1 ? 'was' : 'were';
140
+ return {
141
+ id: 'task_recommendations',
142
+ title: n === 1 ? 'Someone recommended a task to you' : 'Tasks were recommended to you',
143
+ what: 'Another builder thought you should look at a task, and said why. It is a suggestion, not an assignment.',
144
+ state: 'action_needed',
145
+ severity: 'info',
146
+ message: `${subject} ${verb} recommended to you. Nothing has been assigned — you would still claim it yourself, and declining costs nothing.`,
147
+ action: { label: n === 1 ? 'See the recommendation' : 'See the recommendations', href: '/#/collab' },
148
+ };
149
+ }
150
+
151
+ const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed];
125
152
 
126
153
  // ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
127
154
  //
@@ -199,7 +226,7 @@ module.exports = {
199
226
  computeNeeds,
200
227
  // exported for tests / direct reuse
201
228
  artKeyNeed,
202
- helpRequestsNeed,
229
+ helpRequestsNeed, taskRecommendationsNeed,
203
230
  foundingGraceActive,
204
231
  FOUNDING_GRACE_DAYS,
205
232
  SETTINGS_PATH,
@@ -715,6 +715,16 @@ function createClient(opts = {}) {
715
715
  // POST /sso/token — rank: public — POST /sso/token
716
716
  postSsoToken: (args) => request("POST", "/sso/token", { hasBody: true }, args),
717
717
  },
718
+ "taskRecommendations": {
719
+ // POST /task-recommendations — rank: any-builder — POST /task-recommendations
720
+ postTaskRecommendations: (args) => request("POST", "/task-recommendations", { hasBody: true }, args),
721
+ // PATCH /task-recommendations/{id} — rank: any-builder — PATCH /task-recommendations/:id
722
+ patchTaskRecommendationsId: (args) => request("PATCH", "/task-recommendations/{id}", { hasBody: true }, args),
723
+ // GET /task-recommendations/for-me — rank: any-builder — GET /task-recommendations/for-me
724
+ getTaskRecommendationsForMe: (args) => request("GET", "/task-recommendations/for-me", { hasBody: false }, args),
725
+ // GET /task-recommendations/mine — rank: any-builder — GET /task-recommendations/mine
726
+ getTaskRecommendationsMine: (args) => request("GET", "/task-recommendations/mine", { hasBody: false }, args),
727
+ },
718
728
  "taskVisuals": {
719
729
  // GET /task-visuals/{name} — rank: public — GET /task-visuals/:name
720
730
  getTaskVisualsName: (args) => request("GET", "/task-visuals/{name}", { hasBody: false }, args),
@@ -760,6 +770,8 @@ function createClient(opts = {}) {
760
770
  postTasksIdPublishBranch: (args) => request("POST", "/tasks/{id}/publish-branch", { hasBody: true }, args),
761
771
  // GET /tasks/{id}/publish-status — rank: any-builder — GET /tasks/:id/publish-status
762
772
  getTasksIdPublishStatus: (args) => request("GET", "/tasks/{id}/publish-status", { hasBody: false }, args),
773
+ // GET /tasks/{id}/recommendations — rank: any-builder — GET /tasks/:id/recommendations
774
+ getTasksIdRecommendations: (args) => request("GET", "/tasks/{id}/recommendations", { hasBody: false }, args),
763
775
  // POST /tasks/{id}/ship — rank: any-builder — POST /tasks/:id/ship
764
776
  postTasksIdShip: (args) => request("POST", "/tasks/{id}/ship", { hasBody: true }, args),
765
777
  // DELETE /tasks/{id}/visual — rank: any-builder — DELETE /tasks/:id/visual
@@ -19,6 +19,7 @@
19
19
  const db = require('./db');
20
20
  const classifier = require('./task-classifier');
21
21
  const helpRequests = require('./help-requests');
22
+ const taskRecommendations = require('./task-recommendations');
22
23
 
23
24
  module.exports = {
24
25
  // --- retired doorway leaks (ADR 0093 §3) ---------------------------------
@@ -39,6 +40,10 @@ module.exports = {
39
40
  // task 1003171 (C01): the count of open help requests addressed to a builder,
40
41
  // so the kernel's /me can put it in the needs ctx WITHOUT importing this module.
41
42
  openHelpRequestsFor: (...a) => helpRequests.countOpenForBuilder(...a),
43
+ // task 1003173 (C03): the count of open task recommendations waiting on a
44
+ // builder, exposed the same way and for the same reason — a recommendation
45
+ // nobody surfaces is merely stored.
46
+ openTaskRecommendationsFor: (...a) => taskRecommendations.countOpenForRecipient(...a),
42
47
  getTask: (...a) => db.getTask(...a),
43
48
  listVersions: (...a) => db.listVersions(...a),
44
49
  versionProgress: (...a) => db.versionProgress(...a),