diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2eec58b..6570e0d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "agentcomm", "source": "./", "description": "Multi-agent mailbox CLI — any git remote is a bus (git+ssh/github/sqlite/s3/gcs/postgres). register/send/inbox/wait/claim/log/channels so Claude coordinates with other agents or sessions; auto-selects the repo bus inside a git repo.", - "version": "0.14.3", + "version": "0.15.0", "author": { "name": "Yoni Davidson" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ac366bf..bfa7e30 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agentcomm", "description": "Multi-agent mailbox CLI — any git remote is a bus (git+ssh/github/sqlite/s3/gcs/postgres). register/send/inbox/wait/claim/log/channels so Claude coordinates with other agents or sessions; auto-selects the repo bus inside a git repo.", - "version": "0.14.3", + "version": "0.15.0", "author": { "name": "Yoni Davidson", "email": "yonidavidson@tabnine.com" diff --git a/hooks/lib.mjs b/hooks/lib.mjs index 543b5c4..ecb0894 100644 --- a/hooks/lib.mjs +++ b/hooks/lib.mjs @@ -128,6 +128,36 @@ export async function activitySince(cwd, me, stateFile, cap = 4) { }; } +/** Current git branch (mechanical, non-prompt), or null. */ +export async function gitBranch(cwd) { + try { + // symbolic-ref works on unborn branches too (rev-parse HEAD needs a commit) + const b = await new Promise((res, rej) => + execFile('git', ['-C', cwd, 'symbolic-ref', '--short', 'HEAD'], (e, out) => + e ? rej(e) : res(out.trim()), + ), + ); + return b && b !== 'HEAD' ? b : null; + } catch { + return null; + } +} + +/** + * Extra register args that give the board a mechanical default status — + * "on " — WITHOUT clobbering a status the agent actually declared. + * Set it only when the record has no status, or when the existing status is + * itself a branch-default (so it follows branch switches). A real declared + * status (anything not matching the branch pattern) is left untouched. + */ +export function branchStatusArgs(currentStatus, branch) { + if (!branch) return []; + const isBranchDefault = currentStatus == null || /^on \S+$/.test(currentStatus); + if (!isBranchDefault) return []; + if (currentStatus === `on ${branch}`) return []; // already correct, no write + return ['--status', `on ${branch}`]; +} + export function aliasFrom(stderr) { const m = /acting as (\S+)/.exec(stderr ?? ''); return m?.[1] ?? null; diff --git a/hooks/prompt-digest.mjs b/hooks/prompt-digest.mjs index d486eaf..859ec26 100644 --- a/hooks/prompt-digest.mjs +++ b/hooks/prompt-digest.mjs @@ -10,7 +10,7 @@ import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { createHash } from 'node:crypto'; -import { readStdinJson, onTheBus, cli, aliasFrom, activitySince } from './lib.mjs'; +import { readStdinJson, onTheBus, cli, aliasFrom, activitySince, gitBranch, branchStatusArgs } from './lib.mjs'; const input = await readStdinJson(); const cwd = input.cwd || process.cwd(); @@ -23,13 +23,14 @@ try { if (Date.now() - (await fs.stat(stamp)).mtimeMs < 5 * 60_000) process.exit(0); } catch { /* first digest */ } -// Heartbeat rides the digest: a prompt is the strongest "this session is -// alive" signal, and both want the same ~5min cadence. register is async -// through the daemon outbox, so this costs ~0.3s. No --status: a heartbeat -// never overwrites a declared status. -await cli(['register', '--json'], cwd, 3_000); +// Heartbeat rides the digest (~5min). Fetch roster first to read our own +// status, then register — refreshing the mechanical "on " default +// without ever clobbering a status the agent declared. +const preAgents = await cli(['agents', '--json'], cwd, 3_000); +const myPre = preAgents && Array.isArray(preAgents.json) ? preAgents.json.find((a) => a.thisSession) : null; +await cli(['register', '--json', ...branchStatusArgs(myPre?.status, await gitBranch(cwd))], cwd, 3_000); const peek = await cli(['peek', '--json'], cwd, 3_000); -const agents = await cli(['agents', '--json'], cwd, 3_000); +const agents = (await cli(['agents', '--json'], cwd, 3_000)) ?? preAgents; await fs.writeFile(stamp, '').catch(() => {}); if (!peek && !agents) process.exit(0); @@ -62,7 +63,9 @@ const alias0 = aliasFrom(peek?.stderr); // coordination — nudge it (gently: at most once per 30min per repo) let statusNudge = null; const myRec = roster.find((a) => a.name === alias0); -if (myRec && !myRec.status) { +// nudge when there is no status OR only the mechanical branch default — +// agents should upgrade "on " to what they are actually doing +if (myRec && (!myRec.status || /^on \S+$/.test(myRec.status))) { const nudgeStamp = path.join(os.tmpdir(), `agentcomm-nudge-${id}`); let due = true; try { @@ -70,7 +73,7 @@ if (myRec && !myRec.status) { } catch { /* first */ } if (due) { statusNudge = - 'You carry no bus status — declare what you are working on now: `agentcomm register --status ""` (or "blocked: " to recruit help).'; + `Your bus status is ${myRec.status ? `just the branch ("${myRec.status}")` : 'unset'} — say what you are actually doing so teammates see it: \`agentcomm register --status ""\` (or "blocked: " to recruit help).`; await fs.writeFile(nudgeStamp, '').catch(() => {}); } } diff --git a/hooks/session-start.mjs b/hooks/session-start.mjs index 3586056..05c48d5 100644 --- a/hooks/session-start.mjs +++ b/hooks/session-start.mjs @@ -5,7 +5,7 @@ * its derived session alias, and the live roster — before the first prompt. * Silent everywhere else; silent on any failure. */ -import { readStdinJson, onTheBus, cli, busUriFrom, aliasFrom } from './lib.mjs'; +import { readStdinJson, onTheBus, cli, busUriFrom, aliasFrom, gitBranch, branchStatusArgs } from './lib.mjs'; const input = await readStdinJson(); const cwd = input.cwd || process.cwd(); @@ -25,11 +25,22 @@ try { fresh = Date.now() - (await fsp.stat(stamp)).mtimeMs < 10 * 60_000; } catch { /* never registered from here */ } -const reg = fresh ? null : await cli(['register', '--json'], cwd, 20_000); +// Know our current status before registering, so a mechanical branch +// default never clobbers a real one the agent declared. +const pre = await cli(['agents', '--json'], cwd, 20_000); +const branch = await gitBranch(cwd); +let myStatus; +if (pre && Array.isArray(pre.json)) { + const mine = pre.json.find((a) => a.thisSession); + myStatus = mine?.status; +} +const reg = fresh + ? null + : await cli(['register', '--json', ...branchStatusArgs(myStatus, branch)], cwd, 20_000); if (reg) await fsp.writeFile(stamp, '').catch(() => {}); const peek = await cli(['peek', '--json'], cwd); if (!peek) process.exit(0); -const res = await cli(['agents', '--json'], cwd); +const res = (await cli(['agents', '--json'], cwd)) ?? pre; const bus = busUriFrom(peek.stderr) ?? busUriFrom(reg?.stderr); const alias = aliasFrom(peek.stderr) ?? reg?.json?.name; @@ -54,6 +65,7 @@ const lines = [ (a) => `call to action — ${a.name} is asking: "${a.status}". If you can answer from what you already know, reply: \`agentcomm send ${a.name} "" --subject status\`.`, ), + `As your FIRST action, declare what you are actually doing so teammates can see it: \`agentcomm register --status ""\`${branch ? ` (currently shown as "on ${branch}")` : ''}. Update it when your task changes; "blocked: " recruits help.`, 'To coordinate: `agentcomm send ` / `inbox --json` / `wait`; the agentcomm skill has the conventions.', ].filter(Boolean); diff --git a/package.json b/package.json index 93394f5..d07e623 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentcomm", - "version": "0.14.3", + "version": "0.15.0", "description": "A tiny mailbox/message bus for AI agents that shell out to one CLI. Pluggable storage backends behind a single Backend interface.", "type": "module", "license": "MIT", diff --git a/skills/agentcomm/SKILL.md b/skills/agentcomm/SKILL.md index d8b5e9f..1d1ec3c 100644 --- a/skills/agentcomm/SKILL.md +++ b/skills/agentcomm/SKILL.md @@ -172,7 +172,10 @@ help; check `log --limit 10` first (it may already be answered); reply once, on the asker's thread. When unblocked, change your status back to plain work — that stops the recruiting. -**Always carry a status.** At the start of ANY task, declare it: +The hooks give you a mechanical starting status — `on ` — so the +roster is never blank, but that only says WHERE you are, not WHAT you are +doing. **Upgrade it at the start of any task** — declaring overrides the +branch default and sticks until you change it: `agentcomm register --status "reviewing PR 12"` (short, present tense; it persists across heartbeats until you change it — set `--status done` when finishing). An empty status makes you invisible to coordination: nobody diff --git a/test/hooks.e2e.test.ts b/test/hooks.e2e.test.ts index 0cf2e0e..2853a28 100644 --- a/test/hooks.e2e.test.ts +++ b/test/hooks.e2e.test.ts @@ -427,7 +427,7 @@ describe('plugin hooks: bus discipline made mechanical', () => { const first = await runHook('prompt-digest.mjs', { cwd: dir }, dir); const out = JSON.parse(first.stdout) as { hookSpecificOutput: { additionalContext: string } }; - expect(out.hookSpecificOutput.additionalContext).toContain('You carry no bus status'); + expect(out.hookSpecificOutput.additionalContext).toMatch(/bus status is (unset|just the branch)/); // declared → clear throttles → no nudge, and (quiet bus) full silence cliSync(['register', '--status', 'building the feed'], dir); @@ -469,6 +469,35 @@ describe('plugin hooks: bus discipline made mechanical', () => { expect(quiet.stdout).toBe(''); }); + it('session-start sets a mechanical branch-default status; a real status is never clobbered', async () => { + const dir = await markedRepo(); + execFileSync('git', ['-C', dir, 'checkout', '-q', '-b', 'feat-auth']); + + // fresh session, no status → hook sets "on feat-auth" + await runHook('session-start.mjs', { cwd: dir, source: 'startup' }, dir); + let me = rosterJson(dir).find((a) => a.name.startsWith('hooky-'))!; + expect(me.status).toBe('on feat-auth'); + + // agent declares a real status → later session-start must NOT overwrite it + cliSync(['register', '--status', 'reviewing PR 12'], dir); + // clear the register throttle so the hook re-registers + for (const f of await fs.readdir(dir)) { + if (f.startsWith('agentcomm-register-')) await fs.rm(path.join(dir, f)); + } + await runHook('session-start.mjs', { cwd: dir, source: 'startup' }, dir); + me = rosterJson(dir).find((a) => a.name.startsWith('hooky-'))!; + expect(me.status).toBe('reviewing PR 12'); // preserved + + // switching branches only moves a branch-DEFAULT status, not a real one + execFileSync('git', ['-C', dir, 'checkout', '-q', '-b', 'feat-other']); + for (const f of await fs.readdir(dir)) { + if (f.startsWith('agentcomm-register-')) await fs.rm(path.join(dir, f)); + } + await runHook('session-start.mjs', { cwd: dir, source: 'startup' }, dir); + me = rosterJson(dir).find((a) => a.name.startsWith('hooky-'))!; + expect(me.status).toBe('reviewing PR 12'); // still the declared one + }); + it('stop guard honors stop_hook_active (no loops) and throttles repeat checks', async () => { const dir = await markedRepo(); cliSync(['register'], dir);