@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.2

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.
Files changed (101) hide show
  1. package/README.md +46 -0
  2. package/package.json +7 -4
  3. package/src/clients/hub-bridge.mjs +99 -0
  4. package/src/clients/hub.mjs +324 -0
  5. package/src/commands/pulse/cutover.js +39 -0
  6. package/src/commands/pulse/doctor.js +1 -0
  7. package/src/commands/pulse/import.js +62 -0
  8. package/src/commands/pulse/log-write.js +26 -0
  9. package/src/commands/pulse/parity.js +20 -0
  10. package/src/commands/pulse/shadow.js +19 -0
  11. package/src/daemon/main.js +19 -12
  12. package/src/hub/core/auth.mjs +80 -0
  13. package/src/hub/core/changes.mjs +38 -0
  14. package/src/hub/core/conversations.mjs +121 -0
  15. package/src/hub/core/db.mjs +263 -0
  16. package/src/hub/core/edgeIds.mjs +9 -0
  17. package/src/hub/core/hub.mjs +20 -0
  18. package/src/hub/core/ids.mjs +39 -0
  19. package/src/hub/core/leases.mjs +55 -0
  20. package/src/hub/core/messaging.mjs +322 -0
  21. package/src/hub/core/registry.mjs +281 -0
  22. package/src/hub/core/ticks.mjs +58 -0
  23. package/src/hub/core/wake.mjs +125 -0
  24. package/src/hub/home.mjs +16 -0
  25. package/src/hub/hooks/hub-bridge.mjs +167 -0
  26. package/src/hub/keepawake/agent.mjs +96 -0
  27. package/src/hub/lib/brief.mjs +133 -0
  28. package/src/hub/lib/channels.mjs +268 -0
  29. package/src/hub/lib/chat.mjs +163 -0
  30. package/src/hub/lib/closure.mjs +92 -0
  31. package/src/hub/lib/collect-worker.mjs +20 -0
  32. package/src/hub/lib/collect.mjs +342 -0
  33. package/src/hub/lib/deliveries.mjs +222 -0
  34. package/src/hub/lib/git.mjs +93 -0
  35. package/src/hub/lib/me.mjs +123 -0
  36. package/src/hub/lib/personas.mjs +143 -0
  37. package/src/hub/lib/status.mjs +147 -0
  38. package/src/hub/lib/store.mjs +106 -0
  39. package/src/hub/lib/titles.mjs +29 -0
  40. package/src/hub/lib/transcript-feed.mjs +72 -0
  41. package/src/hub/lib/transcripts.mjs +364 -0
  42. package/src/hub/lib/writing.mjs +18 -0
  43. package/src/hub/projections/deliveries.mjs +31 -0
  44. package/src/hub/projections/graph.mjs +149 -0
  45. package/src/hub/runners/drivers.mjs +162 -0
  46. package/src/hub/workers/collector.mjs +128 -0
  47. package/src/hub/workers/manager.mjs +366 -0
  48. package/src/hub/workers/runner.mjs +145 -0
  49. package/src/lib/application.js +82 -34
  50. package/src/lib/channelScanner.js +99 -0
  51. package/src/lib/clientInstaller.js +111 -0
  52. package/src/lib/cloudFirstCommands.js +293 -0
  53. package/src/lib/contract.sha256 +1 -1
  54. package/src/lib/cutover.js +376 -0
  55. package/src/lib/cutoverWiring.js +137 -0
  56. package/src/lib/daemonLock.js +2 -0
  57. package/src/lib/daemonLoop.js +5 -2
  58. package/src/lib/daemonProcess.js +16 -2
  59. package/src/lib/daemonRuntime.js +8 -0
  60. package/src/lib/doctorWarnings.js +53 -0
  61. package/src/lib/errors.js +14 -0
  62. package/src/lib/hubDbReader.js +132 -0
  63. package/src/lib/hubMapping.js +108 -0
  64. package/src/lib/hubScan.js +611 -0
  65. package/src/lib/hubSnapshot.js +64 -0
  66. package/src/lib/mirrorContract.js +1 -1
  67. package/src/lib/mirrorService.js +8 -1
  68. package/src/lib/mirrorSource.js +13 -38
  69. package/src/lib/nodeFloor.js +19 -0
  70. package/src/lib/outboxFlusher.js +75 -0
  71. package/src/lib/pairingLifecycle.js +1 -1
  72. package/src/lib/reconcile.js +316 -0
  73. package/src/lib/reconcileView.js +129 -0
  74. package/src/lib/runnerStore.js +418 -0
  75. package/src/lib/serviceManager.js +61 -0
  76. package/src/lib/shadowService.js +435 -0
  77. package/src/lib/sqlite.js +28 -0
  78. package/src/lib/tokenStore.js +43 -4
  79. package/src/lib/transport.js +53 -5
  80. package/src/lib/transportContract.js +3 -0
  81. package/src/lib/uploadRedactor.js +102 -0
  82. package/src/runner/applier.js +297 -0
  83. package/src/runner/bridge.js +289 -0
  84. package/src/runner/busIdentity.js +499 -0
  85. package/src/runner/capture.js +103 -0
  86. package/src/runner/captureMapping.js +21 -0
  87. package/src/runner/channelDiscipline.js +425 -0
  88. package/src/runner/deliveryStates.js +220 -0
  89. package/src/runner/facade.js +876 -0
  90. package/src/runner/hubCapture.js +160 -0
  91. package/src/runner/lease.js +46 -0
  92. package/src/runner/main.js +35 -0
  93. package/src/runner/render.js +91 -0
  94. package/src/runner/runnerChild.js +57 -0
  95. package/src/runner/runnerHub.js +104 -0
  96. package/src/runner/runnerProcess.js +124 -0
  97. package/src/runner/runnerSchema.js +51 -0
  98. package/src/runner/runnerService.js +140 -0
  99. package/src/runner/spool.js +86 -0
  100. package/src/runner/spoolInto.js +22 -0
  101. package/src/runner/statusPage.js +30 -0
@@ -0,0 +1,96 @@
1
+ // agents-hub keepawake/agent.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Keeps the Mac awake for the hub. Every 30 s it reads the keep-awake mode and holds exactly one `caffeinate` while the
3
+ // Mac should stay awake, reporting to state/keepawake-status.json (read by store.keepAwakeStatus). Runs as the
4
+ // LaunchAgent that keepawake/install.mjs loads.
5
+ import { spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { KEEPAWAKE_STATUS, readSettings } from '../lib/store.mjs';
11
+ import { runnerDb } from '../home.mjs';
12
+
13
+ export const TICK_MS = 30e3;
14
+ export const AGENTS_RECENT_MS = 10 * 60e3;
15
+ const DB = runnerDb();
16
+
17
+ /** `always` holds the Mac awake, `agents` only while an agent was heard from in the last 10 min, anything else never. */
18
+ export function shouldStayAwake(mode, { lastAgentAt = 0, now }) {
19
+ if (mode === 'always') return true;
20
+ if (mode === 'agents') return lastAgentAt > 0 && now - lastAgentAt < AGENTS_RECENT_MS;
21
+ return false;
22
+ }
23
+
24
+ /** When any agent or orchestrator last reached the hub, from hub.db opened read-only; 0 when unknown. */
25
+ export function lastAgentAt(file = DB) {
26
+ let db = null;
27
+ try {
28
+ db = new DatabaseSync(file, { readOnly: true });
29
+ const r = db.prepare(`SELECT MAX(p.last_seen) AS at FROM presence p JOIN actors a ON a.id = p.actor_id
30
+ WHERE a.node_type IN ('agent', 'orchestrator')`).get();
31
+ return Number(r && r.at) || 0;
32
+ } catch { return 0; } finally { try { if (db) db.close(); } catch { /* closed */ } }
33
+ }
34
+
35
+ /**
36
+ * -ims prevents idle, system and disk sleep but lets the display sleep. -w ties the child to this process, so even a
37
+ * killed helper never leaves a caffeinate behind.
38
+ */
39
+ export const CAFFEINATE_ARGS = (pid = process.pid) => ['-ims', '-w', String(pid)];
40
+ const caffeinate = () => spawn('/usr/bin/caffeinate', CAFFEINATE_ARGS(), { stdio: 'ignore' });
41
+
42
+ /** Holds at most one caffeinate child: started when asked to stay awake, killed when not, restarted if it died. */
43
+ export function createKeeper({ start = caffeinate, now = Date.now } = {}) {
44
+ let child = null;
45
+ let since = null;
46
+ const drop = (c) => { if (child === c) { child = null; since = null; } };
47
+ return {
48
+ set(awake) {
49
+ if (awake && !child) {
50
+ const c = start();
51
+ child = c;
52
+ since = now();
53
+ c.on('exit', () => drop(c));
54
+ c.on('error', (e) => { console.error(`caffeinate failed: ${e.message}`); drop(c); });
55
+ } else if (!awake && child) {
56
+ const c = child;
57
+ drop(c);
58
+ c.kill();
59
+ }
60
+ return { running: !!child, since };
61
+ },
62
+ };
63
+ }
64
+
65
+ export function writeStatus(file, status) {
66
+ const tmp = `${file}.${process.pid}.tmp`;
67
+ fs.writeFileSync(tmp, JSON.stringify(status));
68
+ fs.renameSync(tmp, file);
69
+ }
70
+
71
+ function main() {
72
+ const keeper = createKeeper();
73
+ const tick = () => {
74
+ try {
75
+ const { mode } = readSettings().keepAwake;
76
+ const now = Date.now();
77
+ const state = keeper.set(shouldStayAwake(mode, { lastAgentAt: mode === 'agents' ? lastAgentAt() : 0, now }));
78
+ writeStatus(KEEPAWAKE_STATUS, { ...state, mode, heartbeat: now, pid: process.pid });
79
+ } catch (e) { console.error(`keep-awake tick failed: ${e.message}`); }
80
+ };
81
+ tick();
82
+ const timer = setInterval(tick, TICK_MS);
83
+ const quit = () => {
84
+ clearInterval(timer);
85
+ keeper.set(false);
86
+ try { writeStatus(KEEPAWAKE_STATUS, { running: false, since: null, heartbeat: 0, stoppedAt: Date.now() }); } catch { /* best effort */ }
87
+ process.exit(0);
88
+ };
89
+ process.on('SIGTERM', quit);
90
+ process.on('SIGINT', quit);
91
+ process.on('exit', () => keeper.set(false));
92
+ console.log(`keep-awake helper running (pid ${process.pid})`);
93
+ }
94
+
95
+ const invoked = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
96
+ if (invoked) main();
@@ -0,0 +1,133 @@
1
+ // agents-hub lib/brief.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // The written brief for a work stream: brief.md beside the channel, the spec its KICKOFF names, or the KICKOFF body.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { parsePosts } from './channels.mjs';
6
+
7
+ const MAX_CHARS = 12000;
8
+ const MAX_SECTIONS = 24;
9
+ const SPEC_PATH = /(?:^|\s)(?:Spec:\s*)?(docs\/[A-Za-z0-9._/-]+\.md)/;
10
+ const H2 = /^(#{1,2})\s+(.+)$/;
11
+ const NAMED = /^(?:\*\*(.+?)\*\*|([A-Z][A-Za-z0-9 /&-]{2,48}):)\s*$/;
12
+ /** Standing protocol sections — not what the agent was asked to build. */
13
+ const SKIP = /^(workflow\b.*|hard rules|standing rules|browser\b.*|return\b.*|protocol|never|unattended|communicating)$/i;
14
+
15
+ function safeFile(root, rel) {
16
+ if (!rel || rel.includes('\0') || rel.includes('..')) return null;
17
+ let realRoot;
18
+ try { realRoot = fs.realpathSync(root); } catch { return null; }
19
+ const candidate = path.resolve(root, rel);
20
+ let real;
21
+ try { real = fs.realpathSync(candidate); } catch { return null; }
22
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null;
23
+ try { if (!fs.statSync(real).isFile()) return null; } catch { return null; }
24
+ return real;
25
+ }
26
+
27
+ /** At most `bytes` of a regular file; never blocks on a FIFO or reads a device. */
28
+ export function readCapped(file, bytes = MAX_CHARS * 4) {
29
+ let fd;
30
+ try {
31
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
32
+ if (!fs.fstatSync(fd).isFile()) return null;
33
+ const buf = Buffer.alloc(bytes);
34
+ const n = fs.readSync(fd, buf, 0, bytes, 0);
35
+ return buf.subarray(0, n).toString('utf8');
36
+ } catch { return null; } finally { if (fd !== undefined) fs.closeSync(fd); }
37
+ }
38
+
39
+ const readText = (file) => readCapped(file);
40
+
41
+ /** Split a markdown brief into a title and ## / named sections. */
42
+ export function parseBriefSections(text, { maxChars = MAX_CHARS } = {}) {
43
+ const raw = String(text || '').replace(/\r/g, '').slice(0, maxChars);
44
+ const titleMatch = raw.match(/^#\s+(.+)$/m);
45
+ const title = titleMatch ? titleMatch[1].replace(/[*`]/g, '').trim() : null;
46
+ const sections = [];
47
+ let heading = null;
48
+ let buf = [];
49
+ const flush = () => {
50
+ const body = buf.join('\n').trim();
51
+ buf = [];
52
+ if (!heading) {
53
+ if (!body) return;
54
+ heading = 'Overview';
55
+ }
56
+ if (body) sections.push({ heading, body });
57
+ };
58
+ for (const line of raw.split('\n')) {
59
+ const h2 = line.match(H2);
60
+ if (h2) {
61
+ const name = h2[2].replace(/[*`]/g, '').trim();
62
+ if (h2[1] === '#' && title && name === title) continue;
63
+ if (h2[1] === '#' && !title) {
64
+ flush();
65
+ heading = name;
66
+ continue;
67
+ }
68
+ if (h2[1] === '##') {
69
+ flush();
70
+ heading = name;
71
+ continue;
72
+ }
73
+ }
74
+ const named = line.match(NAMED);
75
+ if (named) {
76
+ flush();
77
+ heading = (named[1] || named[2]).replace(/[*`]/g, '').trim();
78
+ continue;
79
+ }
80
+ buf.push(line);
81
+ }
82
+ flush();
83
+ const kept = sections.filter((s) => !SKIP.test(s.heading)).slice(0, MAX_SECTIONS);
84
+ return { title, sections: kept.length ? kept : sections.slice(0, MAX_SECTIONS) };
85
+ }
86
+
87
+ const hasBody = (b) => !!(b && b.sections && b.sections.some((s) => s.body && s.body.trim()));
88
+
89
+ function fromMarkdown(file, source) {
90
+ const text = readText(file);
91
+ if (!text) return null;
92
+ const parsed = parseBriefSections(text);
93
+ if (!parsed.title && !parsed.sections.length) return null;
94
+ return { source, ...parsed };
95
+ }
96
+
97
+ function kickoffOf(channelFile) {
98
+ try {
99
+ const st = fs.statSync(channelFile);
100
+ const posts = parsePosts(fs.readFileSync(channelFile, 'utf8'), st.mtimeMs, undefined, { full: true });
101
+ return posts.find((p) => /^KICKOFF/.test(p.type.toUpperCase())) || null;
102
+ } catch { return null; }
103
+ }
104
+
105
+ /**
106
+ * The task the agents were given for this channel. Prefers brief.md, then the spec named in KICKOFF, then the
107
+ * KICKOFF body. `query` is an optional agent transcript first prompt used when those files are thin.
108
+ */
109
+ export function readTaskBrief(channelFile, { query = null } = {}) {
110
+ const dir = path.dirname(channelFile);
111
+ const briefFile = safeFile(dir, 'brief.md');
112
+ const briefMd = briefFile ? fromMarkdown(briefFile, 'brief.md') : null;
113
+ const kick = kickoffOf(channelFile);
114
+ const kickText = kick ? [kick.head.replace(/^KICKOFF\b[:\s—-]*/i, '').trim(), kick.body].filter(Boolean).join('\n').trim() : '';
115
+ const specRel = kickText.match(SPEC_PATH);
116
+ let spec = null;
117
+ if (specRel) {
118
+ const repoRoot = path.resolve(dir, '..', '..');
119
+ const file = safeFile(repoRoot, specRel[1]);
120
+ if (file) spec = fromMarkdown(file, specRel[1]);
121
+ }
122
+ const kickBrief = kickText
123
+ ? { source: 'kickoff', title: briefMd && briefMd.title, ...parseBriefSections(kickText) }
124
+ : null;
125
+ if (kickBrief && !kickBrief.sections.length && kickText.length > 40) {
126
+ kickBrief.sections = [{ heading: 'Kickoff', body: kickText }];
127
+ }
128
+ const fromQuery = query ? { source: 'transcript', ...parseBriefSections(query) } : null;
129
+ if (fromQuery && !fromQuery.sections.length && query && query.trim()) {
130
+ fromQuery.sections = [{ heading: 'Details', body: query.trim().slice(0, MAX_CHARS) }];
131
+ }
132
+ return [briefMd, spec, fromQuery, kickBrief].find(hasBody) || spec || briefMd || fromQuery || kickBrief || null;
133
+ }
@@ -0,0 +1,268 @@
1
+ // agents-hub lib/channels.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Task channels (.agents/<task>/channel.md): finding them, parsing posts with real dates, and reading a work stream's
3
+ // state from the channel protocol: step, open blockers and asks by finding id, and who has the ball.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { everyRoleClosed, reopens, streamCloser } from './closure.mjs';
7
+
8
+ export const STEPS = ['Research', 'Design', 'Mockup', 'Build', 'Review', 'Checks', 'Proof'];
9
+ export const HEADER = /^## \[([A-Z][A-Z0-9 -]*?)\] (\d{1,2}):(\d{2})(?::(\d{2}))?(?: (IST|UTC|GMT))? ([^\n]*)$/;
10
+ const ROLE_ALIASES = { 'PLATFORM-AUDITOR': 'PLATFORM AUDITOR', AUDITOR: 'PLATFORM AUDITOR' };
11
+ export const normRole = (r) => { const u = String(r || '').toUpperCase().trim(); return ROLE_ALIASES[u] || u; };
12
+ const DAY_S = 86400;
13
+ const DAY_JUMP_S = 2 * 3600;
14
+ const MAX_BODY_LINES = 6;
15
+ const FULL_BODY_LINES = 120;
16
+ const FULL_BODY_CHARS = 8000;
17
+
18
+ /** The first markdown heading of a file (a brief or spec title), or null. */
19
+ export function firstHeading(file) {
20
+ let fd;
21
+ try {
22
+ const st = fs.lstatSync(file);
23
+ if (!st.isFile()) return null;
24
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
25
+ const buf = Buffer.alloc(4000);
26
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
27
+ const m = buf.subarray(0, n).toString('utf8').match(/^#\s+(.+)$/m);
28
+ return m ? m[1].replace(/[*`]/g, '').trim() : null;
29
+ } catch { return null; } finally { if (fd !== undefined) fs.closeSync(fd); }
30
+ }
31
+
32
+ /** Every channel under roots/<repo>/.agents/<task>/channel.md changed since sinceMs. key = "<repo>/<task>". */
33
+ export function channelFiles(roots, sinceMs) {
34
+ const out = [];
35
+ for (const root of roots) {
36
+ let repos = [];
37
+ try { repos = fs.readdirSync(root); } catch { continue; }
38
+ for (const repo of repos) {
39
+ const dir = path.join(root, repo, '.agents');
40
+ let tasks = [];
41
+ try { tasks = fs.readdirSync(dir); } catch { continue; }
42
+ for (const task of tasks) {
43
+ const file = path.join(dir, task, 'channel.md');
44
+ try { const st = fs.statSync(file); if (st.isFile() && st.mtimeMs >= sinceMs) out.push({ key: `${repo}/${task}`, task, repo, file, mtime: st.mtimeMs }); } catch { /* none */ }
45
+ }
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ const ids = (s) => [...new Set((String(s).match(/#[A-Z]?\d+/g) || []).map((x) => x.slice(1)))];
52
+
53
+ /**
54
+ * Posts from a channel's text, oldest first. Headers carry a time but no date: the last post is on the file's
55
+ * modified day, and walking backwards a clock that jumps forward by more than 2 h means the day before. UTC/GMT
56
+ * times are converted to local time first.
57
+ */
58
+ export function parsePosts(text, mtime, utcOffsetS = -new Date(mtime).getTimezoneOffset() * 60, { full = false } = {}) {
59
+ const lines = String(text).replace(/\r/g, '').split('\n');
60
+ const raw = [];
61
+ for (let i = 0; i < lines.length; i++) {
62
+ const m = lines[i].match(HEADER);
63
+ if (!m) continue;
64
+ const body = [];
65
+ const fullBody = [];
66
+ for (let j = i + 1; j < lines.length && !HEADER.test(lines[j]); j++) {
67
+ if (lines[j].trim() && body.length < MAX_BODY_LINES) body.push(lines[j].trim());
68
+ if (full && fullBody.length < FULL_BODY_LINES) fullBody.push(lines[j].trimEnd());
69
+ if (!full && body.length >= MAX_BODY_LINES) break;
70
+ }
71
+ const head = m[6].trim();
72
+ let sec = +m[2] * 3600 + +m[3] * 60 + +(m[4] || 0) + (m[5] === 'UTC' || m[5] === 'GMT' ? utcOffsetS : 0);
73
+ const shift = Math.floor(sec / DAY_S);
74
+ sec -= shift * DAY_S;
75
+ const post = { role: normRole(m[1]), sec, shift, head, type: head.split(/ [—(-] | — /)[0].trim().slice(0, 40), text: [head, ...body].join(' ').slice(0, 900), ids: ids(head) };
76
+ if (full) post.body = fullBody.join('\n').trim().slice(0, FULL_BODY_CHARS);
77
+ raw.push(post);
78
+ }
79
+ const mt = new Date(mtime);
80
+ const day = new Date(mt.getFullYear(), mt.getMonth(), mt.getDate());
81
+ let later = mt.getHours() * 3600 + mt.getMinutes() * 60 + mt.getSeconds() + 600;
82
+ for (let k = raw.length - 1; k >= 0; k--) {
83
+ if (raw[k].sec > later + DAY_JUMP_S) day.setDate(day.getDate() - 1);
84
+ later = raw[k].sec;
85
+ raw[k].at = day.getTime() + raw[k].sec * 1000;
86
+ delete raw[k].sec;
87
+ delete raw[k].shift;
88
+ }
89
+ return raw;
90
+ }
91
+
92
+ const isReviewer = (r) => r.includes('REVIEWER');
93
+ const isCoder = (r) => r.includes('CODER');
94
+ const isAuditor = (r) => r.includes('AUDITOR');
95
+ const ballOf = (role) => (isReviewer(role) ? 'REVIEWER' : isAuditor(role) ? 'AUDITOR' : isCoder(role) ? 'CODER' : role === 'ARCHITECT' ? 'ARCHITECT' : null);
96
+ /** Posts that do not move the work: they never change who has the ball. */
97
+ const CHATTER = /^(WAITING|STATUS|JOINED|POST|NOTE|ACK(?! COMMITTED)|STOPPING|STOOD DOWN|HEARTBEAT|PROGRESS)/;
98
+ /** A post that moves the work: an agent's, and not a check-in such as the scheduled STATUS. */
99
+ export const movesWork = (p) => p.role !== 'USER' && !CHATTER.test(p.type.toUpperCase());
100
+ /** The step one header implies on its own (0 when it implies none); a stream's step is the running maximum of these. */
101
+ export function postStep(p) {
102
+ const t = p.type.toUpperCase();
103
+ let step = 0;
104
+ if (/^(PLAN|INVENTORY|DESIGN)/.test(t)) step = 1;
105
+ if (/MOCKUP/.test(t)) step = 2;
106
+ if (isCoder(p.role) && /^(STARTED|READY|RESOLVED|FIXED|COMMITTED)/.test(t)) step = 3;
107
+ if (isReviewer(p.role) && /^(REVIEW\b|FINDINGS|MAJOR|MINOR|CHANGES|APPROVED|VERIFIED|ACK COMMITTED)/.test(t)) step = 4;
108
+ if (/^(DEPLOYED|DEPLOY POINT|MERGED|LIVE CHECK)/.test(t)) step = 5;
109
+ if (/EVIDENCE/.test(t)) step = 6;
110
+ return step;
111
+ }
112
+ /** What keeps a FINAL header from being a pass: a failure, a hold, or an explicit "not a sign-off". */
113
+ const FINAL_HOLD = /\bFAIL(ED|S)?\b|\bBLOCKED\b|\bPENDING\b|\bWAITING\b|\bSTARTING\b|\bNOT (A )?(RELEASE )?SIGN-OFF\b/;
114
+ export const isFinalFail = (head) => /^FINAL\b/.test(head) && /\bFAIL(ED|S)?\b/.test(head);
115
+ /**
116
+ * A reviewer's final pass. Real headers vary ("FINAL: PASS", "FINAL (qa) — … all APPROVED", "FINAL — S2: APPROVED.",
117
+ * bare "FINAL (W3)"), so any FINAL counts unless it is held; "FINAL REVIEW …" counts only when it says PASS/APPROVED.
118
+ */
119
+ export const isFinalPass = (head) => /^FINAL\b/.test(head) && !FINAL_HOLD.test(head) && (!/^FINAL REVIEW\b/.test(head) || /\b(PASS|APPROVED)\b/.test(head));
120
+ const USER_CHANGES = /^CHANGES REQUESTED\b/;
121
+ const NOT_BLOCKING = /\bNOT BLOCKING\b|\bNON-BLOCKING\b|\bNO BLOCKERS?\b|\bBLOCKERS?:\s*NONE\b|\b0 BLOCKERS?\b/;
122
+ const NEEDS_USER = /\bNEEDS USER\b/;
123
+ /** An agent tagging you in a post ("@you", or "@user"): it needs your input, the same as a NEEDS USER header. */
124
+ export const MENTIONS_YOU = /(^|[\s(\[*_"'])@(you|user)\b/i;
125
+ /** A handle the user also goes by ("aagarwal1", "AdaLovelace"): what may follow an "@" as a tag for them. */
126
+ export const USER_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{1,38}[A-Za-z0-9]$/;
127
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
128
+ let aliasCache = { key: '', re: null };
129
+ function aliasMention(aliases) {
130
+ const list = (aliases || []).filter((a) => typeof a === 'string' && USER_ALIAS.test(a));
131
+ const key = list.join('|');
132
+ if (key !== aliasCache.key) aliasCache = { key, re: list.length ? new RegExp(`(^|[\\s(\\[*_"'])@(${list.map(escapeRe).join('|')})\\b(?![.-]\\w)`, 'i') : null };
133
+ return aliasCache.re;
134
+ }
135
+ /** "@you"/"@user", or "@" one of the user's handles; a handle never matches inside an email, a domain or a longer word. */
136
+ export function mentionsYou(text, aliases = []) {
137
+ if (MENTIONS_YOU.test(text)) return true;
138
+ const re = aliasMention(aliases);
139
+ return !!re && re.test(text);
140
+ }
141
+ const NEEDS_USER_SETTLED = /\bNO NEEDS USER\b|NEEDS USER (ITEMS? )?(SETTLED|RESOLVED|CLOSED|ANSWERED|DECIDED)/;
142
+ const CODER_REPLY = /^(READY|RESOLVED|FIX(ED|ES)?\b)/;
143
+ /** A closing word that is not negated ("NOT RESOLVED", "still not fixed", "isn't addressed" do not close). */
144
+ const CLOSES = /(?<!\b(?:NOT|NEVER|STILL NOT|ISN'T|AREN'T|WASN'T)\s)\b(RESOLVED|FIX(ED|ES)?|ADDRESSED|WITHDRAWN|CLOSED|SATISFIED)\b/;
145
+
146
+ /** Who a QUESTION is for: "QUESTION FOR CODER" goes to the coder; otherwise the architect answers. */
147
+ const questionFor = (head) => (/\b(FOR|TO) (THE )?CODER\b/.test(head) ? 'CODER' : /\b(FOR|TO) (THE )?REVIEWER\b/.test(head) ? 'REVIEWER' : 'ARCHITECT');
148
+
149
+ /** Who must act next after a post, or null when the post does not decide it. */
150
+ function ballAfter(p) {
151
+ const t = p.type.toUpperCase();
152
+ const head = p.head.toUpperCase();
153
+ const r = p.role;
154
+ if (/^QUESTION/.test(t) && r !== 'ARCHITECT') return questionFor(head);
155
+ if (r === 'USER') return USER_CHANGES.test(t) ? 'CODER' : /^DECISION/.test(t) ? 'ARCHITECT' : null;
156
+ if (isCoder(r)) {
157
+ if (CODER_REPLY.test(t)) return 'REVIEWER';
158
+ if (/^(COMMITTED|DONE|DEPLOY POINT)/.test(t)) return 'ARCHITECT';
159
+ return null;
160
+ }
161
+ if (isReviewer(r)) {
162
+ if (isFinalFail(head)) return 'CODER';
163
+ if (isFinalPass(head)) return 'ARCHITECT';
164
+ if (/^(APPROVED|CHANGES|FINDINGS|MAJOR|MINOR|BLOCKER|REVIEW\b|NOT ACKED)/.test(t)) return 'CODER';
165
+ if (/^(ACK COMMITTED|VERIFIED|COMMIT VERIFIED|LIVE CHECK)/.test(t)) return 'ARCHITECT';
166
+ if (/^FINAL REVIEW/.test(t)) return 'REVIEWER';
167
+ return null;
168
+ }
169
+ if (isAuditor(r)) return /\b(BLOCKER|MAJOR)\b/.test(head) && !NOT_BLOCKING.test(head) ? 'CODER' : null;
170
+ if (r === 'ARCHITECT') {
171
+ if (/^(DEPLOYED|EVIDENCE|MERGED)/.test(t)) return 'REVIEWER';
172
+ if (/^(KICKOFF|ANSWER|RULING|NUDGE|NEW STEP|PLAN)/.test(t)) return 'CODER';
173
+ return null;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /** A work stream's state from its posts, oldest first. `aliases`: the user's handles, which tag them like "@you". */
179
+ export function analyseStream(posts, { aliases = [] } = {}) {
180
+ let step = 0; let round = 0; let done = false; let failed = null; let question = null; let ball = null; let ballAt = null;
181
+ let outcome = null; let agentBall = null;
182
+ let lastFinal = null; let lastEvidence = null; let lastCoderReplyAt = 0;
183
+ const blockers = new Map();
184
+ const asks = new Map();
185
+ const lastByRole = {};
186
+ const closeBy = (pred) => { for (const [k, b] of blockers) if (pred(b)) blockers.delete(k); };
187
+ posts.forEach((p, i) => {
188
+ const t = p.type.toUpperCase();
189
+ const head = p.head.toUpperCase();
190
+ lastByRole[p.role] = p;
191
+ if (isCoder(p.role) && /^READY/.test(t)) { round++; if (done || failed) { done = false; failed = null; step = Math.min(step, 4); } }
192
+ step = Math.max(step, postStep(p));
193
+ if (isCoder(p.role) && CODER_REPLY.test(t)) { lastCoderReplyAt = p.at; if (failed && !/^READY/.test(t)) failed = null; }
194
+ if (/EVIDENCE/.test(t)) lastEvidence = p;
195
+ const passes = (isReviewer(p.role) && isFinalPass(head)) || /^RELEASED/.test(t);
196
+ if (isReviewer(p.role) && isFinalFail(head)) { failed = p; done = false; }
197
+ else if (p.role === 'USER' && USER_CHANGES.test(t)) { failed = p; done = false; lastFinal = null; }
198
+ else if (passes) { done = true; failed = null; lastFinal = p; }
199
+ const closes = !passes && streamCloser(p);
200
+ if (closes) { done = true; failed = null; outcome = closes; } else if (passes) outcome = 'passed';
201
+ if (done && reopens(p)) done = false;
202
+
203
+ const raisesBlocker = !isCoder(p.role) && p.role !== 'USER' && !passes && !closes && !CLOSES.test(head)
204
+ && /\bBLOCKER\b|DEPLOY BLOCKED|CHANGES REQUESTED/.test(head) && !NOT_BLOCKING.test(head);
205
+ if (raisesBlocker) {
206
+ for (const id of p.ids.length ? p.ids : [`p${i}`]) blockers.set(`${p.role}:${id}`, { id, role: p.role, at: p.at, text: p.text });
207
+ if (done) { done = false; lastFinal = null; }
208
+ }
209
+ if (!raisesBlocker && p.ids.length && (CLOSES.test(head) || (p.role === 'ARCHITECT' && /^(RULING|ANSWER)/.test(t)))) closeBy((b) => p.ids.includes(b.id));
210
+ if (isReviewer(p.role) && (/^APPROVED/.test(t) || passes)) closeBy((b) => isReviewer(b.role));
211
+ if (isAuditor(p.role) && /ALIGNMENT OK|FINAL/.test(head)) closeBy((b) => isAuditor(b.role));
212
+ if (p.role === 'ARCHITECT' && /^(DEPLOYED|MERGED)/.test(t)) closeBy((b) => b.role === 'ARCHITECT');
213
+
214
+ const opensAsk = p.role !== 'USER' && (NEEDS_USER.test(head) || mentionsYou(p.text, aliases)) && !NEEDS_USER_SETTLED.test(head) && !/^(ANSWER|RULING)/.test(t);
215
+ const settles = (p.role === 'USER' && /^(DECISION|CHANGES REQUESTED)/.test(t))
216
+ || (p.role === 'ARCHITECT' && /^(ANSWER|RULING|NEW STEP)/.test(t) && /USER DECI|PER (THE )?USER|USER APPROVED|USER CHOSE/.test(p.text.toUpperCase()))
217
+ || (NEEDS_USER_SETTLED.test(head) && p.ids.length);
218
+ if (opensAsk) asks.set(p.ids[0] || `p${i}`, p);
219
+ else if (settles) {
220
+ const keys = p.ids.length ? p.ids.filter((id) => asks.has(id)) : [...asks.keys()].slice(-1);
221
+ for (const k of keys) asks.delete(k);
222
+ }
223
+
224
+ if (/^QUESTION/.test(t) && p.role !== 'ARCHITECT' && questionFor(head) === 'ARCHITECT') question = p;
225
+ else if (p.role === 'ARCHITECT' && /\b(ANSWERS?|RULINGS?|RE: QUESTION|DECISION)\b/.test(head)) question = null;
226
+
227
+ if (!CHATTER.test(t)) { const b = ballAfter(p); if (b) { ball = b; ballAt = p.at; if (p.role !== 'USER') agentBall = b; } }
228
+ });
229
+ if (!done && !failed) { const all = everyRoleClosed(posts, agentBall); if (all) { done = true; outcome = all; } }
230
+ if (!done) outcome = null;
231
+ if (done) { asks.clear(); question = null; blockers.clear(); }
232
+ const open = [...blockers.values()];
233
+ const needsUser = [...asks.values()].pop() || null;
234
+ let owner = ball;
235
+ let ownerAt = ballAt;
236
+ if (needsUser) { owner = 'YOU'; ownerAt = needsUser.at; }
237
+ else if (failed) { owner = 'CODER'; ownerAt = failed.at; }
238
+ else if (open.length) {
239
+ const latest = open.reduce((a, b) => (b.at > a.at ? b : a));
240
+ if (open.every((b) => b.role === 'ARCHITECT')) { owner = 'ARCHITECT'; ownerAt = latest.at; }
241
+ else if (lastCoderReplyAt > latest.at) {
242
+ if (!(ballAt > lastCoderReplyAt)) { owner = ballOf(latest.role) || 'REVIEWER'; ownerAt = lastCoderReplyAt; }
243
+ } else { owner = 'CODER'; ownerAt = latest.at; }
244
+ } else if (question) { owner = 'ARCHITECT'; ownerAt = question.at; }
245
+ if (done) { owner = null; ownerAt = null; }
246
+ return { step, stepName: STEPS[step], round, done, outcome, failed, blockers: open, needsUser, asks: [...asks.values()], question, ball: owner, ballAt: ownerAt, lastByRole, lastFinal, lastEvidence };
247
+ }
248
+
249
+ /** Does a channel role ("UI CODER", "PLATFORM AUDITOR") hold the ball named by analyseStream ("CODER", "REVIEWER", …)? */
250
+ export function holds(role, ball) {
251
+ if (!ball || !role) return false;
252
+ if (ball === 'CODER') return isCoder(role);
253
+ if (ball === 'REVIEWER') return isReviewer(role);
254
+ if (ball === 'AUDITOR') return isAuditor(role);
255
+ return role === ball;
256
+ }
257
+
258
+ /**
259
+ * A user decision as channel text: one header line, then the text with every line that could read as a header
260
+ * (or any markdown heading) quoted, so it can never forge a post from another role.
261
+ */
262
+ export function decisionPost(text, { ref = null, at = new Date(), kind = 'DECISION' } = {}) {
263
+ const hhmmss = [at.getHours(), at.getMinutes(), at.getSeconds()].map((n) => String(n).padStart(2, '0')).join(':');
264
+ const safeRef = ref && /^[A-Z]?\d+$/.test(ref) ? ` #${ref}` : '';
265
+ const type = ['CHANGES REQUESTED', 'MESSAGE'].includes(kind) ? kind : 'DECISION';
266
+ const body = String(text).split(/\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]/).map((l) => (/^\s*#/.test(l) ? `> ${l}` : l)).join('\n').trim();
267
+ return `\n## [USER] ${hhmmss} ${type}${safeRef}\n${body}\n`;
268
+ }
@@ -0,0 +1,163 @@
1
+ // agents-hub lib/chat.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Chat: task channels are the agents' own .agents/<task>/channel.md files; project channels live in the hub and get
3
+ // milestones from the hub agent (a stream starting, passing or failing final review) next to what you post.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { channelFiles, decisionPost, firstHeading, isFinalFail, isFinalPass, mentionsYou, normRole, parsePosts } from './channels.mjs';
7
+ import { projectOf } from './deliveries.mjs';
8
+ import { personasSince } from './personas.mjs';
9
+ import { HUB } from './store.mjs';
10
+
11
+ const CHAT = path.join(HUB, 'state', 'chat');
12
+ const MAX_MESSAGES = 300;
13
+ const PROJECT = /^[A-Za-z0-9._-]{1,80}$/;
14
+ fs.mkdirSync(CHAT, { recursive: true });
15
+
16
+ const projectFile = (p) => path.join(CHAT, `project-${p}.jsonl`);
17
+ const LIVE = path.join(HUB, 'state', 'live');
18
+ /** Only real Cursor conversations (transcript ids) have a hook that can claim bus mail. */
19
+ const SESSION_ID = /^[A-Za-z0-9-]{8,80}$/;
20
+ export const hasChatInbox = (id) => SESSION_ID.test(id);
21
+
22
+ function readLog(file) {
23
+ let text = '';
24
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
25
+ return text.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } })
26
+ .filter((m) => m && Number.isFinite(m.at) && typeof m.text === 'string' && typeof m.from === 'string');
27
+ }
28
+ const readProjectLog = (p) => readLog(projectFile(p));
29
+
30
+ /** Leftover live/*.json from before the SQLite hook; presence now lives in hub.db. */
31
+ export function liveOf(id) {
32
+ if (!SESSION_ID.test(id)) return null;
33
+ try { const v = JSON.parse(fs.readFileSync(path.join(LIVE, `${id}.json`), 'utf8')); return Number.isFinite(v.at) ? { at: v.at, event: String(v.event || ''), tool: typeof v.tool === 'string' ? v.tool : null } : null; } catch { return null; }
34
+ }
35
+
36
+ /** Every task channel changed since sinceMs, and one project channel per project they belong to. */
37
+ export function listChannels(roots, sinceMs) {
38
+ const tasks = channelFiles(roots, sinceMs).map((c) => ({
39
+ id: `task:${c.key}`, kind: 'task', key: c.key, name: c.task, repo: c.repo, project: projectOf(c.repo) || c.repo,
40
+ title: firstHeading(path.join(path.dirname(c.file), 'brief.md')), lastAt: c.mtime,
41
+ }));
42
+ const names = [...new Set(tasks.map((t) => t.project))];
43
+ const projects = names.map((p) => {
44
+ const log = readProjectLog(p);
45
+ return { id: `project:${p}`, kind: 'project', name: p, project: p, lastAt: Math.max(0, ...tasks.filter((t) => t.project === p).map((t) => t.lastAt), ...log.map((m) => m.at)) };
46
+ });
47
+ return { projects: projects.sort((a, b) => b.lastAt - a.lastAt), tasks: tasks.sort((a, b) => b.lastAt - a.lastAt) };
48
+ }
49
+
50
+ /** Your one-to-one messages copied into a channel (the only inbox a channel-only agent has), old and new wording. */
51
+ const DIRECT_COPY = /^(To the ([a-z0-9 -]+?)(?:, direct from the user[^:]*)?:|@[\w.-]+ \(for the [A-Z0-9 -]+, direct from you\):)/;
52
+ const isDirectCopy = (m) => m.from === 'USER' && m.type === 'MESSAGE' && DIRECT_COPY.test(m.body);
53
+ const REPLY_TYPE = 'REPLY TO USER';
54
+ const isReplyToYou = (m) => m.from !== 'USER' && m.type.toUpperCase().startsWith(REPLY_TYPE);
55
+ const addressee = (m) => { const x = m.body.match(DIRECT_COPY); return x && x[2] ? x[2].trim() : null; };
56
+
57
+ function allPosts(file) {
58
+ const st = fs.statSync(file);
59
+ return parsePosts(fs.readFileSync(file, 'utf8'), st.mtimeMs, undefined, { full: true })
60
+ .map((p, i) => ({ id: `${p.at}-${i}`, at: p.at, from: p.role, type: p.type, head: p.head, body: p.body || '' }));
61
+ }
62
+
63
+ const OPTION = /^\s*(?:[-*]\s+)?(?:\*\*)?(?:\(([a-hA-H1-9])\)|([a-hA-H1-9])[).:]|Option\s+([A-Ha-h1-9])\s*[:—-])(?:\*\*)?\s+(.{3,300})$/;
64
+ const NEEDS_YOU = /NEEDS USER|USER DECISION|DECISION NEEDED|FOR (THE )?USER/i;
65
+
66
+ /** The choices a post offers ("(a) …", "B) …", "Option 2: …"), in order, when there are at least two. */
67
+ export function optionsIn(body) {
68
+ const out = [];
69
+ for (const line of String(body || '').split('\n')) {
70
+ const m = line.match(OPTION);
71
+ if (m) out.push({ key: (m[1] || m[2] || m[3]).toLowerCase(), text: m[4].replace(/\*\*/g, '').trim() });
72
+ }
73
+ const keys = out.map((o) => o.key);
74
+ return out.length >= 2 && new Set(keys).size === keys.length ? out : [];
75
+ }
76
+
77
+ /**
78
+ * What a NEEDS USER ask is, from its channel: the post itself (full text), who raised it and when, the posts before it
79
+ * about the same finding, and any options it lists. `ref` is the finding id ("A9"); `at` picks the post when there is none.
80
+ */
81
+ export function askDetail(file, { ref = null, at = null, aliases = [] } = {}) {
82
+ const posts = allPosts(file).filter((m) => !isDirectCopy(m) && !isReplyToYou(m));
83
+ const id = ref ? ref.replace(/[^A-Za-z0-9]/g, '') : '';
84
+ const refRe = id ? new RegExp(/^[A-Za-z]/.test(id) ? `(?:#|\\b)${id}\\b` : `#${id}\\b`) : null;
85
+ const mentions = (m) => !!refRe && refRe.test(`${m.head}\n${m.body}`);
86
+ const asksYou = (m) => m.from !== 'USER' && (NEEDS_YOU.test(m.head) || mentionsYou(`${m.head}\n${m.body}`, aliases));
87
+ const target = (at && posts.find((m) => Math.abs(m.at - at) < 1000 && asksYou(m)))
88
+ || [...posts].reverse().find((m) => asksYou(m) && (!ref || mentions(m)))
89
+ || null;
90
+ if (!target) return null;
91
+ const related = ref ? posts.filter((m) => m !== target && mentions(m) && m.at <= target.at).slice(-8) : [];
92
+ const after = posts.filter((m) => m.at > target.at && m.from !== 'USER').slice(-3);
93
+ return {
94
+ post: { from: target.from, at: target.at, head: target.head, type: target.type, body: target.body },
95
+ options: optionsIn(target.body),
96
+ related: related.map((m) => ({ from: m.from, at: m.at, head: m.head, body: m.body.slice(0, 1500) })),
97
+ since: after.map((m) => ({ from: m.from, at: m.at, head: m.head })),
98
+ };
99
+ }
100
+
101
+ /** A task channel as chat messages: every post with its full body, except one-to-one traffic with you. */
102
+ export function taskMessages(file) {
103
+ return allPosts(file).filter((m) => !isDirectCopy(m) && !isReplyToYou(m)).slice(-MAX_MESSAGES);
104
+ }
105
+
106
+ /**
107
+ * An agent known only from its channel posts ("<repo>/<task>:<ROLE>"). It has no bus inbox; it hears you only through
108
+ * its task channel.
109
+ */
110
+ export function channelParticipant(id) {
111
+ const m = String(id || '').match(/^([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+):([A-Z][A-Z0-9 -]{0,39})$/);
112
+ return m ? { key: m[1], role: normRole(m[2]) } : null;
113
+ }
114
+
115
+ /** A direct message to a channel-only agent, as a USER MESSAGE post that names its role and how to answer. */
116
+ export const participantPost = (role, text) => decisionPost(
117
+ `To the ${role.toLowerCase()}, direct from the user (answer in this channel with a post of type ${REPLY_TYPE}, e.g. "## [${role}] <time> ${REPLY_TYPE}"): ${text}`, { kind: 'MESSAGE' });
118
+
119
+ /** The one-to-one with a channel-only agent: your messages to its role and its REPLY TO USER posts, nothing else. */
120
+ export function participantThread(file, role) {
121
+ const mine = role.toLowerCase();
122
+ return allPosts(file)
123
+ .filter((m) => (isDirectCopy(m) && addressee(m) === mine) || (isReplyToYou(m) && normRole(m.from) === role))
124
+ .map((m) => (m.from === 'USER' ? { ...m, body: m.body.replace(DIRECT_COPY, '').trim() } : m))
125
+ .slice(-MAX_MESSAGES);
126
+ }
127
+
128
+ /** A project channel: what people posted, plus the hub agent's milestones from that project's task channels. */
129
+ export function projectMessages(roots, project, sinceMs) {
130
+ if (!PROJECT.test(project)) return null;
131
+ const out = readProjectLog(project).map((m, i) => ({ id: `p-${m.at}-${i}`, at: m.at, from: m.from, type: 'MESSAGE', head: '', body: m.text }));
132
+ for (const c of channelFiles(roots, sinceMs)) {
133
+ if ((projectOf(c.repo) || c.repo) !== project) continue;
134
+ const title = firstHeading(path.join(path.dirname(c.file), 'brief.md')) || c.task;
135
+ let posts = [];
136
+ try { posts = parsePosts(fs.readFileSync(c.file, 'utf8'), c.mtime); } catch { continue; }
137
+ let started = false;
138
+ for (const p of posts) {
139
+ const head = p.head.toUpperCase();
140
+ let milestone = null;
141
+ if (/^KICKOFF/.test(p.type.toUpperCase())) { if (!started) milestone = ['start', `${title} started`]; started = true; }
142
+ else if (p.role.includes('REVIEWER') && isFinalFail(head)) milestone = ['alert', `${title} failed final review`];
143
+ else if (p.role.includes('REVIEWER') && isFinalPass(head)) milestone = ['celebrate', `${title} passed final review`];
144
+ else if (/^RELEASED/.test(head)) milestone = ['celebrate', `${title} was released`];
145
+ if (milestone) out.push({ id: `m-${c.key}-${p.at}`, at: p.at, from: 'HUB', type: milestone[0], head: '', body: milestone[1], stream: c.task });
146
+ }
147
+ }
148
+ for (const p of personasSince(sinceMs)) {
149
+ if (!p.channel || !p.role || p.role === 'ARCHITECT') continue;
150
+ const [repo, task] = p.channel.split('/');
151
+ if ((projectOf(repo) || repo) !== project) continue;
152
+ out.push({ id: `j-${p.id}`, at: p.createdAt, from: 'HUB', type: 'joined', head: '', body: `${p.name} (@${p.username || p.name}) joined #${task} as ${roleName(p.role)}`, persona: { name: p.name, seed: p.seed, role: p.role }, stream: task });
153
+ }
154
+ return out.filter((m) => m.at >= sinceMs).sort((a, b) => a.at - b.at).slice(-MAX_MESSAGES);
155
+ }
156
+
157
+ const roleName = (r) => (r.includes('AUDITOR') ? 'platform auditor' : r.toLowerCase().replace(/-/g, ' '));
158
+
159
+ export function postToProject(project, from, text) {
160
+ if (!PROJECT.test(project)) return false;
161
+ fs.appendFileSync(projectFile(project), JSON.stringify({ at: Date.now(), from, text: String(text).slice(0, 4000) }) + '\n');
162
+ return true;
163
+ }