Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions src/main/hive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,13 +585,10 @@ export class HiveManager {
if (!root) return;
mkdirSync(join(root, 'agents'), { recursive: true });

// Refreshed each bootstrap, like COMMANDS.md just below. It used to be
// written only when absent, which meant a hive created once never saw a
// protocol change again: this repo's own hive still carried the file from
// the day it was initialised, so every protocol addition since had reached
// new hives only. The file is generated, not user-authored, and agents are
// pointed at it as the authority, so a stale copy is worse than a rewrite.
writeFileSync(join(root, 'PROTOCOL.md'), PROTOCOL_MD, 'utf8');
for (const { filename, contents } of GENERATED_HIVE_DOCS) {
const path = join(root, filename);
if (!existsSync(path)) writeFileSync(path, contents, 'utf8');
}

const registry = join(root, 'registry.json');
if (!existsSync(registry)) {
Expand All @@ -613,10 +610,6 @@ export class HiveManager {
const log = join(root, 'log.jsonl');
if (!existsSync(log)) writeFileSync(log, '', 'utf8');

// The Claude Code command reference Michael consults (refreshed each bootstrap
// so it tracks the bundled list).
writeFileSync(join(root, 'COMMANDS.md'), COMMANDS_MD, 'utf8');

// Keep the churny/ephemeral live files out of the hive git repo.
const gitignore = join(root, '.gitignore');
// `crashes/` holds raw PTY output from abnormal agent exits. It is
Expand Down Expand Up @@ -648,6 +641,15 @@ export class HiveManager {
}
}

/** Deliberately replace generated hive docs with the bundled versions. */
refreshGeneratedDocs(): void {
const root = this.root();
if (!root) return;
for (const { filename, contents } of GENERATED_HIVE_DOCS) {
writeFileSync(join(root, filename), contents, 'utf8');
}
}

/** Validate an agent's cwd the way a spawn does — it must be an ABSOLUTE path
* that exists as a directory. Surfaced as `cwdValid` on the registry entry so
* the roster reliably exposes whether a worker's working directory is usable.
Expand Down Expand Up @@ -2776,14 +2778,19 @@ export class HiveManager {
}
}

// ─── PROTOCOL.md (written into the hive, readable by every agent) ────────────
// ─── Generated hive docs (written into the hive for every agent) ─────────────

const GENERATED_DOC_NOTICE =
'<!-- Generated and managed by Munder Difflin. Local edits may be replaced during hive bootstrap. -->';

/** The Claude Code command reference written to <hive>/COMMANDS.md, rendered from
* the SAME source as the UI "commands" tab so they never drift. Leads with the
* orchestrator note: slash = own session only, cli = shell/fleet; monitor
* siblings via fleet.json (claude agents does NOT see them). */
function renderCommandsMd(): string {
const lines: string[] = [
GENERATED_DOC_NOTICE,
'',
'# Claude Code commands',
'',
'Reference of the Claude Code commands available to you. Two kinds:',
Expand All @@ -2804,7 +2811,9 @@ function renderCommandsMd(): string {
}
const COMMANDS_MD = renderCommandsMd();

const PROTOCOL_MD = `# Hive protocol
const PROTOCOL_MD = `${GENERATED_DOC_NOTICE}

# Hive protocol

You are one of several Claude agents sharing this hive. Coordination is entirely
file-based; the harness (main process) is the only thing that runs git and the
Expand Down Expand Up @@ -2949,6 +2958,11 @@ Your \`memory.md\` is mined into the palace automatically, so the durable facts
write there become searchable by every agent. You don't run \`mine\` yourself.
`;

const GENERATED_HIVE_DOCS = [
{ filename: 'PROTOCOL.md', contents: PROTOCOL_MD },
{ filename: 'COMMANDS.md', contents: COMMANDS_MD }
] as const;

// ─── cth-hook shim (written to <hive>/bin/cth-hook.cjs) ──────────────────────
// A minimal pipe: read the hook payload on stdin, tag it with this agent's id,
// forward it to the hive's UDS, and relay the response back to `claude`. All the
Expand Down
1 change: 1 addition & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5047,6 +5047,7 @@ ipcMain.handle('workers:stop', (_evt, workerId: string): { ok: boolean; error?:
function bootstrapHiveServices(): void {
if (!hive.enabled()) return;
hive.ensureHive();
hive.refreshGeneratedDocs();
// Tell the hive what it is running inside, BEFORE anything spawns: the prompt
// builder reads this, so an agent spawned earlier would never learn it.
hive.setRuntimeInfo({ version: app.getVersion(), packaged: app.isPackaged, appPath: app.getAppPath() });
Expand Down
123 changes: 123 additions & 0 deletions test/hive-generated-docs.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
'use strict';

/**
* Generated hive documents are refreshed during hive-service bootstrap, not as a
* side effect of ordinary task or agent mutations. These regressions exercise the
* public HiveManager paths and pin the main-process bootstrap wiring without
* starting Electron, a PTY, or a CLI.
*/

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const loadTs = require('./load-ts.cjs');

const { HiveManager } = loadTs('src/main/hive.ts');

const GENERATED_NOTICE_PREFIX = '<!-- Generated and managed by Munder Difflin.';
const GENERATED_DOCS = [
{ filename: 'PROTOCOL.md', sentinel: 'PROTOCOL_SENTINEL\n', heading: '# Hive protocol' },
{ filename: 'COMMANDS.md', sentinel: 'COMMANDS_SENTINEL\n', heading: '# Claude Code commands' }
];

function floor(t) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'md-generated-docs-'));
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
const hive = new HiveManager(() => home);
hive.ensureHive();
return { hive, root: path.join(home, 'hive') };
}

function writeSentinels(root) {
for (const { filename, sentinel } of GENERATED_DOCS) {
fs.writeFileSync(path.join(root, filename), sentinel, 'utf8');
}
}

function assertSentinels(root) {
for (const { filename, sentinel } of GENERATED_DOCS) {
assert.equal(fs.readFileSync(path.join(root, filename), 'utf8'), sentinel);
}
}

function readGeneratedDoc(root, filename) {
return fs.readFileSync(path.join(root, filename), 'utf8');
}

function assertGenerated(root, docs = GENERATED_DOCS) {
for (const { filename, heading } of docs) {
const contents = readGeneratedDoc(root, filename);
assert.equal(contents.startsWith(GENERATED_NOTICE_PREFIX), true);
assert.equal(contents.split('\n').includes(heading), true);
}
}

test('writeTasks preserves existing generated hive docs', (t) => {
const { hive, root } = floor(t);
writeSentinels(root);

hive.writeTasks([]);

assertSentinels(root);
});

test('ensureAgent preserves existing generated hive docs', async (t) => {
const { hive, root } = floor(t);
writeSentinels(root);

await hive.ensureAgent({ id: 'agent-1', name: 'Agent', provider: 'claude', cwd: root });

assertSentinels(root);
});

test('refreshGeneratedDocs replaces stale generated hive docs', (t) => {
const { hive, root } = floor(t);
writeSentinels(root);

hive.refreshGeneratedDocs();

for (const { filename, sentinel } of GENERATED_DOCS) {
assert.equal(readGeneratedDoc(root, filename).includes(sentinel), false);
}
assertGenerated(root);
});

test('hive-service bootstrap refreshes generated hive docs', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src/main/index.ts'), 'utf8');
const bootstrapBody = source.match(/function bootstrapHiveServices\(\): void \{([\s\S]*?)^\}/m)?.[1];

assert.ok(bootstrapBody, 'bootstrapHiveServices() is missing');
assert.match(
bootstrapBody,
/hive\.ensureHive\(\);\s*hive\.refreshGeneratedDocs\(\);/,
'bootstrapHiveServices() must refresh generated docs after ensuring the hive'
);
assert.equal(
source.match(/hive\.refreshGeneratedDocs\(\);/g)?.length,
1,
'refreshGeneratedDocs() must have one production caller'
);
});

test('ensureHive restores missing generated hive docs', (t) => {
const { hive, root } = floor(t);
for (const { filename } of GENERATED_DOCS) fs.rmSync(path.join(root, filename));

hive.ensureHive();

assertGenerated(root);
});

test('ensureHive restores only the missing generated hive doc', (t) => {
const { hive, root } = floor(t);
const [missing, preserved] = GENERATED_DOCS;
fs.rmSync(path.join(root, missing.filename));
fs.writeFileSync(path.join(root, preserved.filename), preserved.sentinel, 'utf8');

hive.ensureHive();

assertGenerated(root, [missing]);
assert.equal(readGeneratedDoc(root, preserved.filename), preserved.sentinel);
});
Loading