neurastream 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -1,19 +1,54 @@
1
1
  # neurastream
2
2
 
3
- Agent orchestration (queue, guards, blockers, scoring, ledger) as one installable package. The core does not name a harness. Adapters translate harness events into core calls. The first adapter is omp.
3
+ Guardrails for running many AI coding agents at once.
4
4
 
5
- ## Install
5
+ One "main" agent plans the work and reviews it; cheaper "helper" agents do the coding. neurastream is the set of checks between them. It keeps a queue of written plans, refuses helper actions a plan forbids, limits how many helpers run per provider, scores each helper by accepted work per dollar, and only lets a plan leave the queue once its branch is really merged.
6
+
7
+ The core does not depend on one agent tool. Adapters translate a tool's events into core calls. The first adapter is [omp](https://github.com/can1357/oh-my-pi).
8
+
9
+ ```mermaid
10
+ flowchart LR
11
+ O[Owner message] --> P[Plan file in docs/plans]
12
+ P --> Q[queue-check]
13
+ Q --> S[Spawn checks<br/>ledger cap, score gate, brief-lint]
14
+ S --> H[Helper agent<br/>blockers + call budget]
15
+ H --> R[Main review<br/>agent-score accept]
16
+ R --> M[Merge to main]
17
+ M --> C[queue-close<br/>merged proof + done check]
18
+ ```
19
+
20
+ ## What it does
6
21
 
7
- Do not install into the real home while this package is still being built. Use a sandbox home.
22
+ | Part | What it enforces |
23
+ |---|---|
24
+ | Queue (`queue-check`, `queue-close`, `queue-cancel`) | Every open plan is listed in an index and follows a template. A plan leaves the queue only when its branch is merged into remote main and its done check passes; a failing check reopens it. A cancelled plan's unmerged work is saved under `refs/queue-archive/`. |
25
+ | Blockers | Each plan's phase 0 lists what helpers may not do: push, touch a path, run a command, or write a text. The guard refuses those calls. |
26
+ | Guards | Helpers may only edit their plan's `## Progress`; they get a warning at call 90 and are stopped at call 100. Protected files need an owner unlock (a file the owner touches, valid 30 minutes). |
27
+ | Ledger (`agent-ledger`) | Tokens, cost, state and exit reason per agent; `--cap` refuses a spawn when a provider already runs 2. |
28
+ | Scoring (`agent-score`) | Points only for work Main accepted by commit, times the phase difficulty. Ranked per dollar and per hour. |
29
+ | Brief lint (`brief-lint`) | Refuses a helper brief that misses a required rule (acceptance, stop condition, no push, call budget). |
30
+ | Machine health (`machine-guard`, `main-start`, `heavy-run`) | Load, free memory, disk and `/tmp` limits; one heavy test run at a time. |
31
+ | Unlanded work (`wip-check`, `wip-snapshot`, `wip-backup`) | Lists branches on no remote and dirty worktrees; snapshots them as hidden commits; can push them to a backup repo. |
32
+ | Session rollover | When the main session file passes 10 MB, the next owner message starts a fresh session from a handoff note. |
33
+
34
+ ## Requirements
35
+
36
+ - [Bun](https://bun.sh) 1.x
37
+ - git
38
+ - omp, for the guards and rollover (the command-line tools work without it)
39
+
40
+ ## Install
8
41
 
9
42
  ```sh
10
- export HOME=/tmp/neurastream-home-dev
11
43
  bun add -g neurastream
12
44
  neurastream init
13
45
  neurastream adapter install omp
46
+ neurastream doctor
14
47
  ```
15
48
 
16
- `init` writes `~/.config/neurastream/config.yml` from the defaults in `docs/config.md`, plus an empty index, notes, the queue template, blocker wrappers, and the state directories. A second run changes nothing. `adapter install omp` links the extension under that same home (`~/.omp/agent/extensions` and `omp plugin link`). Neither command touches `~/.omp` or `~/.local` of the real user.
49
+ `init` writes `~/.config/neurastream/config.yml` from the defaults in [docs/config.md](docs/config.md), plus an empty index, notes, the queue template, blocker wrappers, and the state directories. A second run changes nothing. `adapter install omp` links the extension (`~/.omp/agent/extensions` and `omp plugin link`). `doctor` exits 0 when the install matches the package.
50
+
51
+ The defaults are the author's setup (paths, the backup repo, provider names). Edit `config.yml` before relying on them. To try it without touching your real home, set `HOME=/tmp/neurastream-home-dev` first.
17
52
 
18
53
  Check the merged result:
19
54
 
@@ -21,11 +56,21 @@ Check the merged result:
21
56
  neurastream config --json
22
57
  ```
23
58
 
24
- Compatibility bins keep today's names (`agent-score`, `agent-ledger`, `brief-lint`, `oc-run`, `wip-check`, `wip-backup`, `wip-snapshot`, `pm-fail-facts`, `main-health`, `queue-check`, `queue-close`, `queue-cancel`, `main-start`, `machine-guard`, `glm-reset`, `priority-watch`, `resource-alerts`, `tmp-snapshot`, `heavy-run`). They are the same program, selected by the bin name.
59
+ Every tool is one program, selected by the name it was called with: `agent-score`, `agent-ledger`, `brief-lint`, `oc-run`, `wip-check`, `wip-backup`, `wip-snapshot`, `pm-fail-facts`, `main-health`, `queue-check`, `queue-close`, `queue-cancel`, `main-start`, `machine-guard`, `glm-reset`, `priority-watch`, `resource-alerts`, `tmp-snapshot`, `heavy-run`.
60
+
61
+ ## Develop
62
+
63
+ ```sh
64
+ bun install
65
+ bun run typecheck
66
+ bun test
67
+ ```
68
+
69
+ Green is both exiting 0.
25
70
 
26
- Scoring: `agent-score accept` records accepted work by commit; `agent-score validate <Agent> --plan <plan.md> --phase <P> --evidence <report.md> --section "<heading prefix>"` records a validation phase (checks/tests ran, report section written, no commits) with proof Main cannot fake by typing: the plan line must be ticked `- [x]`, the cited section body must name a heavy-run label run by that agent (from `paths.heavy_log`), and the phase must not be scored already. The ranking board counts it in `Accepted (diag/val)` as `N (d/v)`.
71
+ ## Scoring details
27
72
 
28
- Green is `bun run typecheck` and `bun test`, both exit 0.
73
+ `agent-score accept` records accepted work by commit; `agent-score validate <Agent> --plan <plan.md> --phase <P> --evidence <report.md> --section "<heading prefix>"` records a validation phase (checks/tests ran, report section written, no commits) with proof Main cannot fake by typing: the plan line must be ticked `- [x]`, the cited section body must name a heavy-run label run by that agent (from `paths.heavy_log`), and the phase must not be scored already. The ranking board counts it in `Accepted (diag/val)` as `N (d/v)`.
29
74
 
30
75
  ## Main reads the code for its plans
31
76
 
@@ -51,6 +96,7 @@ The switch is never automatic:
51
96
  - The owner's next plain message then closes the session and starts a fresh one. The fresh session opens with the note verbatim, the generated `main-start` state (including the owner holds), and then the owner's message. The previous session file stays where it is.
52
97
  - While own jobs (subagents included) are still running, the switch waits: a rollover would cancel them, so the owner's message stays in the current session until they finish.
53
98
  - `/main-rollover <message>` does the same by hand. It refuses below the threshold, without a fresh note, or while jobs run.
99
+ - `/main-rollover` shows a notice at once and gathers `main-start` in the background, so omp keeps repainting and reading keys until the new session opens.
54
100
  - `rollover.enabled: false` turns the feature off.
55
101
 
56
102
  ## Changing a script
@@ -63,3 +109,7 @@ neurastream adapter install omp
63
109
  ```
64
110
 
65
111
  Live copies are never hand-edited. `neurastream doctor` names each hand-edited or foreign installed file, and a stale install (the installed version is not `npm view neurastream version`). If the registry cannot be reached, that check is a warning, not a failure.
112
+
113
+ ## License
114
+
115
+ MIT. See [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "neurastream",
3
- "version": "0.1.7",
4
- "description": "Harness-agnostic agent orchestration: queue, guards, blockers, scoring, ledger.",
3
+ "version": "0.1.9",
4
+ "description": "Guardrails for running many AI coding agents at once: plan queue, blockers, call budgets, provider caps, scoring, merge-verified close.",
5
+ "keywords": ["ai-agents", "coding-agents", "orchestration", "guardrails", "omp", "bun", "cli"],
6
+ "homepage": "https://github.com/umiddey/neurastream#readme",
7
+ "bugs": "https://github.com/umiddey/neurastream/issues",
5
8
  "type": "module",
6
9
  "license": "MIT",
7
10
  "repository": {
8
11
  "type": "git",
9
12
  "url": "git+https://github.com/umiddey/neurastream.git"
10
13
  },
14
+ "engines": {
15
+ "bun": ">=1.0"
16
+ },
11
17
  "publishConfig": {
12
18
  "access": "public"
13
19
  },
@@ -432,7 +432,8 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
432
432
  c.notify(`main-rollover: own jobs are still running (${c.runningJobs().join(", ")}); a rollover would cancel them.`);
433
433
  return;
434
434
  }
435
- const generated = [await mainStartText([], cfg), holdsBlock(q.ownerHolds())].join("\n");
435
+ c.notify("main-rollover: collecting main-start state for the handoff (this can take a minute)…");
436
+ const generated = [await mainStartText([], cfg, { inheritStdin: false }), holdsBlock(q.ownerHolds())].join("\n");
436
437
  const handoff = buildHandoff({
437
438
  generated,
438
439
  note: readFileSync(path, "utf8"),
@@ -54,17 +54,10 @@ function costOf(m: Record<string, unknown>): number {
54
54
  return c && typeof c.total === "number" ? c.total : 0;
55
55
  }
56
56
 
57
- interface Built {
58
- run: Run;
59
- mtime: number;
60
- }
61
-
62
- function build(file: string, cfg: Config): Built | null {
57
+ function build(file: string, cfg: Config, mtime: number): Run | null {
63
58
  let text: string;
64
- let mtime: number;
65
59
  try {
66
60
  text = readFileSync(file, "utf8");
67
- mtime = statSync(file).mtimeMs / 1000;
68
61
  } catch {
69
62
  return null;
70
63
  }
@@ -202,7 +195,7 @@ function build(file: string, cfg: Config): Built | null {
202
195
  scoreSurvived: done && !scoreErr,
203
196
  scoreEnd: scoreErr || (done ? "done" : "no final message"),
204
197
  };
205
- return { run, mtime };
198
+ return run;
206
199
  }
207
200
 
208
201
  function readMain(file: string): MainSession {
@@ -253,9 +246,15 @@ export function ompRunSource(root: string, cfg: Config): RunSource {
253
246
  const files = [...jsonlAt(root, 1), ...jsonlAt(root, 2)];
254
247
  const runs: Run[] = [];
255
248
  for (const file of files) {
256
- const built = build(file, cfg);
257
- if (!built || built.mtime < since) continue;
258
- runs.push(built.run);
249
+ let mtime: number;
250
+ try {
251
+ mtime = statSync(file).mtimeMs / 1000;
252
+ } catch {
253
+ continue;
254
+ }
255
+ if (mtime < since) continue;
256
+ const run = build(file, cfg, mtime);
257
+ if (run) runs.push(run);
259
258
  }
260
259
  return runs;
261
260
  },
@@ -64,13 +64,21 @@ export function agentLedger(args: string[], cfg: Config, source: RunSource): num
64
64
 
65
65
  if (!args.includes("--cap")) return 0;
66
66
  const live: Record<string, string[]> = {};
67
+ const notLive: string[] = [];
67
68
  for (const r of rows) {
68
69
  if ((r.statusDetail.startsWith("RUNNING") || r.statusDetail.startsWith("WAITING")) && !r.id.startsWith("MAIN")) {
70
+ if ((nowSec() - r.lastWriteAt) / 60 >= cfg.ledger.idle_not_live_min) {
71
+ notLive.push(r.id);
72
+ continue;
73
+ }
69
74
  const provider = r.model.split("/")[0] ?? "";
70
75
  (live[provider] ??= []).push(r.id);
71
76
  }
72
77
  }
73
78
  process.stdout.write(`running per provider: ${pyDictInt(Object.fromEntries(Object.entries(live).map(([k, v]) => [k, v.length])))}\n`);
79
+ if (notLive.length) {
80
+ process.stdout.write(`NOT LIVE (no write for ≥ ${cfg.ledger.idle_not_live_min}m, not counted): ${notLive.join(", ")}\n`);
81
+ }
74
82
  let full = Object.entries(live).filter(([, v]) => v.length >= cfg.ledger.max_running_per_provider).map(([k]) => k);
75
83
  for (const k of full) process.stdout.write(`FULL ${k}: ${live[k]!.join(", ")}\n`);
76
84
  const over = rows.filter(r => r.statusDetail.includes("OVER-BUDGET")).map(r => r.id);
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { Config } from "./config.ts";
@@ -8,39 +8,83 @@ function bin(name: string, cfg: Config): string {
8
8
  return join(cfg.paths.home, ".local/bin", name);
9
9
  }
10
10
 
11
+ interface RunOpts {
12
+ stdin?: "inherit" | "ignore" | "pipe";
13
+ /** Written to the child's stdin when stdin is "pipe". */
14
+ input?: string;
15
+ stderr?: "inherit" | "pipe" | "ignore";
16
+ }
17
+
18
+ interface RunResult {
19
+ stdout: string;
20
+ stderr: string;
21
+ /** Set when the child could not start (e.g. ENOENT). */
22
+ error?: Error;
23
+ }
24
+
25
+ // Runs a child without blocking the caller's event loop. Resolves once the child has closed its
26
+ // streams, or immediately when it fails to start. The handoff runs inside a live agent session,
27
+ // which must keep handling events while Main's state is gathered.
28
+ function run(file: string, args: string[], opts: RunOpts = {}): Promise<RunResult> {
29
+ const { promise, resolve } = Promise.withResolvers<RunResult>();
30
+ const child = spawn(file, args, {
31
+ stdio: [opts.stdin ?? "ignore", "pipe", opts.stderr ?? "pipe"],
32
+ });
33
+ let stdout = "";
34
+ let stderr = "";
35
+ child.stdout?.setEncoding("utf8").on("data", chunk => {
36
+ stdout += chunk;
37
+ });
38
+ child.stderr?.setEncoding("utf8").on("data", chunk => {
39
+ stderr += chunk;
40
+ });
41
+ // A filter such as awk may exit before it has read all its input.
42
+ child.stdin?.on("error", () => {});
43
+ if (opts.stdin === "pipe") child.stdin?.end(opts.input ?? "");
44
+ child.on("error", error => resolve({ stdout, stderr, error }));
45
+ child.on("close", () => resolve({ stdout, stderr }));
46
+ return promise;
47
+ }
48
+
11
49
  // Main's session-start preflight as one text. The CLI prints it; the session rollover handoff
12
50
  // (src/core/rollover.ts) carries the same text into the new session. Children's stdout is
13
51
  // collected in order, so the printed output matches the original script line for line.
14
- export function mainStartText(args: string[], cfg: Config): string {
52
+ // inheritStdin: the CLI passes its terminal to the children that ask the operator something;
53
+ // an agent host passes false, because its stdin is the session's input.
54
+ // stderr: the CLI lets a child's stderr reach the terminal. An agent host captures it and appends it
55
+ // after that child's stdout, because a write to the terminal would draw over the session's UI.
56
+ export async function mainStartText(args: string[], cfg: Config, opts: { inheritStdin: boolean }): Promise<string> {
15
57
  const out: string[] = [];
16
58
  const say = (line: string) => out.push(`${line}\n`);
17
59
  const h = args[0] ?? String(cfg.health.default_hours);
60
+ const childStdin = opts.inheritStdin ? "inherit" : "ignore";
61
+ const childStderr = opts.inheritStdin ? "inherit" : "pipe";
18
62
  say("=== machine");
19
- const mg = spawnSync(bin("machine-guard", cfg), [], { encoding: "utf8", env: process.env });
20
- const mgLines = (mg.stdout ?? "").trim().split("\n");
63
+ const mg = await run(bin("machine-guard", cfg), [], { stderr: "ignore" });
64
+ const mgLines = mg.stdout.trim().split("\n");
21
65
  say(mgLines.at(-1) ?? "");
22
66
  say(readFileSync("/proc/loadavg", "utf8").trim());
23
67
  say("");
24
68
  say(`=== agents (last ${h}h; DIED = check its worktree, relaunch FRESH with its progress file)`);
25
- const ledger = spawnSync(bin("agent-ledger", cfg), ["--hours", h], { encoding: "utf8", env: process.env });
26
- const awk = spawnSync(
69
+ const ledger = await run(bin("agent-ledger", cfg), ["--hours", h], { stderr: "ignore" });
70
+ const awk = await run(
27
71
  "awk",
28
72
  ["NR==1 || /DIED/ {print; next} {ok[++n]=$0} END {for (i=1;i<=n;i++) print ok[i]}"],
29
- { input: ledger.stdout ?? "", encoding: "utf8" },
73
+ { stdin: "pipe", input: ledger.stdout, stderr: "ignore" },
30
74
  );
31
- out.push(awk.stdout ?? "");
32
- const cap = spawnSync(bin("agent-ledger", cfg), ["--cap"], { encoding: "utf8", env: process.env, stdio: ["ignore", "pipe", "ignore"] });
33
- for (const line of (cap.stdout ?? "").split("\n")) {
75
+ out.push(awk.stdout);
76
+ const cap = await run(bin("agent-ledger", cfg), ["--cap"], { stderr: "ignore" });
77
+ for (const line of cap.stdout.split("\n")) {
34
78
  if (/^(PRIORITY IDLE|FULL)/.test(line)) say(line);
35
79
  }
36
80
  say("");
37
81
  say("=== main thread");
38
- const health = spawnSync(bin("main-health", cfg), [], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"] });
39
- out.push(health.stdout ?? "");
82
+ const health = await run(bin("main-health", cfg), [], { stdin: childStdin, stderr: childStderr });
83
+ out.push(health.stdout + health.stderr);
40
84
  say("");
41
85
  say("=== queue");
42
- const check = spawnSync(bin("queue-check", cfg), [], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"] });
43
- out.push(check.stdout ?? "");
86
+ const check = await run(bin("queue-check", cfg), [], { stdin: childStdin, stderr: childStderr });
87
+ out.push(check.stdout + check.stderr);
44
88
  say("");
45
89
  say("=== heavy-run lock");
46
90
  try {
@@ -52,14 +96,14 @@ export function mainStartText(args: string[], cfg: Config): string {
52
96
  // The CLI file itself (shebang `#!/usr/bin/env bun`), never process.execPath: inside a host
53
97
  // harness that is the harness binary, which would start a new agent session instead.
54
98
  const cli = join(dirname(fileURLToPath(import.meta.url)), "../cli/main.ts");
55
- const doctor = spawnSync(cli, ["doctor"], { encoding: "utf8", env: process.env });
56
- const text = `${doctor.stdout ?? ""}${doctor.stderr ?? ""}`;
99
+ const doctor = await run(cli, ["doctor"]);
100
+ const text = `${doctor.stdout}${doctor.stderr}`;
57
101
  out.push(text.endsWith("\n") || text === "" ? text : `${text}\n`);
58
102
  if (doctor.error) say(doctor.error.message);
59
103
  return out.join("");
60
104
  }
61
105
 
62
- export function mainStart(args: string[], cfg: Config): number {
63
- process.stdout.write(mainStartText(args, cfg));
106
+ export async function mainStart(args: string[], cfg: Config): Promise<number> {
107
+ process.stdout.write(await mainStartText(args, cfg, { inheritStdin: true }));
64
108
  return 0;
65
109
  }