diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7820ed8..a6d9294 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.1", + "version": "0.14.2", "author": { "name": "Yoni Davidson" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ff16870..7722b59 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.1", + "version": "0.14.2", "author": { "name": "Yoni Davidson", "email": "yonidavidson@tabnine.com" diff --git a/hooks/prompt-digest.mjs b/hooks/prompt-digest.mjs index 1446fec..d486eaf 100644 --- a/hooks/prompt-digest.mjs +++ b/hooks/prompt-digest.mjs @@ -37,11 +37,25 @@ const pending = Array.isArray(peek?.json) ? peek.json.length : 0; const roster = agents && Array.isArray(agents.json) ? agents.json : []; const names = roster.map((a) => a.name).sort(); let known = []; +let knownStatuses = {}; try { - known = JSON.parse(await fs.readFile(rosterFile, 'utf8')); + const snap = JSON.parse(await fs.readFile(rosterFile, 'utf8')); + if (Array.isArray(snap)) known = snap; // pre-0.14.2 snapshot shape + else { + known = snap.names ?? []; + knownStatuses = snap.statuses ?? {}; + } } catch { /* no snapshot yet */ } -await fs.writeFile(rosterFile, JSON.stringify(names)).catch(() => {}); +const statuses = Object.fromEntries(roster.filter((a) => a.status).map((a) => [a.name, a.status])); +await fs.writeFile(rosterFile, JSON.stringify({ names, statuses: { ...knownStatuses, ...statuses } })).catch(() => {}); const joined = known.length ? names.filter((n) => !known.includes(n)) : []; +// a status CHANGE is news in itself — "X started doing Y" must reach the +// others within one digest cycle, not wait for unrelated traffic +const statusChanges = known.length + ? roster.filter( + (a) => a.status && a.name !== aliasFrom(peek?.stderr) && knownStatuses[a.name] !== a.status, + ) + : []; const activeAgents = roster.filter((a) => Date.now() - Date.parse(a.lastSeen) < 10 * 60_000); const alias0 = aliasFrom(peek?.stderr); // status adoption: an agent with no declared status is invisible to @@ -79,8 +93,11 @@ if (joined.length) ); const isAsk = (t) => /^(blocked|need|help)\b/i.test(t ?? ''); const asks = activeAgents.filter((a) => a.name !== aliasFrom(peek?.stderr) && isAsk(a.status)); +const changed = statusChanges.filter((a) => !isAsk(a.status)); +if (changed.length) + bits.push(`now working — ${changed.slice(0, 4).map((a) => `${a.name}: ${a.status}`).join(' · ')}`); const withStatus = activeAgents - .filter((a) => a.status && !isAsk(a.status)) + .filter((a) => a.status && !isAsk(a.status) && !changed.some((c) => c.name === a.name)) .map((a) => `${a.name}: ${a.status}`); if (withStatus.length) bits.push(`working — ${withStatus.slice(0, 4).join(' · ')}`); bits.push(`${activeAgents.length}/${roster.length} agents active`); @@ -92,7 +109,14 @@ for (const a of asks.slice(0, 3)) { 'Otherwise continue your own task.', ); } -if (!pending && joined.length === 0 && ctas.length === 0 && activity.length === 0 && !statusNudge) +if ( + !pending && + joined.length === 0 && + ctas.length === 0 && + activity.length === 0 && + statusChanges.length === 0 && + !statusNudge +) process.exit(0); // no news, no noise process.stdout.write( diff --git a/package.json b/package.json index da51585..5715342 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentcomm", - "version": "0.14.1", + "version": "0.14.2", "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/test/hooks.e2e.test.ts b/test/hooks.e2e.test.ts index 58947d3..0cf2e0e 100644 --- a/test/hooks.e2e.test.ts +++ b/test/hooks.e2e.test.ts @@ -283,6 +283,17 @@ describe('plugin hooks: bus discipline made mechanical', () => { for (const f of await fs.readdir(dir)) { if (f.startsWith('agentcomm-digest-') && !f.includes('roster')) await fs.rm(path.join(dir, f)); } + // clearing the ask IS news (the change to plain work is announced, no CTA) + const cleared = await runHook('prompt-digest.mjs', { cwd: dir }, dir); + const clearedOut = JSON.parse(cleared.stdout) as { hookSpecificOutput: { additionalContext: string } }; + expect(clearedOut.hookSpecificOutput.additionalContext).toContain('now working — worker-1: building auth'); + expect(clearedOut.hookSpecificOutput.additionalContext).not.toContain('call to action'); + + // and once seen, steady state → silence + for (const f of await fs.readdir(dir)) { + if (f.startsWith('agentcomm-digest-') && !f.includes('roster') && !f.includes('acts')) + await fs.rm(path.join(dir, f)); + } const quiet = await runHook('prompt-digest.mjs', { cwd: dir }, dir); expect(quiet.stdout).toBe(''); }); @@ -429,6 +440,35 @@ describe('plugin hooks: bus discipline made mechanical', () => { expect(second.stdout).toBe(''); }); + it("a status CHANGE alone is news — 'X started doing Y' reaches others in one cycle", async () => { + const dir = await markedRepo(); + cliSync(['register', '--status', 'my own work'], dir); + cliSync(['register', '--status', 'idling'], dir, 'schema-worker'); + + // prime the snapshot (statuses recorded) + await runHook('prompt-digest.mjs', { cwd: dir }, dir); + + // ONLY a status change — no mail, no riders, no asks + cliSync(['register', '--status', 'designing the auth schema'], dir, 'schema-worker'); + for (const f of await fs.readdir(dir)) { + if (f.startsWith('agentcomm-digest-') && !f.includes('roster') && !f.includes('acts')) + await fs.rm(path.join(dir, f)); + } + const news = await runHook('prompt-digest.mjs', { cwd: dir }, dir); + const out = JSON.parse(news.stdout) as { hookSpecificOutput: { additionalContext: string } }; + expect(out.hookSpecificOutput.additionalContext).toContain( + 'now working — schema-worker: designing the auth schema', + ); + + // unchanged next cycle → silence again + for (const f of await fs.readdir(dir)) { + if (f.startsWith('agentcomm-digest-') && !f.includes('roster') && !f.includes('acts')) + await fs.rm(path.join(dir, f)); + } + const quiet = await runHook('prompt-digest.mjs', { cwd: dir }, dir); + expect(quiet.stdout).toBe(''); + }); + it('stop guard honors stop_hook_active (no loops) and throttles repeat checks', async () => { const dir = await markedRepo(); cliSync(['register'], dir);