@haiyangbg/buildbeat 3.1.0 → 3.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +31 -10
  2. package/README.en.md +3 -3
  3. package/SKILL.md +19 -299
  4. package/docs/CAPABILITY-MATRIX.md +1 -1
  5. package/docs/README.md +6 -5
  6. package/docs/RELEASING.md +5 -5
  7. package/docs/v2/RFC-0001-product-definition.md +5 -5
  8. package/docs/v2/RFC-0002-domain-model.md +2 -2
  9. package/docs/v2/RFC-0003-workflow-policy.md +3 -3
  10. package/docs/v2/SPEC-0001-events-v1.md +2 -2
  11. package/docs/v2/guide/00-how-to-talk.en.md +61 -0
  12. package/docs/v2/guide/00-how-to-talk.md +2 -0
  13. package/docs/v2/guide/02-workflow-guide.en.md +125 -0
  14. package/docs/v2/guide/02-workflow-guide.md +7 -1
  15. package/docs/v2/guide/03-policy-guide.en.md +55 -0
  16. package/docs/v2/guide/03-policy-guide.md +2 -0
  17. package/docs/v2/guide/04-adapter-guide.en.md +66 -0
  18. package/docs/v2/guide/04-adapter-guide.md +2 -0
  19. package/docs/v2/guide/05-worker-contract.en.md +60 -0
  20. package/docs/v2/guide/05-worker-contract.md +2 -0
  21. package/docs/v2/guide/09-security-boundaries.en.md +41 -0
  22. package/docs/v2/guide/09-security-boundaries.md +4 -2
  23. package/docs/v2/guide/10-recovery.en.md +1 -1
  24. package/docs/v2/guide/10-recovery.md +1 -1
  25. package/docs/v2/guide/README.en.md +36 -0
  26. package/docs/v2/guide/README.md +8 -6
  27. package/docs/v2/skill/01-principles.md +26 -0
  28. package/docs/v2/skill/02-project-layout.md +31 -0
  29. package/docs/v2/skill/03-collaboration-rules.md +45 -0
  30. package/docs/v2/skill/04-rhythm-and-rituals.md +102 -0
  31. package/docs/v2/skill/05-red-lines.md +13 -0
  32. package/docs/v2/skill/06-bootstrap-and-takeover.md +84 -0
  33. package/docs/v2/skill/07-templates-and-lessons.md +24 -0
  34. package/package.json +7 -16
  35. package/src/v2/cli/run-config-check.js +10 -4
  36. package/src/v2/cli/run.js +35 -11
  37. package/src/v2/engine/yaml-subset.js +17 -11
  38. package/src/v2/presets/policies/ui-render-gate.yaml +1 -1
  39. package/src/v2/runtime/decisions.js +1 -1
  40. package/src/v2/runtime/gc.js +41 -25
  41. package/src/v2/runtime/metrics.js +3 -2
  42. package/src/v2/runtime/orchestrator.js +542 -412
  43. package/src/v2/workspace/workspace-manager.js +62 -3
  44. package/templates/v2/CLAUDE.md +1 -1
  45. package/templates/v2/run-config.example.yaml +2 -0
package/src/v2/cli/run.js CHANGED
@@ -64,17 +64,17 @@ Usage:
64
64
  buildbeat resume --config <run-config.yaml> [--run <RUN-ID>] [--adopt <sha> --by <name>] # --adopt: hand fix committed in the worktree; skip fix, resume at verify
65
65
  buildbeat status --repo <path> --run <RUN-ID> [--stall-after <minutes>]
66
66
  buildbeat inbox --repo <path>
67
- buildbeat overview --repo <path> [--work <WORK-ID>] [--json true]
67
+ buildbeat overview --repo <path> [--work <WORK-ID>] [--json]
68
68
  buildbeat approve --repo <path> --run <RUN-ID> --transition <t> [--by <name>] [--config <run-config.yaml>]
69
69
  buildbeat reject --repo <path> --run <RUN-ID> [--transition <t>] [--reason <text>] [--by <name>]
70
70
  buildbeat accept --repo <path> --work <WORK-ID> --artifact <plan|intent|spec> [--by <name>]
71
71
  buildbeat doctor --config <run-config.yaml>
72
72
  buildbeat events --repo <path> --run <RUN-ID>
73
73
  buildbeat replay --repo <path> --run <RUN-ID>
74
- buildbeat metrics --repo <path> [--json true]
74
+ buildbeat metrics --repo <path> [--json]
75
75
  buildbeat stop --repo <path> --run <RUN-ID> --reason <text>
76
- buildbeat gc --repo <path> [--apply true] [--force true]
77
- buildbeat watch --repo <path> --run <RUN-ID> [--stall-after <minutes>] [--interval <seconds>] [--once true]
76
+ buildbeat gc --repo <path> [--apply] [--force]
77
+ buildbeat watch --repo <path> --run <RUN-ID> [--stall-after <minutes>] [--interval <seconds>] [--once]
78
78
  buildbeat observe run --config <observe.yaml>
79
79
  buildbeat observe status --repo <path>
80
80
  buildbeat observe triage --repo <path> --intent <ref> --action <fix_now|schedule|dismiss> [--by <name>] [--note <text>]
@@ -83,15 +83,33 @@ Usage:
83
83
  buildbeat findings adjudicate --repo <path> --work <WORK-ID> --fingerprint <fp> --action <accept|dismiss> [--by <name>] [--note <text>]
84
84
  `;
85
85
 
86
+ // Switches may stand alone (`--json`) or take an explicit true/false
87
+ // (`--json true`, the older spelling); every other flag needs a value.
88
+ const SWITCHES = new Set(["json", "apply", "force", "once"]);
89
+
86
90
  function parseFlags(argv) {
87
91
  const flags = {};
88
- for (let index = 0; index < argv.length; index += 2) {
92
+ for (let index = 0; index < argv.length; index += 1) {
89
93
  const key = argv[index];
90
- const value = argv[index + 1];
91
- if (!key?.startsWith("--") || value === undefined) {
92
- throw new Error(`bad arguments near: ${key ?? "(end)"}`);
94
+ if (!key.startsWith("--") || key === "--") {
95
+ throw new Error(`unexpected argument: ${key}`);
96
+ }
97
+ const name = key.slice(2);
98
+ const next = argv[index + 1];
99
+ if (SWITCHES.has(name)) {
100
+ if (next === "true" || next === "false") {
101
+ flags[name] = next;
102
+ index += 1;
103
+ } else {
104
+ flags[name] = "true";
105
+ }
106
+ continue;
107
+ }
108
+ if (next === undefined || next.startsWith("--")) {
109
+ throw new Error(`${key} needs a value`);
93
110
  }
94
- flags[key.slice(2)] = value;
111
+ flags[name] = next;
112
+ index += 1;
95
113
  }
96
114
  return flags;
97
115
  }
@@ -406,6 +424,7 @@ function loadRunConfig(flags, command) {
406
424
  requires: config.requires ?? [],
407
425
  reviewTriage: config.reviewTriage === "required" ? "required" : null,
408
426
  supersede: config.supersede ?? "waiting",
427
+ parallel: config.parallel === true,
409
428
  stallAfterMs: config.stallAfterMs !== undefined ? Number(config.stallAfterMs) : DEFAULT_STALL_AFTER_MS,
410
429
  planDigest: digestOfWorkFile("plan.md"),
411
430
  intentDigest: digestOfWorkFile("intent.md"),
@@ -531,7 +550,7 @@ async function commandStart(flags) {
531
550
  console.error(`blocked by ${holder}: ${summary}`);
532
551
  console.error(` watch it: buildbeat status --repo ${label} --run ${holder}`);
533
552
  }
534
- console.error("queue position: next after the holder(s) above stop or wait on a human (the repository allows one driving run at a time; worktrees are already isolated)");
553
+ console.error("queue position: next after the holder(s) above stop or wait on a human (by default one run drives a repository at a time; works whose run configs both set parallel: true can drive together)");
535
554
  }
536
555
  throw error;
537
556
  }
@@ -829,6 +848,11 @@ function commandDoctor(flags) {
829
848
  console.log("environment contract: none declared (implicit PATH facts stay unchecked)");
830
849
  }
831
850
  console.log(`supersede: ${options.supersede} (new run for the same work ${options.supersede === "off" ? "leaves" : "supersedes"} older WAITING_HUMAN runs)`);
851
+ console.log(
852
+ options.parallel
853
+ ? "concurrency: parallel (runs of other works with parallel: true may drive at the same time; runs of this work stay exclusive; verifiers must not share fixed ports or databases)"
854
+ : "concurrency: exclusive (default: one driving run per repository; set parallel: true to drive alongside other works)",
855
+ );
832
856
  console.log(`stall threshold: ${formatMs(options.stallAfterMs)} without worker output`);
833
857
  const { config: notify, error: notifyError } = notifyConfigFor(options.repoRoot);
834
858
  if (notifyError) {
@@ -1078,7 +1102,7 @@ function commandGc(flags) {
1078
1102
  if (flags.apply !== "true") {
1079
1103
  console.log(
1080
1104
  actionable > 0
1081
- ? `plan only: ${actionable} action(s); rerun with --apply true to execute (branches whose candidate lives only there are always kept)`
1105
+ ? `plan only: ${actionable} action(s); rerun with --apply to execute (branches whose candidate lives only there are always kept)`
1082
1106
  : "nothing to collect",
1083
1107
  );
1084
1108
  return;
@@ -142,20 +142,26 @@ function parseList(lines, start, indent) {
142
142
  }
143
143
  const first = childLines[0].content;
144
144
  const quotedScalar = first.startsWith('"') || first.startsWith("'");
145
- const mapItem = !quotedScalar && MAP_ITEM.test(first);
146
- if (childLines.length === 1 && !mapItem) {
147
- // Real YAML reads "echo a: b" as a map; rather than guess, ask for quotes.
148
- if (!quotedScalar && (first.includes(": ") || first.endsWith(":"))) {
149
- throw new YamlSubsetError(
150
- `list item ${JSON.stringify(first)} contains ": "; quote it (- ${JSON.stringify(first)}) or write it as key: value`,
151
- line.lineNo,
152
- );
145
+ if (childLines.length > 1) {
146
+ // Multi-line items keep the previous rule and messages unchanged.
147
+ if (!quotedScalar && first.includes(":")) {
148
+ result.push(parseMapFromLines(childLines, itemIndent));
149
+ } else {
150
+ throw new YamlSubsetError("unsupported list item shape", line.lineNo);
153
151
  }
154
- result.push(parseScalar(first, childLines[0].lineNo));
155
- } else if (mapItem) {
152
+ index = cursor;
153
+ continue;
154
+ }
155
+ if (!quotedScalar && MAP_ITEM.test(first)) {
156
156
  result.push(parseMapFromLines(childLines, itemIndent));
157
+ } else if (!quotedScalar && (first.includes(": ") || first.endsWith(":"))) {
158
+ // Real YAML reads "echo a: b" as a map; rather than guess, ask for quotes.
159
+ throw new YamlSubsetError(
160
+ `list item ${JSON.stringify(first)} contains ": "; quote it (- ${JSON.stringify(first)}) or write it as key: value`,
161
+ line.lineNo,
162
+ );
157
163
  } else {
158
- throw new YamlSubsetError("unsupported list item shape", line.lineNo);
164
+ result.push(parseScalar(first, childLines[0].lineNo));
159
165
  }
160
166
  index = cursor;
161
167
  }
@@ -1,4 +1,4 @@
1
- # Invariant 22 (v1 lessons #3, machine form): a UI delivery's spec approval
1
+ # Invariant 22 (lessons.md「静态稿拍板 → 返工螺旋」, machine form): a UI delivery's spec approval
2
2
  # subject must include renderable proof — a screenshot with a digest —
3
3
  # before a human may stamp it. Static prose is not an approvable design.
4
4
  kind: policy
@@ -2,7 +2,7 @@
2
2
  // An approval is recorded only after re-reading the workspace and confirming
3
3
  // the subject is still exactly what the request showed — if the candidate
4
4
  // moved or the tree is dirty, the request is refreshed instead of stamped
5
- // (lessons #18: no rubber-stamping a moved target). Every decision lands both
5
+ // (lessons.md「读过期 race」: no rubber-stamping a moved target). Every decision lands both
6
6
  // as a DECISION_RECORDED event and as a line in the Git plane
7
7
  // (delivery/work/<work>/decisions.jsonl).
8
8
 
@@ -19,7 +19,14 @@ import { join } from "node:path";
19
19
  import { execFileSync } from "node:child_process";
20
20
 
21
21
  import { EventLedger } from "../storage/event-ledger.js";
22
- import { describeLockOwner, inspectLock, readback, reclaimStaleLock } from "../workspace/workspace-manager.js";
22
+ import {
23
+ describeLockOwner,
24
+ inspectLock,
25
+ isRunLockName,
26
+ readback,
27
+ reclaimStaleLock,
28
+ withRepoGitLock,
29
+ } from "../workspace/workspace-manager.js";
23
30
  import { resolveRepoRef } from "./repo-ref.js";
24
31
 
25
32
  function git(cwd, args) {
@@ -65,18 +72,25 @@ export function planGc(repoRoot) {
65
72
  const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
66
73
  const locksDir = join(repoRoot, ".buildbeat", "runtime", "locks");
67
74
  const rows = [];
68
- // The repository-wide lock belongs to no run: reclaimable only when its
69
- // owner process is provably gone (same host, pid no longer exists).
70
- const activeLock = join(locksDir, "active-run.lock");
71
- if (existsSync(activeLock)) {
72
- const seen = inspectLock(activeLock);
73
- const row = { run: "(repository)", status: "active-run lock", actions: [], keep: [] };
75
+ // Locks that belong to no single run (active-run, and the @work /
76
+ // @parallel / @repo-git locks): reclaimable only when their owner process
77
+ // is provably gone (same host, pid no longer exists).
78
+ const sharedLocks = existsSync(locksDir)
79
+ ? readdirSync(locksDir)
80
+ .filter((entry) => entry.endsWith(".lock") && !isRunLockName(entry.slice(0, -".lock".length)))
81
+ .sort()
82
+ : [];
83
+ for (const entry of sharedLocks) {
84
+ const name = entry.slice(0, -".lock".length);
85
+ const lockPath = join(locksDir, entry);
86
+ const seen = inspectLock(lockPath);
87
+ const row = { run: "(repository)", status: `${name} lock`, actions: [], keep: [] };
74
88
  if (seen.state === "dead") {
75
- row.actions.push({ kind: "remove-lock", path: activeLock, owner: seen.owner });
89
+ row.actions.push({ kind: "remove-lock", path: lockPath, owner: seen.owner });
76
90
  } else if (seen.state === "unknown") {
77
- row.keep.push("active-run lock has no owner record (older buildbeat?); remove it by hand once no buildbeat process is running");
91
+ row.keep.push(`${name} lock has no owner record (older buildbeat?); remove it by hand once no buildbeat process is running`);
78
92
  } else {
79
- row.keep.push(`active-run lock held by ${describeLockOwner(seen.owner)}${seen.state === "foreign-host" ? " (another host)" : " (still running)"}`);
93
+ row.keep.push(`${name} lock held by ${describeLockOwner(seen.owner)}${seen.state === "foreign-host" ? " (another host)" : " (still running)"}`);
80
94
  }
81
95
  rows.push(row);
82
96
  }
@@ -178,24 +192,26 @@ export function applyGc(repoRoot, rows, { force = false } = {}) {
178
192
  result.error = "worktree dirty; rerun with --force true to discard";
179
193
  continue;
180
194
  }
181
- if (action.registered) {
182
- const args = ["worktree", "remove"];
183
- if (force || action.dirty) {
184
- args.push("--force");
195
+ withRepoGitLock(repoRoot, () => {
196
+ if (action.registered) {
197
+ const args = ["worktree", "remove"];
198
+ if (force || action.dirty) {
199
+ args.push("--force");
200
+ }
201
+ args.push(action.path);
202
+ git(repoRoot, args);
203
+ } else if (action.present) {
204
+ rmSync(action.path, { recursive: true, force: true });
185
205
  }
186
- args.push(action.path);
187
- git(repoRoot, args);
188
- } else if (action.present) {
189
- rmSync(action.path, { recursive: true, force: true });
190
- }
191
- try {
192
- git(repoRoot, ["worktree", "prune"]);
193
- } catch {
194
- // prune is best-effort
195
- }
206
+ try {
207
+ git(repoRoot, ["worktree", "prune"]);
208
+ } catch {
209
+ // prune is best-effort
210
+ }
211
+ });
196
212
  result.done = true;
197
213
  } else if (action.kind === "delete-branch") {
198
- git(repoRoot, ["branch", "-D", action.branch]);
214
+ withRepoGitLock(repoRoot, () => git(repoRoot, ["branch", "-D", action.branch]));
199
215
  result.done = true;
200
216
  }
201
217
  } catch (error) {
@@ -1,6 +1,7 @@
1
1
  // buildbeat metrics v0: local, read-only, derived entirely from run ledgers.
2
- // No collection, no upload (V2-PLAN §6 / lessons #8: without numbers you are
3
- // forever doing precise work on the wrong thing).
2
+ // No collection, no upload (V2-PLAN §6; lessons.md「流程只管"怎么做对",不管
3
+ // "做的是不是对的事"」: without numbers you are forever doing precise work on
4
+ // the wrong thing).
4
5
 
5
6
  import { existsSync, readdirSync } from "node:fs";
6
7
  import { join } from "node:path";