@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.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.
- package/README.md +55 -0
- package/package.json +7 -4
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +63 -0
- package/src/commands/pulse/doctor.js +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +20 -12
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +411 -0
- package/src/lib/cutoverRequest.js +485 -0
- package/src/lib/cutoverWiring.js +140 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +57 -6
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +64 -1
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +149 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Pulse 1.1.1 (F3): the terminal headers of agents-hub.mdc and delivery-discipline.mdc, and what each one ends. Pure
|
|
2
|
+
// and dependency-free: the plugin (src/hub/lib/closure.mjs) and the cloud (bll/projections/closure.js) hold this file
|
|
3
|
+
// byte for byte, and both test it against the same test/fixtures/closure-cases.json.
|
|
4
|
+
|
|
5
|
+
/** What keeps a FINAL header from being a pass (channels.mjs at 6b2fb0b). */
|
|
6
|
+
const FINAL_HOLD = /\bFAIL(ED|S)?\b|\bBLOCKED\b|\bPENDING\b|\bWAITING\b|\bSTARTING\b|\bNOT (A )?(RELEASE )?SIGN-OFF\b/;
|
|
7
|
+
const isFinal = (head) => /^FINAL\b/.test(head);
|
|
8
|
+
const finalFails = (head) => isFinal(head) && /\bFAIL(ED|S)?\b/.test(head);
|
|
9
|
+
const finalPasses = (head) => isFinal(head) && !FINAL_HOLD.test(head) && (!/^FINAL REVIEW\b/.test(head) || /\b(PASS|APPROVED)\b/.test(head));
|
|
10
|
+
/** Matched as words at the start of the type, with or without a dash after: `STOPPED`, `STOPPED: why`, `STOPPED — why`. */
|
|
11
|
+
const TERMINAL = [
|
|
12
|
+
[/^RELEASE ABORTED\b/, 'stopped'],
|
|
13
|
+
[/^RELEASED\b/, 'passed'],
|
|
14
|
+
[/^STOPPED\b/, 'stopped'],
|
|
15
|
+
[/^HANDED OVER\b/, 'handed-over'],
|
|
16
|
+
[/^DONE\b/, 'done'],
|
|
17
|
+
];
|
|
18
|
+
const RELEASE = /^(RELEASED|RELEASE ABORTED)\b/;
|
|
19
|
+
const REOPENS = /^(READY|KICKOFF|RESUMED|PLAN)\b/;
|
|
20
|
+
/** Posts that do not move the work (channels.mjs CHATTER at 6b2fb0b). */
|
|
21
|
+
const CHATTER = /^(WAITING|STATUS|JOINED|POST|NOTE|ACK(?! COMMITTED)|STOPPING|STOOD DOWN|HEARTBEAT|PROGRESS)/;
|
|
22
|
+
const TEXT = {
|
|
23
|
+
passed: 'Final review passed', failed: 'Final review failed', done: 'Closed: marked done', stopped: 'Closed: stopped', 'handed-over': 'Closed: handed over',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const upper = (s) => String(s || '').toUpperCase().trim();
|
|
27
|
+
const isUser = (p) => upper(p.role) === 'USER';
|
|
28
|
+
const isReviewer = (p) => upper(p.role).includes('REVIEWER');
|
|
29
|
+
|
|
30
|
+
/** What a header says about its author's own work: passed, failed, done, stopped or handed-over; null when it ends nothing. */
|
|
31
|
+
export function roleOutcome(p) {
|
|
32
|
+
if (!p || isUser(p)) return null;
|
|
33
|
+
const head = upper(p.head || p.type);
|
|
34
|
+
if (isReviewer(p) && isFinal(head)) return finalFails(head) ? 'failed' : finalPasses(head) ? 'passed' : null;
|
|
35
|
+
const hit = TERMINAL.find(([re]) => re.test(upper(p.type)));
|
|
36
|
+
return hit ? hit[1] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The outcome one header closes the whole stream with, or null: a reviewer's final pass, a release or its abort, or
|
|
41
|
+
* any terminal header of the stream's ARCHITECT, who owns it. A FINAL: FAIL ends only the reviewer's own turn.
|
|
42
|
+
*/
|
|
43
|
+
export function streamCloser(p) {
|
|
44
|
+
const outcome = roleOutcome(p);
|
|
45
|
+
if (!outcome || outcome === 'failed') return null;
|
|
46
|
+
if (isReviewer(p) && outcome === 'passed') return outcome;
|
|
47
|
+
if (RELEASE.test(upper(p.type))) return outcome;
|
|
48
|
+
return upper(p.role) === 'ARCHITECT' ? outcome : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pulse 1.1.2 (E2, E3): a header that ends its author's turn, so a channel whose newest post is one is not waiting on
|
|
53
|
+
* anyone: FINAL: PASS or FAIL, DONE, STOPPED, HANDED OVER, RELEASED, RELEASE ABORTED, or APPROVED from a reviewer.
|
|
54
|
+
*/
|
|
55
|
+
export function isTerminal(p) {
|
|
56
|
+
if (!p || isUser(p)) return false;
|
|
57
|
+
return Boolean(roleOutcome(p)) || (isReviewer(p) && /^APPROVED\b/.test(upper(p.type)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A READY, KICKOFF, RESUMED or PLAN from an agent reopens a closed stream. */
|
|
61
|
+
export const reopens = (p) => !!p && !isUser(p) && REOPENS.test(upper(p.type));
|
|
62
|
+
|
|
63
|
+
const moves = (p) => !isUser(p) && !CHATTER.test(upper(p.type));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* With no single closer standing: the stream is closed when every role that posted moving headers (USER left out)
|
|
67
|
+
* has a terminal one as its latest, none of them failed, and whoever holds the ball (`ball`, as analyseStream names
|
|
68
|
+
* it: CODER, REVIEWER, ARCHITECT, AUDITOR or YOU) is one of those roles. A coder's DONE hands the ball to an architect
|
|
69
|
+
* who has not posted, so it leaves the stream open. The newest terminal header names the outcome. Posts oldest first.
|
|
70
|
+
*/
|
|
71
|
+
export function everyRoleClosed(posts, ball = null) {
|
|
72
|
+
const latest = new Map();
|
|
73
|
+
for (const p of posts || []) if (moves(p)) latest.set(upper(p.role), p);
|
|
74
|
+
if (!latest.size || ball === 'YOU') return null;
|
|
75
|
+
if (ball && ![...latest.keys()].some((role) => role.includes(ball))) return null;
|
|
76
|
+
const ends = [...latest.values()].map((p) => ({ p, outcome: roleOutcome(p) }));
|
|
77
|
+
if (!ends.every((e) => e.outcome && e.outcome !== 'failed')) return null;
|
|
78
|
+
return ends.reduce((a, b) => ((b.p.at || 0) >= (a.p.at || 0) ? b : a)).outcome;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The words a closed stream's card shows. */
|
|
82
|
+
export const closedText = (outcome) => TEXT[outcome] || TEXT.passed;
|
|
83
|
+
|
|
84
|
+
/** A delivery whose streams are all closed: "Final review passed" when every one passed, else what each ended as. */
|
|
85
|
+
export function closedReason(outcomes) {
|
|
86
|
+
const list = outcomes.map((o) => o || 'passed');
|
|
87
|
+
if (list.every((o) => o === 'passed')) return TEXT.passed;
|
|
88
|
+
const counts = new Map();
|
|
89
|
+
for (const o of list) counts.set(o, (counts.get(o) || 0) + 1);
|
|
90
|
+
const words = { passed: 'passed', done: 'marked done', stopped: 'stopped', 'handed-over': 'handed over' };
|
|
91
|
+
return `Closed: ${[...counts].map(([o, n]) => `${n} ${words[o] || o}`).join(', ')}`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// agents-hub lib/collect-worker.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Runs collect() off the server's event loop, then persists discoveries and returns a pure projection.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { hubHome, runnerDb } from '../home.mjs';
|
|
5
|
+
import { parentPort } from 'node:worker_threads';
|
|
6
|
+
import { collect } from './collect.mjs';
|
|
7
|
+
import { openHub } from '../core/hub.mjs';
|
|
8
|
+
import { discoveryRunner } from '../workers/collector.mjs';
|
|
9
|
+
|
|
10
|
+
const HUB_ROOT = hubHome();
|
|
11
|
+
const hub = openHub(path.join(HUB_ROOT, 'state'), runnerDb());
|
|
12
|
+
const run = discoveryRunner({ hub, collect });
|
|
13
|
+
|
|
14
|
+
parentPort.on('message', ({ id, hours, managerView, aliases, discoveryEpoch }) => {
|
|
15
|
+
try {
|
|
16
|
+
parentPort.postMessage({ id, body: run(hours, managerView, aliases, { discoveryEpoch }) });
|
|
17
|
+
} catch (e) {
|
|
18
|
+
parentPort.postMessage({ id, error: String((e && e.stack) || e) });
|
|
19
|
+
}
|
|
20
|
+
});
|