@thecolonylab/hivemind 0.1.0 → 0.1.3

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.
@@ -39,7 +39,7 @@ export async function coverageCommand(opts) {
39
39
  process.exitCode = 2;
40
40
  return;
41
41
  }
42
- // The server only knows its own path (e.g. /artifacts/...) — it has no idea what URL you're
42
+ // The server only knows its own path (e.g. /runs/...) — it has no idea what URL you're
43
43
  // reaching it at (localhost in dev, a real domain in prod), so the full link gets built here.
44
44
  for (const page of result.affectedPages) {
45
45
  for (const t of page.testsRun) {
@@ -1,6 +1,9 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { mkdir, readFile, writeFile, copyFile, access } from 'node:fs/promises';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ const execFileAsync = promisify(execFile);
4
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
8
  // dist/commands/init.js -> package root (skill/ ships alongside dist/, see package.json "files")
6
9
  const packageRoot = join(__dirname, '..', '..');
@@ -9,6 +12,53 @@ async function exists(path) {
9
12
  .then(() => true)
10
13
  .catch(() => false);
11
14
  }
15
+ /**
16
+ * Cloud mode (the default) never touches Playwright on this machine at all — the browser lives on
17
+ * the server. But `--local` mode does, and the starter hivemind.config.json defaults to
18
+ * localhost:3000, so most fresh projects are headed toward --local sooner or later. Checking here
19
+ * means the first `--local` run doesn't stall on an unexpected multi-minute download mid-test —
20
+ * `chromium.executablePath()` is a cheap path lookup, not a launch, so this stays fast when the
21
+ * browser's already installed (every run after the first).
22
+ */
23
+ async function ensureLocalBrowser() {
24
+ const { chromium } = await import('playwright');
25
+ const installed = await exists(chromium.executablePath());
26
+ if (installed) {
27
+ console.log('✓ local browser (for --local mode) already installed');
28
+ return;
29
+ }
30
+ console.log('⧗ downloading local browser for --local mode (one-time, ~150MB)...');
31
+ try {
32
+ await execFileAsync('npx', ['playwright', 'install', 'chromium']);
33
+ console.log('✓ local browser installed');
34
+ }
35
+ catch (err) {
36
+ console.log(`- couldn't auto-install the local browser (${err?.message || err}) — run \`npx playwright install chromium\` yourself before using --local`);
37
+ }
38
+ }
39
+ const GITIGNORE_BLOCK = `# HIVEMIND:START
40
+ # hivemind local state — real evidence (screenshots, run history, folded-in memory), but not
41
+ # meant to be reviewed in a PR the way hive/tests/ is. Regenerated by every run, safe to delete.
42
+ hive/history/
43
+ hive/artifacts/
44
+ hive/memory.md
45
+ hive/last-run.json
46
+ # HIVEMIND:END
47
+ `;
48
+ /** One hive/ folder for everything — tests/ is the only part meant to be committed and reviewed;
49
+ * history/, artifacts/, memory.md, and last-run.json are local-only, excluded here rather than
50
+ * living in a separate dot-prefixed folder. */
51
+ async function ensureGitignore(cwd) {
52
+ const path = join(cwd, '.gitignore');
53
+ const existing = (await exists(path)) ? await readFile(path, 'utf-8') : '';
54
+ if (existing.includes('# HIVEMIND:START')) {
55
+ console.log('- .gitignore already has the hivemind block, left untouched');
56
+ return;
57
+ }
58
+ const updated = existing ? `${existing.trimEnd()}\n\n${GITIGNORE_BLOCK}` : GITIGNORE_BLOCK;
59
+ await writeFile(path, updated, 'utf-8');
60
+ console.log(existing ? '✓ .gitignore updated so hive/history, artifacts, memory stay local-only' : '✓ .gitignore created so hive/history, artifacts, memory stay local-only');
61
+ }
12
62
  /**
13
63
  * One-time setup for a project adopting hivemind — matches TesterArmy's `ta agent init` pattern.
14
64
  * Idempotent: safe to run again later, never overwrites something already there.
@@ -17,6 +67,7 @@ export async function initCommand() {
17
67
  const cwd = process.cwd();
18
68
  await mkdir(join(cwd, 'hive', 'tests'), { recursive: true });
19
69
  console.log('✓ hive/tests/ ready');
70
+ await ensureGitignore(cwd);
20
71
  const configPath = join(cwd, 'hivemind.config.json');
21
72
  if (await exists(configPath)) {
22
73
  console.log('- hivemind.config.json already exists, left untouched');
@@ -45,6 +96,7 @@ export async function initCommand() {
45
96
  await mkdir(skillDir, { recursive: true });
46
97
  await copyFile(join(packageRoot, 'skill', 'hivemind-cli', 'SKILL.md'), join(skillDir, 'SKILL.md'));
47
98
  console.log('✓ .agents/skills/hivemind-cli/SKILL.md installed');
99
+ await ensureLocalBrowser();
48
100
  console.log('\nDone. A coding agent working in this repo now knows how to use hivemind.');
49
101
  console.log('Next: hivemind create "<what to test>" --url <your app url>');
50
102
  }
@@ -1,3 +1,4 @@
1
+ const MAX_BROWSER_LOGS = 200;
1
2
  /**
2
3
  * The Forager — runs on your machine, executes exactly what the Hivemind brain tells it to, one
3
4
  * named tool call at a time. Same `execute(name, input)` shape used in-process on the server for
@@ -11,14 +12,55 @@ export class Forager {
11
12
  runStart;
12
13
  pageStack = [];
13
14
  screenshots = [];
15
+ browserLogs = [];
14
16
  constructor(page, runStart) {
15
17
  this.page = page;
16
18
  this.runStart = runStart;
19
+ this.attachLogging(page);
20
+ }
21
+ /** Same reasoning as the server's ToolExecutor: a click can succeed while the page underneath
22
+ * throws a JS error or a fetch 500s, and the report today has no way to show that. This is the
23
+ * ONLY side that ever sees these events for a --local run — the server never opened this
24
+ * browser — so every call's result carries the full accumulated log back over the socket
25
+ * (see execute() below); RemoteToolRunner just keeps the latest one it receives. */
26
+ attachLogging(page) {
27
+ const push = (entry) => {
28
+ if (this.browserLogs.length < MAX_BROWSER_LOGS)
29
+ this.browserLogs.push(entry);
30
+ };
31
+ page.on('console', (msg) => {
32
+ if (msg.type() === 'log' || msg.type() === 'debug')
33
+ return;
34
+ push({ type: 'console', level: msg.type(), text: msg.text().slice(0, 500), elapsedMs: Date.now() - this.runStart });
35
+ });
36
+ page.on('pageerror', (err) => {
37
+ push({ type: 'console', level: 'error', text: err.message.slice(0, 500), elapsedMs: Date.now() - this.runStart });
38
+ });
39
+ page.on('requestfailed', (req) => {
40
+ push({
41
+ type: 'network',
42
+ level: 'failed',
43
+ text: `${req.method()} ${req.url()} — ${req.failure()?.errorText || 'failed'}`.slice(0, 500),
44
+ elapsedMs: Date.now() - this.runStart,
45
+ });
46
+ });
47
+ page.on('response', (res) => {
48
+ if (res.status() < 400)
49
+ return;
50
+ push({ type: 'network', level: String(res.status()), text: `${res.request().method()} ${res.url()} → ${res.status()}`.slice(0, 500), elapsedMs: Date.now() - this.runStart });
51
+ });
17
52
  }
18
53
  getScreenshots() {
19
54
  return this.screenshots;
20
55
  }
56
+ getBrowserLogs() {
57
+ return this.browserLogs;
58
+ }
21
59
  async execute(name, input) {
60
+ const result = await this.executeInner(name, input);
61
+ return { ...result, browserLogs: this.browserLogs };
62
+ }
63
+ async executeInner(name, input) {
22
64
  try {
23
65
  switch (name) {
24
66
  case 'browser_navigate': {
@@ -170,6 +212,7 @@ export class Forager {
170
212
  await newPage.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => { });
171
213
  this.pageStack.push(prevPage);
172
214
  this.page = newPage;
215
+ this.attachLogging(newPage);
173
216
  newPage.on('close', () => {
174
217
  if (this.page === newPage) {
175
218
  const fallback = this.pageStack.pop();
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { exec } from 'node:child_process';
4
+ import { readApiKey } from '../credentials.js';
4
5
  function serverUrl() {
5
6
  return process.env.HIVEMIND_SERVER_URL || 'https://app.thecolonylab.com';
6
7
  }
@@ -9,7 +10,7 @@ function open(url) {
9
10
  exec(`${opener} "${url}"`);
10
11
  }
11
12
  export async function reportCommand() {
12
- const pointerPath = join(process.cwd(), '.hive', 'last-run.json');
13
+ const pointerPath = join(process.cwd(), 'hive', 'last-run.json');
13
14
  try {
14
15
  const pointer = JSON.parse(await readFile(pointerPath, 'utf-8'));
15
16
  console.log(`Opening ${pointer.reportUrl}`);
@@ -21,9 +22,15 @@ export async function reportCommand() {
21
22
  }
22
23
  export async function historyCommand(opts) {
23
24
  const url = `${serverUrl()}/api/runs`;
24
- const res = await fetch(url).catch(() => null);
25
+ const apiKey = await readApiKey();
26
+ const res = await fetch(url, apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : undefined).catch(() => null);
25
27
  if (!res || !res.ok) {
26
- console.error(`Could not reach hivemind server at ${serverUrl()}. Is it running? (hivemind-server)`);
28
+ if (res?.status === 401) {
29
+ console.error(`Not signed in — run "hivemind login <key>" (get a key from the dashboard's Settings page).`);
30
+ }
31
+ else {
32
+ console.error(`Could not reach hivemind server at ${serverUrl()}. Is it running? (hivemind-server)`);
33
+ }
27
34
  process.exitCode = 2;
28
35
  return;
29
36
  }
@@ -24,15 +24,15 @@ async function runPool(items, limit, worker) {
24
24
  }
25
25
  async function readLocalMemory(cwd) {
26
26
  try {
27
- return await readFile(join(cwd, '.hive', 'memory.md'), 'utf-8');
27
+ return await readFile(join(cwd, 'hive', 'memory.md'), 'utf-8');
28
28
  }
29
29
  catch {
30
30
  return '';
31
31
  }
32
32
  }
33
33
  async function writeLocalMemory(cwd, content) {
34
- const path = join(cwd, '.hive', 'memory.md');
35
- await mkdir(join(cwd, '.hive'), { recursive: true });
34
+ const path = join(cwd, 'hive', 'memory.md');
35
+ await mkdir(join(cwd, 'hive'), { recursive: true });
36
36
  await writeFile(path, content, 'utf-8');
37
37
  }
38
38
  export async function runCommand(target, opts) {
@@ -81,7 +81,7 @@ export async function runCommand(target, opts) {
81
81
  console.error(`✗ ${test.name}: ${err.message}`);
82
82
  results[index] = {
83
83
  name: test.name,
84
- result: { status: 'error', verdict: err.message, steps: [], toolLog: [], screenshots: [], durationMs: 0 },
84
+ result: { status: 'error', verdict: err.message, steps: [], toolLog: [], screenshots: [], browserLogs: [], durationMs: 0 },
85
85
  };
86
86
  return;
87
87
  }
@@ -95,7 +95,7 @@ export async function runCommand(target, opts) {
95
95
  console.error(`✗ ${test.name}: ${message}`);
96
96
  results[index] = {
97
97
  name: test.name,
98
- result: { status: 'error', verdict: message, steps: [], toolLog: [], screenshots: [], durationMs: 0 },
98
+ result: { status: 'error', verdict: message, steps: [], toolLog: [], screenshots: [], browserLogs: [], durationMs: 0 },
99
99
  };
100
100
  return;
101
101
  }
@@ -116,7 +116,7 @@ export async function runCommand(target, opts) {
116
116
  credential,
117
117
  headed: opts.headed,
118
118
  });
119
- await writeFile(join(cwd, '.hive', 'last-run.json'), JSON.stringify({ reportUrl: reportPath, name: test.name }, null, 2)).catch(() => { });
119
+ await writeFile(join(cwd, 'hive', 'last-run.json'), JSON.stringify({ reportUrl: reportPath, name: test.name }, null, 2)).catch(() => { });
120
120
  results[index] = { name: test.name, result, reportUrl: reportPath };
121
121
  if (!opts.json) {
122
122
  printResult(test.name, result);
@@ -133,7 +133,7 @@ export async function runCommand(target, opts) {
133
133
  });
134
134
  const reportUrl = `${serverUrl}${reportPath}`;
135
135
  await writeLocalMemory(cwd, updatedMemory).catch(() => { });
136
- await writeFile(join(cwd, '.hive', 'last-run.json'), JSON.stringify({ reportUrl, name: test.name }, null, 2)).catch(() => { });
136
+ await writeFile(join(cwd, 'hive', 'last-run.json'), JSON.stringify({ reportUrl, name: test.name }, null, 2)).catch(() => { });
137
137
  results[index] = { name: test.name, result, reportUrl };
138
138
  if (!opts.json) {
139
139
  printResult(test.name, result);
@@ -145,7 +145,7 @@ export async function runCommand(target, opts) {
145
145
  console.error(`✗ ${test.name}: ${err.message}`);
146
146
  results[index] = {
147
147
  name: test.name,
148
- result: { status: 'error', verdict: err.message, steps: [], toolLog: [], screenshots: [], durationMs: 0 },
148
+ result: { status: 'error', verdict: err.message, steps: [], toolLog: [], screenshots: [], browserLogs: [], durationMs: 0 },
149
149
  };
150
150
  }
151
151
  });
@@ -40,6 +40,7 @@ export async function runTestLocally(opts) {
40
40
  try {
41
41
  const wsUrl = opts.serverUrl.replace(/^http/, 'ws') + '/ws/local-run';
42
42
  const apiKey = await readApiKey();
43
+ let hostedReportUrl;
43
44
  const result = await new Promise((resolve, reject) => {
44
45
  const headers = { 'User-Agent': 'Hivemind-Forager/0.1.0' };
45
46
  if (apiKey)
@@ -69,6 +70,7 @@ export async function runTestLocally(opts) {
69
70
  return;
70
71
  }
71
72
  if (msg.type === 'finish') {
73
+ hostedReportUrl = msg.reportUrl;
72
74
  resolve(msg.result);
73
75
  socket.close();
74
76
  return;
@@ -89,7 +91,9 @@ export async function runTestLocally(opts) {
89
91
  await session.close();
90
92
  closed = true;
91
93
  await writeRunArtifacts(opts.cwd, runId, opts.name, result);
92
- const reportPath = `${artifactDirFor(opts.cwd, runId)}/report.html`;
94
+ // Prefer the real, hosted, shareable report (shows up in the dashboard's Runs list) — the
95
+ // local file written above is just a backup for offline viewing, same data either way.
96
+ const reportPath = hostedReportUrl ? `${opts.serverUrl}${hostedReportUrl}` : `${artifactDirFor(opts.cwd, runId)}/report.html`;
93
97
  return { result, reportPath };
94
98
  }
95
99
  finally {
@@ -5,8 +5,11 @@ import { slugify } from '../testFile/write.js';
5
5
  function timestampDir() {
6
6
  return new Date().toISOString().replace(/[:.]/g, '-');
7
7
  }
8
+ /** Everything lives under one hive/ folder — tests/ is the committed part, history/ and
9
+ * artifacts/ are local-only and gitignored (see `hivemind init`'s .gitignore rules). One folder
10
+ * to look in instead of splitting state across hive/ and a separate .hive/. */
8
11
  export function artifactDirFor(cwd, runId) {
9
- return join(cwd, 'artifacts', runId);
12
+ return join(cwd, 'hive', 'artifacts', runId);
10
13
  }
11
14
  /** Creates the artifact directory for a run BEFORE it starts, so ToolExecutor can write screenshots into it. */
12
15
  export async function createArtifactDir(cwd, testName) {
@@ -20,7 +23,7 @@ export async function writeRunArtifacts(cwd, runId, testName, result) {
20
23
  const artifactDir = artifactDirFor(cwd, runId);
21
24
  const html = renderHtmlReport(testName, result);
22
25
  await writeFile(join(artifactDir, 'report.html'), html, 'utf-8');
23
- const historyDir = join(cwd, '.hive', 'history');
26
+ const historyDir = join(cwd, 'hive', 'history');
24
27
  await mkdir(historyDir, { recursive: true });
25
28
  const entry = {
26
29
  name: testName,
@@ -34,7 +37,7 @@ export async function writeRunArtifacts(cwd, runId, testName, result) {
34
37
  return artifactDir;
35
38
  }
36
39
  export async function readHistory(cwd) {
37
- const historyDir = join(cwd, '.hive', 'history');
40
+ const historyDir = join(cwd, 'hive', 'history');
38
41
  try {
39
42
  const files = await readdir(historyDir);
40
43
  const entries = await Promise.all(files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thecolonylab/hivemind",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Forager — the CLI that runs on your machine and executes for the Hivemind brain. Describe a flow in plain English, an AI agent drives a real browser and reports pass/fail. No LLM key needed on this machine.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "commander": "^12.1.0",
22
22
  "dotenv": "^17.4.2",
23
- "playwright": "^1.48.0",
23
+ "playwright": "1.48.0",
24
24
  "ws": "^8.21.0"
25
25
  },
26
26
  "devDependencies": {
@@ -6,8 +6,9 @@ code or selectors required.
6
6
 
7
7
  - **Skill:** see `.agents/skills/hivemind-cli/SKILL.md` for the full command reference, test file
8
8
  format, login/precondition conventions, and how to read results.
9
- - **Setup check:** `hivemind --help`; server defaults to `http://localhost:4321` unless
10
- `HIVEMIND_SERVER_URL` is set.
9
+ - **Setup check:** `hivemind --help`; the CLI talks to the hosted server at
10
+ `https://app.thecolonylab.com` by default — only set `HIVEMIND_SERVER_URL` if pointing at a
11
+ self-hosted server instead.
11
12
  - **Default workflow:** right after building or changing a UI flow, write or generate a test in
12
13
  `hive/tests/`, run it with `hivemind run <file> --url <url> --json`, and fix your own code if it
13
14
  fails — before telling the user the feature is done.
@@ -25,7 +25,7 @@ machine — the hivemind server (wherever `HIVEMIND_SERVER_URL` points) holds it
25
25
 
26
26
  ```bash
27
27
  hivemind --help
28
- echo $HIVEMIND_SERVER_URL # if unset, defaults to http://localhost:4321
28
+ echo $HIVEMIND_SERVER_URL # if unset, defaults to the hosted server at https://app.thecolonylab.com
29
29
  ```
30
30
 
31
31
  If the command isn't found, it isn't installed — tell the user rather than trying to work around it.
@@ -50,6 +50,17 @@ hivemind explore <url>
50
50
  # Open the most recent run's report, or list past runs.
51
51
  hivemind report
52
52
  hivemind history [--json]
53
+
54
+ # Diff-aware regression check — for CI, or before opening a PR. Reads the real git diff, figures
55
+ # out which known pages are affected, RE-RUNS whatever local tests already touch them (this is
56
+ # the actual regression check), and drafts a new test for any genuine gap left uncovered. Add
57
+ # --local when testing a build that only exists on this machine/runner (same reasoning as `run`).
58
+ # --pr-comment <path> writes a ready-to-post markdown report (numbered pass/fail + report links).
59
+ hivemind coverage [--changed <base>] [--local] [--pr-comment <path>]
60
+
61
+ # Pull down a test `coverage` drafted for a gap — identified by a short code (e.g.
62
+ # "amber-fern-cove") shown in its output/PR comment, not by pasting any code.
63
+ hivemind apply <code>
53
64
  ```
54
65
 
55
66
  Always use `--json` on `run` when you (the agent) are consuming the result programmatically — it