@haiyangbg/buildbeat 3.3.1 → 4.1.0
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/CHANGELOG.md +26 -0
- package/README.en.md +20 -124
- package/README.md +20 -124
- package/SKILL.md +73 -84
- package/docs/CAPABILITY-MATRIX.md +30 -39
- package/docs/MIGRATION.md +93 -0
- package/docs/README.md +8 -37
- package/docs/RELEASING.md +4 -3
- package/docs/v2/RFC-0001-product-definition.md +3 -0
- package/docs/v2/RFC-0002-domain-model.md +3 -0
- package/docs/v2/RFC-0003-workflow-policy.md +3 -0
- package/docs/v2/SPEC-0001-events-v1.md +3 -0
- package/docs/v2/guide/00-how-to-talk.en.md +12 -56
- package/docs/v2/guide/00-how-to-talk.md +12 -56
- package/docs/v2/guide/01-quickstart.en.md +42 -111
- package/docs/v2/guide/01-quickstart.md +42 -111
- package/docs/v2/guide/02-workflow-guide.en.md +13 -123
- package/docs/v2/guide/02-workflow-guide.md +14 -122
- package/docs/v2/guide/03-policy-guide.en.md +6 -50
- package/docs/v2/guide/03-policy-guide.md +6 -50
- package/docs/v2/guide/04-adapter-guide.en.md +7 -60
- package/docs/v2/guide/04-adapter-guide.md +7 -60
- package/docs/v2/guide/05-worker-contract.en.md +6 -53
- package/docs/v2/guide/05-worker-contract.md +7 -52
- package/docs/v2/guide/06-evidence-guide.en.md +8 -44
- package/docs/v2/guide/06-evidence-guide.md +9 -43
- package/docs/v2/guide/07-approval-guide.en.md +8 -110
- package/docs/v2/guide/07-approval-guide.md +8 -121
- package/docs/v2/guide/09-security-boundaries.en.md +13 -37
- package/docs/v2/guide/09-security-boundaries.md +13 -37
- package/docs/v2/guide/10-recovery.en.md +12 -96
- package/docs/v2/guide/10-recovery.md +12 -96
- package/docs/v2/guide/11-session-handoff.en.md +10 -75
- package/docs/v2/guide/11-session-handoff.md +10 -75
- package/docs/v2/guide/12-without-runtime.en.md +46 -0
- package/docs/v2/guide/12-without-runtime.md +46 -0
- package/docs/v2/guide/README.en.md +16 -36
- package/docs/v2/guide/README.md +16 -36
- package/example/AGENTS.md +15 -69
- package/example/README.md +8 -23
- package/example/delivery/envelope/prompts/builder.md +3 -1
- package/example/delivery/envelope/prompts/fixer.md +3 -1
- package/example/delivery/envelope/prompts/reviewer.md +3 -1
- package/example/delivery/work/WORK-EXPORT-DATE-FILTER/run-config.yaml +1 -1
- package/example//346/214/207/346/214/245/345/217/260.md +8 -40
- package/package.json +2 -1
- package/src/v2/adapters/notification-payload.js +40 -0
- package/src/v2/adapters/shell.js +3 -1
- package/src/v2/cli/run-config-check.js +129 -45
- package/src/v2/cli/run.js +853 -254
- package/src/v2/engine/reducer.js +2 -0
- package/src/v2/engine/risk-preset.js +14 -47
- package/src/v2/engine/workflow.js +177 -185
- package/src/v2/evidence/collector.js +12 -1
- package/src/v2/evidence/image.js +346 -0
- package/src/v2/policy/policy.js +219 -286
- package/src/v2/presets/software-delivery.yaml +1 -16
- package/src/v2/runtime/cache.js +22 -1
- package/src/v2/runtime/decisions.js +185 -31
- package/src/v2/runtime/findings.js +4 -1
- package/src/v2/runtime/notify.js +42 -36
- package/src/v2/runtime/orchestrator.js +811 -297
- package/src/v2/runtime/overview-repos.js +79 -0
- package/src/v2/runtime/overview.js +279 -47
- package/src/v2/runtime/release.js +233 -0
- package/src/v2/runtime/run-record.js +1 -0
- package/src/v2/workspace/workspace-manager.js +8 -0
- package/templates/v2/AGENTS.md +20 -73
- package/templates/v2/BUILDBEAT.md +2 -13
- package/templates/v2/envelope/prompts/builder.md +3 -1
- package/templates/v2/envelope/prompts/fixer.md +3 -1
- package/templates/v2/envelope/prompts/reviewer.md +3 -1
- package/templates/v2/run-config.example.yaml +9 -7
- package/templates/v2/work.example.md +16 -0
- package/templates/v2//346/214/207/346/214/245/345/217/260.md +8 -40
- package/docs/v2/skill/01-principles.md +0 -26
- package/docs/v2/skill/02-project-layout.md +0 -31
- package/docs/v2/skill/03-collaboration-rules.md +0 -45
- package/docs/v2/skill/04-rhythm-and-rituals.md +0 -104
- package/docs/v2/skill/05-red-lines.md +0 -13
- package/docs/v2/skill/06-bootstrap-and-takeover.md +0 -84
- package/docs/v2/skill/07-templates-and-lessons.md +0 -24
- package/example/.buildbeat/observe.yaml +0 -31
- package/src/v2/observe/observe-config.js +0 -194
- package/src/v2/observe/observe-reducer.js +0 -117
- package/src/v2/observe/observe.js +0 -427
- package/src/v2/presets/observe.yaml +0 -45
- package/src/v2/presets/policies/ui-render-gate.yaml +0 -13
- package/src/v2/presets/release-readback.yaml +0 -36
- package/src/v2/presets/risk/controlled.yaml +0 -39
- package/src/v2/presets/risk/fast.yaml +0 -19
- package/src/v2/presets/risk/release.yaml +0 -21
- package/src/v2/presets/risk/standard.yaml +0 -28
- package/templates/ARCHITECTURE.md +0 -50
- package/templates/contracts/PROTOCOL.md +0 -28
- package/templates/pm/adr/ADR-0000-template.md +0 -25
- package/templates/pm/adr/README.md +0 -15
- package/templates/pm/decisions.md +0 -11
- package/templates/standards/CODE.md +0 -23
- package/templates/standards/DESIGN.md +0 -36
- package/templates/standards/REVIEW.md +0 -20
- package/templates/standards/STACK.md +0 -31
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Read-only repository/config discovery shared by single- and multi-repo status.
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { parseYamlSubset } from "../engine/yaml-subset.js";
|
|
6
|
+
|
|
7
|
+
export const pathLabel = (cwd, path) => relative(cwd, path) || ".";
|
|
8
|
+
export const shellArg = (value) => /^[a-zA-Z0-9_./:-]+$/.test(value)
|
|
9
|
+
? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
10
|
+
export const directory = (path) => {
|
|
11
|
+
try { return statSync(path).isDirectory(); } catch { return false; }
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Require an actual checkout root, not a directory inside a parent checkout.
|
|
15
|
+
export function repositoryRoot(path, cache = new Map()) {
|
|
16
|
+
const real = realpathSync(path);
|
|
17
|
+
if (cache.has(real)) {
|
|
18
|
+
const cached = cache.get(real);
|
|
19
|
+
if (cached instanceof Error) throw cached;
|
|
20
|
+
return cached;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const top = execFileSync("git", ["-C", real, "rev-parse", "--show-toplevel"], {
|
|
24
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
25
|
+
}).trim();
|
|
26
|
+
if (realpathSync(top) !== real) throw new Error("not a git repository root");
|
|
27
|
+
cache.set(real, real);
|
|
28
|
+
return real;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
cache.set(real, error);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function workConfigs(repoRoot, workId, warnings = []) {
|
|
36
|
+
const dir = join(repoRoot, "delivery", "work", workId);
|
|
37
|
+
if (!directory(dir)) return [];
|
|
38
|
+
return readdirSync(dir).sort().filter((name) => /^run-config.*\.ya?ml$/.test(name)).map((name) => {
|
|
39
|
+
const path = join(dir, name);
|
|
40
|
+
try {
|
|
41
|
+
const doc = parseYamlSubset(readFileSync(path, "utf8"));
|
|
42
|
+
if (typeof doc.repo !== "string" || !doc.repo.trim()) throw new Error("repo must be a non-empty path");
|
|
43
|
+
const target = resolve(dirname(path), doc.repo);
|
|
44
|
+
return { path, target, release: Object.hasOwn(doc, "release") };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
warnings.push(`${path}: ${error.message.replaceAll("\n", " ")}`);
|
|
47
|
+
return { path, error: true, release: null };
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function configTargets(configs, warnings, roots = new Map()) {
|
|
53
|
+
return configs.map((config) => {
|
|
54
|
+
if (!config.target) return config;
|
|
55
|
+
try { return { ...config, repo: repositoryRoot(config.target, roots) }; }
|
|
56
|
+
catch (error) {
|
|
57
|
+
warnings.push(`${config.path}: ${config.target}: ${error.message.replaceAll("\n", " ")}`);
|
|
58
|
+
return { ...config, error: true };
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function discoverRepos(repoRoot, warnings, roots = new Map()) {
|
|
64
|
+
const repos = new Set([repoRoot]);
|
|
65
|
+
const workRoot = join(repoRoot, "delivery", "work");
|
|
66
|
+
if (directory(workRoot)) {
|
|
67
|
+
for (const id of readdirSync(workRoot).sort()) {
|
|
68
|
+
for (const config of configTargets(workConfigs(repoRoot, id, warnings), warnings, roots)) {
|
|
69
|
+
if (config.repo) repos.add(config.repo);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const name of readdirSync(repoRoot).sort()) {
|
|
74
|
+
const child = join(repoRoot, name);
|
|
75
|
+
if (!directory(join(child, "delivery", "work"))) continue;
|
|
76
|
+
try { repos.add(repositoryRoot(child, roots)); } catch { /* only discover git roots */ }
|
|
77
|
+
}
|
|
78
|
+
return [...repos];
|
|
79
|
+
}
|
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
import { execFileSync } from "node:child_process";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
import { listInbox } from "./decisions.js";
|
|
16
|
+
import { configTargets, directory, discoverRepos, pathLabel, shellArg, workConfigs } from "./overview-repos.js";
|
|
15
17
|
|
|
16
18
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
17
19
|
import { latestAdjudications, readFindingsAccount } from "./findings.js";
|
|
18
20
|
import { nextReply } from "./notify.js";
|
|
21
|
+
import { readReleases } from "./release.js";
|
|
22
|
+
import { describeLiveness } from "./liveness.js";
|
|
19
23
|
import { computeWorkCost, renderWorkCost } from "./work-cost.js";
|
|
20
24
|
|
|
21
25
|
function sha256File(path) {
|
|
@@ -45,34 +49,59 @@ export function artifactStatus(workDir, decisions, artifact) {
|
|
|
45
49
|
return { exists: false, accepted: false, stale: false };
|
|
46
50
|
}
|
|
47
51
|
const digest = sha256File(path);
|
|
48
|
-
const accepts = decisions.filter(
|
|
52
|
+
const accepts = decisions.filter(
|
|
53
|
+
(row) =>
|
|
54
|
+
row.transition === `accept-${artifact}` && row.decision === "approved",
|
|
55
|
+
);
|
|
49
56
|
const latest = accepts[accepts.length - 1];
|
|
50
57
|
if (!latest) {
|
|
51
58
|
return { exists: true, accepted: false, stale: false };
|
|
52
59
|
}
|
|
53
60
|
const stale = latest.subject?.digest !== digest;
|
|
54
|
-
return {
|
|
61
|
+
return {
|
|
62
|
+
exists: true,
|
|
63
|
+
accepted: !stale,
|
|
64
|
+
stale,
|
|
65
|
+
by: latest.by,
|
|
66
|
+
at: latest.ts,
|
|
67
|
+
};
|
|
55
68
|
}
|
|
56
69
|
|
|
57
|
-
function isAncestor(repoRoot, sha, ref) {
|
|
70
|
+
function isAncestor(repoRoot, sha, ref, context) {
|
|
71
|
+
const key = JSON.stringify([repoRoot, sha, ref]);
|
|
72
|
+
if (context.ancestry.has(key)) return context.ancestry.get(key);
|
|
58
73
|
try {
|
|
59
|
-
execFileSync(
|
|
74
|
+
execFileSync(
|
|
75
|
+
"git",
|
|
76
|
+
["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref],
|
|
77
|
+
{ stdio: "ignore" },
|
|
78
|
+
);
|
|
79
|
+
context.ancestry.set(key, true);
|
|
60
80
|
return true;
|
|
61
81
|
} catch {
|
|
82
|
+
context.ancestry.set(key, false);
|
|
62
83
|
return false;
|
|
63
84
|
}
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
function headRef(repoRoot) {
|
|
87
|
+
function headRef(repoRoot, context) {
|
|
88
|
+
if (context.heads.has(repoRoot)) return context.heads.get(repoRoot);
|
|
67
89
|
try {
|
|
68
|
-
|
|
90
|
+
const ref = execFileSync(
|
|
91
|
+
"git",
|
|
92
|
+
["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
93
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
94
|
+
).trim();
|
|
95
|
+
context.heads.set(repoRoot, ref);
|
|
96
|
+
return ref;
|
|
69
97
|
} catch {
|
|
98
|
+
context.heads.set(repoRoot, "HEAD");
|
|
70
99
|
return "HEAD";
|
|
71
100
|
}
|
|
72
101
|
}
|
|
73
102
|
|
|
74
103
|
// Runs for a work: runtime ledgers first, run-records for the rest.
|
|
75
|
-
function runsFor(repoRoot, workId) {
|
|
104
|
+
export function runsFor(repoRoot, workId) {
|
|
76
105
|
const runs = new Map();
|
|
77
106
|
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
78
107
|
if (existsSync(runsDir)) {
|
|
@@ -131,34 +160,86 @@ function runsFor(repoRoot, workId) {
|
|
|
131
160
|
}
|
|
132
161
|
}
|
|
133
162
|
}
|
|
134
|
-
return [...runs.values()].sort((a, b) =>
|
|
163
|
+
return [...runs.values()].sort((a, b) =>
|
|
164
|
+
String(a.createdAt).localeCompare(String(b.createdAt)),
|
|
165
|
+
);
|
|
135
166
|
}
|
|
136
167
|
|
|
137
|
-
|
|
168
|
+
// Per status invocation: known local roots need no validation subprocess,
|
|
169
|
+
// and forwarded works share repository facts with the later target scan.
|
|
170
|
+
function overviewContext(repoRoot) {
|
|
171
|
+
return { roots: new Map([[realpathSync(repoRoot), realpathSync(repoRoot)]]), heads: new Map(), ancestry: new Map() };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function computeOverview(
|
|
175
|
+
repoRoot,
|
|
176
|
+
{ work = null, repoLabel: defaultRepoLabel = ".", cwd = null, followTargets = true, warnings = [], configsOverride = null, context = overviewContext(repoRoot), skipWorks = new Set(), localCompatibility = false } = {},
|
|
177
|
+
) {
|
|
138
178
|
const workRoot = join(repoRoot, "delivery", "work");
|
|
139
179
|
const rows = [];
|
|
140
|
-
|
|
141
|
-
|
|
180
|
+
const workIds = new Set(directory(workRoot) ? readdirSync(workRoot).filter((id) => directory(join(workRoot, id))) : []);
|
|
181
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
182
|
+
if (directory(runsDir)) {
|
|
183
|
+
for (const id of readdirSync(runsDir)) {
|
|
184
|
+
const file = join(runsDir, id, "events.jsonl");
|
|
185
|
+
if (!existsSync(file)) continue;
|
|
186
|
+
const run = EventLedger.open(file).state.run;
|
|
187
|
+
if (run?.work) workIds.add(run.work);
|
|
188
|
+
}
|
|
142
189
|
}
|
|
143
|
-
const mainRef = headRef(repoRoot);
|
|
144
|
-
for
|
|
145
|
-
|
|
190
|
+
const mainRef = headRef(repoRoot, context);
|
|
191
|
+
// Resolve once; do not spawn another Git process for each local work.
|
|
192
|
+
const currentRoot = realpathSync(repoRoot);
|
|
193
|
+
for (const workId of [...workIds].sort()) {
|
|
194
|
+
if ((work && workId !== work) || skipWorks.has(workId)) {
|
|
146
195
|
continue;
|
|
147
196
|
}
|
|
148
197
|
const workDir = join(workRoot, workId);
|
|
149
|
-
|
|
150
|
-
|
|
198
|
+
const configs = configsOverride ?? workConfigs(repoRoot, workId, warnings);
|
|
199
|
+
const resolved = followTargets ? configTargets(configs, warnings, context.roots) : configs;
|
|
200
|
+
const owner = followTargets ? resolved.find((item) => item.repo && item.repo !== currentRoot &&
|
|
201
|
+
(directory(join(item.repo, "delivery", "work", workId)) || runsFor(item.repo, workId).length)) : null;
|
|
202
|
+
const usable = resolved.filter((item) => !item.error);
|
|
203
|
+
const selected = owner ?? usable[0] ?? resolved[0] ?? null;
|
|
204
|
+
const applicable = selected?.repo ? resolved.filter((item) => item.repo === selected.repo) : usable;
|
|
205
|
+
const local = localCompatibility && (!selected?.target || selected.repo === currentRoot);
|
|
206
|
+
const config = (local ? null : applicable.find((item) => item.release)) ?? selected;
|
|
207
|
+
if (owner) {
|
|
208
|
+
const targetRows = computeOverview(config.repo, {
|
|
209
|
+
work: workId, repoLabel: shellArg(pathLabel(cwd ?? process.cwd(), config.repo)),
|
|
210
|
+
cwd: cwd ?? process.cwd(), followTargets: false, warnings, configsOverride: applicable, context,
|
|
211
|
+
});
|
|
212
|
+
if (targetRows.length) {
|
|
213
|
+
rows.push({ ...targetRows[0], repo: config.repo, repoLabel: pathLabel(cwd ?? process.cwd(), config.repo) });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
151
216
|
}
|
|
217
|
+
// Cross-repo work still belongs here until the target has records.
|
|
218
|
+
// Its commands need a copyable owner path, not the legacy local label.
|
|
219
|
+
const repoLabel = followTargets && config?.target && config.repo !== currentRoot
|
|
220
|
+
? shellArg(pathLabel(cwd ?? process.cwd(), repoRoot)) : defaultRepoLabel;
|
|
221
|
+
const pathFor = (item) =>
|
|
222
|
+
shellArg(cwd && !local ? pathLabel(cwd, item.path) : `delivery/work/${workId}/${item.path.split("/").at(-1)}`);
|
|
223
|
+
const configPath = config ? pathFor(config) : "<run-config.yaml>";
|
|
224
|
+
const runs = runsFor(repoRoot, workId);
|
|
225
|
+
if (!configs.length && !runs.length && !["work.md", "intent.md", "plan.md", "decisions.jsonl", "runs"].some((name) => existsSync(join(workDir, name)))) continue;
|
|
152
226
|
const decisions = readJsonl(join(workDir, "decisions.jsonl"));
|
|
153
|
-
const
|
|
154
|
-
const
|
|
227
|
+
const unified = existsSync(join(workDir, "work.md"));
|
|
228
|
+
const intent = artifactStatus(
|
|
229
|
+
workDir,
|
|
230
|
+
decisions,
|
|
231
|
+
unified ? "work" : "intent",
|
|
232
|
+
);
|
|
233
|
+
const plan = unified ? intent : artifactStatus(workDir, decisions, "plan");
|
|
155
234
|
const envFacts = existsSync(join(workDir, "env-facts.md"));
|
|
156
235
|
const findingRows = readFindingsAccount(repoRoot, workId);
|
|
157
236
|
const adjudicated = latestAdjudications(findingRows);
|
|
158
237
|
const openFindings = findingRows.filter(
|
|
159
|
-
(row) =>
|
|
238
|
+
(row) =>
|
|
239
|
+
row.kind === "finding" &&
|
|
240
|
+
(row.severity === "P0" || row.severity === "P1") &&
|
|
241
|
+
!adjudicated.has(row.fingerprint),
|
|
160
242
|
).length;
|
|
161
|
-
const runs = runsFor(repoRoot, workId);
|
|
162
243
|
const live = runs.filter((run) => run.status !== "SUPERSEDED");
|
|
163
244
|
const latest = live[live.length - 1] ?? null;
|
|
164
245
|
// "Merged" is a fact about any candidate of the work, not only the
|
|
@@ -166,28 +247,47 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
166
247
|
// (its in-run review budget ran out and closure happened elsewhere) and
|
|
167
248
|
// overview reported the shipped work as STOPPED_CANCELLED.
|
|
168
249
|
const mergedRun =
|
|
169
|
-
[...runs]
|
|
250
|
+
[...runs]
|
|
251
|
+
.reverse()
|
|
252
|
+
.find(
|
|
253
|
+
(run) =>
|
|
254
|
+
run.candidate && isAncestor(repoRoot, run.candidate, mainRef, context),
|
|
255
|
+
) ?? null;
|
|
170
256
|
const merged = Boolean(mergedRun);
|
|
171
257
|
const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
|
|
172
258
|
const isReleaseLane = (run) =>
|
|
173
|
-
Boolean(run) &&
|
|
259
|
+
Boolean(run) &&
|
|
260
|
+
(run.workflow === "release-readback" ||
|
|
261
|
+
run.steps.some((step) => RELEASE_STEPS.includes(step)));
|
|
174
262
|
|
|
175
263
|
// A Work is closed by an explicit row in decisions.jsonl:
|
|
176
264
|
// {"transition":"close-work","decision":"closed"|"cancelled","subject":{"result":"..."}}
|
|
177
265
|
// A live run (RUNNING / WAITING_HUMAN) contradicts a closure and wins, so a
|
|
178
266
|
// stale close row can never hide something that still needs a human.
|
|
179
|
-
const closure = [...decisions]
|
|
180
|
-
|
|
267
|
+
const closure = [...decisions]
|
|
268
|
+
.reverse()
|
|
269
|
+
.find((row) => row.transition === "close-work");
|
|
270
|
+
const readback = readReleases(repoRoot, workId).at(-1) ?? null;
|
|
271
|
+
const liveRun =
|
|
272
|
+
latest &&
|
|
273
|
+
(latest.status === "RUNNING" || latest.status === "WAITING_HUMAN");
|
|
181
274
|
|
|
182
275
|
let stage;
|
|
183
276
|
let next;
|
|
184
277
|
if (closure && !liveRun) {
|
|
185
278
|
stage = closure.decision === "cancelled" ? "CANCELLED" : "CLOSED";
|
|
186
|
-
const result =
|
|
279
|
+
const result =
|
|
280
|
+
typeof closure.subject?.result === "string" &&
|
|
281
|
+
closure.subject.result.length > 0
|
|
282
|
+
? closure.subject.result
|
|
283
|
+
: "see decisions.jsonl";
|
|
187
284
|
next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
|
|
188
|
-
} else if (!intent.exists) {
|
|
285
|
+
} else if (!intent.exists && (!latest || directory(workDir))) {
|
|
286
|
+
// A work directory without a work description stays NO_INTENT even
|
|
287
|
+
// when it has runs, as in 4.0.0; only a work known solely from
|
|
288
|
+
// runtime ledgers (no directory here) shows its run state.
|
|
189
289
|
stage = "NO_INTENT";
|
|
190
|
-
next = `write delivery/work/${workId}/
|
|
290
|
+
next = `write delivery/work/${workId}/work.md (goal, scope, acceptance, implementation plan)`;
|
|
191
291
|
} else if (!latest) {
|
|
192
292
|
if (!plan.exists) {
|
|
193
293
|
stage = intent.accepted ? "INTENT_ACCEPTED" : "INTENT_DRAFT";
|
|
@@ -195,24 +295,38 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
195
295
|
? `write delivery/work/${workId}/plan.md, then accept it`
|
|
196
296
|
: `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact intent --by <you>`;
|
|
197
297
|
} else if (!plan.accepted) {
|
|
198
|
-
stage =
|
|
199
|
-
|
|
200
|
-
|
|
298
|
+
stage = unified
|
|
299
|
+
? plan.stale
|
|
300
|
+
? "WORK_STALE"
|
|
301
|
+
: "WORK_DRAFT"
|
|
302
|
+
: plan.stale
|
|
303
|
+
? "PLAN_STALE"
|
|
304
|
+
: "PLAN_DRAFT";
|
|
305
|
+
const artifacts = unified
|
|
306
|
+
? "work"
|
|
307
|
+
: intent.accepted
|
|
308
|
+
? "plan"
|
|
309
|
+
: "intent,plan";
|
|
310
|
+
next = `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact ${artifacts} --by <you>${plan.stale ? ` # ${unified ? "work.md" : "plan"} changed since acceptance` : ""}`;
|
|
201
311
|
} else {
|
|
202
312
|
stage = "READY_TO_RUN";
|
|
203
|
-
const configs = readdirSync(workDir).filter((name) => /^run-config.*\.ya?ml$/.test(name));
|
|
204
313
|
next =
|
|
205
314
|
configs.length > 0
|
|
206
|
-
? `buildbeat
|
|
315
|
+
? `buildbeat run --config ${configPath}`
|
|
207
316
|
: `no run-config in delivery/work/${workId}: write one, or close it with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"..."}} if it was doc-only`;
|
|
208
317
|
}
|
|
209
318
|
} else if (latest.status === "RUNNING") {
|
|
210
319
|
stage = "RUNNING";
|
|
211
320
|
next = `buildbeat status --repo ${repoLabel} --run ${latest.id}`;
|
|
212
321
|
} else if (latest.status === "WAITING_HUMAN") {
|
|
213
|
-
stage =
|
|
214
|
-
|
|
215
|
-
|
|
322
|
+
stage =
|
|
323
|
+
latest.pendingHuman?.kind === "final-decision"
|
|
324
|
+
? "MERGE_DECISION"
|
|
325
|
+
: "WAITING_HUMAN";
|
|
326
|
+
const replies = latest.state
|
|
327
|
+
? nextReply({ repoLabel, state: latest.state })
|
|
328
|
+
: [];
|
|
329
|
+
next = replies[0] ?? `buildbeat status --repo ${repoLabel} --work ${workId}`;
|
|
216
330
|
} else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
|
|
217
331
|
// A release-readback lane that reached wait-close and was approved is
|
|
218
332
|
// a closed release window, not "nothing to merge".
|
|
@@ -220,9 +334,27 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
220
334
|
next = `release window closed by ${latest.id}; close the work with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"released"}}`;
|
|
221
335
|
} else if (merged) {
|
|
222
336
|
stage = "MERGED";
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
337
|
+
// After the merge: the person releases, the project's readback proves
|
|
338
|
+
// it, and the window closes on a passing readback. The readback lives
|
|
339
|
+
// in whichever run config has a release: section; when every config
|
|
340
|
+
// was read and none has one, `buildbeat release` could only fail, so
|
|
341
|
+
// the merge is the end of the work. Unreadable or absent configs keep
|
|
342
|
+
// the generic hint.
|
|
343
|
+
const releaseConfig = configs.find((item) => item.release) ?? null;
|
|
344
|
+
const releasePath = releaseConfig ? pathFor(releaseConfig) : configPath;
|
|
345
|
+
const noReleaseStep =
|
|
346
|
+
configs.length > 0 && !releaseConfig && !configs.some((item) => item.error);
|
|
347
|
+
const close = `buildbeat decide --repo ${repoLabel} --work ${workId} --action close --result <what was released> --by <you>`;
|
|
348
|
+
next = !readback
|
|
349
|
+
? noReleaseStep
|
|
350
|
+
? "merged; no release readback configured, nothing left to do (add a release: section to the run config to record a readback and close the window)"
|
|
351
|
+
: `candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release it (a human action), then buildbeat release --config ${releasePath}; then ${close}`
|
|
352
|
+
: readback.status === "passed"
|
|
353
|
+
? `readback passed @ ${readback.ts} (${readback.commit.slice(0, 7)}); close the window: ${close}`
|
|
354
|
+
: `latest readback failed @ ${readback.ts} (exit ${readback.exitCode}); fix the release, then buildbeat release --config ${releasePath} again`;
|
|
355
|
+
if (latest.status !== "SUCCEEDED") {
|
|
356
|
+
next += ` # latest run ${latest.id} ended ${latest.status} after the merge`;
|
|
357
|
+
}
|
|
226
358
|
} else if (latest.status === "SUCCEEDED") {
|
|
227
359
|
stage = "MERGE_READY";
|
|
228
360
|
next = latest.candidate
|
|
@@ -231,24 +363,52 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
231
363
|
} else {
|
|
232
364
|
stage = `STOPPED_${latest.status}`;
|
|
233
365
|
next = plan.accepted
|
|
234
|
-
? `decide: retry (buildbeat
|
|
235
|
-
:
|
|
366
|
+
? `decide: retry (buildbeat run --config ${configPath} --new) or close the work`
|
|
367
|
+
: `${unified ? "work.md" : "plan"} not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
|
|
236
368
|
}
|
|
237
369
|
rows.push({
|
|
238
370
|
work: workId,
|
|
371
|
+
...(config ? { config: config.path, hasRelease: configs.some((item) => item.release) ? true : configs.some((item) => item.error) ? null : false } : { hasRelease: false }),
|
|
372
|
+
...(config?.target && (!config.repo || config.repo !== currentRoot) && followTargets ? { targetRepo: config.repo ?? config.target, targetLabel: pathLabel(cwd ?? process.cwd(), config.repo ?? config.target) } : {}),
|
|
239
373
|
stage,
|
|
240
374
|
intent,
|
|
241
375
|
plan,
|
|
376
|
+
...(unified ? { workArtifact: "work" } : {}),
|
|
242
377
|
envFacts,
|
|
243
378
|
openFindings,
|
|
379
|
+
findings: findingRows
|
|
380
|
+
.filter((item) => item.kind === "finding")
|
|
381
|
+
.map((item) => ({
|
|
382
|
+
...item,
|
|
383
|
+
adjudication: adjudicated.get(item.fingerprint)?.action ?? "open",
|
|
384
|
+
})),
|
|
244
385
|
runs: runs.length,
|
|
245
386
|
cost: runs.length > 0 ? computeWorkCost(repoRoot, workId) : null,
|
|
246
387
|
latest: latest
|
|
247
|
-
? {
|
|
388
|
+
? {
|
|
389
|
+
id: latest.id,
|
|
390
|
+
status: latest.status,
|
|
391
|
+
candidate: latest.candidate,
|
|
392
|
+
at: latest.lastAt,
|
|
393
|
+
source: latest.source,
|
|
394
|
+
terminalReason: latest.terminal?.reason ?? null,
|
|
395
|
+
waiting: latest.pendingHuman?.transition ?? null,
|
|
396
|
+
}
|
|
248
397
|
: null,
|
|
249
398
|
merged,
|
|
250
399
|
mergedCandidate: mergedRun?.candidate ?? null,
|
|
251
|
-
|
|
400
|
+
...(readback
|
|
401
|
+
? {
|
|
402
|
+
readback: {
|
|
403
|
+
status: readback.status,
|
|
404
|
+
at: readback.ts,
|
|
405
|
+
commit: readback.commit,
|
|
406
|
+
digest: readback.digest,
|
|
407
|
+
...(readback.note ? { note: readback.note } : {}),
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
: {}),
|
|
411
|
+
next: local ? next : next.replaceAll("--config <run-config.yaml>", `--config ${configPath}`),
|
|
252
412
|
});
|
|
253
413
|
}
|
|
254
414
|
return rows;
|
|
@@ -271,14 +431,31 @@ export function renderOverview(rows) {
|
|
|
271
431
|
const lines = [];
|
|
272
432
|
for (const row of rows) {
|
|
273
433
|
lines.push(`${row.work} ${row.stage}`);
|
|
274
|
-
|
|
275
|
-
|
|
434
|
+
if (row.repo) lines.push(` repo: ${row.repoLabel ?? row.repo}`);
|
|
435
|
+
if (row.targetRepo) lines.push(` runs in: ${row.targetLabel ?? row.targetRepo}`);
|
|
436
|
+
const parts =
|
|
437
|
+
row.workArtifact === "work"
|
|
438
|
+
? [`work.md ${mark(row.plan)}`, `runs ${row.runs}`]
|
|
439
|
+
: [
|
|
440
|
+
`intent ${mark(row.intent)}`,
|
|
441
|
+
`plan ${mark(row.plan)}`,
|
|
442
|
+
`runs ${row.runs}`,
|
|
443
|
+
];
|
|
444
|
+
const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(
|
|
445
|
+
row.stage,
|
|
446
|
+
);
|
|
276
447
|
if (row.openFindings > 0 && !settled) {
|
|
277
448
|
// Unadjudicated, not necessarily unresolved: a fixer may have closed
|
|
278
449
|
// them without anyone recording a verdict. The number says "nobody
|
|
279
450
|
// ruled on these", which is exactly what a human should know.
|
|
280
451
|
parts.push(`unadjudicated P0/P1 findings ${row.openFindings}`);
|
|
281
452
|
}
|
|
453
|
+
if (!settled && row.findings?.length) {
|
|
454
|
+
for (const finding of row.findings)
|
|
455
|
+
lines.push(
|
|
456
|
+
` [${finding.severity} ${finding.fingerprint}] (${finding.adjudication}) ${finding.summary}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
282
459
|
if (row.envFacts) {
|
|
283
460
|
parts.push("env-facts ✓");
|
|
284
461
|
}
|
|
@@ -293,10 +470,65 @@ export function renderOverview(rows) {
|
|
|
293
470
|
? ` candidate ${row.latest.candidate.slice(0, 7)}${row.latest.candidate === row.mergedCandidate ? " (merged)" : ""}`
|
|
294
471
|
: "";
|
|
295
472
|
const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
|
|
296
|
-
const why = row.latest.terminalReason
|
|
297
|
-
|
|
473
|
+
const why = row.latest.terminalReason
|
|
474
|
+
? ` — ${row.latest.terminalReason.slice(0, 100)}`
|
|
475
|
+
: "";
|
|
476
|
+
lines.push(
|
|
477
|
+
` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`,
|
|
478
|
+
);
|
|
298
479
|
}
|
|
299
480
|
lines.push(` next: ${row.next}`);
|
|
300
481
|
}
|
|
301
482
|
return lines.join("\n");
|
|
302
483
|
}
|
|
484
|
+
|
|
485
|
+
// The main repo's configuration remains the command source; all state and
|
|
486
|
+
// decisions come from the checkout that owns the work. Only the main repo's
|
|
487
|
+
// pointers are followed, so targets cannot recursively expand this view.
|
|
488
|
+
export function computeRepositoryOverview(repoRoot, { work = null, allRepos = false, cwd = process.cwd(), localRepoLabel = shellArg(pathLabel(cwd, repoRoot)) } = {}) {
|
|
489
|
+
repoRoot = realpathSync(repoRoot);
|
|
490
|
+
const context = overviewContext(repoRoot);
|
|
491
|
+
const warnings = [];
|
|
492
|
+
const roots = allRepos ? discoverRepos(repoRoot, warnings, context.roots) : [repoRoot];
|
|
493
|
+
const primary = computeOverview(repoRoot, { work, cwd, repoLabel: allRepos ? shellArg(pathLabel(cwd, repoRoot)) : localRepoLabel, warnings, context, localCompatibility: !allRepos });
|
|
494
|
+
const groups = new Map(roots.map((repo) => [repo, new Map()]));
|
|
495
|
+
for (const row of primary) {
|
|
496
|
+
const owner = row.repo ?? repoRoot;
|
|
497
|
+
if (!groups.has(owner)) groups.set(owner, new Map());
|
|
498
|
+
groups.get(owner).set(row.work, row);
|
|
499
|
+
}
|
|
500
|
+
if (allRepos) {
|
|
501
|
+
for (const root of roots.filter((root) => root !== repoRoot)) {
|
|
502
|
+
for (const row of computeOverview(root, { work, cwd, followTargets: false, warnings, context, skipWorks: new Set(groups.get(root).keys()), repoLabel: shellArg(pathLabel(cwd, root)) })) {
|
|
503
|
+
if (!groups.get(root).has(row.work)) groups.get(root).set(row.work, row);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const pending = [];
|
|
508
|
+
const repos = [];
|
|
509
|
+
for (const [repo, byWork] of groups) {
|
|
510
|
+
const works = [...byWork.values()].sort((a, b) => a.work.localeCompare(b.work)).map((row) => ({
|
|
511
|
+
...row, repo, repoLabel: pathLabel(cwd, repo),
|
|
512
|
+
...(row.targetRepo ? { targetLabel: pathLabel(cwd, row.targetRepo) } : {}),
|
|
513
|
+
}));
|
|
514
|
+
const settled = { total: 0, closed: 0, cancelled: 0, merged: 0 };
|
|
515
|
+
for (const row of works) {
|
|
516
|
+
if (allRepos && row.stage === "RUNNING" && row.latest?.source === "runtime") {
|
|
517
|
+
const ledger = EventLedger.open(join(repo, ".buildbeat", "runtime", "runs", row.latest.id, "events.jsonl"));
|
|
518
|
+
const live = describeLiveness({ repoRoot: repo, runId: row.latest.id, ledger });
|
|
519
|
+
if (live.inFlight?.stalled) row.stage = "STALLED";
|
|
520
|
+
}
|
|
521
|
+
const kind = row.stage === "CLOSED" ? "closed" : row.stage === "CANCELLED" ? "cancelled" : row.stage === "MERGED" && row.hasRelease === false ? "merged" : null;
|
|
522
|
+
row.settled = Boolean(kind);
|
|
523
|
+
if (kind) { settled[kind]++; settled.total++; }
|
|
524
|
+
}
|
|
525
|
+
repos.push({ repo, works, settled });
|
|
526
|
+
for (const row of listInbox(repo)) {
|
|
527
|
+
if (work && row.work !== work && !row.corrupted) continue;
|
|
528
|
+
if (!allRepos && repo !== repoRoot && !byWork.has(row.work) && !row.corrupted) continue;
|
|
529
|
+
if (repo === repoRoot && primary.some((item) => item.work === row.work && item.repo && item.repo !== repoRoot)) continue;
|
|
530
|
+
pending.push({ ...row, repo, ...(byWork.get(row.work)?.config ? { config: byWork.get(row.work).config } : {}) });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return { repos, pending, warnings: [...new Set(warnings)] };
|
|
534
|
+
}
|