@broject/bro 0.0.0 → 0.2.1
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/LICENSE +21 -0
- package/README.md +186 -2
- package/dist/dist-CZDdATdS.js +1166 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7956 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +3 -0
- package/package.json +44 -12
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { parse } from "smol-toml";
|
|
8
|
+
|
|
9
|
+
//#region ../core/dist/index.js
|
|
10
|
+
function gh(args, cwd) {
|
|
11
|
+
const proc = spawnSync("gh", args, {
|
|
12
|
+
cwd,
|
|
13
|
+
stdio: [
|
|
14
|
+
"ignore",
|
|
15
|
+
"pipe",
|
|
16
|
+
"pipe"
|
|
17
|
+
],
|
|
18
|
+
encoding: "utf8"
|
|
19
|
+
});
|
|
20
|
+
if (proc.status !== 0) throw new Error(`gh ${args[0]} failed: ${(proc.stderr ?? "").trim()}`);
|
|
21
|
+
return proc.stdout ?? "";
|
|
22
|
+
}
|
|
23
|
+
function ghJson(args, cwd) {
|
|
24
|
+
return JSON.parse(gh(args, cwd));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* `gh` without the throw — for commands whose exit code carries meaning
|
|
28
|
+
* (`gh pr checks` exits 1 when checks fail while still printing JSON).
|
|
29
|
+
*/
|
|
30
|
+
function ghTry(args, cwd) {
|
|
31
|
+
const proc = spawnSync("gh", args, {
|
|
32
|
+
cwd,
|
|
33
|
+
stdio: [
|
|
34
|
+
"ignore",
|
|
35
|
+
"pipe",
|
|
36
|
+
"pipe"
|
|
37
|
+
],
|
|
38
|
+
encoding: "utf8"
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
code: proc.status ?? 1,
|
|
42
|
+
out: proc.stdout ?? "",
|
|
43
|
+
err: (proc.stderr ?? "").trim()
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function git(args) {
|
|
47
|
+
const proc = spawnSync("git", args, {
|
|
48
|
+
stdio: [
|
|
49
|
+
"ignore",
|
|
50
|
+
"pipe",
|
|
51
|
+
"pipe"
|
|
52
|
+
],
|
|
53
|
+
encoding: "utf8"
|
|
54
|
+
});
|
|
55
|
+
if (proc.status !== 0) throw new Error(`git ${args[0]} failed: ${(proc.stderr ?? "").trim()}`);
|
|
56
|
+
return proc.stdout ?? "";
|
|
57
|
+
}
|
|
58
|
+
function gitTry(args) {
|
|
59
|
+
const proc = spawnSync("git", args, {
|
|
60
|
+
stdio: [
|
|
61
|
+
"ignore",
|
|
62
|
+
"pipe",
|
|
63
|
+
"pipe"
|
|
64
|
+
],
|
|
65
|
+
encoding: "utf8"
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
code: proc.status ?? 1,
|
|
69
|
+
out: proc.stdout ?? "",
|
|
70
|
+
err: (proc.stderr ?? "").trim()
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function bd(args, cwd) {
|
|
74
|
+
return execFileSync("bd", args, {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
cwd,
|
|
77
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
78
|
+
stdio: [
|
|
79
|
+
"ignore",
|
|
80
|
+
"pipe",
|
|
81
|
+
"pipe"
|
|
82
|
+
],
|
|
83
|
+
timeout: 15e3
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Non-throwing bd — same contract as gitTry for paths where beads is
|
|
87
|
+
* optional (merge slot, hooks): a missing binary or absent database must
|
|
88
|
+
* degrade, not stall. */
|
|
89
|
+
function bdTry(args, timeoutMs = 15e3, cwd) {
|
|
90
|
+
const proc = spawnSync("bd", args, {
|
|
91
|
+
cwd,
|
|
92
|
+
stdio: [
|
|
93
|
+
"ignore",
|
|
94
|
+
"pipe",
|
|
95
|
+
"pipe"
|
|
96
|
+
],
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
timeout: timeoutMs,
|
|
99
|
+
maxBuffer: 64 * 1024 * 1024
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
code: proc.status ?? 1,
|
|
103
|
+
out: proc.stdout ?? "",
|
|
104
|
+
err: (proc.stderr ?? proc.error?.message ?? "").trim()
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function bdJson(args, cwd) {
|
|
108
|
+
const out = bd([...args, "--json"], cwd);
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(out);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new Error(`bd returned malformed JSON — ${err instanceof Error ? err.message : String(err)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Classify a provenance ref — shared by drill and retro evidence. */
|
|
116
|
+
function refKind(ref) {
|
|
117
|
+
if (/\/pull\/|\/merge_requests\//.test(ref)) return "pr";
|
|
118
|
+
if (/^[0-9a-f]{40}$/.test(ref)) return "git-sha";
|
|
119
|
+
return "work-id";
|
|
120
|
+
}
|
|
121
|
+
/** Provenance event kind for an evidence ref — a work-id is not a commit. */
|
|
122
|
+
function evidenceKind(ref) {
|
|
123
|
+
switch (refKind(ref)) {
|
|
124
|
+
case "pr": return "land";
|
|
125
|
+
case "git-sha": return "commit";
|
|
126
|
+
default: return "used";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Only a real `.beads` *directory* counts — a file or dangling path must
|
|
130
|
+
* not suppress init (it would fail loudly rather than be repaired). */
|
|
131
|
+
function beadsDirExists() {
|
|
132
|
+
try {
|
|
133
|
+
return statSync(join(process.cwd(), ".beads")).isDirectory();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const code = err.code;
|
|
136
|
+
if (code === "ENOENT" || code === "ENOTDIR") return false;
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Stealth-init `.beads` in the current repo when missing — the single init
|
|
142
|
+
* flags contract shared by debt sync and `bro setup` (local exclude,
|
|
143
|
+
* nothing lands in git). Returns true when it initialized.
|
|
144
|
+
*/
|
|
145
|
+
function initBeadsStealth() {
|
|
146
|
+
if (beadsDirExists()) return false;
|
|
147
|
+
try {
|
|
148
|
+
bd([
|
|
149
|
+
"init",
|
|
150
|
+
"--stealth",
|
|
151
|
+
"--skip-agents",
|
|
152
|
+
"--skip-hooks",
|
|
153
|
+
"--quiet"
|
|
154
|
+
]);
|
|
155
|
+
return true;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (beadsDirExists()) return false;
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function checkBeads() {
|
|
162
|
+
try {
|
|
163
|
+
bd(["--version"]);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err.code === "ENOENT") throw new Error("bd not found — install beads first (https://github.com/gastownhall/beads)");
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
bd([
|
|
170
|
+
"list",
|
|
171
|
+
"--json",
|
|
172
|
+
"-n",
|
|
173
|
+
"1"
|
|
174
|
+
]);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const stderr = err.stderr?.trim();
|
|
177
|
+
throw new Error(`bd list failed — ${stderr || (err instanceof Error ? err.message : String(err))} (run \`bd init\` if beads is not initialized here)`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const STORE_BACKENDS = [
|
|
181
|
+
"jsonl",
|
|
182
|
+
"beads",
|
|
183
|
+
"gitref"
|
|
184
|
+
];
|
|
185
|
+
const PERSONALITIES = [
|
|
186
|
+
"terse",
|
|
187
|
+
"mentor",
|
|
188
|
+
"sarcastic"
|
|
189
|
+
];
|
|
190
|
+
const debtSection = (raw) => ({ dir: typeof raw === "object" && raw !== null && typeof raw.dir === "string" && raw.dir.trim() !== "" ? raw.dir : DEFAULT_CONFIG.debt.dir });
|
|
191
|
+
const syncSection = (raw) => {
|
|
192
|
+
const obj = typeof raw === "object" && raw !== null ? raw : {};
|
|
193
|
+
return {
|
|
194
|
+
...DEFAULT_CONFIG.sync,
|
|
195
|
+
...Object.fromEntries(Object.entries(obj).filter(([, v]) => typeof v === "string" && v.trim() !== "")),
|
|
196
|
+
beads: typeof obj.beads === "boolean" ? obj.beads : DEFAULT_CONFIG.sync.beads
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
const actSection = (raw) => {
|
|
200
|
+
const obj = typeof raw === "object" && raw !== null ? raw : {};
|
|
201
|
+
return {
|
|
202
|
+
ignoreChecks: Array.isArray(obj.ignoreChecks) ? obj.ignoreChecks.filter((v) => typeof v === "string" && v.trim() !== "") : [],
|
|
203
|
+
maxRounds: typeof obj.maxRounds === "number" && Number.isInteger(obj.maxRounds) && obj.maxRounds >= 0 ? obj.maxRounds : DEFAULT_CONFIG.act.maxRounds
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
/** bro.config.json `connectors` section — facade → connector precedence,
|
|
207
|
+
* e.g. { "reviews": "gitlab", "tasks": "jira" }. Only string→string
|
|
208
|
+
* entries survive; anything else is dropped. */
|
|
209
|
+
const connectorsSection = (raw) => {
|
|
210
|
+
const out = {};
|
|
211
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
212
|
+
for (const [k, v] of Object.entries(raw)) if (k.trim() !== "" && typeof v === "string" && v.trim() !== "") out[k.trim()] = v.trim();
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
};
|
|
216
|
+
/** Default global-beads store — XDG data dir, not a repo path. */
|
|
217
|
+
const DEFAULT_GLOBAL_BEADS_DIR = join(homedir(), ".local", "share", "bro", "beads");
|
|
218
|
+
/** bro.config.json `beads` section — `beads.global` is the global store
|
|
219
|
+
* dir. BRO_GLOBAL_BEADS wins over the file: a per-machine path should
|
|
220
|
+
* not need a committed edit. `~` expands against the home dir. */
|
|
221
|
+
const beadsSection = (raw) => {
|
|
222
|
+
const fromConfig = typeof raw === "object" && raw !== null ? raw.global : void 0;
|
|
223
|
+
const env = process.env.BRO_GLOBAL_BEADS;
|
|
224
|
+
const v = (typeof env === "string" && env.trim() !== "" ? env : void 0) ?? (typeof fromConfig === "string" && fromConfig.trim() !== "" ? fromConfig : void 0);
|
|
225
|
+
if (v === void 0) return { global: DEFAULT_GLOBAL_BEADS_DIR };
|
|
226
|
+
const p = v.trim();
|
|
227
|
+
let dir = p;
|
|
228
|
+
if (p === "~") dir = homedir();
|
|
229
|
+
else if (p.startsWith("~/")) dir = join(homedir(), p.slice(2));
|
|
230
|
+
return { global: dir };
|
|
231
|
+
};
|
|
232
|
+
/** bro.config.json `stack` section — how `bro work enter` picks the base
|
|
233
|
+
* for a new worktree when a session already produced a PR branch.
|
|
234
|
+
* 'manual' (default): stack only on explicit --stack/--base.
|
|
235
|
+
* 'auto': base on the current worktree's branch whenever it isn't the
|
|
236
|
+
* main checkout's branch — second+ PR in a session lands on the stack
|
|
237
|
+
* head; gh-stack then drives submit/sync/merge bottom-up. */
|
|
238
|
+
const stackSection = (raw) => {
|
|
239
|
+
const m = typeof raw === "object" && raw !== null ? raw.mode : void 0;
|
|
240
|
+
if (m !== void 0 && m !== "auto" && m !== "manual") {
|
|
241
|
+
console.error(`bro.config: stack.mode must be "auto" or "manual" — got ${JSON.stringify(m)}`);
|
|
242
|
+
return { mode: "manual" };
|
|
243
|
+
}
|
|
244
|
+
return { mode: m ?? "manual" };
|
|
245
|
+
};
|
|
246
|
+
const SDD_MODES = [
|
|
247
|
+
"off",
|
|
248
|
+
"remind",
|
|
249
|
+
"gate"
|
|
250
|
+
];
|
|
251
|
+
/** bro.config.json `sdd` section — spec-driven development policy.
|
|
252
|
+
* off (default): nothing emitted. remind: session-start/prompt context
|
|
253
|
+
* names claimed beads without a spec. gate: the stop gate also blocks
|
|
254
|
+
* once while an own claim lacks a spec. `dir` is where `specs/<id>.md`
|
|
255
|
+
* lives; a `spec:` link in the bead description counts too. */
|
|
256
|
+
const sddSection = (raw) => {
|
|
257
|
+
const obj = typeof raw === "object" && raw !== null ? raw : {};
|
|
258
|
+
if (obj.mode !== void 0 && !SDD_MODES.includes(obj.mode)) console.error(`bro.config: sdd.mode must be one of ${SDD_MODES.map((m) => `"${m}"`).join("|")} — got ${JSON.stringify(obj.mode)}`);
|
|
259
|
+
return {
|
|
260
|
+
mode: obj.mode !== void 0 && SDD_MODES.includes(obj.mode) ? obj.mode : DEFAULT_CONFIG.sdd.mode,
|
|
261
|
+
dir: typeof obj.dir === "string" && obj.dir.trim() !== "" ? obj.dir.trim() : DEFAULT_CONFIG.sdd.dir
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
/** Sections core normalizes itself — identical to what the built-in
|
|
265
|
+
* plugins declare as their configSchema. */
|
|
266
|
+
const CORE_SECTIONS = {
|
|
267
|
+
debt: debtSection,
|
|
268
|
+
sync: syncSection,
|
|
269
|
+
act: actSection,
|
|
270
|
+
connectors: connectorsSection,
|
|
271
|
+
sdd: sddSection
|
|
272
|
+
};
|
|
273
|
+
const DEFAULT_CONFIG = {
|
|
274
|
+
stores: ["jsonl", "beads"],
|
|
275
|
+
personality: "terse",
|
|
276
|
+
debt: { dir: ".agents/review-debt" },
|
|
277
|
+
sync: {
|
|
278
|
+
ref: "refs/bro/data",
|
|
279
|
+
remote: "origin",
|
|
280
|
+
beads: true
|
|
281
|
+
},
|
|
282
|
+
act: {
|
|
283
|
+
ignoreChecks: [],
|
|
284
|
+
maxRounds: 3
|
|
285
|
+
},
|
|
286
|
+
connectors: {},
|
|
287
|
+
sdd: {
|
|
288
|
+
mode: "off",
|
|
289
|
+
dir: "specs"
|
|
290
|
+
},
|
|
291
|
+
plugins: []
|
|
292
|
+
};
|
|
293
|
+
function normalizeStores(raw) {
|
|
294
|
+
const isBackend = (s) => typeof s === "string" && STORE_BACKENDS.includes(s);
|
|
295
|
+
if (Array.isArray(raw.stores)) return [...new Set(["jsonl", ...raw.stores.filter(isBackend)])];
|
|
296
|
+
if (raw.stores !== void 0) {
|
|
297
|
+
console.error("warning: bro.config.json \"stores\" must be an array — using jsonl-only");
|
|
298
|
+
return ["jsonl"];
|
|
299
|
+
}
|
|
300
|
+
if (raw.store === "beads" || raw.store === "both") return ["jsonl", "beads"];
|
|
301
|
+
if (raw.store !== void 0) return ["jsonl"];
|
|
302
|
+
return [...DEFAULT_CONFIG.stores];
|
|
303
|
+
}
|
|
304
|
+
/** Identity helper for bro.config.ts: `export default defineConfig({…})`
|
|
305
|
+
* gives typed sections when @broject/core is a local dep; a plain object
|
|
306
|
+
* works without it. Extra keys are plugin sections (see bro-akl). */
|
|
307
|
+
function defineConfig(config) {
|
|
308
|
+
return config;
|
|
309
|
+
}
|
|
310
|
+
function unwrapDefault(mod) {
|
|
311
|
+
if (typeof mod !== "object" || mod === null) return mod;
|
|
312
|
+
const m = mod;
|
|
313
|
+
return (m[Symbol.toStringTag] === "Module" || Object.keys(m).every((k) => k === "default" || k === "__esModule")) && "default" in m ? m.default : mod;
|
|
314
|
+
}
|
|
315
|
+
function loadTsConfig(name, path) {
|
|
316
|
+
if (!process.features.typescript) {
|
|
317
|
+
console.error(`warning: ${name} needs Node >=22.18 (native type stripping) — using jsonl-only stores`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const abs = resolve(path);
|
|
321
|
+
const req = createRequire(abs);
|
|
322
|
+
try {
|
|
323
|
+
return unwrapDefault(req(abs));
|
|
324
|
+
} catch (err) {
|
|
325
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
326
|
+
if (!/Transform failed|Expected identifier|Cannot use export|ERR_REQUIRE/.test(msg)) throw err;
|
|
327
|
+
const out = execFileSync(process.execPath, ["--eval", `import(${JSON.stringify(pathToFileURL(abs).href)}).then(m => process.stdout.write(JSON.stringify(m.default ?? null)))`], { encoding: "utf8" });
|
|
328
|
+
return JSON.parse(out);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** Reads one config file; undefined = failed (caller falls back to
|
|
332
|
+
* jsonl-only — a broken file must not silently enable beads). */
|
|
333
|
+
function readConfigFile(name, path) {
|
|
334
|
+
try {
|
|
335
|
+
if (name.endsWith(".ts")) return loadTsConfig(name, path);
|
|
336
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
337
|
+
} catch (err) {
|
|
338
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
339
|
+
let hint = ` (${msg})`;
|
|
340
|
+
if (/module is not defined|exports is not defined/.test(msg)) hint = " (this repo is ESM — use `export default`, not module.exports)";
|
|
341
|
+
else if (/Transform failed|Expected identifier/.test(msg)) hint = " (this dir resolves as CommonJS — use `module.exports` or add `\"type\": \"module\"` to package.json)";
|
|
342
|
+
console.error(`warning: ${name} failed to load — skipping${hint}`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** Runs every section schema over the raw file — a throwing schema warns
|
|
347
|
+
* and falls back to schema(undefined), never crashes the load. */
|
|
348
|
+
function applySections(config, raw, sections) {
|
|
349
|
+
for (const [key, schema] of Object.entries({
|
|
350
|
+
...CORE_SECTIONS,
|
|
351
|
+
...sections
|
|
352
|
+
})) try {
|
|
353
|
+
config[key] = schema(raw[key]);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
356
|
+
console.error(`warning: bro.config "${key}" invalid (${msg}) — using defaults`);
|
|
357
|
+
config[key] = schema(void 0);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/** The main checkout's root when cwd sits in a linked worktree —
|
|
361
|
+
* `--git-common-dir` resolves to `<main>/.git` there (and to
|
|
362
|
+
* `<cwd>/.git` in the main checkout itself, which dedupes). */
|
|
363
|
+
function mainWorktreeRoot(cwd) {
|
|
364
|
+
const r = gitTry([
|
|
365
|
+
"-C",
|
|
366
|
+
cwd,
|
|
367
|
+
"rev-parse",
|
|
368
|
+
"--git-common-dir"
|
|
369
|
+
]);
|
|
370
|
+
if (r.code !== 0 || r.out.trim() === "") return null;
|
|
371
|
+
const common = resolve(cwd, r.out.trim());
|
|
372
|
+
if (basename(common) === ".git") return dirname(common);
|
|
373
|
+
const parent = dirname(common);
|
|
374
|
+
let names = [];
|
|
375
|
+
try {
|
|
376
|
+
names = readdirSync(parent);
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
for (const name of names) {
|
|
381
|
+
const dir = join(parent, name);
|
|
382
|
+
const gitfile = join(dir, ".git");
|
|
383
|
+
try {
|
|
384
|
+
if (!statSync(gitfile).isFile()) continue;
|
|
385
|
+
const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(gitfile, "utf8"));
|
|
386
|
+
if (m && resolve(dir, m[1].trim()) === common) return dir;
|
|
387
|
+
} catch {}
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
function loadConfig(cwd = process.cwd(), sections = {}) {
|
|
392
|
+
const fallback = (stores) => {
|
|
393
|
+
const config = {
|
|
394
|
+
...DEFAULT_CONFIG,
|
|
395
|
+
stores
|
|
396
|
+
};
|
|
397
|
+
applySections(config, {}, sections);
|
|
398
|
+
return config;
|
|
399
|
+
};
|
|
400
|
+
const dirs = [cwd, mainWorktreeRoot(cwd)].filter((d) => d !== null && d.trim() !== "");
|
|
401
|
+
let sawBroken = false;
|
|
402
|
+
for (const dir of new Set(dirs)) {
|
|
403
|
+
const r = loadDirConfig(dir, sections);
|
|
404
|
+
if (r === "broken") sawBroken = true;
|
|
405
|
+
else if (r !== null) return r;
|
|
406
|
+
}
|
|
407
|
+
return fallback(sawBroken ? ["jsonl"] : [...DEFAULT_CONFIG.stores]);
|
|
408
|
+
}
|
|
409
|
+
/** Tries bro.config.ts → bro.config.json inside one dir. 'broken' = a
|
|
410
|
+
* config exists but failed (the dir's remaining names are skipped —
|
|
411
|
+
* .ts precedence never promotes the sibling .json); null = nothing
|
|
412
|
+
* here. */
|
|
413
|
+
function loadDirConfig(dir, sections) {
|
|
414
|
+
for (const name of ["bro.config.ts", "bro.config.json"]) {
|
|
415
|
+
const path = join(dir, name);
|
|
416
|
+
if (!existsSync(path)) continue;
|
|
417
|
+
const raw = readConfigFile(name, path);
|
|
418
|
+
if (raw === void 0) return "broken";
|
|
419
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
420
|
+
console.error(`${name}: root must be an object — skipping`);
|
|
421
|
+
return "broken";
|
|
422
|
+
}
|
|
423
|
+
const { stores: _s, store: _legacy, plugins: _p,...rest } = raw;
|
|
424
|
+
const config = {
|
|
425
|
+
...DEFAULT_CONFIG,
|
|
426
|
+
...rest,
|
|
427
|
+
stores: normalizeStores(raw),
|
|
428
|
+
plugins: normalizePluginSpecs(dir, raw.plugins)
|
|
429
|
+
};
|
|
430
|
+
applySections(config, raw, sections);
|
|
431
|
+
return config;
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
/** Relative specs anchor at the config's own dir — an inherited config's
|
|
436
|
+
* `./x.ts` plugin must resolve in the main worktree, not the linked
|
|
437
|
+
* one. Containment is kept here: a spec escaping its anchor is dropped,
|
|
438
|
+
* so the absolute path reaching importPluginModule can't bypass its
|
|
439
|
+
* repo-root guard. */
|
|
440
|
+
function normalizePluginSpecs(dir, plugins) {
|
|
441
|
+
if (!Array.isArray(plugins)) return [];
|
|
442
|
+
return plugins.filter((v) => typeof v === "string").map((v) => v.trim()).filter((v) => v !== "").flatMap((v) => {
|
|
443
|
+
if (!v.startsWith(".")) return [v];
|
|
444
|
+
const anchor = resolve(dir);
|
|
445
|
+
const abs = resolve(dir, v);
|
|
446
|
+
if (abs !== anchor && !abs.startsWith(anchor + sep)) {
|
|
447
|
+
console.error(`warning: plugin spec "${v}" escapes ${dir} — skipped`);
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
return [abs];
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
const DATA_REF = "refs/bro/data";
|
|
454
|
+
function g(root, args, input) {
|
|
455
|
+
return execFileSync("git", [
|
|
456
|
+
"-C",
|
|
457
|
+
root,
|
|
458
|
+
...args
|
|
459
|
+
], {
|
|
460
|
+
encoding: "utf8",
|
|
461
|
+
input,
|
|
462
|
+
stdio: [
|
|
463
|
+
"pipe",
|
|
464
|
+
"pipe",
|
|
465
|
+
"pipe"
|
|
466
|
+
]
|
|
467
|
+
}).trim();
|
|
468
|
+
}
|
|
469
|
+
/** Untrimmed read — file contents must survive byte-for-byte. */
|
|
470
|
+
function gRaw(root, args) {
|
|
471
|
+
return execFileSync("git", [
|
|
472
|
+
"-C",
|
|
473
|
+
root,
|
|
474
|
+
...args
|
|
475
|
+
], {
|
|
476
|
+
encoding: "utf8",
|
|
477
|
+
stdio: [
|
|
478
|
+
"pipe",
|
|
479
|
+
"pipe",
|
|
480
|
+
"pipe"
|
|
481
|
+
]
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
/** Resolve the repo root for cwd, or null outside a worktree. */
|
|
485
|
+
function dataRefRoot(cwd = process.cwd()) {
|
|
486
|
+
const r = gitTry([
|
|
487
|
+
"-C",
|
|
488
|
+
cwd,
|
|
489
|
+
"rev-parse",
|
|
490
|
+
"--show-toplevel"
|
|
491
|
+
]);
|
|
492
|
+
return r.code === 0 ? r.out.trim() : null;
|
|
493
|
+
}
|
|
494
|
+
function refSha(root, ref) {
|
|
495
|
+
const r = gitTry([
|
|
496
|
+
"-C",
|
|
497
|
+
root,
|
|
498
|
+
"rev-parse",
|
|
499
|
+
"--verify",
|
|
500
|
+
"--quiet",
|
|
501
|
+
ref
|
|
502
|
+
]);
|
|
503
|
+
return r.code === 0 ? r.out.trim() : null;
|
|
504
|
+
}
|
|
505
|
+
/** All git ops share one throwaway index bound to the repo root —
|
|
506
|
+
* created empty per call so a crashed process can't leave a half-staged
|
|
507
|
+
* private index behind. */
|
|
508
|
+
function withIndex(root, fn) {
|
|
509
|
+
const dir = mkdtempSync(join(tmpdir(), "bro-dataref-"));
|
|
510
|
+
const indexPath = join(dir, "index");
|
|
511
|
+
const ix = (args, input) => execFileSync("git", [
|
|
512
|
+
"-C",
|
|
513
|
+
root,
|
|
514
|
+
...args
|
|
515
|
+
], {
|
|
516
|
+
encoding: "utf8",
|
|
517
|
+
input,
|
|
518
|
+
env: {
|
|
519
|
+
...process.env,
|
|
520
|
+
GIT_INDEX_FILE: indexPath
|
|
521
|
+
},
|
|
522
|
+
stdio: [
|
|
523
|
+
"pipe",
|
|
524
|
+
"pipe",
|
|
525
|
+
"pipe"
|
|
526
|
+
]
|
|
527
|
+
}).trim();
|
|
528
|
+
try {
|
|
529
|
+
return fn(ix);
|
|
530
|
+
} finally {
|
|
531
|
+
rmSync(dir, {
|
|
532
|
+
recursive: true,
|
|
533
|
+
force: true
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/** `git ls-tree -rz` → path → {mode, sha}. */
|
|
538
|
+
function treeEntries(root, sha) {
|
|
539
|
+
const out = g(root, [
|
|
540
|
+
"ls-tree",
|
|
541
|
+
"-rz",
|
|
542
|
+
sha
|
|
543
|
+
]);
|
|
544
|
+
const map = /* @__PURE__ */ new Map();
|
|
545
|
+
for (const rec of out.split("\0")) {
|
|
546
|
+
if (rec === "") continue;
|
|
547
|
+
const tab = rec.indexOf(" ");
|
|
548
|
+
const [mode, , sha$1] = rec.slice(0, tab).split(" ");
|
|
549
|
+
const path = rec.slice(tab + 1);
|
|
550
|
+
if (tab === -1 || !mode || !sha$1 || !path) throw new Error(`malformed ls-tree record: ${JSON.stringify(rec.slice(0, 60))}`);
|
|
551
|
+
map.set(path, {
|
|
552
|
+
mode,
|
|
553
|
+
sha: sha$1
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
return map;
|
|
557
|
+
}
|
|
558
|
+
/** Line-union merge for append-only JSONL — order preserved, dupes dropped. */
|
|
559
|
+
function unionJsonl(a, b) {
|
|
560
|
+
const seen = /* @__PURE__ */ new Set();
|
|
561
|
+
const out = [];
|
|
562
|
+
for (const line of `${a}\n${b}`.split("\n")) {
|
|
563
|
+
const t = line.trim();
|
|
564
|
+
if (t === "" || seen.has(t)) continue;
|
|
565
|
+
seen.add(t);
|
|
566
|
+
out.push(t);
|
|
567
|
+
}
|
|
568
|
+
return `${out.join("\n")}\n`;
|
|
569
|
+
}
|
|
570
|
+
/** Tree-level merge of two data-ref heads: .jsonl unions by line, every
|
|
571
|
+
* other conflict resolves to ours — regenerated artifacts (summaries,
|
|
572
|
+
* indexes) are safe to clobber and the JSONL holds the real history. */
|
|
573
|
+
function mergeDataHeads(root, ref, ours, theirs) {
|
|
574
|
+
const a = treeEntries(root, ours);
|
|
575
|
+
const merged = new Map(a);
|
|
576
|
+
for (const [path, e] of treeEntries(root, theirs)) {
|
|
577
|
+
const cur = merged.get(path);
|
|
578
|
+
if (!cur) {
|
|
579
|
+
merged.set(path, e);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (cur.sha !== e.sha && path.endsWith(".jsonl")) {
|
|
583
|
+
const union = unionJsonl(g(root, ["show", `${ours}:${path}`]), g(root, ["show", `${theirs}:${path}`]));
|
|
584
|
+
merged.set(path, {
|
|
585
|
+
mode: "100644",
|
|
586
|
+
sha: g(root, [
|
|
587
|
+
"hash-object",
|
|
588
|
+
"-w",
|
|
589
|
+
"--stdin"
|
|
590
|
+
], union)
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
g(root, [
|
|
595
|
+
"update-ref",
|
|
596
|
+
ref,
|
|
597
|
+
g(root, [
|
|
598
|
+
"commit-tree",
|
|
599
|
+
withIndex(root, (ix) => {
|
|
600
|
+
ix(["read-tree", "--empty"]);
|
|
601
|
+
let info = "";
|
|
602
|
+
for (const [path, e] of merged) info += `${e.mode} ${e.sha}\t${path}\0`;
|
|
603
|
+
ix([
|
|
604
|
+
"update-index",
|
|
605
|
+
"-z",
|
|
606
|
+
"--index-info"
|
|
607
|
+
], info);
|
|
608
|
+
return ix(["write-tree"]);
|
|
609
|
+
}),
|
|
610
|
+
"-p",
|
|
611
|
+
ours,
|
|
612
|
+
"-p",
|
|
613
|
+
theirs,
|
|
614
|
+
"-m",
|
|
615
|
+
"bro data: merge replicas"
|
|
616
|
+
]),
|
|
617
|
+
ours
|
|
618
|
+
]);
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Commit the untracked+ignored files under relDir to the data ref.
|
|
622
|
+
* Returns the ref head (existing sha when the tree didn't change, null
|
|
623
|
+
* when there's nothing to sync).
|
|
624
|
+
*/
|
|
625
|
+
function dataRefCommit(root, relDir, message, ref = DATA_REF) {
|
|
626
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
627
|
+
const base = refSha(root, ref);
|
|
628
|
+
const result = withIndex(root, (ix) => {
|
|
629
|
+
ix(base ? ["read-tree", base] : ["read-tree", "--empty"]);
|
|
630
|
+
const files = ix([
|
|
631
|
+
"ls-files",
|
|
632
|
+
"-o",
|
|
633
|
+
"-i",
|
|
634
|
+
"--exclude-standard",
|
|
635
|
+
"-z",
|
|
636
|
+
"--",
|
|
637
|
+
relDir
|
|
638
|
+
]).split("\0").filter((f) => f !== "");
|
|
639
|
+
const staged = new Set(ix([
|
|
640
|
+
"ls-files",
|
|
641
|
+
"-z",
|
|
642
|
+
"--",
|
|
643
|
+
relDir
|
|
644
|
+
]).split("\0").filter(Boolean));
|
|
645
|
+
for (const f of files) {
|
|
646
|
+
ix([
|
|
647
|
+
"update-index",
|
|
648
|
+
"--add",
|
|
649
|
+
"--",
|
|
650
|
+
f
|
|
651
|
+
]);
|
|
652
|
+
staged.delete(f);
|
|
653
|
+
}
|
|
654
|
+
for (const gone of staged) ix([
|
|
655
|
+
"update-index",
|
|
656
|
+
"--remove",
|
|
657
|
+
"--",
|
|
658
|
+
gone
|
|
659
|
+
]);
|
|
660
|
+
if (files.length === 0 && staged.size === 0 && base === null) return null;
|
|
661
|
+
return ix(["write-tree"]);
|
|
662
|
+
});
|
|
663
|
+
if (result === null) return null;
|
|
664
|
+
if (result === (base === null ? null : g(root, ["rev-parse", `${base}^{tree}`]))) return base;
|
|
665
|
+
const commit = g(root, [
|
|
666
|
+
"commit-tree",
|
|
667
|
+
result,
|
|
668
|
+
...base ? ["-p", base] : [],
|
|
669
|
+
"-m",
|
|
670
|
+
message
|
|
671
|
+
]);
|
|
672
|
+
if (gitTry([
|
|
673
|
+
"-C",
|
|
674
|
+
root,
|
|
675
|
+
"update-ref",
|
|
676
|
+
ref,
|
|
677
|
+
commit,
|
|
678
|
+
base ?? ""
|
|
679
|
+
]).code === 0) return commit;
|
|
680
|
+
}
|
|
681
|
+
return refSha(root, ref);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Push the data ref with CAS retry. A rejected push means a replica won
|
|
685
|
+
* the race: fetch it, merge trees (JSONL union), retry. Returns false on
|
|
686
|
+
* offline/no-remote after bounded attempts — callers treat sync as
|
|
687
|
+
* best-effort and warn, never fail the command.
|
|
688
|
+
*/
|
|
689
|
+
function dataRefPush(root, remote = "origin", ref = DATA_REF) {
|
|
690
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
691
|
+
if (gitTry([
|
|
692
|
+
"-C",
|
|
693
|
+
root,
|
|
694
|
+
"push",
|
|
695
|
+
remote,
|
|
696
|
+
`${ref}:${ref}`
|
|
697
|
+
]).code === 0) return true;
|
|
698
|
+
if (gitTry([
|
|
699
|
+
"-C",
|
|
700
|
+
root,
|
|
701
|
+
"fetch",
|
|
702
|
+
remote,
|
|
703
|
+
ref
|
|
704
|
+
]).code !== 0) return false;
|
|
705
|
+
const theirs = g(root, ["rev-parse", "FETCH_HEAD"]);
|
|
706
|
+
const ours = refSha(root, ref);
|
|
707
|
+
if (ours === null) return false;
|
|
708
|
+
if (gitTry([
|
|
709
|
+
"-C",
|
|
710
|
+
root,
|
|
711
|
+
"merge-base",
|
|
712
|
+
"--is-ancestor",
|
|
713
|
+
theirs,
|
|
714
|
+
ours
|
|
715
|
+
]).code !== 0) mergeDataHeads(root, ref, ours, theirs);
|
|
716
|
+
}
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Pull the remote data ref and write its files under the worktree —
|
|
721
|
+
* restores artifacts on a fresh clone. Local-only files are left alone;
|
|
722
|
+
* the ref is the truth for everything it contains. Returns the number of
|
|
723
|
+
* files written, or -1 when no remote ref exists.
|
|
724
|
+
*/
|
|
725
|
+
function dataRefPull(root, remote = "origin", ref = DATA_REF) {
|
|
726
|
+
if (gitTry([
|
|
727
|
+
"-C",
|
|
728
|
+
root,
|
|
729
|
+
"fetch",
|
|
730
|
+
remote,
|
|
731
|
+
ref
|
|
732
|
+
]).code !== 0) return -1;
|
|
733
|
+
const theirs = g(root, ["rev-parse", "FETCH_HEAD"]);
|
|
734
|
+
const ours = refSha(root, ref);
|
|
735
|
+
if (ours === null) g(root, [
|
|
736
|
+
"update-ref",
|
|
737
|
+
ref,
|
|
738
|
+
theirs
|
|
739
|
+
]);
|
|
740
|
+
else if (gitTry([
|
|
741
|
+
"-C",
|
|
742
|
+
root,
|
|
743
|
+
"merge-base",
|
|
744
|
+
"--is-ancestor",
|
|
745
|
+
ours,
|
|
746
|
+
theirs
|
|
747
|
+
]).code !== 0) if (gitTry([
|
|
748
|
+
"-C",
|
|
749
|
+
root,
|
|
750
|
+
"merge-base",
|
|
751
|
+
"--is-ancestor",
|
|
752
|
+
theirs,
|
|
753
|
+
ours
|
|
754
|
+
]).code === 0) {} else mergeDataHeads(root, ref, ours, theirs);
|
|
755
|
+
else g(root, [
|
|
756
|
+
"update-ref",
|
|
757
|
+
ref,
|
|
758
|
+
theirs
|
|
759
|
+
]);
|
|
760
|
+
const tracked = new Set(g(root, ["ls-files", "-z"]).split("\0").filter(Boolean));
|
|
761
|
+
const realRoot = realpathSync(root);
|
|
762
|
+
let written = 0;
|
|
763
|
+
for (const [path] of treeEntries(root, ref)) {
|
|
764
|
+
const dest = resolve(root, path);
|
|
765
|
+
if (dest !== root && !dest.startsWith(root + sep)) {
|
|
766
|
+
console.error(`warning: skipping out-of-root data ref path: ${path}`);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (tracked.has(path)) {
|
|
770
|
+
console.error(`warning: skipping tracked path in data ref: ${path}`);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
774
|
+
const realParent = realpathSync(dirname(dest));
|
|
775
|
+
if (realParent !== realRoot && !realParent.startsWith(realRoot + sep)) {
|
|
776
|
+
console.error(`warning: skipping data ref path through symlinked dir: ${path}`);
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
writeFileSync(dest, gRaw(root, ["show", `${ref}:${path}`]));
|
|
780
|
+
written += 1;
|
|
781
|
+
}
|
|
782
|
+
return written;
|
|
783
|
+
}
|
|
784
|
+
/** Identity helper — typed declaration sites, same role as defineConfig. */
|
|
785
|
+
function definePlugin(plugin) {
|
|
786
|
+
return plugin;
|
|
787
|
+
}
|
|
788
|
+
/** Standard verbs map CLI spelling → adapter method. Any other method
|
|
789
|
+
* name is itself a verb. */
|
|
790
|
+
const STANDARD_VERBS = {
|
|
791
|
+
list: "list",
|
|
792
|
+
show: "get",
|
|
793
|
+
get: "get",
|
|
794
|
+
new: "create",
|
|
795
|
+
create: "create",
|
|
796
|
+
set: "update",
|
|
797
|
+
update: "update",
|
|
798
|
+
rm: "remove",
|
|
799
|
+
remove: "remove"
|
|
800
|
+
};
|
|
801
|
+
/** Verb names an adapter exposes — its function members plus canonical
|
|
802
|
+
* spellings for the standard methods it implements. */
|
|
803
|
+
function docVerbs(adapter) {
|
|
804
|
+
const verbs = /* @__PURE__ */ new Set();
|
|
805
|
+
for (const [verb, method] of Object.entries(STANDARD_VERBS)) if (typeof adapter[method] === "function") verbs.add(verb);
|
|
806
|
+
for (const k of Object.keys(adapter)) if (!k.startsWith("_") && typeof adapter[k] === "function") verbs.add(k);
|
|
807
|
+
return [...verbs].sort((a, b) => a.localeCompare(b));
|
|
808
|
+
}
|
|
809
|
+
/** Resolve CLI verb → the adapter method to invoke (standard verbs map
|
|
810
|
+
* to their method names; anything else is taken literally). */
|
|
811
|
+
function verbMethod(adapter, verb) {
|
|
812
|
+
const method = adapter[STANDARD_VERBS[verb] ?? verb];
|
|
813
|
+
return typeof method === "function" ? method : void 0;
|
|
814
|
+
}
|
|
815
|
+
/** A type matches `token` by name or alias. */
|
|
816
|
+
function docTypeNamed(type, token) {
|
|
817
|
+
return type.name === token || (type.aliases?.includes(token) ?? false);
|
|
818
|
+
}
|
|
819
|
+
function filterArgs(f) {
|
|
820
|
+
const args = [];
|
|
821
|
+
if (f.status) args.push("--status", f.status);
|
|
822
|
+
for (const l of f.labels ?? []) args.push("-l", l);
|
|
823
|
+
for (const l of f.excludeLabels ?? []) args.push("--exclude-label", l);
|
|
824
|
+
if (f.all) args.push("--all");
|
|
825
|
+
if (f.limit !== void 0) args.push("-n", String(f.limit));
|
|
826
|
+
if (f.type) args.push("--type", f.type);
|
|
827
|
+
return args;
|
|
828
|
+
}
|
|
829
|
+
function createArgs(i) {
|
|
830
|
+
const args = [
|
|
831
|
+
"create",
|
|
832
|
+
"--title",
|
|
833
|
+
i.title
|
|
834
|
+
];
|
|
835
|
+
if (i.description) args.push("-d", i.description);
|
|
836
|
+
if (i.type) args.push("-t", i.type);
|
|
837
|
+
if (i.priority !== void 0) args.push("-p", String(i.priority));
|
|
838
|
+
for (const l of i.labels ?? []) args.push("-l", l);
|
|
839
|
+
if (i.deps?.length) args.push("--deps", i.deps.join(","));
|
|
840
|
+
if (i.parent) args.push("--parent", i.parent);
|
|
841
|
+
if (i.ephemeral) args.push("--ephemeral");
|
|
842
|
+
if (i.noInheritLabels) args.push("--no-inherit-labels");
|
|
843
|
+
if (i.externalRef) args.push("--external-ref", i.externalRef);
|
|
844
|
+
if (i.metadata) args.push("--metadata", JSON.stringify(i.metadata));
|
|
845
|
+
return args;
|
|
846
|
+
}
|
|
847
|
+
/** The beads-backed TaskStore. `dir` selects the store via cwd —
|
|
848
|
+
* callers that need the global store resolve it themselves
|
|
849
|
+
* (`resolveGlobalDir` lives in the cli layer). */
|
|
850
|
+
function taskStore(dir) {
|
|
851
|
+
return {
|
|
852
|
+
list: (f = {}) => bdJson([
|
|
853
|
+
"list",
|
|
854
|
+
"--json",
|
|
855
|
+
...filterArgs(f)
|
|
856
|
+
], dir),
|
|
857
|
+
ready: (f = {}) => bdJson(["ready", ...filterArgs(f)], dir),
|
|
858
|
+
get: (id) => {
|
|
859
|
+
const r = bdJson(["show", id], dir);
|
|
860
|
+
return (Array.isArray(r) ? r[0] : r) ?? void 0;
|
|
861
|
+
},
|
|
862
|
+
create: (i) => {
|
|
863
|
+
const r = bdJson(createArgs(i), dir);
|
|
864
|
+
const row = Array.isArray(r) ? r[0] : r;
|
|
865
|
+
if (!row) throw new Error("task create returned no row — backend contract broken");
|
|
866
|
+
return row;
|
|
867
|
+
},
|
|
868
|
+
update: (id, patch) => {
|
|
869
|
+
const args = ["update", id];
|
|
870
|
+
for (const [k, v] of Object.entries(patch)) args.push(...v === "true" ? [`--${k}`] : [`--${k}`, String(v)]);
|
|
871
|
+
bd(args, dir);
|
|
872
|
+
},
|
|
873
|
+
claim: (id) => {
|
|
874
|
+
bd([
|
|
875
|
+
"update",
|
|
876
|
+
id,
|
|
877
|
+
"--claim"
|
|
878
|
+
], dir);
|
|
879
|
+
},
|
|
880
|
+
reopen: (id) => {
|
|
881
|
+
bd(["reopen", id], dir);
|
|
882
|
+
},
|
|
883
|
+
close: (id, reason) => {
|
|
884
|
+
bd([
|
|
885
|
+
"close",
|
|
886
|
+
id,
|
|
887
|
+
...reason ? ["--reason", reason] : []
|
|
888
|
+
], dir);
|
|
889
|
+
},
|
|
890
|
+
remove: (id) => {
|
|
891
|
+
bd([
|
|
892
|
+
"delete",
|
|
893
|
+
id,
|
|
894
|
+
"--force"
|
|
895
|
+
], dir);
|
|
896
|
+
},
|
|
897
|
+
note: (id, text) => {
|
|
898
|
+
bd([
|
|
899
|
+
"note",
|
|
900
|
+
id,
|
|
901
|
+
text
|
|
902
|
+
], dir);
|
|
903
|
+
},
|
|
904
|
+
children: (id) => bdJson(["children", id], dir),
|
|
905
|
+
deps: (ids, opts = {}) => bdJson([
|
|
906
|
+
"dep",
|
|
907
|
+
"list",
|
|
908
|
+
...ids,
|
|
909
|
+
...opts.type ? ["-t", opts.type] : [],
|
|
910
|
+
...opts.direction ? [`--direction=${opts.direction}`] : []
|
|
911
|
+
], dir),
|
|
912
|
+
link: (from, to, type = "related") => {
|
|
913
|
+
bd([
|
|
914
|
+
"link",
|
|
915
|
+
from,
|
|
916
|
+
to,
|
|
917
|
+
"--type",
|
|
918
|
+
type
|
|
919
|
+
], dir);
|
|
920
|
+
},
|
|
921
|
+
prefix: () => {
|
|
922
|
+
const probe = bdTry([
|
|
923
|
+
"config",
|
|
924
|
+
"get",
|
|
925
|
+
"issue_prefix"
|
|
926
|
+
], 15e3, dir);
|
|
927
|
+
if (probe.code !== 0) throw new Error(`task store unreachable — ${probe.err || "bd error"}`);
|
|
928
|
+
const line = probe.out.trim().split("\n").pop()?.trim() ?? "";
|
|
929
|
+
if (line === "" || /not set/i.test(line)) return;
|
|
930
|
+
const eq = line.indexOf("=");
|
|
931
|
+
const v = (eq >= 0 ? line.slice(eq + 1) : line).trim().replace(/^['"]|['"]$/g, "");
|
|
932
|
+
return v === "" ? void 0 : v;
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
const shortTitle = (t) => {
|
|
937
|
+
const flat = (t ?? "").replace(/\s+/g, " ").trim();
|
|
938
|
+
return flat.length > 60 ? `${flat.slice(0, 60)}…` : flat;
|
|
939
|
+
};
|
|
940
|
+
/** Bead ids this session claimed — the `task` aspect marker records one
|
|
941
|
+
* claim per detail line (line 1 is the timestamp). No session or no
|
|
942
|
+
* marker → nothing is "mine". Exported for policy connectors (sdd)
|
|
943
|
+
* that scope their nudges to the session's own claims. */
|
|
944
|
+
function sessionTaskClaims(ctx) {
|
|
945
|
+
const mine = /* @__PURE__ */ new Set();
|
|
946
|
+
if (!ctx.sessionId) return mine;
|
|
947
|
+
try {
|
|
948
|
+
const gd = gitTry([
|
|
949
|
+
"-C",
|
|
950
|
+
ctx.dir,
|
|
951
|
+
"rev-parse",
|
|
952
|
+
"--git-common-dir"
|
|
953
|
+
]);
|
|
954
|
+
if (gd.code !== 0) return mine;
|
|
955
|
+
const safe = ctx.sessionId.replace(/[^\w.-]/g, "_");
|
|
956
|
+
const marker = join(resolve(ctx.dir, gd.out.trim()), "bro", "hooks", `${safe}.task`);
|
|
957
|
+
for (const d of readFileSync(marker, "utf8").split("\n").slice(1)) {
|
|
958
|
+
const id = d.trim();
|
|
959
|
+
if (id) mine.add(id);
|
|
960
|
+
}
|
|
961
|
+
} catch {}
|
|
962
|
+
return mine;
|
|
963
|
+
}
|
|
964
|
+
const registry = [{
|
|
965
|
+
name: "beads",
|
|
966
|
+
tasks: (ctx) => taskStore(ctx.dir),
|
|
967
|
+
hooks: () => ({
|
|
968
|
+
sessionStart(ctx) {
|
|
969
|
+
try {
|
|
970
|
+
const ready = taskStore(ctx.dir).ready().slice(0, 8).map((r) => ` ${r.id} ${shortTitle(r.title)}`.trimEnd());
|
|
971
|
+
return ready.length > 0 ? [`bd ready:\n${ready.join("\n")}`] : [];
|
|
972
|
+
} catch {
|
|
973
|
+
return [];
|
|
974
|
+
}
|
|
975
|
+
},
|
|
976
|
+
parallelWork(ctx) {
|
|
977
|
+
try {
|
|
978
|
+
const mine = sessionTaskClaims(ctx);
|
|
979
|
+
const claimed = taskStore(ctx.dir).list({ status: "in_progress" }).filter((r) => !mine.has(r.id)).slice(0, 5).map((r) => `${r.id} ${shortTitle(r.title)}`.trim());
|
|
980
|
+
return claimed.length > 0 ? [`claimed beads: ${claimed.join(", ")}`] : [];
|
|
981
|
+
} catch {
|
|
982
|
+
return [];
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
stopGate(ctx) {
|
|
986
|
+
try {
|
|
987
|
+
const claimed = taskStore(ctx.dir).list({ status: "in_progress" });
|
|
988
|
+
if (claimed.length === 0) return [];
|
|
989
|
+
const mine = sessionTaskClaims(ctx);
|
|
990
|
+
const own = claimed.filter((r) => mine.has(r.id));
|
|
991
|
+
const foreign = claimed.filter((r) => !mine.has(r.id));
|
|
992
|
+
const fmt = (r) => `${r.id} ${shortTitle(r.title)}`.trim();
|
|
993
|
+
const out = [];
|
|
994
|
+
if (own.length > 0) out.push({
|
|
995
|
+
aspect: "task",
|
|
996
|
+
block: `bro: claimed beads open: ${own.slice(0, 5).map(fmt).join(", ")} — close (\`bd close <id>\`) or release the claim before stopping`
|
|
997
|
+
});
|
|
998
|
+
if (foreign.length > 0) out.push({
|
|
999
|
+
aspect: "task",
|
|
1000
|
+
passive: `bro: claimed beads open: ${foreign.slice(0, 5).map(fmt).join(", ")}`
|
|
1001
|
+
});
|
|
1002
|
+
return out;
|
|
1003
|
+
} catch {
|
|
1004
|
+
return [];
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
})
|
|
1008
|
+
}];
|
|
1009
|
+
/** Plugin connectors register here (BroPlugin.connectors). Duplicate
|
|
1010
|
+
* names are skipped — a plugin cannot shadow a built-in system. */
|
|
1011
|
+
function registerConnector(c) {
|
|
1012
|
+
if (registry.some((x) => x.name === c.name)) {
|
|
1013
|
+
console.error(`warning: connector "${c.name}" already registered — skipped`);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
registry.push(c);
|
|
1017
|
+
}
|
|
1018
|
+
function remoteUrl(dir) {
|
|
1019
|
+
const r = gitTry([
|
|
1020
|
+
"-C",
|
|
1021
|
+
dir,
|
|
1022
|
+
"remote",
|
|
1023
|
+
"get-url",
|
|
1024
|
+
"origin"
|
|
1025
|
+
]);
|
|
1026
|
+
return r.code === 0 && r.out.trim() !== "" ? r.out.trim() : void 0;
|
|
1027
|
+
}
|
|
1028
|
+
/** The connector chosen to serve `kind` — shared by facade() and auth
|
|
1029
|
+
* probing so both resolve through the same precedence. */
|
|
1030
|
+
function pickConnector(kind, ctx, opts) {
|
|
1031
|
+
const providers = registry.filter((c) => typeof c[kind] === "function");
|
|
1032
|
+
const named = opts.connector ?? opts.prefer?.[kind];
|
|
1033
|
+
let pick;
|
|
1034
|
+
if (named !== void 0) {
|
|
1035
|
+
pick = providers.find((c) => c.name === named);
|
|
1036
|
+
if (!pick) throw new Error(`connector "${named}" does not provide "${kind}"`);
|
|
1037
|
+
} else {
|
|
1038
|
+
const url = remoteUrl(ctx.dir);
|
|
1039
|
+
const remote = url === void 0 ? void 0 : providers.find((c) => c.matchRemote?.(url));
|
|
1040
|
+
pick = remote ?? providers[0];
|
|
1041
|
+
if (!pick) throw new Error(`no connector provides "${kind}"`);
|
|
1042
|
+
if (remote === void 0 && providers.length > 1) console.error(`warning: ${providers.map((c) => c.name).join(", ")} all provide "${kind}" — using ${pick.name}; set connectors.${kind} in bro.config.json`);
|
|
1043
|
+
}
|
|
1044
|
+
return pick;
|
|
1045
|
+
}
|
|
1046
|
+
/** Resolve the facade a command needs. Throws when nothing provides it —
|
|
1047
|
+
* callers that probe rather than require should catch or pre-check
|
|
1048
|
+
* `connectors()`. */
|
|
1049
|
+
function facade(kind, ctx, opts = {}) {
|
|
1050
|
+
const provides = pickConnector(kind, ctx, opts)[kind];
|
|
1051
|
+
return provides(ctx);
|
|
1052
|
+
}
|
|
1053
|
+
/** The serving connector's auth probe — null when ready, else the
|
|
1054
|
+
* remediation line. Resolution failures surface as the message: a
|
|
1055
|
+
* missing provider is as unusable as a missing credential. */
|
|
1056
|
+
function facadeAuth(kind, ctx, opts = {}) {
|
|
1057
|
+
try {
|
|
1058
|
+
return pickConnector(kind, ctx, opts).auth?.(ctx) ?? null;
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
return err instanceof Error ? err.message : String(err);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
/** Hard auth gate for commands — exits(1) with the remediation line when
|
|
1064
|
+
* the resolved connector isn't usable. */
|
|
1065
|
+
function ensureAuth(kind, ctx, opts = {}) {
|
|
1066
|
+
const msg = facadeAuth(kind, ctx, opts);
|
|
1067
|
+
if (msg) {
|
|
1068
|
+
console.error(`error: ${msg}`);
|
|
1069
|
+
process.exit(1);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
/** facade('reviews') bound to a dir — the common resolution path for
|
|
1073
|
+
* review-host commands (act/debt/hooks). */
|
|
1074
|
+
function reviewHost(dir = process.cwd(), prefer) {
|
|
1075
|
+
return facade("reviews", { dir }, { prefer });
|
|
1076
|
+
}
|
|
1077
|
+
/** Every registered connector's hook probes — hooks collect, never pick:
|
|
1078
|
+
* each system reports its own ambient state. */
|
|
1079
|
+
function connectorHooks(ctx) {
|
|
1080
|
+
const out = [];
|
|
1081
|
+
for (const c of registry) try {
|
|
1082
|
+
const h = c.hooks?.(ctx);
|
|
1083
|
+
if (h) out.push(h);
|
|
1084
|
+
} catch {}
|
|
1085
|
+
return out;
|
|
1086
|
+
}
|
|
1087
|
+
/** Per-probe budget — a hung connector (dead network, wedged CLI) must
|
|
1088
|
+
* not stall the whole hook. Racing the probe means the output goes out
|
|
1089
|
+
* on time even if a spawned child lingers. */
|
|
1090
|
+
const PROBE_TIMEOUT_MS = 4e3;
|
|
1091
|
+
async function probeWithTimeout(p, fallback) {
|
|
1092
|
+
let timer;
|
|
1093
|
+
const timeout = new Promise((resolve$1) => {
|
|
1094
|
+
timer = setTimeout(() => resolve$1(fallback), PROBE_TIMEOUT_MS);
|
|
1095
|
+
timer.unref?.();
|
|
1096
|
+
});
|
|
1097
|
+
try {
|
|
1098
|
+
return await Promise.race([Promise.resolve(p), timeout]);
|
|
1099
|
+
} finally {
|
|
1100
|
+
clearTimeout(timer);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/** Collect a line-producing probe across all connectors — fail-open
|
|
1104
|
+
* per connector, one wedged system must not starve the rest. */
|
|
1105
|
+
async function collectLines(ctx, probe) {
|
|
1106
|
+
const out = [];
|
|
1107
|
+
for (const h of connectorHooks(ctx)) try {
|
|
1108
|
+
out.push(...await probeWithTimeout(probe(h), void 0) ?? []);
|
|
1109
|
+
} catch {}
|
|
1110
|
+
return out;
|
|
1111
|
+
}
|
|
1112
|
+
/** Collect session-start context lines from all connectors. */
|
|
1113
|
+
function sessionStartLines(ctx) {
|
|
1114
|
+
return collectLines(ctx, (h) => h.sessionStart?.(ctx));
|
|
1115
|
+
}
|
|
1116
|
+
/** Collect parallel-work signals from all connectors. */
|
|
1117
|
+
function parallelWorkLines(ctx) {
|
|
1118
|
+
return collectLines(ctx, (h) => h.parallelWork?.(ctx));
|
|
1119
|
+
}
|
|
1120
|
+
/** Collect prompt-submit context from all connectors. */
|
|
1121
|
+
function promptContextLines(ctx, prompt) {
|
|
1122
|
+
return collectLines(ctx, (h) => h.promptSubmit?.(ctx, prompt));
|
|
1123
|
+
}
|
|
1124
|
+
/** Collect stop-gate contributions from all connectors — the caller
|
|
1125
|
+
* applies the session-arming policy to each. */
|
|
1126
|
+
async function stopGateContributions(ctx) {
|
|
1127
|
+
const out = [];
|
|
1128
|
+
for (const h of connectorHooks(ctx)) try {
|
|
1129
|
+
out.push(...await probeWithTimeout(h.stopGate?.(ctx), void 0) ?? []);
|
|
1130
|
+
} catch {}
|
|
1131
|
+
return out;
|
|
1132
|
+
}
|
|
1133
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1134
|
+
/** Reads + TOML-parses a plan file. Throws with the file path in the
|
|
1135
|
+
* message — callers never add context of their own. */
|
|
1136
|
+
function readPlanDoc(file) {
|
|
1137
|
+
let text;
|
|
1138
|
+
try {
|
|
1139
|
+
text = readFileSync(file, "utf8");
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1142
|
+
throw new Error(`${file}: cannot read plan — ${msg}`);
|
|
1143
|
+
}
|
|
1144
|
+
let doc;
|
|
1145
|
+
try {
|
|
1146
|
+
doc = parse(text);
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
throw new Error(`${file}: invalid TOML — ${err instanceof Error ? err.message : String(err)}`);
|
|
1149
|
+
}
|
|
1150
|
+
if (!isRecord(doc)) throw new Error(`${file}: plan must be a TOML table`);
|
|
1151
|
+
return doc;
|
|
1152
|
+
}
|
|
1153
|
+
/** The envelope's routing key — undefined when absent or non-string. */
|
|
1154
|
+
function planKind(doc) {
|
|
1155
|
+
return typeof doc.kind === "string" ? doc.kind : void 0;
|
|
1156
|
+
}
|
|
1157
|
+
function makePrinter(_personality) {
|
|
1158
|
+
return {
|
|
1159
|
+
info: (msg) => console.error(msg),
|
|
1160
|
+
warn: (msg) => console.error(`warning: ${msg}`),
|
|
1161
|
+
data: (line) => console.log(line)
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
//#endregion
|
|
1166
|
+
export { parallelWorkLines as A, stackSection as B, ghJson as C, initBeadsStealth as D, gitTry as E, registerConnector as F, syncSection as H, reviewHost as I, sddSection as L, promptContextLines as M, readPlanDoc as N, loadConfig as O, refKind as P, sessionStartLines as R, gh as S, git as T, taskStore as U, stopGateContributions as V, verbMethod as W, docTypeNamed as _, bd as a, evidenceKind as b, beadsSection as c, dataRefPull as d, dataRefPush as f, definePlugin as g, defineConfig as h, actSection as i, planKind as j, makePrinter as k, checkBeads as l, debtSection as m, PERSONALITIES as n, bdJson as o, dataRefRoot as p, STANDARD_VERBS as r, bdTry as s, DEFAULT_GLOBAL_BEADS_DIR as t, dataRefCommit as u, docVerbs as v, ghTry as w, facade as x, ensureAuth as y, sessionTaskClaims as z };
|