bare-agent 0.8.0 → 0.10.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/tools/defer.js ADDED
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * tools/defer.js — emit a deferred-action record to a JSONL queue.
5
+ *
6
+ * LLM-callable form: `defer({ action, when })` appends ONE JSONL record
7
+ * to the defer queue file and returns `{ id }`. bareagent does NOT wake
8
+ * up later — the running process exits when the loop ends. An external
9
+ * scheduler (cron + `examples/wake.sh`) reads the queue and fires due
10
+ * actions by re-invoking bareagent.
11
+ *
12
+ * Two-phase gate semantics (per bareagent PRD §10.7 + bareguard PRD §14):
13
+ * - At emit (this tool): one gate.check on `{ type: 'defer', args: { action, when }, _ctx }`
14
+ * runs the full pipeline (defer.ratePerMinute, tools.allowlist on `defer`,
15
+ * content.* over the JSON-serialized form). Bareguard does NOT extract
16
+ * args.action and run a second pipeline against it at emit time.
17
+ * - At fire (wake.sh invokes bareagent with the inner action): a separate
18
+ * gate.check runs the full pipeline against the inner action as a fresh
19
+ * action. Two distinct gate.check calls, two distinct audit lines,
20
+ * reconstructable via parent_run_id.
21
+ *
22
+ * Queue file format — one JSON record per line, append-only:
23
+ * { id, ts_emitted, when, action, parent_run_id, status }
24
+ * Status updates are appends, not edits: wake.sh appends
25
+ * { id, status: 'fired', ts }
26
+ * Reconstruction folds by `id` (latest wins).
27
+ *
28
+ * Default queue path: ./bareagent-defers.jsonl (cwd-only, project-scoped).
29
+ * Override via BAREAGENT_DEFER_QUEUE env var or createDeferTool({queuePath}).
30
+ */
31
+
32
+ const fs = require('node:fs');
33
+ const fsp = require('node:fs/promises');
34
+ const path = require('node:path');
35
+ const crypto = require('node:crypto');
36
+
37
+ const DEFAULT_QUEUE_PATH = './bareagent-defers.jsonl';
38
+ const ID_PREFIX = 'def_';
39
+
40
+ /**
41
+ * Generate a sortable, unique id. 9-char base36 timestamp + 20-char hex
42
+ * random. Lexicographically sortable by emit time; unique enough for any
43
+ * realistic defer rate. Same shape as the PRD's `def_01J...` sketch.
44
+ */
45
+ function generateId() {
46
+ const ts = Date.now().toString(36).padStart(9, '0');
47
+ const rand = crypto.randomBytes(10).toString('hex');
48
+ return `${ID_PREFIX}${ts}_${rand}`;
49
+ }
50
+
51
+ /**
52
+ * Resolve the active queue path. Precedence:
53
+ * 1. Caller-supplied option (createDeferTool({ queuePath: '...' }))
54
+ * 2. BAREAGENT_DEFER_QUEUE env var
55
+ * 3. ./bareagent-defers.jsonl
56
+ */
57
+ function resolveQueuePath(option) {
58
+ return option
59
+ || process.env.BAREAGENT_DEFER_QUEUE
60
+ || DEFAULT_QUEUE_PATH;
61
+ }
62
+
63
+ /**
64
+ * Validate a `when` field. Accepts an ISO 8601 timestamp string. Rejects
65
+ * past timestamps loosely (more than 60s in the past) — the wake script
66
+ * would fire them immediately, which is almost always not what the agent
67
+ * meant. Future timestamps within reason are accepted as-is.
68
+ *
69
+ * Returns { ok: true, iso } on success, { ok: false, error } on failure.
70
+ */
71
+ function validateWhen(when) {
72
+ if (typeof when !== 'string' || !when) {
73
+ return { ok: false, error: 'when must be an ISO 8601 timestamp string' };
74
+ }
75
+ const t = Date.parse(when);
76
+ if (Number.isNaN(t)) {
77
+ return { ok: false, error: `when is not a valid ISO 8601 timestamp: ${when}` };
78
+ }
79
+ const driftMs = Date.now() - t;
80
+ if (driftMs > 60_000) {
81
+ return { ok: false, error: `when is more than 60s in the past (drift=${driftMs}ms) — would fire immediately` };
82
+ }
83
+ return { ok: true, iso: new Date(t).toISOString() };
84
+ }
85
+
86
+ /**
87
+ * Validate an `action` field. Must be an object with a string `type`.
88
+ * Anything else is the LLM either confused or trying to defer something
89
+ * meaningless.
90
+ */
91
+ function validateAction(action) {
92
+ if (!action || typeof action !== 'object' || Array.isArray(action)) {
93
+ return { ok: false, error: 'action must be an object' };
94
+ }
95
+ if (typeof action.type !== 'string' || !action.type) {
96
+ return { ok: false, error: 'action.type must be a non-empty string' };
97
+ }
98
+ return { ok: true };
99
+ }
100
+
101
+ /**
102
+ * Append one JSONL record to the queue file. fs.promises.appendFile is
103
+ * atomic for writes < PIPE_BUF on POSIX (4KB on Linux); a JSON record
104
+ * with a small action is well under that.
105
+ */
106
+ async function appendRecord(queuePath, record) {
107
+ const dir = path.dirname(path.resolve(queuePath));
108
+ // Best-effort dir creation; ignore "already exists".
109
+ try { await fsp.mkdir(dir, { recursive: true }); } catch { /* fine */ }
110
+ const line = JSON.stringify(record) + '\n';
111
+ if (line.length > 4000) {
112
+ // Soft guard — if the action payload is huge, the audit-and-fire chain
113
+ // will still work but POSIX atomicity guarantee is gone. Warn.
114
+ process.stderr.write(`[defer] record is ${line.length}B (> ~4KB POSIX_PIPE_BUF) — atomicity not guaranteed\n`);
115
+ }
116
+ await fsp.appendFile(queuePath, line);
117
+ }
118
+
119
+ /**
120
+ * Read the queue and reconstruct the live status of each id by folding
121
+ * append-only status lines (latest wins). Exposed for tests + library
122
+ * users; the wake script does its own jq-based fold.
123
+ */
124
+ async function readQueue(queuePath) {
125
+ const path = resolveQueuePath(queuePath);
126
+ try {
127
+ const text = await fsp.readFile(path, 'utf8');
128
+ const records = {};
129
+ for (const line of text.split('\n')) {
130
+ if (!line.trim()) continue;
131
+ let r;
132
+ try { r = JSON.parse(line); } catch { continue; }
133
+ if (!r.id) continue;
134
+ records[r.id] = { ...records[r.id], ...r };
135
+ }
136
+ return Object.values(records);
137
+ } catch (err) {
138
+ if (err.code === 'ENOENT') return [];
139
+ throw err;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * @param {object} [options]
145
+ * @param {string} [options.queuePath] - Override queue file path.
146
+ * @returns {{tool: object, readQueue: Function}}
147
+ */
148
+ function createDeferTool(options = {}) {
149
+ const queuePath = resolveQueuePath(options.queuePath);
150
+
151
+ const tool = {
152
+ name: 'defer',
153
+ description:
154
+ 'Append a deferred action to the queue. The action will be fired at or after `when` by the external wake script (cron + examples/wake.sh). bareagent does NOT wake up — the queue is project-scoped JSONL on disk. Returns { id }. Use sparingly: defer.ratePerMinute caps emits per agent family (default 15/min in bareguard 0.2).',
155
+ parameters: {
156
+ type: 'object',
157
+ properties: {
158
+ action: {
159
+ type: 'object',
160
+ description: 'The action to fire. Must have a string `type` field naming a tool the wake-time agent can invoke (e.g. `{ type: "spawn", args: { config: "specialists/check-ci.json" } }`).',
161
+ },
162
+ when: {
163
+ type: 'string',
164
+ description: 'ISO 8601 timestamp for when to fire (e.g. "2026-04-30T18:00:00Z"). Must not be more than 60s in the past.',
165
+ },
166
+ },
167
+ required: ['action', 'when'],
168
+ },
169
+ execute: async ({ action, when }) => {
170
+ const a = validateAction(action);
171
+ if (!a.ok) throw new Error(`[defer] ${a.error}`);
172
+ const w = validateWhen(when);
173
+ if (!w.ok) throw new Error(`[defer] ${w.error}`);
174
+
175
+ const record = {
176
+ id: generateId(),
177
+ ts_emitted: new Date().toISOString(),
178
+ when: w.iso,
179
+ action,
180
+ parent_run_id:
181
+ process.env.BAREGUARD_RUN_ID
182
+ || process.env.BAREGUARD_PARENT_RUN_ID
183
+ || null,
184
+ status: 'pending',
185
+ };
186
+ await appendRecord(queuePath, record);
187
+ return { id: record.id };
188
+ },
189
+ };
190
+
191
+ return {
192
+ tool,
193
+ readQueue: () => readQueue(queuePath),
194
+ queuePath,
195
+ };
196
+ }
197
+
198
+ module.exports = {
199
+ createDeferTool,
200
+ readQueue,
201
+ generateId, // exported for tests
202
+ resolveQueuePath, // exported for tests
203
+ };
package/tools/spawn.js ADDED
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * tools/spawn.js — fork a child bareagent process.
5
+ *
6
+ * LLM-callable form: `spawn({ config, input? })` blocks until the child
7
+ * exits and returns the child's final result. Per the PRD: LLMs don't
8
+ * manage handles across tool calls, so blocking is the only sane LLM
9
+ * surface. Library callers can use the lower-level `spawnChild()` export
10
+ * for fire-and-forget / handle-based use.
11
+ *
12
+ * The child is bareagent itself, invoked as:
13
+ * <node> <bin/cli.js> --config <config-path>
14
+ *
15
+ * Env-var threading (per bareguard 0.1.1+ stitching contract):
16
+ * - BAREGUARD_AUDIT_PATH — single audit file across the family
17
+ * - BAREGUARD_BUDGET_FILE — shared budget ledger
18
+ * - BAREGUARD_PARENT_RUN_ID — parent's run_id becomes child's parent
19
+ * - BAREGUARD_SPAWN_DEPTH — incremented; bareguard.limits.maxDepth caps it
20
+ *
21
+ * Stream model (per v0.9 §10.6 decision):
22
+ * ONE JSONL channel per child. Child stdout is the structured event
23
+ * stream. Child stderr is captured here and re-emitted as
24
+ * `{type: 'child:stderr', text, ts}` events on the parent's stream
25
+ * (if any). No two-channel split.
26
+ *
27
+ * Action shape sent to gate.check (when wired through wireGate):
28
+ * { type: 'spawn', args: { config, input }, _ctx }
29
+ * Bareguard treats `args` as opaque — content patterns scan the
30
+ * JSON-serialized form. spawn.ratePerMinute (bareguard 0.2+) caps emits
31
+ * per-family.
32
+ */
33
+
34
+ const { spawn: cpSpawn } = require('node:child_process');
35
+ const path = require('node:path');
36
+ const readline = require('node:readline');
37
+
38
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min — children should finish or be killed
39
+
40
+ /**
41
+ * Resolve the bareagent CLI path. Prefers the local repo's bin/cli.js so
42
+ * the test suite + dev runs use the in-tree CLI; falls back to npx.
43
+ */
44
+ function resolveCliPath() {
45
+ // tools/spawn.js → ../bin/cli.js (works in dev tree and when installed via npm)
46
+ return path.resolve(__dirname, '..', 'bin', 'cli.js');
47
+ }
48
+
49
+ /**
50
+ * Library-level: spawn a child and return a handle.
51
+ *
52
+ * Returns: {
53
+ * wait() — Promise<{ text, usage, cost, error, events }>
54
+ * onLine(fn) — subscribe to every JSONL event from child stdout
55
+ * kill(sig?) — terminate the child
56
+ * pid — child process id
57
+ * }
58
+ *
59
+ * Use this from library code; the LLM-callable tool below wraps it with blocking semantics.
60
+ */
61
+ function spawnChild({ config, input, cliPath, timeoutMs, stream } = {}) {
62
+ if (typeof config !== 'string' || !config) {
63
+ throw new Error('[spawn] requires { config: <path> }');
64
+ }
65
+ const cli = cliPath || resolveCliPath();
66
+ const child = cpSpawn(process.execPath, [cli, '--config', config], {
67
+ stdio: ['pipe', 'pipe', 'pipe'],
68
+ env: {
69
+ ...process.env,
70
+ BAREGUARD_AUDIT_PATH: process.env.BAREGUARD_AUDIT_PATH || '',
71
+ BAREGUARD_BUDGET_FILE: process.env.BAREGUARD_BUDGET_FILE || '',
72
+ BAREGUARD_PARENT_RUN_ID: process.env.BAREGUARD_RUN_ID
73
+ || process.env.BAREGUARD_PARENT_RUN_ID
74
+ || '',
75
+ BAREGUARD_SPAWN_DEPTH: String((Number(process.env.BAREGUARD_SPAWN_DEPTH) || 0) + 1),
76
+ },
77
+ });
78
+
79
+ if (input !== undefined) {
80
+ child.stdin.write(JSON.stringify(input) + '\n');
81
+ }
82
+ child.stdin.end();
83
+
84
+ const events = [];
85
+ const lineSubscribers = [];
86
+ const onLine = (fn) => { lineSubscribers.push(fn); return () => {
87
+ const i = lineSubscribers.indexOf(fn);
88
+ if (i >= 0) lineSubscribers.splice(i, 1);
89
+ }; };
90
+
91
+ // stdout — JSONL events from the child loop
92
+ const outRl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
93
+ outRl.on('line', (line) => {
94
+ if (!line) return;
95
+ let event;
96
+ try { event = JSON.parse(line); }
97
+ catch {
98
+ // Not JSON — treat as raw text on the child's stdout (rare; surface as event)
99
+ event = { type: 'child:stdout_raw', text: line, ts: new Date().toISOString() };
100
+ }
101
+ events.push(event);
102
+ for (const fn of lineSubscribers) {
103
+ try { fn(event); } catch (err) {
104
+ // never let a subscriber kill the read loop
105
+ process.stderr.write(`[spawn] onLine subscriber threw: ${err.message}\n`);
106
+ }
107
+ }
108
+ });
109
+
110
+ // stderr — re-emit as child:stderr events on the same JSONL channel.
111
+ // Per the v0.9 decision: one stream per child. Wake.sh captures everything
112
+ // (events + debug) by redirecting child stdout alone; stderr was the
113
+ // *parent's* problem to consolidate into the JSONL stream.
114
+ const errRl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
115
+ errRl.on('line', (line) => {
116
+ if (!line) return;
117
+ const event = { type: 'child:stderr', text: line, ts: new Date().toISOString() };
118
+ events.push(event);
119
+ if (stream) {
120
+ try { stream.emit(event); } catch { /* swallow */ }
121
+ }
122
+ });
123
+
124
+ // Pre-register close-event promises NOW (not lazily inside child.on('exit')).
125
+ // The close event can fire before the exit handler runs; attaching .once()
126
+ // after the fact would hang forever.
127
+ const outClosePromise = new Promise(r => outRl.once('close', r));
128
+ const errClosePromise = new Promise(r => errRl.once('close', r));
129
+
130
+ // Timeout: kill child if it overruns. The grace period after SIGTERM is 5s
131
+ // before SIGKILL — enough for the child to flush its final JSONL line.
132
+ let killTimer = null;
133
+ if (timeoutMs && timeoutMs > 0) {
134
+ killTimer = setTimeout(() => {
135
+ try { child.kill('SIGTERM'); } catch { /* already dead */ }
136
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, 5000).unref();
137
+ }, timeoutMs);
138
+ killTimer.unref();
139
+ }
140
+
141
+ const exitPromise = new Promise((resolve) => {
142
+ child.on('exit', async (code, signal) => {
143
+ if (killTimer) clearTimeout(killTimer);
144
+ // Drain stdio readlines before resolving — last line may still be in buffer.
145
+ await Promise.all([outClosePromise, errClosePromise]);
146
+ resolve({ code, signal });
147
+ });
148
+ child.on('error', (err) => {
149
+ if (killTimer) clearTimeout(killTimer);
150
+ resolve({ code: null, signal: null, spawnError: err });
151
+ });
152
+ });
153
+
154
+ async function wait() {
155
+ const { code, signal, spawnError } = await exitPromise;
156
+ if (spawnError) {
157
+ return {
158
+ text: '',
159
+ usage: { inputTokens: 0, outputTokens: 0 },
160
+ cost: 0,
161
+ error: `[spawn] failed to spawn child: ${spawnError.message}`,
162
+ events,
163
+ exitCode: null,
164
+ signal: null,
165
+ };
166
+ }
167
+ // Pluck the final loop:done event — that's the canonical child result.
168
+ const done = events.findLast?.(e => e.type === 'loop:done')
169
+ || [...events].reverse().find(e => e.type === 'loop:done');
170
+ if (done) {
171
+ return {
172
+ text: done.data?.text || '',
173
+ usage: done.data?.usage || { inputTokens: 0, outputTokens: 0 },
174
+ cost: done.data?.cost ?? 0,
175
+ error: done.data?.warning || null,
176
+ events,
177
+ exitCode: code,
178
+ signal,
179
+ };
180
+ }
181
+ // No loop:done — child exited abnormally or never reached the LLM.
182
+ const errEvent = events.find(e => e.type === 'loop:error' || e.type === 'error');
183
+ return {
184
+ text: '',
185
+ usage: { inputTokens: 0, outputTokens: 0 },
186
+ cost: 0,
187
+ error: errEvent?.data?.error || `[spawn] child exited (code=${code}, signal=${signal}) without loop:done`,
188
+ events,
189
+ exitCode: code,
190
+ signal,
191
+ };
192
+ }
193
+
194
+ function kill(sig = 'SIGTERM') {
195
+ try { child.kill(sig); } catch { /* already dead */ }
196
+ }
197
+
198
+ return { wait, onLine, kill, pid: child.pid };
199
+ }
200
+
201
+ /**
202
+ * LLM-callable spawn tool. Blocks; returns the child's final result.
203
+ *
204
+ * @param {object} [options]
205
+ * @param {string} [options.cliPath] - Override the bareagent CLI path (default: ./bin/cli.js relative to this file).
206
+ * @param {number} [options.timeoutMs] - Force-kill child after this many ms (default 10 min).
207
+ * @param {object} [options.stream] - bareagent Stream instance — child:stderr events get re-emitted here.
208
+ * @returns {{tool: object, spawnChild: Function}}
209
+ */
210
+ function createSpawnTool(options = {}) {
211
+ const tool = {
212
+ name: 'spawn',
213
+ description:
214
+ 'Fork a child bareagent process with the given config file and optional JSON input. Blocks until the child finishes; returns its final {text, usage, cost, error, events}. Use this to delegate work to a specialist agent. Per-family limits (maxChildren, maxDepth, spawn.ratePerMinute) are enforced by bareguard.',
215
+ parameters: {
216
+ type: 'object',
217
+ properties: {
218
+ config: {
219
+ type: 'string',
220
+ description: 'Path to a bareagent config JSON file (specialist definition). Resolved relative to the parent process cwd.',
221
+ },
222
+ input: {
223
+ description: 'Optional JSON input passed to the child on stdin (any shape; the child config decides how to interpret it).',
224
+ },
225
+ },
226
+ required: ['config'],
227
+ },
228
+ execute: async ({ config, input }) => {
229
+ const handle = spawnChild({
230
+ config,
231
+ input,
232
+ cliPath: options.cliPath,
233
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
234
+ stream: options.stream,
235
+ });
236
+ return await handle.wait();
237
+ },
238
+ };
239
+ return { tool, spawnChild };
240
+ }
241
+
242
+ module.exports = { createSpawnTool, spawnChild };