claude-mem 13.4.0 → 13.4.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.
- package/.codex-plugin/plugin.json +1 -1
- package/dist/npx-cli/index.js +333 -304
- package/dist/opencode-plugin/index.js +1 -1
- package/openclaw/openclaw.plugin.json +1 -1
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +3 -3
- package/plugin/bun.lock +163 -0
- package/plugin/modes/code.json +16 -16
- package/plugin/package.json +2 -2
- package/plugin/scripts/bun-runner.js +76 -54
- package/plugin/scripts/context-generator.cjs +73 -60
- package/plugin/scripts/mcp-server.cjs +37 -30
- package/plugin/scripts/server-beta-service.cjs +158 -132
- package/plugin/scripts/transcript-watcher.cjs +26 -0
- package/plugin/scripts/version-check.js +125 -1
- package/plugin/scripts/worker-service.cjs +266 -234
- package/plugin/skills/smart-explore/SKILL.md +6 -3
- package/plugin/skills/standup/SKILL.md +157 -0
- package/plugin/skills/standup/agent-brief.md +47 -0
- package/plugin/skills/standup/standup.mjs +662 -0
- package/plugin/ui/viewer-bundle.js +14 -14
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// standup — a markdown-based group chat for multiple AI coding agents.
|
|
3
|
+
//
|
|
4
|
+
// Each agent embodies its git branch name and talks to the others by appending
|
|
5
|
+
// turns to a single shared markdown file (default ~/.claude-mem/STANDUP.md).
|
|
6
|
+
// The file has YAML front matter holding the shared GOAL and PROMPT the group
|
|
7
|
+
// must converge on; the body is the chat log. Agents `watch` the file to listen,
|
|
8
|
+
// `post` to speak, `agree` to register consensus, and `summation` to close it.
|
|
9
|
+
//
|
|
10
|
+
// Zero deps. Node 20+ (top-level await, fs/promises). No network.
|
|
11
|
+
//
|
|
12
|
+
// Concurrency: every write takes an atomic lock (mkdir <file>.lock) so two
|
|
13
|
+
// agents posting at the same instant can't clobber each other — the exact
|
|
14
|
+
// failure mode that silently reverts work when multiple agents share a target.
|
|
15
|
+
//
|
|
16
|
+
// Config / resolution order:
|
|
17
|
+
// --file <path> | STANDUP_FILE | ~/.claude-mem/STANDUP.md
|
|
18
|
+
// --agent <name> | STANDUP_AGENT | current git branch | "agent"
|
|
19
|
+
//
|
|
20
|
+
// Commands:
|
|
21
|
+
// worktrees [--since 4h] [--json] list worktrees, newest
|
|
22
|
+
// first; --since N{m,h,d,w}
|
|
23
|
+
// keeps only those with a
|
|
24
|
+
// commit or uncommitted edit
|
|
25
|
+
// in the window ("all"=off)
|
|
26
|
+
// prs [--since 4h] [--json] list open GitHub PRs via
|
|
27
|
+
// gh, newest first; --since
|
|
28
|
+
// filters by last update
|
|
29
|
+
// open --goal "..." --prompt "..." [--agent N] create the channel
|
|
30
|
+
// join [--agent N] [--message "..."] add self + say hello
|
|
31
|
+
// post --message "..." [--agree "..."] [--agent N] append a turn
|
|
32
|
+
// agree --deliverable "..." [--agent N] append an AGREE turn
|
|
33
|
+
// watch [--agent N] [--timeout SEC] [--interval SEC] block until someone
|
|
34
|
+
// ELSE posts; prints their turn
|
|
35
|
+
// read [--tail N] [--since AGENT] print the chat
|
|
36
|
+
// status participants + consensus
|
|
37
|
+
// summation --text "..." [--agent N] close the room (status: agreed)
|
|
38
|
+
//
|
|
39
|
+
// Exit codes: 0 ok / change seen, 2 watch timeout, 1 usage or error.
|
|
40
|
+
|
|
41
|
+
import { readFile, writeFile, mkdir, rmdir, rename, stat } from "node:fs/promises";
|
|
42
|
+
import { statSync } from "node:fs";
|
|
43
|
+
import { homedir } from "node:os";
|
|
44
|
+
import { dirname, join } from "node:path";
|
|
45
|
+
import { execSync } from "node:child_process";
|
|
46
|
+
|
|
47
|
+
// ----------------------------------------------------------------------- args
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const cmd = argv[0];
|
|
50
|
+
const opts = {};
|
|
51
|
+
for (let i = 1; i < argv.length; i++) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a.startsWith("--")) {
|
|
54
|
+
const key = a.slice(2);
|
|
55
|
+
const next = argv[i + 1];
|
|
56
|
+
if (next === undefined || next.startsWith("--")) opts[key] = true;
|
|
57
|
+
else {
|
|
58
|
+
opts[key] = next;
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { cmd, opts };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { cmd, opts } = parseArgs(process.argv.slice(2));
|
|
67
|
+
|
|
68
|
+
function defaultFile() {
|
|
69
|
+
return (
|
|
70
|
+
opts.file ||
|
|
71
|
+
process.env.STANDUP_FILE ||
|
|
72
|
+
join(homedir(), ".claude-mem", "STANDUP.md")
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function gitBranch() {
|
|
77
|
+
try {
|
|
78
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
79
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
80
|
+
})
|
|
81
|
+
.toString()
|
|
82
|
+
.trim();
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function agentName() {
|
|
89
|
+
const n = opts.agent || process.env.STANDUP_AGENT || gitBranch() || "agent";
|
|
90
|
+
return String(n).trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const FILE = defaultFile();
|
|
94
|
+
|
|
95
|
+
// --------------------------------------------------------------------- helpers
|
|
96
|
+
function nowIso() {
|
|
97
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function exists(p) {
|
|
101
|
+
try {
|
|
102
|
+
await stat(p);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function read() {
|
|
110
|
+
return (await readFile(FILE, "utf8")).toString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Atomic lock via mkdir (fails if the dir already exists). Retries with a
|
|
114
|
+
// short backoff so simultaneous agents serialize instead of clobbering.
|
|
115
|
+
async function withLock(fn) {
|
|
116
|
+
const lock = FILE + ".lock";
|
|
117
|
+
const deadline = Date.now() + 10_000;
|
|
118
|
+
for (;;) {
|
|
119
|
+
try {
|
|
120
|
+
await mkdir(lock);
|
|
121
|
+
break;
|
|
122
|
+
} catch {
|
|
123
|
+
if (Date.now() > deadline) {
|
|
124
|
+
// Stale lock? Take it rather than deadlock forever.
|
|
125
|
+
try {
|
|
126
|
+
await rmdir(lock);
|
|
127
|
+
} catch {}
|
|
128
|
+
await mkdir(lock).catch(() => {});
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
await sleep(80);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return await fn();
|
|
136
|
+
} finally {
|
|
137
|
+
await rmdir(lock).catch(() => {});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sleep(ms) {
|
|
142
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Split a standup doc into { yaml (raw text), body }.
|
|
146
|
+
function splitDoc(text) {
|
|
147
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
148
|
+
if (!m) return { yaml: "", body: text };
|
|
149
|
+
return { yaml: m[1], body: m[2] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Minimal front-matter readers (zero-dep; we only need a few fields).
|
|
153
|
+
function yamlScalar(yaml, key) {
|
|
154
|
+
const re = new RegExp(`^${key}:\\s*(.*)$`, "m");
|
|
155
|
+
const m = yaml.match(re);
|
|
156
|
+
if (!m) return null;
|
|
157
|
+
const inline = m[1].trim();
|
|
158
|
+
// Block scalar (>- , >, | , |-): value is the indented lines that follow.
|
|
159
|
+
if (/^[|>][+-]?$/.test(inline)) {
|
|
160
|
+
const after = yaml.slice(m.index + m[0].length).split("\n").slice(1);
|
|
161
|
+
const lines = [];
|
|
162
|
+
for (const l of after) {
|
|
163
|
+
if (/^\s+\S/.test(l) || l.trim() === "") lines.push(l.trim());
|
|
164
|
+
else break;
|
|
165
|
+
}
|
|
166
|
+
return lines.join(" ").trim();
|
|
167
|
+
}
|
|
168
|
+
return inline.replace(/^["']|["']$/g, "");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function yamlList(yaml, key) {
|
|
172
|
+
// Matches: key:\n - a\n - b (until a non-indented line)
|
|
173
|
+
const re = new RegExp(`^${key}:\\s*\\n((?:\\s*-\\s*.+\\n?)*)`, "m");
|
|
174
|
+
const m = yaml.match(re);
|
|
175
|
+
if (!m) return [];
|
|
176
|
+
return m[1]
|
|
177
|
+
.split("\n")
|
|
178
|
+
.map((l) => l.replace(/^\s*-\s*/, "").trim())
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Parse chat turns: each starts with "### <agent> — <iso>".
|
|
183
|
+
function parseTurns(body) {
|
|
184
|
+
const turns = [];
|
|
185
|
+
const re = /^###\s+(.+?)\s+—\s+(\S+)\s*$/gm;
|
|
186
|
+
let m;
|
|
187
|
+
const heads = [];
|
|
188
|
+
while ((m = re.exec(body))) {
|
|
189
|
+
heads.push({ agent: m[1].trim(), ts: m[2].trim(), idx: m.index, end: re.lastIndex });
|
|
190
|
+
}
|
|
191
|
+
for (let i = 0; i < heads.length; i++) {
|
|
192
|
+
const start = heads[i].end;
|
|
193
|
+
const stop = i + 1 < heads.length ? heads[i + 1].idx : body.length;
|
|
194
|
+
turns.push({
|
|
195
|
+
agent: heads[i].agent,
|
|
196
|
+
ts: heads[i].ts,
|
|
197
|
+
text: body.slice(start, stop).trim(),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return turns;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function lastTurn(body) {
|
|
204
|
+
const t = parseTurns(body);
|
|
205
|
+
return t.length ? t[t.length - 1] : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Append a turn under "## Chat", taking the lock. Adds the author to
|
|
209
|
+
// participants if missing. Optionally appends an AGREE line.
|
|
210
|
+
async function appendTurn({ agent, message, agree }) {
|
|
211
|
+
await withLock(async () => {
|
|
212
|
+
let text = await read();
|
|
213
|
+
const { yaml, body } = splitDoc(text);
|
|
214
|
+
|
|
215
|
+
// ensure participant listed
|
|
216
|
+
let newYaml = yaml;
|
|
217
|
+
const participants = yamlList(yaml, "participants");
|
|
218
|
+
if (!participants.includes(agent)) {
|
|
219
|
+
newYaml = yaml.replace(
|
|
220
|
+
/^participants:\s*\n((?:\s*-\s*.+\n?)*)/m,
|
|
221
|
+
(full) => full.replace(/\n?$/, `\n - ${agent}\n`),
|
|
222
|
+
);
|
|
223
|
+
if (newYaml === yaml) {
|
|
224
|
+
// no participants block — append one
|
|
225
|
+
newYaml = yaml.trimEnd() + `\nparticipants:\n - ${agent}\n`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let block = `\n### ${agent} — ${nowIso()}\n\n${message.trim()}\n`;
|
|
230
|
+
if (agree) block += `\nAGREE: ${agree.trim()}\n`;
|
|
231
|
+
|
|
232
|
+
// ensure a "## Chat" section exists
|
|
233
|
+
let newBody = body;
|
|
234
|
+
if (!/^##\s+Chat\s*$/m.test(newBody)) newBody += `\n## Chat\n`;
|
|
235
|
+
newBody = newBody.replace(/\s*$/, "\n") + block;
|
|
236
|
+
|
|
237
|
+
text = `---\n${newYaml.replace(/\n?$/, "\n")}---\n${newBody}`;
|
|
238
|
+
await writeFile(FILE, text);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// -------------------------------------------------------------------- commands
|
|
243
|
+
// Parse a time window like "1h", "4h", "24h", "7d", "30m", "2w" into
|
|
244
|
+
// milliseconds. "all" / "any" / "none" (or nothing) → null, meaning no filter.
|
|
245
|
+
// Anything unrecognized → null with a warning, so a typo widens rather than
|
|
246
|
+
// silently hiding worktrees.
|
|
247
|
+
function parseWindowMs(s) {
|
|
248
|
+
if (!s || s === true) return null;
|
|
249
|
+
const v = String(s).trim().toLowerCase();
|
|
250
|
+
if (v === "all" || v === "any" || v === "none" || v === "*") return null;
|
|
251
|
+
const m = v.match(/^(\d+)\s*([mhdw])$/);
|
|
252
|
+
if (!m) {
|
|
253
|
+
console.error(`standup: unrecognized window "${s}" — showing all worktrees`);
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const unit = { m: 60e3, h: 3600e3, d: 86400e3, w: 604800e3 }[m[2]];
|
|
257
|
+
return Number(m[1]) * unit;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Human-friendly "time since" for a unix-ms timestamp.
|
|
261
|
+
function humanAge(ms) {
|
|
262
|
+
if (!ms) return "unknown";
|
|
263
|
+
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
264
|
+
if (s < 60) return `${s}s ago`;
|
|
265
|
+
const m = Math.round(s / 60);
|
|
266
|
+
if (m < 60) return `${m}m ago`;
|
|
267
|
+
const h = Math.round(m / 60);
|
|
268
|
+
if (h < 48) return `${h}h ago`;
|
|
269
|
+
return `${Math.round(h / 24)}d ago`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The most recent moment a worktree saw work: the newest of its last commit
|
|
273
|
+
// time and any uncommitted change (staged, modified, or untracked). This is
|
|
274
|
+
// what "active in the last N hours" keys off — a branch with live unpushed
|
|
275
|
+
// edits counts as active even if its last commit is old. Returns unix ms, or 0
|
|
276
|
+
// when nothing can be determined.
|
|
277
|
+
function worktreeActivityMs(path) {
|
|
278
|
+
let last = 0;
|
|
279
|
+
try {
|
|
280
|
+
const sec = execSync("git log -1 --format=%ct", {
|
|
281
|
+
cwd: path,
|
|
282
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
283
|
+
})
|
|
284
|
+
.toString()
|
|
285
|
+
.trim();
|
|
286
|
+
if (sec) last = Math.max(last, Number(sec) * 1000);
|
|
287
|
+
} catch {}
|
|
288
|
+
try {
|
|
289
|
+
const out = execSync("git status --porcelain", {
|
|
290
|
+
cwd: path,
|
|
291
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
292
|
+
}).toString();
|
|
293
|
+
for (const line of out.split("\n")) {
|
|
294
|
+
if (!line.trim()) continue;
|
|
295
|
+
// porcelain rows are "XY <path>" or, for renames, "XY old -> new".
|
|
296
|
+
let p = line.slice(3).trim();
|
|
297
|
+
const arrow = p.indexOf(" -> ");
|
|
298
|
+
if (arrow >= 0) p = p.slice(arrow + 4);
|
|
299
|
+
p = p.replace(/^"|"$/g, "");
|
|
300
|
+
try {
|
|
301
|
+
const mt = statSync(join(path, p)).mtimeMs;
|
|
302
|
+
if (mt > last) last = mt;
|
|
303
|
+
} catch {}
|
|
304
|
+
}
|
|
305
|
+
} catch {}
|
|
306
|
+
return last;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// List git worktrees as { branch, path }. Used by the /standup orchestrator to
|
|
310
|
+
// discover who's in the room. Skips detached / bare entries.
|
|
311
|
+
function gitWorktrees() {
|
|
312
|
+
let out;
|
|
313
|
+
try {
|
|
314
|
+
out = execSync("git worktree list --porcelain", {
|
|
315
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
316
|
+
}).toString();
|
|
317
|
+
} catch {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
const items = [];
|
|
321
|
+
let cur = {};
|
|
322
|
+
for (const line of out.split("\n")) {
|
|
323
|
+
if (line.startsWith("worktree ")) cur = { path: line.slice(9).trim() };
|
|
324
|
+
else if (line.startsWith("branch "))
|
|
325
|
+
cur.branch = line.slice(7).replace("refs/heads/", "").trim();
|
|
326
|
+
else if (line.trim() === "") {
|
|
327
|
+
if (cur.path && cur.branch) items.push(cur);
|
|
328
|
+
cur = {};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (cur.path && cur.branch) items.push(cur);
|
|
332
|
+
return items;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Open PRs via the gh CLI — the other kind of room candidate. A PR is a branch
|
|
336
|
+
// living on the remote; including one means an agent fetches it read-only,
|
|
337
|
+
// reports what it changes, and the consolidation plan folds it in alongside the
|
|
338
|
+
// local worktrees. Returns null when gh is unavailable / unauthenticated / the
|
|
339
|
+
// repo has no GitHub remote, so the orchestrator can degrade to worktrees-only.
|
|
340
|
+
function ghPRs() {
|
|
341
|
+
let out;
|
|
342
|
+
try {
|
|
343
|
+
out = execSync(
|
|
344
|
+
"gh pr list --state open --limit 200 --json number,title,headRefName,updatedAt,author,isDraft",
|
|
345
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
346
|
+
).toString();
|
|
347
|
+
} catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
return JSON.parse(out);
|
|
352
|
+
} catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function cmdPRs() {
|
|
358
|
+
const prs = ghPRs();
|
|
359
|
+
if (prs == null)
|
|
360
|
+
die("gh unavailable, unauthenticated, or no GitHub remote (try `gh auth login`)");
|
|
361
|
+
const windowMs = parseWindowMs(opts.since);
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
let rows = prs.map((p) => ({
|
|
364
|
+
number: p.number,
|
|
365
|
+
title: p.title,
|
|
366
|
+
branch: p.headRefName,
|
|
367
|
+
author: p.author?.login || "",
|
|
368
|
+
isDraft: !!p.isDraft,
|
|
369
|
+
updatedAt: p.updatedAt || null,
|
|
370
|
+
updatedMs: p.updatedAt ? Date.parse(p.updatedAt) : 0,
|
|
371
|
+
age: p.updatedAt ? humanAge(Date.parse(p.updatedAt)) : "unknown",
|
|
372
|
+
}));
|
|
373
|
+
if (windowMs != null) rows = rows.filter((p) => p.updatedMs && now - p.updatedMs <= windowMs);
|
|
374
|
+
rows.sort((a, b) => b.updatedMs - a.updatedMs);
|
|
375
|
+
if (opts.json) {
|
|
376
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (!rows.length) {
|
|
380
|
+
console.error(windowMs != null ? `no open PRs updated within "${opts.since}"` : `no open PRs`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
for (const p of rows) {
|
|
384
|
+
const draft = p.isDraft ? " [draft]" : "";
|
|
385
|
+
console.log(`#${p.number}\t${p.age.padEnd(8)}\t${p.branch}\t${p.title}${draft}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function cmdWorktrees() {
|
|
390
|
+
const here = process.cwd();
|
|
391
|
+
const windowMs = parseWindowMs(opts.since);
|
|
392
|
+
const now = Date.now();
|
|
393
|
+
let rows = gitWorktrees().map((w) => {
|
|
394
|
+
const activityMs = worktreeActivityMs(w.path);
|
|
395
|
+
return {
|
|
396
|
+
branch: w.branch,
|
|
397
|
+
path: w.path,
|
|
398
|
+
current: w.path === here,
|
|
399
|
+
lastActivity: activityMs
|
|
400
|
+
? new Date(activityMs).toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
401
|
+
: null,
|
|
402
|
+
lastActivityMs: activityMs,
|
|
403
|
+
age: humanAge(activityMs),
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
// Scope to the window (commits OR uncommitted edits inside it), newest first.
|
|
407
|
+
if (windowMs != null) {
|
|
408
|
+
rows = rows.filter((w) => w.lastActivityMs && now - w.lastActivityMs <= windowMs);
|
|
409
|
+
}
|
|
410
|
+
rows.sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
|
|
411
|
+
|
|
412
|
+
if (opts.json) {
|
|
413
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (!rows.length) {
|
|
417
|
+
console.error(
|
|
418
|
+
windowMs != null
|
|
419
|
+
? `no worktrees active within "${opts.since}" — widen the window or use --since all`
|
|
420
|
+
: `no worktrees found`,
|
|
421
|
+
);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
for (const w of rows) {
|
|
425
|
+
const mine = w.current ? " (current)" : "";
|
|
426
|
+
console.log(`${w.age.padEnd(8)}\t${w.branch}\t${w.path}${mine}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function cmdOpen() {
|
|
431
|
+
const agent = agentName();
|
|
432
|
+
const goal = opts.goal;
|
|
433
|
+
const prompt = opts.prompt;
|
|
434
|
+
if (!goal || !prompt) die("open needs --goal and --prompt");
|
|
435
|
+
if (await exists(FILE)) {
|
|
436
|
+
if (!opts.force) die(`channel exists: ${FILE} (use --force to rotate it aside)`);
|
|
437
|
+
const archived = FILE.replace(/\.md$/, `-${nowIso().replace(/[:T]/g, "-").replace("Z", "")}.md`);
|
|
438
|
+
await rename(FILE, archived);
|
|
439
|
+
console.error(`rotated existing channel → ${archived}`);
|
|
440
|
+
}
|
|
441
|
+
await mkdir(dirname(FILE), { recursive: true });
|
|
442
|
+
const ts = nowIso();
|
|
443
|
+
const fold = (s) => s.replace(/\s+/g, " ").trim();
|
|
444
|
+
const doc = `---
|
|
445
|
+
channel: STANDUP
|
|
446
|
+
opened_by: ${agent}
|
|
447
|
+
opened_at: ${ts}
|
|
448
|
+
status: open
|
|
449
|
+
goal: >-
|
|
450
|
+
${fold(goal)}
|
|
451
|
+
prompt: >-
|
|
452
|
+
${fold(prompt)}
|
|
453
|
+
participants:
|
|
454
|
+
- ${agent}
|
|
455
|
+
protocol: |
|
|
456
|
+
1. Before each turn, READ the whole file. Only respond to messages posted
|
|
457
|
+
after your previous turn.
|
|
458
|
+
2. Append your turn at the end of "## Chat" as:
|
|
459
|
+
### <agent-name> — <ISO-8601 UTC>
|
|
460
|
+
<your message>
|
|
461
|
+
3. To register consensus, include a line exactly: "AGREE: <deliverable>"
|
|
462
|
+
4. When ALL participants have an AGREE line for the same deliverable, the
|
|
463
|
+
last agent to agree appends "## SUMMATION" and flips status: agreed.
|
|
464
|
+
5. New agent joining? Add yourself to participants and say Hello in Chat.
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
# Standup — group chat
|
|
468
|
+
|
|
469
|
+
The room is open. Post under **## Chat** following the protocol above.
|
|
470
|
+
|
|
471
|
+
## Chat
|
|
472
|
+
|
|
473
|
+
### ${agent} — ${ts}
|
|
474
|
+
|
|
475
|
+
Hello! 👋 I'm \`${agent}\`. The room is open — goal and prompt are in the
|
|
476
|
+
front matter. Counter-proposals welcome. I'm listening.
|
|
477
|
+
`;
|
|
478
|
+
await writeFile(FILE, doc);
|
|
479
|
+
console.log(`opened ${FILE} as "${agent}"`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function cmdJoin() {
|
|
483
|
+
const agent = agentName();
|
|
484
|
+
if (!(await exists(FILE))) die(`no channel at ${FILE} — run "open" first`);
|
|
485
|
+
const msg =
|
|
486
|
+
opts.message ||
|
|
487
|
+
`Hello! 👋 I'm \`${agent}\`. Joining the room and listening.`;
|
|
488
|
+
await appendTurn({ agent, message: msg });
|
|
489
|
+
console.log(`joined as "${agent}"`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function cmdPost() {
|
|
493
|
+
const agent = agentName();
|
|
494
|
+
if (!opts.message) die("post needs --message");
|
|
495
|
+
if (!(await exists(FILE))) die(`no channel at ${FILE}`);
|
|
496
|
+
await appendTurn({ agent, message: opts.message, agree: opts.agree });
|
|
497
|
+
console.log(`posted as "${agent}"`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function cmdAgree() {
|
|
501
|
+
const agent = agentName();
|
|
502
|
+
const d = opts.deliverable;
|
|
503
|
+
if (!d) die("agree needs --deliverable");
|
|
504
|
+
await appendTurn({
|
|
505
|
+
agent,
|
|
506
|
+
message: opts.message || `I'm in. AGREE on the deliverable below.`,
|
|
507
|
+
agree: d,
|
|
508
|
+
});
|
|
509
|
+
console.log(`agreed as "${agent}": ${d}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function cmdWatch() {
|
|
513
|
+
const agent = agentName();
|
|
514
|
+
if (!(await exists(FILE))) die(`no channel at ${FILE}`);
|
|
515
|
+
const timeout = Number(opts.timeout || 1800) * 1000;
|
|
516
|
+
const interval = Number(opts.interval || 5) * 1000;
|
|
517
|
+
const baselineText = await read();
|
|
518
|
+
let baseTurns = parseTurns(splitDoc(baselineText).body).length;
|
|
519
|
+
const start = Date.now();
|
|
520
|
+
process.stderr.write(
|
|
521
|
+
`watching ${FILE} as "${agent}" (every ${interval / 1000}s, timeout ${
|
|
522
|
+
timeout / 1000
|
|
523
|
+
}s)…\n`,
|
|
524
|
+
);
|
|
525
|
+
for (;;) {
|
|
526
|
+
if (Date.now() - start > timeout) {
|
|
527
|
+
console.log("TIMEOUT — no one else posted.");
|
|
528
|
+
process.exit(2);
|
|
529
|
+
}
|
|
530
|
+
await sleep(interval);
|
|
531
|
+
let text;
|
|
532
|
+
try {
|
|
533
|
+
text = await read();
|
|
534
|
+
} catch {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const turns = parseTurns(splitDoc(text).body);
|
|
538
|
+
if (turns.length <= baseTurns) continue;
|
|
539
|
+
const fresh = turns.slice(baseTurns);
|
|
540
|
+
baseTurns = turns.length;
|
|
541
|
+
// ignore our own turns — keep listening for someone ELSE
|
|
542
|
+
const others = fresh.filter((t) => t.agent !== agent);
|
|
543
|
+
if (!others.length) continue;
|
|
544
|
+
console.log(`NEW (${others.length}) after ${Math.round((Date.now() - start) / 1000)}s:\n`);
|
|
545
|
+
for (const t of others) {
|
|
546
|
+
console.log(`### ${t.agent} — ${t.ts}\n${t.text}\n`);
|
|
547
|
+
}
|
|
548
|
+
process.exit(0);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function cmdRead() {
|
|
553
|
+
if (!(await exists(FILE))) die(`no channel at ${FILE}`);
|
|
554
|
+
const { body } = splitDoc(await read());
|
|
555
|
+
let turns = parseTurns(body);
|
|
556
|
+
if (opts.since) {
|
|
557
|
+
// turns after the named agent's last post
|
|
558
|
+
let lastIdx = -1;
|
|
559
|
+
turns.forEach((t, i) => {
|
|
560
|
+
if (t.agent === opts.since) lastIdx = i;
|
|
561
|
+
});
|
|
562
|
+
if (lastIdx >= 0) turns = turns.slice(lastIdx + 1);
|
|
563
|
+
}
|
|
564
|
+
if (opts.tail) turns = turns.slice(-Number(opts.tail));
|
|
565
|
+
for (const t of turns) console.log(`### ${t.agent} — ${t.ts}\n${t.text}\n`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function cmdStatus() {
|
|
569
|
+
if (!(await exists(FILE))) die(`no channel at ${FILE}`);
|
|
570
|
+
const { yaml, body } = splitDoc(await read());
|
|
571
|
+
const participants = yamlList(yaml, "participants");
|
|
572
|
+
const status = yamlScalar(yaml, "status") || "open";
|
|
573
|
+
const turns = parseTurns(body);
|
|
574
|
+
// latest AGREE per agent
|
|
575
|
+
const agreeByAgent = new Map();
|
|
576
|
+
for (const t of turns) {
|
|
577
|
+
const m = t.text.match(/^AGREE:\s*(.+)$/m);
|
|
578
|
+
if (m) agreeByAgent.set(t.agent, m[1].trim());
|
|
579
|
+
}
|
|
580
|
+
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
581
|
+
const agreedValues = participants.map((p) => agreeByAgent.get(p) || null);
|
|
582
|
+
const allAgreed =
|
|
583
|
+
participants.length > 0 &&
|
|
584
|
+
agreedValues.every(Boolean) &&
|
|
585
|
+
new Set(agreedValues.map(norm)).size === 1;
|
|
586
|
+
console.log(`channel : ${FILE}`);
|
|
587
|
+
console.log(`status : ${status}`);
|
|
588
|
+
console.log(`goal : ${yamlScalar(yaml, "goal") || "(see file)"}`);
|
|
589
|
+
console.log(`turns : ${turns.length}`);
|
|
590
|
+
console.log(`participants (${participants.length}):`);
|
|
591
|
+
for (const p of participants) {
|
|
592
|
+
const a = agreeByAgent.get(p);
|
|
593
|
+
console.log(` - ${p}${a ? ` ✓ AGREE: ${a}` : " … no agree yet"}`);
|
|
594
|
+
}
|
|
595
|
+
console.log(
|
|
596
|
+
allAgreed
|
|
597
|
+
? "consensus: REACHED — all participants agree. Write a ## SUMMATION."
|
|
598
|
+
: "consensus: not yet",
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function cmdSummation() {
|
|
603
|
+
const agent = agentName();
|
|
604
|
+
if (!opts.text) die("summation needs --text");
|
|
605
|
+
await withLock(async () => {
|
|
606
|
+
let text = await read();
|
|
607
|
+
const { yaml, body } = splitDoc(text);
|
|
608
|
+
const newYaml = yaml.replace(/^status:\s*.+$/m, "status: agreed");
|
|
609
|
+
const block = `\n## SUMMATION\n\n_by ${agent} — ${nowIso()}_\n\n${opts.text.trim()}\n`;
|
|
610
|
+
text = `---\n${newYaml.replace(/\n?$/, "\n")}---\n${body.replace(/\s*$/, "\n")}${block}`;
|
|
611
|
+
await writeFile(FILE, text);
|
|
612
|
+
});
|
|
613
|
+
console.log(`summation written; status → agreed`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function die(msg) {
|
|
617
|
+
console.error(`standup: ${msg}`);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const USAGE = `standup — markdown group chat for multiple coding agents
|
|
622
|
+
|
|
623
|
+
usage: standup <command> [--flags]
|
|
624
|
+
|
|
625
|
+
open --goal "..." --prompt "..." create the channel (you say hello)
|
|
626
|
+
[--force rotates an existing room aside]
|
|
627
|
+
worktrees [--since 4h] [--json] list worktrees, newest first; --since
|
|
628
|
+
N{m,h,d,w} keeps only those active in the
|
|
629
|
+
window (commit OR uncommitted edit)
|
|
630
|
+
prs [--since 4h] [--json] list open GitHub PRs (via gh), newest
|
|
631
|
+
first; --since filters by last update
|
|
632
|
+
join [--message "..."] add yourself + say hello
|
|
633
|
+
post --message "..." [--agree "..."] append a turn
|
|
634
|
+
agree --deliverable "..." append an AGREE turn
|
|
635
|
+
watch [--timeout SEC] [--interval SEC] block until someone ELSE posts
|
|
636
|
+
read [--tail N] [--since AGENT] print the chat
|
|
637
|
+
status participants + consensus check
|
|
638
|
+
summation --text "..." close the room (status: agreed)
|
|
639
|
+
|
|
640
|
+
agent name defaults to your git branch; override with --agent or STANDUP_AGENT.
|
|
641
|
+
file defaults to ~/.claude-mem/STANDUP.md; override with --file or STANDUP_FILE.`;
|
|
642
|
+
|
|
643
|
+
const table = {
|
|
644
|
+
open: cmdOpen,
|
|
645
|
+
worktrees: cmdWorktrees,
|
|
646
|
+
prs: cmdPRs,
|
|
647
|
+
join: cmdJoin,
|
|
648
|
+
post: cmdPost,
|
|
649
|
+
agree: cmdAgree,
|
|
650
|
+
watch: cmdWatch,
|
|
651
|
+
read: cmdRead,
|
|
652
|
+
status: cmdStatus,
|
|
653
|
+
summation: cmdSummation,
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
657
|
+
console.log(USAGE);
|
|
658
|
+
process.exit(cmd ? 0 : 1);
|
|
659
|
+
}
|
|
660
|
+
const fn = table[cmd];
|
|
661
|
+
if (!fn) die(`unknown command "${cmd}"\n\n${USAGE}`);
|
|
662
|
+
await fn();
|