@afokapu/atdd-bun 0.10.31 → 0.10.32

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 (50) hide show
  1. package/README.md +2 -112
  2. package/conventions/atdd-bun.profiles/atdd-bun.profiles.presets.convention.yaml +2 -3
  3. package/conventions/planner.docs/planner.docs.area-index-required.convention.yaml +2 -2
  4. package/conventions/planner.docs/planner.docs.artifact-path-shape.convention.yaml +1 -1
  5. package/conventions/planner.docs/planner.docs.asciidoc-only.convention.yaml +0 -1
  6. package/conventions/planner.docs/planner.docs.identity-required.convention.yaml +1 -1
  7. package/conventions/planner.docs/planner.docs.undeclared-change.convention.yaml +1 -2
  8. package/detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs +7 -5
  9. package/detectors/planner_docs_capability/README.md +1 -1
  10. package/integrity.json +21 -49
  11. package/package.json +1 -1
  12. package/relationships.yaml +0 -151
  13. package/src/agent.ts +2 -18
  14. package/src/cli.ts +0 -8
  15. package/src/docs-capability.ts +6 -24
  16. package/src/enforce.ts +1 -2
  17. package/src/hooks.ts +1 -5
  18. package/src/index.ts +0 -2
  19. package/src/integrity.ts +3 -9
  20. package/src/setup.ts +1 -3
  21. package/templates/agents/AGENTS.block.md +0 -2
  22. package/templates/github/atdd-bun.yml +0 -5
  23. package/conventions/delivery/delivery.approved-sha-resolves.convention.yaml +0 -33
  24. package/conventions/delivery/delivery.board.convention.yaml +0 -60
  25. package/conventions/delivery/delivery.config-schema.convention.yaml +0 -49
  26. package/conventions/delivery/delivery.evidence-schema.convention.yaml +0 -34
  27. package/conventions/delivery/delivery.findings-resolved.convention.yaml +0 -42
  28. package/conventions/delivery/delivery.merge-gate.convention.yaml +0 -46
  29. package/conventions/delivery/delivery.model-allowed.convention.yaml +0 -29
  30. package/conventions/delivery/delivery.operating-model.convention.yaml +0 -97
  31. package/conventions/delivery/delivery.review.convention.yaml +0 -42
  32. package/conventions/delivery/delivery.reviewer-independent.convention.yaml +0 -34
  33. package/conventions/delivery/delivery.stages-complete.convention.yaml +0 -33
  34. package/detectors/delivery_evidence/atdd.implementation.yaml +0 -33
  35. package/detectors/delivery_evidence/detect.mjs +0 -9
  36. package/detectors/delivery_evidence/fixtures/clean/atdd-bun.yaml +0 -8
  37. package/detectors/delivery_evidence/fixtures/clean/docs/delivery/tranches/api/evidence.yaml +0 -54
  38. package/detectors/delivery_evidence/fixtures/dirty/atdd-bun.yaml +0 -3
  39. package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/api/evidence.yaml +0 -31
  40. package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/data/evidence.yaml +0 -8
  41. package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/orphan/README.md +0 -1
  42. package/detectors/delivery_evidence/fixtures/dirty/docs/delivery/tranches/ui/evidence.yaml +0 -5
  43. package/detectors/planner_docs_capability/fixtures/dirty_missing_index/docs/delivery/release.adoc +0 -10
  44. package/detectors/planner_docs_capability/fixtures/dirty_missing_index/docs/purpose/index.adoc +0 -5
  45. package/planner-schemas/delivery-config.schema.json +0 -95
  46. package/planner-schemas/delivery-evidence.schema.json +0 -103
  47. package/src/board-ui.ts +0 -91
  48. package/src/board.ts +0 -222
  49. package/src/delivery.ts +0 -567
  50. package/templates/agents/delivery/SKILL.md +0 -15
@@ -1,103 +0,0 @@
1
- {
2
- "$schema": "http://json-schema.org/draft-07/schema#",
3
- "title": "Delivery Evidence Schema",
4
- "description": "Validates one <delivery root>/<tranche>/evidence.yaml: the record a tranche leaves of who wrote each stage and of its reviews. Local shape only: stage completeness, allowed models and fallbacks, reviewer independence, finding outcomes and SHA resolution are cross-field validator concerns (src/delivery.ts).",
5
- "$comment": "Shipped by the delivery profile (delivery_evidence). `reviews` is chronological: a repair loop appends a new entry for the same stage rather than editing the earlier one.",
6
- "type": "object",
7
- "required": ["tranche", "status", "reviews"],
8
- "additionalProperties": false,
9
- "properties": {
10
- "tranche": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "description": "Must equal the tranche folder name." },
11
- "status": { "enum": ["open", "ready"], "description": "open: the lifecycle is in progress. ready: every configured stage is approved and approved_sha is the commit to merge." },
12
- "base_sha": { "$ref": "#/definitions/sha", "$comment": "Informational: the commit the tranche started from. Not checked; the gate derives its range from Git." },
13
- "approved_sha": { "$ref": "#/definitions/sha" },
14
- "pr": { "type": "string", "minLength": 1, "$comment": "Informational: the pull request URL. Not checked." },
15
- "work": { "type": "array", "items": { "$ref": "#/definitions/work" }, "description": "Who wrote each stage, in order: one entry per stage written, more after a repair." },
16
- "reviews": { "type": "array", "items": { "$ref": "#/definitions/review" } }
17
- },
18
- "if": { "required": ["status"], "properties": { "status": { "const": "ready" } } },
19
- "then": { "required": ["approved_sha"] },
20
- "definitions": {
21
- "sha": { "type": "string", "pattern": "^[0-9a-f]{7,40}$" },
22
- "actor": {
23
- "type": "object",
24
- "required": ["model", "run"],
25
- "additionalProperties": false,
26
- "properties": {
27
- "model": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
28
- "run": { "type": "string", "minLength": 1, "description": "Identifies the process: a session id, a pane id plus start time, or the report file. Two entries with the same run are the same process." }
29
- }
30
- },
31
- "fallback": {
32
- "type": "object",
33
- "required": ["from", "kind", "reason"],
34
- "additionalProperties": false,
35
- "description": "Why a later model in the stage's list was used: counted failures of the preferred one, or the operator's order (kind operator), which references its ruling instead and is accepted only for a model the stage lists.",
36
- "if": { "required": ["kind"], "properties": { "kind": { "const": "operator" } } },
37
- "then": { "required": ["decided_by", "decided_at", "ruling"], "not": { "anyOf": [{ "required": ["failures"] }, { "required": ["window"] }] } },
38
- "else": { "required": ["failures", "window"] },
39
- "properties": {
40
- "role": { "enum": ["writer", "author", "reviewer"], "description": "Which actor fell back; author is the 0.9 spelling of writer. Default: writer on a work entry, reviewer on a review." },
41
- "from": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
42
- "kind": { "enum": ["outage", "rate_limit", "no_report", "timeout", "operator"], "description": "Why the model counts as unavailable, or operator when the operator ordered the substitution. A verdict is never one of these." },
43
- "failures": { "type": "integer", "minimum": 1, "description": "Failures observed before falling back; at least delivery.fallback.after_failures." },
44
- "window": {
45
- "type": "object",
46
- "required": ["from", "to"],
47
- "additionalProperties": false,
48
- "description": "When the first and last counted failures happened; the span is at most delivery.fallback.within_minutes.",
49
- "properties": { "from": { "type": "string", "format": "date-time" }, "to": { "type": "string", "format": "date-time" } }
50
- },
51
- "decided_by": { "type": "string", "minLength": 2, "description": "operator only: who ruled, by name or board identity. Never the driver on its own authority." },
52
- "decided_at": { "type": "string", "format": "date-time", "description": "operator only: when the ruling was given." },
53
- "ruling": { "type": "string", "minLength": 3, "description": "operator only: where the ruling is recorded (a board message id, an ADR or a PR)." },
54
- "reason": { "type": "string", "minLength": 10, "description": "Why the preferred model was not used: an outage, a rate limit, no auditable report, or the operator's order. Never a verdict." }
55
- }
56
- },
57
- "finding": {
58
- "type": "object",
59
- "required": ["id", "severity", "evidence", "invariant", "proposed_fix"],
60
- "additionalProperties": false,
61
- "properties": {
62
- "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", "description": "Stable across re-reviews, so an upheld finding keeps its id." },
63
- "severity": { "enum": ["critical", "high", "medium", "low"] },
64
- "evidence": { "type": "string", "minLength": 5, "description": "Concrete: file:line, a failing command, a counter-example." },
65
- "invariant": { "type": "string", "minLength": 5 },
66
- "affects": { "type": "array", "items": { "type": "string", "minLength": 1 } },
67
- "proposed_fix": { "type": "string", "minLength": 5, "description": "A precise correction for the author to apply; the reviewer never applies it." },
68
- "rebuttal": { "type": "string", "minLength": 20, "description": "The author's written dispute, with evidence. One round only." },
69
- "outcome": { "enum": ["fixed", "withdrawn", "human"] },
70
- "decision": { "type": "string", "minLength": 5, "description": "The human's ruling when outcome is human." }
71
- }
72
- },
73
- "work": {
74
- "type": "object",
75
- "required": ["stage", "sha", "writer"],
76
- "additionalProperties": false,
77
- "properties": {
78
- "stage": { "enum": ["plan", "red", "green", "refactor", "final", "plan_review", "test_review", "code_review", "final_review"] },
79
- "sha": { "$ref": "#/definitions/sha", "description": "The commit the stage's work ended at." },
80
- "writer": { "$ref": "#/definitions/actor" },
81
- "fallback": { "type": "array", "items": { "$ref": "#/definitions/fallback" } }
82
- }
83
- },
84
- "review": {
85
- "type": "object",
86
- "required": ["stage", "sha", "reviewer", "verdict", "checked"],
87
- "additionalProperties": false,
88
- "properties": {
89
- "stage": { "enum": ["plan", "red", "green", "refactor", "final", "plan_review", "test_review", "code_review", "final_review"], "description": "The stage reviewed. The 0.9 names read as plan, red, refactor and final." },
90
- "sha": { "$ref": "#/definitions/sha" },
91
- "author": { "$ref": "#/definitions/actor", "$comment": "0.9: the writer of the reviewed work. Records now name writers under work." },
92
- "reviewer": { "$ref": "#/definitions/actor" },
93
- "fallback": { "type": "array", "items": { "$ref": "#/definitions/fallback" } },
94
- "verdict": { "enum": ["approve", "request_changes"] },
95
- "checked": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "description": "Everything the reviewer went through, not only what failed." },
96
- "findings": { "type": "array", "items": { "$ref": "#/definitions/finding" } },
97
- "report": { "type": "string", "minLength": 1, "description": "Path of the retained raw reviewer output, inside the tranche's folder." }
98
- },
99
- "if": { "required": ["verdict"], "properties": { "verdict": { "const": "request_changes" } } },
100
- "then": { "required": ["findings"], "properties": { "findings": { "minItems": 1 } } }
101
- }
102
- }
103
- }
package/src/board-ui.ts DELETED
@@ -1,91 +0,0 @@
1
- import { allowedTopics, DIRECTORY, format, read, type Message } from "./board";
2
-
3
- /** `atdd-bun chat` alone: a terminal view of the board, every topic in a panel on the left and the selected topic's
4
- * conversation on the right, refreshed every second. ntfy cannot list its topics, so the board keeps a directory
5
- * topic (atdd-topics) that atdd-bun chat post fills with each topic's name on its first message. */
6
-
7
- /** Every topic the directory names, in order. */
8
- export async function knownTopics(url: string): Promise<string[]> {
9
- const entries = await read(url, [DIRECTORY]).catch(() => [] as Message[]);
10
- return [...new Set(entries.map(entry => entry.raw.trim()).filter(topic => /^[A-Za-z0-9_-]{1,64}$/.test(topic)))].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
11
- }
12
-
13
- export type UiState = { topics: string[]; messages: Map<string, Message[]>; selected: number; scroll: number; url: string };
14
-
15
- /** A topic's parent is the longest other topic it extends (atdd-p-t3-final-1 under atdd-p-t3 under atdd-p). */
16
- function tree(topics: string[]): Array<{ topic: string; depth: number; label: string }> {
17
- return topics.map(topic => {
18
- const ancestors = topics.filter(other => other !== topic && topic.startsWith(`${other}-`));
19
- const parent = ancestors.sort((a, b) => b.length - a.length)[0];
20
- return { topic, depth: ancestors.length, label: parent ? topic.slice(parent.length + 1) : topic };
21
- });
22
- }
23
-
24
- const fit = (text: string, width: number) => text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text.padEnd(width);
25
- function wrap(text: string, width: number): string[] {
26
- const out: string[] = [];
27
- for (const line of text.split("\n")) {
28
- if (!line) { out.push(""); continue; }
29
- for (let i = 0; i < line.length; i += width) out.push(line.slice(i, i + width));
30
- }
31
- return out;
32
- }
33
-
34
- /** The screen as lines, without escape codes except the selection's inverse video: pure, so it can be tested. */
35
- export function render(state: UiState, width: number, height: number): string[] {
36
- const left = Math.max(20, Math.min(40, Math.floor(width / 3))), right = Math.max(10, width - left - 3), rows = Math.max(1, height - 2);
37
- const nodes = tree(state.topics), selected = nodes[state.selected]?.topic;
38
- const panel = nodes.map((node, i) => {
39
- const count = String(state.messages.get(node.topic)?.length ?? 0);
40
- const text = `${" ".repeat(node.depth)}${node.label}`;
41
- const cell = `${fit(text, left - count.length - 1)} ${count}`;
42
- return i === state.selected ? `\x1b[7m${cell}\x1b[0m` : cell;
43
- });
44
- if (!panel.length) panel.push(fit("no topics yet", left));
45
- const body = selected ? (state.messages.get(selected) ?? []).flatMap(message => wrap(format(message), right)) : wrap("Nothing posted yet. Topics appear here as agents post to them.", right);
46
- const end = Math.max(0, body.length - state.scroll), start = Math.max(0, end - rows), view = body.slice(start, end);
47
- const first = Math.max(0, Math.min(state.selected - Math.floor(rows / 2), panel.length - rows));
48
- const header = fit(` Delivery board ${state.url} · ${state.topics.length} topics · ↑↓ topic PgUp/PgDn scroll q quit`, width);
49
- const lines = [header, `${"─".repeat(left)}─┬─${"─".repeat(right)}`];
50
- for (let row = 0; row < rows - 1; row++) lines.push(`${panel[first + row] ?? " ".repeat(left)} │ ${fit(view[row] ?? "", right)}`);
51
- lines.push(fit(selected ? ` ${selected}${state.scroll ? ` (scrolled up ${state.scroll})` : ""}` : "", width));
52
- return lines;
53
- }
54
-
55
- /** Runs the view until q. Without a terminal it prints the topic list and exits. */
56
- export async function runUi(url: string, env: Record<string, string | undefined> = process.env): Promise<number> {
57
- const visible = async () => { const all = await knownTopics(url); return env.ATDD_AGENT ? all.filter(topic => allowedTopics(env).includes(topic)) : all; };
58
- const state: UiState = { topics: [], messages: new Map(), selected: 0, scroll: 0, url };
59
- const refresh = async () => {
60
- state.topics = await visible();
61
- for (const topic of state.topics) {
62
- const known = state.messages.get(topic) ?? [], since = known.at(-1)?.id ?? "all";
63
- const fresh = await read(url, [topic], since).catch(() => [] as Message[]);
64
- if (fresh.length || !state.messages.has(topic)) state.messages.set(topic, [...known, ...fresh]);
65
- }
66
- };
67
- await refresh();
68
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
69
- for (const topic of state.topics) console.log(`${topic} ${state.messages.get(topic)?.length ?? 0}`);
70
- return 0;
71
- }
72
- const draw = () => process.stdout.write(`\x1b[H\x1b[2J${render(state, process.stdout.columns || 100, process.stdout.rows || 30).join("\n")}`);
73
- process.stdout.write("\x1b[?1049h\x1b[?25l");
74
- process.stdin.setRawMode(true); process.stdin.resume();
75
- let done: (code: number) => void = () => {};
76
- const finished = new Promise<number>(resolve => { done = resolve; });
77
- const quit = () => { clearInterval(timer); process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write("\x1b[?25h\x1b[?1049l"); done(0); };
78
- process.stdin.on("data", (data: Buffer) => {
79
- const key = data.toString();
80
- if (key === "q" || key === "\x03") return quit();
81
- if (key === "\x1b[A" || key === "k") { state.selected = Math.max(0, state.selected - 1); state.scroll = 0; }
82
- if (key === "\x1b[B" || key === "j") { state.selected = Math.min(state.topics.length - 1, state.selected + 1); state.scroll = 0; }
83
- if (key === "\x1b[5~") state.scroll += 10;
84
- if (key === "\x1b[6~") state.scroll = Math.max(0, state.scroll - 10);
85
- draw();
86
- });
87
- process.stdout.on("resize", draw);
88
- const timer = setInterval(async () => { await refresh(); draw(); }, 1000);
89
- draw();
90
- return finished;
91
- }
package/src/board.ts DELETED
@@ -1,222 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { readFile } from "node:fs/promises";
5
- import { join } from "node:path";
6
- import { runUi } from "./board-ui";
7
-
8
- /** The delivery board: a local ntfy server the coordinator, drivers, writers and reviewers talk through
9
- * (the delivery.board convention). One topic per level (program, tranche, review conversation); every message
10
- * carries a header naming its sender, recipients and subject. The board is where the work is visible; the
11
- * evidence record stays the only thing the delivery rules judge. */
12
-
13
- export const DEFAULT_BOARD_URL = "http://127.0.0.1:2586";
14
- const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
15
- const TOPIC_LIMIT = 64;
16
- /** The board's directory: one message per topic, its name, posted with the topic's first message. */
17
- export const DIRECTORY = "atdd-topics";
18
-
19
- /** The board is opt-in: on only when atdd-bun.yaml names delivery.board. Its URL is $ATDD_BOARD_URL, else
20
- * delivery.board.url, else the default; the environment moves the board on one machine but never switches it on. Only a
21
- * local address is accepted, so a wrong setting can never send a message to a public server. */
22
- export async function boardUrl(root = process.cwd(), env: Record<string, string | undefined> = process.env): Promise<string> {
23
- let board: unknown;
24
- const file = join(root, "atdd-bun.yaml");
25
- if (existsSync(file)) try { board = (Bun.YAML.parse(await readFile(file, "utf8")) as { delivery?: { board?: unknown } } | null)?.delivery?.board; } catch { /* reported by delivery.config-schema */ }
26
- if (!board || typeof board !== "object" || Array.isArray(board)) throw new Error("the board is not enabled; add delivery.board to atdd-bun.yaml (for example board: { url: http://127.0.0.1:2586 }) to have agents talk through it");
27
- const configured = (board as { url?: unknown }).url;
28
- return localUrl(env.ATDD_BOARD_URL || (typeof configured === "string" ? configured : DEFAULT_BOARD_URL));
29
- }
30
-
31
- export function localUrl(raw: string): string {
32
- let url: URL;
33
- try { url = new URL(raw); } catch { throw new Error(`board URL ${raw} is not a URL`); }
34
- if (!/^https?:$/.test(url.protocol) || !LOCAL_HOSTS.has(url.hostname)) throw new Error(`board URL ${raw} is not local; the board runs on this machine (127.0.0.1, localhost or [::1])`);
35
- return `${url.protocol}//${url.host}`;
36
- }
37
-
38
- /** A topic name derived from its level: atdd-<program>[-<tranche>[-<stage>-<round>]]. Parts are lowercased and
39
- * reduced to [a-z0-9-]; a name longer than ntfy's 64 characters keeps its start and a hash of the whole. */
40
- export function topicName(program: string, tranche?: string, stage?: string, round?: string | number): string {
41
- const parts = [program, tranche, stage, round === undefined ? undefined : String(round)].filter((part): part is string => part !== undefined && part !== "");
42
- if (!parts.length) throw new Error("a topic needs at least the program name");
43
- if (stage !== undefined && round === undefined) throw new Error("a review conversation topic needs its stage and round");
44
- const slug = (part: string) => part.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
45
- const name = ["atdd", ...parts.map(slug)].join("-");
46
- if (name.length <= TOPIC_LIMIT) return name;
47
- const hash = createHash("sha256").update(name).digest("hex").slice(0, 8);
48
- return `${name.slice(0, TOPIC_LIMIT - 9).replace(/-+$/, "")}-${hash}`;
49
- }
50
-
51
- /** Topics this agent may use, from $ATDD_TOPICS (comma-separated), given by whoever launched it. */
52
- export function allowedTopics(env: Record<string, string | undefined> = process.env): string[] {
53
- return (env.ATDD_TOPICS ?? "").split(",").map(topic => topic.trim()).filter(Boolean);
54
- }
55
-
56
- export function checkTopic(topic: string, env: Record<string, string | undefined> = process.env): void {
57
- if (!/^[A-Za-z0-9_-]{1,64}$/.test(topic)) throw new Error(`${topic} is not a topic name; derive one with atdd-bun chat topic`);
58
- const allowed = allowedTopics(env);
59
- if (!allowed.includes(topic)) throw new Error(`${topic} is not one of this agent's topics (ATDD_TOPICS: ${allowed.join(", ") || "none"}); an agent reads and writes only the topics it was launched with`);
60
- }
61
-
62
- export const HEADER_FIELDS = ["kind", "stage", "reply-to", "repo", "branch", "worktree", "goal", "base", "sha"] as const;
63
- export type Header = { from: string; to: string[]; participants: string[]; conversation: string } & Partial<Record<(typeof HEADER_FIELDS)[number], string>>;
64
- export type Message = { id: string; time: number; topic: string; header: Partial<Header>; body: string; raw: string };
65
-
66
- export function envelope(header: Header, body: string): string {
67
- const value = (v: string | string[]) => Array.isArray(v) ? `[${v.join(", ")}]` : JSON.stringify(v);
68
- return `---\n${Object.entries(header).filter(([, v]) => v !== undefined).map(([k, v]) => `${k}: ${value(v as string | string[])}`).join("\n")}\n---\n${body.trim()}`;
69
- }
70
-
71
- export function parseEnvelope(text: string): { header: Partial<Header>; body: string } {
72
- const match = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
73
- if (!match) return { header: {}, body: text };
74
- try { return { header: (Bun.YAML.parse(match[1]) ?? {}) as Partial<Header>, body: match[2] }; } catch { return { header: {}, body: text }; }
75
- }
76
-
77
- const addressedTo = (message: Message, agent: string) => [message.header.to ?? []].flat().includes(agent);
78
-
79
- /** Posts with a few retries (a busy or restarting board), then fails loudly: a message is never silently lost. The first
80
- * message on a topic also names the topic in the directory, so the board's view can list it. */
81
- export async function post(url: string, topic: string, header: Header, body: string): Promise<string> {
82
- const first = await fetch(`${url}/${topic}/json?poll=1&since=latest`).then(res => res.ok ? res.text() : "x").then(text => !text.trim()).catch(() => false);
83
- let error = "";
84
- for (let attempt = 0; attempt < 5; attempt++) {
85
- try {
86
- const res = await fetch(`${url}/${topic}`, { method: "POST", body: envelope(header, body), headers: { Title: `${header.from} -> ${header.to.join(", ")}` } });
87
- if (res.ok) {
88
- const id = (await res.json() as { id: string }).id;
89
- if (first) await fetch(`${url}/${DIRECTORY}`, { method: "POST", body: topic }).catch(() => undefined);
90
- return id;
91
- }
92
- error = `${res.status} ${await res.text()}`;
93
- } catch (e) { error = String(e); }
94
- await Bun.sleep(250 * 2 ** attempt);
95
- }
96
- throw new Error(`post to ${topic} failed: ${error}`);
97
- }
98
-
99
- /** Every message on the topics after `since` (a message id, or "all"). */
100
- export async function read(url: string, topics: string[], since = "all", signal?: AbortSignal): Promise<Message[]> {
101
- const res = await fetch(`${url}/${topics.join(",")}/json?poll=1&since=${since}`, { signal });
102
- if (!res.ok) throw new Error(`reading ${topics.join(", ")} failed: ${res.status} ${await res.text()}`);
103
- return (await res.text()).split("\n").filter(Boolean).map(line => JSON.parse(line)).filter(m => m.event === "message")
104
- .map(m => ({ id: m.id, time: m.time, topic: m.topic, raw: m.message, ...parseEnvelope(m.message) }));
105
- }
106
-
107
- /** Waits for the next message addressed to `agent` on any of `topics` after `since`, passing over the kinds in `skip`.
108
- * It polls rather than streams: a live stream can drop a message published while it connects. ntfy reads `since` (a
109
- * message id) as that message's time on every topic, so one cursor serves several topics. Returns null after
110
- * `timeoutSeconds`, so an agent's tool call stays bounded. */
111
- /** The longest one poll may take. ntfy answers a poll at once, so only a stalled request ever reaches it. */
112
- const POLL_LIMIT_MS = 10_000;
113
-
114
- /** What a listener prints when nothing has arrived for a while: the owner's check-in, for the agent to act on (move
115
- * the work, or tell whoever it waits on), never to answer with a "still here" post. */
116
- export function checkIn(agent: string, quietSeconds: number, rearm = false): string {
117
- const quiet = quietSeconds <= 0 ? "" : quietSeconds < 60 ? ` for ${quietSeconds} s` : ` for ${Math.round(quietSeconds / 60)} min`;
118
- return `CHECK-IN for ${agent}: no message${quiet}. Where are you? Are you moving forward, or blocked on someone, and do they know? Is your stakeholder informed? Are you avoiding ceremony and rabbit holes, effectiveness first, then efficiency? Act on it: move the work, or post once to whoever you wait on; never post just to say you are still here.${rearm ? " Then re-arm this listener." : ""}`;
119
- }
120
-
121
- export async function waitFor(url: string, topics: string[], agent: string, since = "all", timeoutSeconds = 0, skip: string[] = [], intervalMs = 500, seen: (id: string) => void = () => {}): Promise<Message | null> {
122
- const deadline = timeoutSeconds > 0 ? Date.now() + timeoutSeconds * 1000 : Infinity;
123
- for (;;) {
124
- let messages: Message[] = [];
125
- // Each poll is bounded: a request that never answers (a half-open connection after the board restarts or the
126
- // machine sleeps) would otherwise hold the wait past its deadline and deliver nothing. It counts as unavailable.
127
- const bound = AbortSignal.timeout(Math.max(1, Math.min(POLL_LIMIT_MS, deadline - Date.now())));
128
- try { messages = await read(url, topics, since, bound); } catch { /* the board is briefly unavailable: keep waiting */ }
129
- for (const message of messages) { since = message.id; seen(since); if (addressedTo(message, agent) && !skip.includes(message.header.kind ?? "")) return message; }
130
- if (Date.now() >= deadline) return null;
131
- await Bun.sleep(intervalMs);
132
- }
133
- }
134
-
135
- /** Where `wait` keeps its place, per identity and topic set, so restarting a watcher is always the same command.
136
- * Best effort: where the file cannot be read or written, wait behaves as if it had no saved place. */
137
- function cursorFile(agent: string, topics: string[], env: Record<string, string | undefined>): string {
138
- const key = `${agent}__${[...topics].sort().join("+")}`.replace(/[^A-Za-z0-9@+_.-]/g, "_");
139
- return join(env.ATDD_BOARD_STATE || join(homedir(), ".atdd-board", "cursors"), key);
140
- }
141
- const loadCursor = (file: string) => { try { return readFileSync(file, "utf8").trim() || undefined; } catch { return undefined; } };
142
- /** The place to start from on a topic set this identity has never listened to: its OLDEST saved place on any other set,
143
- * so nothing unread is skipped and nothing older is replayed. Without one, the start of the board, as before. */
144
- function seedCursor(agent: string, env: Record<string, string | undefined>): string | undefined {
145
- const dir = env.ATDD_BOARD_STATE || join(homedir(), ".atdd-board", "cursors"), prefix = `${agent}__`.replace(/[^A-Za-z0-9@+_.-]/g, "_");
146
- try {
147
- const saved = readdirSync(dir).filter(name => name.startsWith(prefix)).map(name => ({ file: join(dir, name), at: statSync(join(dir, name)).mtimeMs })).sort((a, b) => a.at - b.at);
148
- for (const { file } of saved) { const id = loadCursor(file); if (id) return id; }
149
- } catch { /* no saved places */ }
150
- return undefined;
151
- }
152
- const saveCursor = (file: string, id: string) => { try { mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, `${id}\n`); } catch { /* best effort */ } };
153
-
154
- export function format(message: Message, withTopic = false): string {
155
- const time = new Date(message.time * 1000).toTimeString().slice(0, 8), h = message.header;
156
- const route = `${h.from ?? "?"} -> ${[h.to ?? []].flat().join(", ") || "?"}`, tags = [h.kind, h.stage].filter(Boolean).join(" · ");
157
- return `${withTopic ? `[${message.topic}] ` : ""}${time} ${route}${tags ? ` (${tags})` : ""} #${message.id}\n${message.body.trim()}\n`;
158
- }
159
-
160
- /** `atdd-bun chat` opens the board's terminal view; `atdd-bun chat <topic|post|read|wait> …` is for agents.
161
- * Returns the exit code: 0 done, 1 error, 2 wait timed out. */
162
- export async function chat(args: string[], env: Record<string, string | undefined> = process.env, root = process.cwd()): Promise<number> {
163
- const [command, ...rest] = args;
164
- const flag = (name: string) => { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : undefined; };
165
- const list = (value?: string) => (value ?? "").split(",").map(item => item.trim()).filter(Boolean);
166
- const positional = rest.filter((arg, i) => !arg.startsWith("--") && !(i > 0 && rest[i - 1].startsWith("--") && rest[i - 1] !== "--mine"));
167
- const agent = () => { if (!env.ATDD_AGENT) throw new Error("ATDD_AGENT is not set: every agent is launched with its identity"); return env.ATDD_AGENT; };
168
- try {
169
- if (command === "topic") { console.log(topicName(positional[0], positional[1], positional[2], positional[3])); return 0; }
170
- const url = await boardUrl(root, env);
171
- if (command === undefined) return await runUi(url, env);
172
- const topics = list(positional[0]);
173
- if (!topics.length) throw new Error(`usage: atdd-bun chat ${command} <topic> …`);
174
- const me = agent();
175
- topics.forEach(topic => checkTopic(topic, env));
176
- if (command === "wait") {
177
- // Without --since, continue from where this identity last stopped on these topics; either way, remember the place.
178
- const file = cursorFile(me, topics, env);
179
- const message = await waitFor(url, topics, me, flag("since") || loadCursor(file) || seedCursor(me, env) || "all", Number(flag("timeout") ?? 0), list(flag("skip")), 500, id => saveCursor(file, id));
180
- if (!message) { console.log(checkIn(me, Number(flag("timeout") ?? 0), true)); return 2; }
181
- console.log(format(message, topics.length > 1));
182
- return 0;
183
- }
184
- if (command === "follow") {
185
- // The listener for a host that wakes its agent on each line a running process prints: every message addressed to
186
- // this identity, as it arrives, until --timeout (seconds; none = until stopped). Same place-keeping as wait.
187
- const file = cursorFile(me, topics, env), skip = list(flag("skip")), total = Number(flag("timeout") ?? 0);
188
- const deadline = total > 0 ? Date.now() + total * 1000 : Infinity;
189
- let since = flag("since") || loadCursor(file) || seedCursor(me, env) || "all";
190
- // After --check-in seconds without a message (default 20 minutes; 0 = never), the agent is asked to check itself.
191
- const quietLimit = Number(flag("check-in") ?? 1200) * 1000;
192
- let quietSince = Date.now();
193
- for (;;) {
194
- const left = Math.ceil((deadline - Date.now()) / 1000);
195
- if (left <= 0) return 0;
196
- const message = await waitFor(url, topics, me, since, Math.min(60, left, quietLimit > 0 ? Math.max(1, Math.ceil((quietSince + quietLimit - Date.now()) / 1000)) : 60), skip, 500, id => { since = id; saveCursor(file, id); });
197
- if (message) { console.log(format(message, topics.length > 1)); quietSince = Date.now(); }
198
- else if (quietLimit > 0 && Date.now() - quietSince >= quietLimit) { console.log(checkIn(me, Math.round((Date.now() - quietSince) / 1000))); quietSince = Date.now(); }
199
- }
200
- }
201
- if (topics.length !== 1) throw new Error(`${command} takes one topic`);
202
- const topic = topics[0];
203
- if (command === "post") {
204
- const to = list(flag("to"));
205
- if (!to.length) throw new Error("--to is required: every message names its recipients");
206
- const body = await new Response(Bun.stdin.stream()).text();
207
- if (!body.trim()) throw new Error("the message body is read from standard input and is empty");
208
- const header: Header = { from: me, to, participants: [...new Set([me, ...to, ...list(flag("participants"))])], conversation: topic };
209
- for (const field of HEADER_FIELDS) if (flag(field)) header[field] = flag(field);
210
- console.log(await post(url, topic, header, body));
211
- return 0;
212
- }
213
- if (command === "read") {
214
- for (const message of await read(url, [topic], flag("since") || "all")) if (!rest.includes("--mine") || addressedTo(message, me)) console.log(format(message));
215
- return 0;
216
- }
217
- throw new Error(`unknown chat command ${command}; run atdd-bun chat alone for the board, or topic, post, read, wait`);
218
- } catch (error) {
219
- console.error(error instanceof Error ? error.message : String(error));
220
- return 1;
221
- }
222
- }