@haiyangbg/buildbeat 0.0.0 → 1.20.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +296 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +288 -0
  4. package/README.md +283 -4
  5. package/SKILL.md +303 -0
  6. package/bin/buildbeat.js +5 -0
  7. package/bin/solobaton.js +6 -0
  8. package/docs/CAPABILITY-MATRIX.md +50 -0
  9. package/docs/CHECKS.md +326 -0
  10. package/docs/CLI-PILOT-2026-08-23.md +25 -0
  11. package/docs/CLI-STRATEGY-2026-08.md +55 -0
  12. package/docs/CLI.md +233 -0
  13. package/docs/EXECUTION-PLAN.md +487 -0
  14. package/docs/LEGACY-V1.16-MIGRATION.md +54 -0
  15. package/docs/PHASE1-PILOT-2026-08-24.md +32 -0
  16. package/docs/PHASE2-BUILDBEAT-PILOT-2026-08-25.md +75 -0
  17. package/docs/PHASE2-PILOT-2026-08-25.md +88 -0
  18. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +42 -0
  19. package/docs/PHASE4-STABILITY-AUDIT-2026-08-25.md +33 -0
  20. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +52 -0
  21. package/docs/RELEASING.md +117 -0
  22. package/docs/ROADMAP.md +873 -0
  23. package/example/.buildbeat/manifest.json +45 -0
  24. package/example/AGENTS.md +19 -0
  25. package/example/ARCHITECTURE.md +39 -0
  26. package/example/BUILDBEAT.md +17 -0
  27. package/example/CLAUDE.md +7 -0
  28. package/example/README.md +53 -0
  29. package/example/contracts/PROTOCOL.md +38 -0
  30. package/example/pm/NOW.md +22 -0
  31. package/example/pm/adr/ADR-0001-local-first-sqlite.md +25 -0
  32. package/example/pm/adr/README.md +7 -0
  33. package/example/pm/archive//344/270/200/346/234/237/evidence/gate1.md +5 -0
  34. package/example/pm/archive//344/270/200/346/234/237/evidence/gate2.md +5 -0
  35. package/example/pm/archive//344/270/200/346/234/237/evidence/gate3.md +5 -0
  36. package/example/pm/archive//344/270/200/346/234/237/evidence/gate4.md +5 -0
  37. package/example/pm/archive//344/270/200/346/234/237/evidence/implementation.md +5 -0
  38. package/example/pm/decisions.md +20 -0
  39. package/example/pm/status//344/272/247/345/223/201.md +20 -0
  40. package/example/pm/status//345/205/250/346/240/210.md +15 -0
  41. package/example/pm/status//346/265/213/350/257/225.md +15 -0
  42. package/example/pm//344/270/200/346/234/237-/347/234/213/346/235/277.md +97 -0
  43. package/example/standards/CODE.md +18 -0
  44. package/example/standards/DESIGN.md +34 -0
  45. package/example/standards/REVIEW.md +16 -0
  46. package/example/standards/STACK.md +31 -0
  47. package/lessons.md +119 -0
  48. package/package.json +48 -7
  49. package/src/cli.js +323 -0
  50. package/src/constants.js +199 -0
  51. package/src/doctor.js +267 -0
  52. package/src/planner.js +251 -0
  53. package/src/project.js +839 -0
  54. package/src/upgrader.js +1249 -0
  55. package/src/writer.js +534 -0
  56. package/templates/.claude/agents/reviewer.md +62 -0
  57. package/templates/AGENTS.md +64 -0
  58. package/templates/ARCHITECTURE.md +50 -0
  59. package/templates/BUILDBEAT.md +13 -0
  60. package/templates/CLAUDE.md +7 -0
  61. package/templates/contracts/PROTOCOL.md +32 -0
  62. package/templates/gitignore.template +19 -0
  63. package/templates/pm/NOW.md +26 -0
  64. package/templates/pm/adr/ADR-0000-template.md +25 -0
  65. package/templates/pm/adr/README.md +15 -0
  66. package/templates/pm/changes/README.md +44 -0
  67. package/templates/pm/decisions.md +12 -0
  68. package/templates/pm/status/README.md +32 -0
  69. package/templates/pm//345/275/223/346/234/237/347/234/213/346/235/277.md +62 -0
  70. package/templates/scripts/bus-check.sh +1850 -0
  71. package/templates/scripts/design-preview.sh +44 -0
  72. package/templates/scripts/drift-check.sh +112 -0
  73. package/templates/scripts/pre-commit.sh +74 -0
  74. package/templates/scripts/verify-status.sh +105 -0
  75. package/templates/standards/CODE.md +23 -0
  76. package/templates/standards/DESIGN.md +36 -0
  77. package/templates/standards/REVIEW.md +20 -0
  78. package/templates/standards/STACK.md +37 -0
  79. package/templates//346/214/207/346/214/245/345/217/260.md +35 -0
package/src/writer.js ADDED
@@ -0,0 +1,534 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ existsSync,
6
+ fsyncSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ rmdirSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import {
20
+ CLI_VERSION,
21
+ FILE_POLICIES,
22
+ GITIGNORE_BEGIN_MARKER,
23
+ GITIGNORE_END_MARKER,
24
+ GITIGNORE_MARKER_PAIRS,
25
+ MANIFEST_PATH,
26
+ MANIFEST_SCHEMA_VERSION,
27
+ RENDER_REQUIRED_PLACEHOLDERS,
28
+ SCAFFOLD_VERSION,
29
+ } from "./constants.js";
30
+ import { inspectProject, plannedFiles } from "./project.js";
31
+
32
+ const TEMPLATE_ROOT = fileURLToPath(new URL("../templates/", import.meta.url));
33
+ const COMPACT_SCRIPT_REFERENCE_TEMPLATES = new Set([
34
+ "AGENTS.md",
35
+ "ARCHITECTURE.md",
36
+ "contracts/PROTOCOL.md",
37
+ "pm/NOW.md",
38
+ "pm/status/README.md",
39
+ "pm/当期看板.md",
40
+ "指挥台.md",
41
+ ]);
42
+
43
+ export class WriteError extends Error {
44
+ constructor(code, message, cause = null) {
45
+ super(message, cause ? { cause } : undefined);
46
+ this.name = "WriteError";
47
+ this.code = code;
48
+ }
49
+ }
50
+
51
+ function sha256(bytes) {
52
+ return createHash("sha256").update(bytes).digest("hex");
53
+ }
54
+
55
+ function replaceAll(text, token, value) {
56
+ return text.split(token).join(value);
57
+ }
58
+
59
+ function stableDate(now) {
60
+ const value = now instanceof Date ? now : new Date(now);
61
+ if (Number.isNaN(value.getTime())) {
62
+ throw new WriteError("render.invalid_date", "The scaffold render date is invalid.");
63
+ }
64
+ return value;
65
+ }
66
+
67
+ function localCalendarDate(now) {
68
+ const value = stableDate(now);
69
+ const year = String(value.getFullYear()).padStart(4, "0");
70
+ const month = String(value.getMonth() + 1).padStart(2, "0");
71
+ const day = String(value.getDate()).padStart(2, "0");
72
+ return `${year}-${month}-${day}`;
73
+ }
74
+
75
+ function deterministicValues({ layout, projectName, now }) {
76
+ return new Map([
77
+ ["<项目名>", projectName || "project"],
78
+ ["<yyyy-mm-dd>", localCalendarDate(now)],
79
+ ["<X.Y>", SCAFFOLD_VERSION.replace(/^v/, "")],
80
+ ["<默认|紧凑>", layout === "compact" ? "紧凑" : "默认"],
81
+ ]);
82
+ }
83
+
84
+ function ownedGitignoreFragment(body) {
85
+ const normalizedBody = body.replace(/\r\n/g, "\n").replace(/\n*$/, "");
86
+ return Buffer.from(
87
+ `${GITIGNORE_BEGIN_MARKER}\n${normalizedBody}\n${GITIGNORE_END_MARKER}\n`,
88
+ "utf8",
89
+ );
90
+ }
91
+
92
+ export function prepareScaffold({ layout, projectName, now = new Date() }) {
93
+ const values = deterministicValues({ layout, projectName, now });
94
+ const files = [];
95
+ const renderedPlaceholders = [];
96
+ const pendingPlaceholders = [];
97
+ let gitignore = null;
98
+
99
+ for (const item of plannedFiles(layout)) {
100
+ const source = path.join(TEMPLATE_ROOT, item.template);
101
+ let text = readFileSync(source, "utf8");
102
+ for (const [token, value] of values) {
103
+ if (text.includes(token)) {
104
+ text = replaceAll(text, token, value);
105
+ renderedPlaceholders.push({ path: item.target, token, value });
106
+ }
107
+ }
108
+ if (
109
+ layout === "compact" &&
110
+ COMPACT_SCRIPT_REFERENCE_TEMPLATES.has(item.template) &&
111
+ text.includes("scripts/")
112
+ ) {
113
+ text = replaceAll(text, "scripts/", "pm/scripts/");
114
+ renderedPlaceholders.push({
115
+ path: item.target,
116
+ token: "scripts/",
117
+ value: "pm/scripts/",
118
+ });
119
+ }
120
+
121
+ const pendingTokens = (RENDER_REQUIRED_PLACEHOLDERS[item.template] || [])
122
+ .filter((token) => text.includes(token));
123
+ if (pendingTokens.length > 0) {
124
+ pendingPlaceholders.push({ path: item.target, tokens: pendingTokens });
125
+ }
126
+
127
+ const mode = lstatSync(source).mode & 0o777;
128
+ if (item.policy === FILE_POLICIES.MERGE_ONLY) {
129
+ const fragment = ownedGitignoreFragment(text);
130
+ gitignore = {
131
+ ...item,
132
+ fragment,
133
+ baselineSha256: sha256(fragment),
134
+ mode,
135
+ };
136
+ } else {
137
+ const content = Buffer.from(text, "utf8");
138
+ files.push({
139
+ ...item,
140
+ content,
141
+ baselineSha256: sha256(content),
142
+ mode,
143
+ });
144
+ }
145
+ }
146
+
147
+ renderedPlaceholders.sort((a, b) =>
148
+ a.path.localeCompare(b.path) || a.token.localeCompare(b.token),
149
+ );
150
+ pendingPlaceholders.sort((a, b) => a.path.localeCompare(b.path));
151
+ if (gitignore === null) {
152
+ throw new WriteError("integration.gitignore_missing", "The bundled gitignore template is missing.");
153
+ }
154
+ return { files, gitignore, renderedPlaceholders, pendingPlaceholders };
155
+ }
156
+
157
+ function ensureDirectory(directory, createdDirectories) {
158
+ if (existsSync(directory)) {
159
+ const stat = lstatSync(directory);
160
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
161
+ throw new WriteError("path.unsafe", `A required directory path is not a real directory: ${directory}`);
162
+ }
163
+ return;
164
+ }
165
+ const parent = path.dirname(directory);
166
+ if (parent === directory) {
167
+ throw new WriteError("path.unsafe", `Cannot create target directory: ${directory}`);
168
+ }
169
+ ensureDirectory(parent, createdDirectories);
170
+ try {
171
+ mkdirSync(directory, { mode: 0o755 });
172
+ createdDirectories.push(directory);
173
+ } catch (error) {
174
+ if (error.code === "EEXIST") {
175
+ ensureDirectory(directory, createdDirectories);
176
+ return;
177
+ }
178
+ throw error;
179
+ }
180
+ }
181
+
182
+ function assertRelativeTarget(relative) {
183
+ if (
184
+ typeof relative !== "string" ||
185
+ relative.length === 0 ||
186
+ relative.includes("\\") ||
187
+ path.posix.isAbsolute(relative) ||
188
+ path.win32.isAbsolute(relative) ||
189
+ path.posix.normalize(relative) !== relative ||
190
+ relative.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
191
+ ) {
192
+ throw new WriteError("path.unsafe", `Unsafe scaffold target path: ${relative}`);
193
+ }
194
+ }
195
+
196
+ function ensureTargetParent(target, relative, createdDirectories) {
197
+ assertRelativeTarget(relative);
198
+ ensureDirectory(path.dirname(path.join(target, ...relative.split("/"))), createdDirectories);
199
+ }
200
+
201
+ function fsyncDirectory(directory) {
202
+ if (process.platform === "win32") {
203
+ return;
204
+ }
205
+ const descriptor = openSync(directory, "r");
206
+ try {
207
+ fsyncSync(descriptor);
208
+ } finally {
209
+ closeSync(descriptor);
210
+ }
211
+ }
212
+
213
+ function atomicWrite(
214
+ filename,
215
+ bytes,
216
+ mode,
217
+ { overwrite = false, nextTempId, onRenamed = null },
218
+ ) {
219
+ const parent = path.dirname(filename);
220
+ const basename = path.basename(filename);
221
+ let temp = null;
222
+ let descriptor = null;
223
+ try {
224
+ for (let attempt = 0; attempt < 100; attempt += 1) {
225
+ const candidate = path.join(
226
+ parent,
227
+ `.${basename}.buildbeat-${process.pid}-${nextTempId()}-${attempt}.tmp`,
228
+ );
229
+ try {
230
+ descriptor = openSync(candidate, "wx", mode);
231
+ temp = candidate;
232
+ break;
233
+ } catch (error) {
234
+ if (error.code !== "EEXIST") {
235
+ throw error;
236
+ }
237
+ }
238
+ }
239
+ if (descriptor === null || temp === null) {
240
+ throw new WriteError("write.temp_unavailable", `Could not allocate a temporary sibling for ${filename}.`);
241
+ }
242
+ writeFileSync(descriptor, bytes);
243
+ fsyncSync(descriptor);
244
+ closeSync(descriptor);
245
+ descriptor = null;
246
+ chmodSync(temp, mode);
247
+
248
+ if (!overwrite && existsSync(filename)) {
249
+ throw new WriteError("files.collide", `Destination appeared during the write transaction: ${filename}`);
250
+ }
251
+ if (overwrite) {
252
+ const current = lstatSync(filename);
253
+ if (!current.isFile() || current.isSymbolicLink()) {
254
+ throw new WriteError("integration.gitignore_unsafe", ".gitignore is no longer a regular file.");
255
+ }
256
+ }
257
+ renameSync(temp, filename);
258
+ temp = null;
259
+ if (typeof onRenamed === "function") {
260
+ onRenamed();
261
+ }
262
+ fsyncDirectory(parent);
263
+ } finally {
264
+ if (descriptor !== null) {
265
+ closeSync(descriptor);
266
+ }
267
+ if (temp !== null) {
268
+ try {
269
+ unlinkSync(temp);
270
+ } catch (error) {
271
+ if (error.code !== "ENOENT") {
272
+ throw error;
273
+ }
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ function markerCount(text, marker) {
280
+ return text.split(marker).length - 1;
281
+ }
282
+
283
+ function assertGitignoreBytesSafe(bytes) {
284
+ const text = bytes.toString("utf8");
285
+ if (GITIGNORE_MARKER_PAIRS.some(
286
+ ([beginMarker, endMarker]) =>
287
+ markerCount(text, beginMarker) !== 0 || markerCount(text, endMarker) !== 0,
288
+ )) {
289
+ throw new WriteError(
290
+ "integration.gitignore_fragment_present",
291
+ "A BuildBeat or legacy Solobaton .gitignore marker already exists without a valid schema 2 ownership record.",
292
+ );
293
+ }
294
+ }
295
+
296
+ function mergeGitignore(existing, fragment) {
297
+ if (existing.length === 0) {
298
+ return fragment;
299
+ }
300
+ const separator = existing.at(-1) === 0x0a ? Buffer.from("\n") : Buffer.from("\n\n");
301
+ return Buffer.concat([existing, separator, fragment]);
302
+ }
303
+
304
+ function preflight(plan) {
305
+ if (plan.preview || plan.writesPerformed || !["init", "adopt"].includes(plan.command)) {
306
+ throw new WriteError("write.invalid_plan", "Only a ready init/adopt apply plan can be written.");
307
+ }
308
+ let inspection;
309
+ try {
310
+ inspection = inspectProject(plan.target, {
311
+ collisionLayout: plan.layout,
312
+ includeDependencies: false,
313
+ });
314
+ } catch (error) {
315
+ throw new WriteError("target.unsafe", error.message, error);
316
+ }
317
+ if (plan.command === "adopt" && !inspection.exists) {
318
+ throw new WriteError("target.not_found", "Brownfield adoption requires an existing project directory.");
319
+ }
320
+ if (inspection.installation.state !== "not-installed") {
321
+ throw new WriteError(
322
+ `install.${inspection.installation.state.replaceAll("-", "_")}`,
323
+ "The target is already installed, partial, or mixed; no write was attempted.",
324
+ );
325
+ }
326
+ if (inspection.manifest.state !== "missing") {
327
+ throw new WriteError(
328
+ "manifest.already_present",
329
+ "A lifecycle manifest already exists or is unreadable; ownership cannot be inferred.",
330
+ );
331
+ }
332
+ if (inspection.collisions.length > 0) {
333
+ throw new WriteError(
334
+ "files.collide",
335
+ `${inspection.collisions.length} destination path(s) collide with existing project content.`,
336
+ );
337
+ }
338
+ if (inspection.gitWorktree.state === "dirty") {
339
+ throw new WriteError("git.dirty", "The target-root Git worktree is not clean.");
340
+ }
341
+ if (inspection.gitWorktree.state === "unavailable") {
342
+ throw new WriteError("git.status_unavailable", "The target has a root .git entry, but Git status failed.");
343
+ }
344
+ if (inspection.gitignore.state === "unsafe") {
345
+ throw new WriteError("integration.gitignore_unsafe", ".gitignore is not a readable regular file.");
346
+ }
347
+ if (inspection.gitignore.beginMarkers > 0 || inspection.gitignore.endMarkers > 0) {
348
+ throw new WriteError(
349
+ "integration.gitignore_fragment_present",
350
+ "A BuildBeat or legacy Solobaton .gitignore marker already exists without a valid schema 2 ownership record.",
351
+ );
352
+ }
353
+ return inspection;
354
+ }
355
+
356
+ function rollback({ createdFiles, createdDirectories, gitignoreBackup, gitignoreModified, nextTempId }) {
357
+ const failures = [];
358
+ for (const filename of [...createdFiles].reverse()) {
359
+ try {
360
+ unlinkSync(filename);
361
+ } catch (error) {
362
+ if (error.code !== "ENOENT") {
363
+ failures.push(`${filename}: ${error.code || error.message}`);
364
+ }
365
+ }
366
+ }
367
+ if (gitignoreModified && gitignoreBackup !== null) {
368
+ try {
369
+ atomicWrite(
370
+ gitignoreBackup.path,
371
+ gitignoreBackup.bytes,
372
+ gitignoreBackup.mode,
373
+ { overwrite: true, nextTempId },
374
+ );
375
+ } catch (error) {
376
+ failures.push(`${gitignoreBackup.path}: ${error.code || error.message}`);
377
+ }
378
+ }
379
+ for (const directory of [...createdDirectories].reverse()) {
380
+ try {
381
+ rmdirSync(directory);
382
+ } catch (error) {
383
+ if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") {
384
+ failures.push(`${directory}: ${error.code || error.message}`);
385
+ }
386
+ }
387
+ }
388
+ return failures;
389
+ }
390
+
391
+ export function applyScaffold(plan, { now = new Date(), faultInjector = null } = {}) {
392
+ const inspection = preflight(plan);
393
+ const rendered = prepareScaffold({
394
+ layout: plan.layout,
395
+ projectName: inspection.projectName.value,
396
+ now,
397
+ });
398
+ const target = path.resolve(plan.target);
399
+ const createdFiles = [];
400
+ const createdDirectories = [];
401
+ let gitignoreBackup = null;
402
+ let gitignoreModified = false;
403
+ let tempId = 0;
404
+ const nextTempId = () => {
405
+ tempId += 1;
406
+ return tempId;
407
+ };
408
+ const maybeFault = (phase, relative) => {
409
+ if (typeof faultInjector === "function") {
410
+ faultInjector({ phase, path: relative, writes: createdFiles.length });
411
+ }
412
+ };
413
+
414
+ try {
415
+ ensureDirectory(target, createdDirectories);
416
+
417
+ for (const item of rendered.files) {
418
+ ensureTargetParent(target, item.target, createdDirectories);
419
+ const filename = path.join(target, ...item.target.split("/"));
420
+ atomicWrite(filename, item.content, item.mode, {
421
+ nextTempId,
422
+ onRenamed: () => createdFiles.push(filename),
423
+ });
424
+ maybeFault("file", item.target);
425
+ }
426
+
427
+ const gitignorePath = path.join(target, ".gitignore");
428
+ ensureTargetParent(target, ".gitignore", createdDirectories);
429
+ let existingGitignore = Buffer.alloc(0);
430
+ let gitignoreMode = rendered.gitignore.mode || 0o644;
431
+ const gitignoreExisted = existsSync(gitignorePath);
432
+ if (gitignoreExisted) {
433
+ const stat = lstatSync(gitignorePath);
434
+ if (!stat.isFile() || stat.isSymbolicLink()) {
435
+ throw new WriteError("integration.gitignore_unsafe", ".gitignore is not a regular file.");
436
+ }
437
+ existingGitignore = readFileSync(gitignorePath);
438
+ assertGitignoreBytesSafe(existingGitignore);
439
+ gitignoreMode = stat.mode & 0o777;
440
+ gitignoreBackup = { path: gitignorePath, bytes: existingGitignore, mode: gitignoreMode };
441
+ }
442
+ atomicWrite(
443
+ gitignorePath,
444
+ mergeGitignore(existingGitignore, rendered.gitignore.fragment),
445
+ gitignoreMode,
446
+ {
447
+ overwrite: gitignoreExisted,
448
+ nextTempId,
449
+ onRenamed: () => {
450
+ if (gitignoreExisted) {
451
+ gitignoreModified = true;
452
+ } else {
453
+ createdFiles.push(gitignorePath);
454
+ }
455
+ },
456
+ },
457
+ );
458
+ maybeFault("gitignore", ".gitignore");
459
+
460
+ const manifest = {
461
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
462
+ scaffoldVersion: SCAFFOLD_VERSION,
463
+ cliVersion: CLI_VERSION,
464
+ layout: plan.layout,
465
+ installedAt: stableDate(now).toISOString(),
466
+ files: Object.fromEntries(
467
+ rendered.files.map((item) => [
468
+ item.target,
469
+ { policy: item.policy, baselineSha256: item.baselineSha256 },
470
+ ]),
471
+ ),
472
+ integrations: {
473
+ gitignore: {
474
+ path: ".gitignore",
475
+ beginMarker: GITIGNORE_BEGIN_MARKER,
476
+ endMarker: GITIGNORE_END_MARKER,
477
+ baselineSha256: rendered.gitignore.baselineSha256,
478
+ },
479
+ hooks: null,
480
+ },
481
+ };
482
+ ensureTargetParent(target, MANIFEST_PATH, createdDirectories);
483
+ maybeFault("before-manifest", MANIFEST_PATH);
484
+ const manifestFilename = path.join(target, ...MANIFEST_PATH.split("/"));
485
+ atomicWrite(
486
+ manifestFilename,
487
+ Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
488
+ 0o644,
489
+ {
490
+ nextTempId,
491
+ onRenamed: () => createdFiles.push(manifestFilename),
492
+ },
493
+ );
494
+
495
+ return {
496
+ ...plan,
497
+ preview: false,
498
+ writesPerformed: true,
499
+ targetExists: true,
500
+ detected: {
501
+ ...plan.detected,
502
+ projectName: inspection.projectName,
503
+ },
504
+ writtenPaths: [
505
+ ...rendered.files.map((item) => item.target),
506
+ ".gitignore",
507
+ MANIFEST_PATH,
508
+ ],
509
+ renderedPlaceholders: rendered.renderedPlaceholders,
510
+ pendingPlaceholders: rendered.pendingPlaceholders,
511
+ manifestPath: MANIFEST_PATH,
512
+ ready: true,
513
+ };
514
+ } catch (error) {
515
+ const rollbackFailures = rollback({
516
+ createdFiles,
517
+ createdDirectories,
518
+ gitignoreBackup,
519
+ gitignoreModified,
520
+ nextTempId,
521
+ });
522
+ if (rollbackFailures.length > 0) {
523
+ throw new WriteError(
524
+ "rollback.incomplete",
525
+ `Write failed and rollback was incomplete: ${rollbackFailures.join("; ")}`,
526
+ error,
527
+ );
528
+ }
529
+ if (error instanceof WriteError) {
530
+ throw error;
531
+ }
532
+ throw new WriteError("write.failed", `Scaffold write failed and was rolled back: ${error.message}`, error);
533
+ }
534
+ }
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: reviewer
3
+ description: review-ready 后单次静默执行的只读审查专家。按 milestone / risk-delta / closure 三种模式核查;未稳定候选返回 NOT_READY,审查中 hash 改变返回 SUPERSEDED。
4
+ tools: Read, Grep, Glob, Bash
5
+ ---
6
+
7
+ 你是 <项目名> 的资深独立审查者。**写者≠审者**是质量硬约束 —— 你只读、只出问题清单,**绝不修改代码**。
8
+
9
+ > `Bash` 只用于读取候选事实(`git status --short` / `git rev-parse` / `git diff` / `git show` / `git log`)和运行项目已经声明的本地验证命令。禁止 checkout / switch / restore / clean / add / commit / push,禁止安装依赖、联网、部署或用 shell 写文件;命令可能改工作树时不要运行。
10
+
11
+ > **单次静默核查**:除输入缺失或真实阻塞外,不要向主会话发送中间进度、分批 findings 或“仍在审”更新;完成整个 scope 后,先重新读取每个候选仓的 `HEAD` 与 `git status --short`,确认 candidate 未变且仍干净,再一次返回最终正文。发生变化只回 `SUPERSEDED`,避免一轮审查制造多轮 reviewer 状态噪音。
12
+
13
+ ## 背景
14
+ - 项目用「协作总线」多域协作;全栈域可能同时持有契约两端 → **你是契约自审风险的补偿防线**。
15
+ - 唯一契约入口 = `contracts/PROTOCOL.md`;当期需求/设计在 `pm/NOW.md` 指向的看板 + `pm/changes/`。
16
+ - 核查门按**风险批次 + 里程碑候选**运行,不是每个小任务都重跑一次完整审查。轻量机器闸每次提交,受影响测试按变更批次,全量测试绑定里程碑候选。
17
+
18
+ ## 调用输入(先核这五项)
19
+
20
+ 主会话应给出:
21
+
22
+ 1. `mode`: `milestone` / `risk-delta` / `closure`。
23
+ 2. `candidate`:精确 commit hash;多仓则给 hash 集。`milestone` 没有 candidate → 只能判「候选未固定」,不得给可合并结论。
24
+ 3. `base`:`risk-delta` 必填,审查范围固定为 `base..candidate`;`closure` 改填 finding ID + 修复 hash。
25
+ 4. `scope`:需求/设计/契约/提案/证据入口。能从 NOW 自查到的不要反问人。
26
+ 5. `review_ready`:`milestone` 必须给出以下四项证据,缺一项就只返回 `NOT_READY`,不展开审查:
27
+ - 工作包内实现与写者自查已完成,没有已知待修项或计划中的 candidate 修改;
28
+ - 每个候选仓 `HEAD=candidate`,且 `git status --short` 为空;
29
+ - 受影响/全量自动化达到项目要求的 L3,有 UI 时真渲染证据已就绪;
30
+ - candidate 与证据指针已固定,reviewer 期间主会话不会继续修改候选。
31
+
32
+ ### 三种模式
33
+
34
+ - **`milestone`**:只对 review-ready 的稳定候选 hash 集做一次完整四方核查;默认唯一会产生完整报告的模式。写者自查尚在发现/修复问题时不得启动。
35
+ - **`risk-delta`**:只核修改已冻结对外契约、里程碑完成后新出现的鉴权/租户/Secret/fail-closed/持久化语义或不可逆外部副作用;写者在首次 milestone 前自行发现并收敛的问题不是 risk-delta。结论只覆盖该 delta,**不得外推为整体候选通过**。
36
+ - **`closure`**:主会话应先把首轮 P0/P1 全部合并修完,再一次复核指定 finding 集与必要回归;不得按 finding 逐个调用,不得复制首轮整份背景或重新发散无关 P2。
37
+
38
+ 若同一 candidate 已有 milestone 结论且 hash 未变、机器证据仍绿,直接指出「复用既有结论」;**不要为再放心一次重复全审**。审查返回前 candidate 有变化,立即返回 `SUPERSEDED: <old> -> <new>` 并停止,不得继续核旧 hash 或把写者自发现的连续修补包装成 delta 链;主会话重新满足 review-ready 后再提交替代 milestone。milestone 已完成后,指定 finding 修复走一次合并 `closure`,真正新增的高风险语义走 `risk-delta`;无关改动混进 closure 时判范围不纯。最终候选由「原 milestone 结论 + 后续必要的 delta/closure」覆盖,不把旧 hash 的结论冒充新 hash 结论。
39
+
40
+ ## 审什么(按此顺序,逐条给证据)
41
+ 1. **四方一致**:实现 ↔ 设计稿(`design/`)↔ 契约(`contracts/PROTOCOL.md`)↔ 需求(当期 spec/提案)。任一方对不上 = 问题。
42
+ 2. **契约两端**:调用方与实现方是否都符合 PROTOCOL(字段名/类型/鉴权/错误码)。同会话改两端最易绕过核查——重点盯。
43
+ 3. **架构违规 / 重复造轮子 / 边界条件**。
44
+ 4. **安全**:鉴权服务端强制(非按钮隐藏)、凭据不落盘、注入/SSRF、多用户数据隔离。
45
+ 5. **四态**(有 UI 时):加载 / 空 / 错误 / 移动端是否都处理。
46
+ 6. **界面零元注释**(有 UI 时):可见界面无调试信息 / 口径解释脚注 / 字段说明 / mock 标记 / 开发者自留文案;发现通常判 P1。
47
+
48
+ ## 输出
49
+ - 头部固定列出:`mode` / `base` / `candidate`(多仓 hash 集)/ scope / 证据状态(已复跑 / 只读核验 / 未复跑及原因)。
50
+ - `milestone` 头部另列 review-ready 四项;任一不成立就只输出 `NOT_READY + 缺项`,不产生 P0/P1/P2 报告。
51
+ - 按严重度排序:🔴 P0(阻塞)/ 🟡 P1(阻塞)/ 🟢 P2(默认挂账,不触发一轮完整复核;项目立项时显式升格的除外)。
52
+ - **每条带可核验证据**:`文件:行` / 契约条目 / 复现步骤。无证据的猜测标「待核」,不混入结论。
53
+ - 不照单全收上游说法:宁可标「未确认」也不臆断。
54
+ - 首轮 milestone 报告只保留一份问题原文;主会话合并修完 P0/P1 后,closure 一次追加下表,不重述全文:
55
+
56
+ | Finding | 修复 hash | 复核范围 | 结果 |
57
+ |---|---|---|---|
58
+
59
+ - `milestone` 结尾给一句:□ 可合并 □ 修 P0/P1 再合 □ 不通过。
60
+ - `risk-delta` / `closure` 结尾给一句:□ 本 delta/finding 已关闭 □ 仍阻塞;并明确「不代表整体候选通过」。
61
+
62
+ 交回主会话,由人在 Gate3 决定是否合并。**你不合并、不放行。**
@@ -0,0 +1,64 @@
1
+ # AGENTS.md — <项目名> 工作区 · 工作包路由 + 协作总线
2
+
3
+ > 本文件走开放标准 `AGENTS.md`,被工作区下**任意会话**自动装载(Claude Code / Cursor / Codex / Gemini CLI / Aider / Zed 等均认)。目的:每个会话开工即知道「当前工作包 / AI 视角 / 读哪 / 写哪」,不靠人转述上下文。
4
+ > **层叠规则**(标准语义):会话从被编辑文件所在目录向上收集沿途所有 `AGENTS.md` 合并,**离得越近优先级越高**。所以本文件只写全局的(路由 / 总线规则 / 红线),各代码子仓的局部细节写进**该仓自己的 `AGENTS.md`**,别往上堆。
5
+ > 全栈总图(架构/基础设施/凭据位置)见 `./ARCHITECTURE.md`,按需读、别全文灌(重)。
6
+ > 根目录的 `CLAUDE.md` 只是一行指针(兼容只认该文件名的工具),**内容单点在本文件**,别往那份里复制任何规则。
7
+
8
+ ## 1. 工作包路由 —— Builder 端到端负责,会话按 AI 视角隔离
9
+
10
+ > 协作单元是需求/功能工作包。一个 Builder 对工作包的产品判断、实现、测试、合并和发布证据端到端负责;下表只是可调用的 AI 专业视角与文件写边界,不是人类岗位或审批链。多个 Builder 默认认领不同工作包,共享事实仍走 Git 文件总线。
11
+
12
+ | AI 视角 | cwd | 可写(拥有) | 只读 | 开工先读 | 状态写回 |
13
+ |---|---|---|---|---|---|
14
+ | **产品**(规格/编排) | `pm/` | `pm/**` | 全仓 | `pm/NOW.md` | 当期看板 + `pm/status/产品.md` |
15
+ | **全栈**(实现,含运维) | 工作区根(同持 <N> 个代码仓) | `<代码仓1>/**` + `<代码仓2>/**`(**按仓分别 stage,不 `git add -A`**) | `pm/*` 当期文件、契约 | 各代码仓自己的 `AGENTS.md` + `pm/NOW.md` | `pm/status/全栈.md`(带 hash)+ 各仓 `CHANGELOG.md` + `contracts/PROTOCOL.md` |
16
+ | **测试**(E2E·走查) | `<被测仓>/` | `tests/**` · 视觉基线 · 走查报告 | 实现 + spec + 设计稿 + 契约 | `pm/NOW.md` + `tests/README.md` | `pm/status/测试.md` + 核查门证据(E2E 报告/视觉 diff/对比图,**落 `pm/archive/<期>/evidence/`,换期零搬运**) |
17
+
18
+ > **设计生成 = 外部工具**(非会话):产品视角写 brief(必须要求**单 HTML 可渲染入口 + 关键流可点**)→ 人喂设计工具 → 稿落 `design/design_N期/` → 测试视角走查 + 提带图 bug。
19
+ > 🔴 **同一 Builder 合并多视角的补偿控制**:里程碑候选必须由 reviewer subagent(只读,见 `.claude/agents/reviewer.md`)全核 + 测试视角独立核两端;实现中出现冻结契约/鉴权/租户/Secret/fail-closed/持久化或不可逆副作用变化时做 `risk-delta` 定向核。写者≠审者不变,但不按每个草稿或小任务重复全审。
20
+ > **开工/收工护栏**:任意会话开工**先跑 `bash scripts/bus-check.sh`** + 各仓 `git pull`;收工前回写证据/状态后再跑 `bus-check --strict`,并保留 warning/unverified 边界。
21
+
22
+ ## 1.5 UI 规范摘要(非 UI 项目可删)
23
+
24
+ > 项目若启用可选规范,完整设计原则、token、组件、交互、状态与可访问性单点见 `standards/DESIGN.md`;本文件只保留开工必读摘要,不复制正文。该文件缺失不报错。
25
+
26
+ - 延用既定设计语言与 token;每个可见流程处理 loading / empty / error / disabled / 适用的移动端状态,关键操作提供明确且可访问的反馈。
27
+ - 上线界面零调试信息、实现说明、mock 标记或开发者元注释;Gate2 与终签都以真渲染可点结果走查,静态稿或规范数值不能替代。
28
+
29
+ ## 2. 协作总线十条规则
30
+
31
+ **① 唯一看板指针** —— 入口永远是 `pm/NOW.md`,它指向当期看板;**换期只改 NOW 一处,看板文件名不得写死进本文件或其它文档**。
32
+ **② 契约落盘不喊话** —— 跨边界接口先改 `contracts/PROTOCOL.md` 再动代码;收到协议声明**独立核查再信**(实测/读代码/查部署配置),不照单全收。
33
+ **③ 交接靠 commit + 落盘** —— 工作包完成或到真实阻塞点 → 状态行带**交付候选/报告** commit hash,下游读 repo 即知进度;hash 不是 status 行自身 commit(禁止自引用)。原子 commit 可以多次,但不为每个 commit 单独收尾和打断人;尚无新候选就写已核基线 hash +「无新候选」,不得编 hash。
34
+ **④ 开工 + 收工护栏** —— 开工先 `bash scripts/bus-check.sh` + 各仓 `git pull`;**部署/改契约/migration 等不可逆动作前再跑一次**。收工前跑受影响测试,回写 contracts/decisions/看板/status/证据,再跑 `bus-check --strict`;它的 exit 0 不消除 warning/unverified。pre-commit 挂同一 strict 机器闸(见 `scripts/pre-commit.sh`)。
35
+ **⑤ 三轨制** —— 快轨(小改:直接改+核查门)/ 标准轨(单功能全流程)/ 重轨(契约变更/大改:+`pm/changes/` 提案+多 agent 评审);NOW 标本期轨道。工作包默认取一条可独立验收的纵向结果或下一个 Gate/里程碑候选,不按文件、commit 或验收条目拆会话。
36
+ **⑥ 核查门(review-ready + 一次候选核查)** —— 轻量机器闸每次提交都跑,受影响自动化测试按变更批次跑。**首次 milestone reviewer 只能在 review-ready 后启动**:工作包内实现与写者自查已完成;所有候选仓 `HEAD=candidate` 且工作树干净;受影响/全量 L3 与真渲染证据已绿;没有已知待修项或计划中的 candidate 修改。此前发现的鉴权/租户/Secret/fail-closed/持久化等实现问题统一记入实现语义清单并先自行收敛,**不得边改 candidate 边开 reviewer**;只有要修改已冻结对外契约或产生不可逆外部副作用才 `STOP_NOW`,批准后按一个风险批次做 `risk-delta`。默认每个工作包、每道 Gate 只启动 **1 次 milestone**;P0/P1 全部修完后再做 **1 次合并 closure**,P2 不复核。reviewer 返回前 candidate 若变化,原审查立即标 `SUPERSEDED`,不得把写者自发现的连续修补包装成 delta 链;重新满足 review-ready 后才启动替代 milestone。已完成 milestone 后出现新的高风险语义变化才核 `risk-delta`;同一 candidate 且机器证据仍绿直接复用。首轮报告保留原文,closure 只追加表。**完成 = hash + 可核验证据**;标准轨最低 L3,重轨与上线必须 L4,L1 只作补充定位。
37
+ **⑦ 变更提案 + 状态分写** —— 跨工作包/共享边界变更走 `pm/changes/` delta 提案;**各 AI 视角只写自己的 `pm/status/{视角}.md`**,别人只读。状态按工作包/里程碑批量更新,不为每个子产物另起一次交接。
38
+ **⑧ 视觉问题带图对比** —— 提 UI bug / 判设计符合性必附『实现截图 ⟷ 设计稿截图』并排 + 标注差异点;纯文字不算证据。
39
+ **⑨ 单点事实** —— 线上版本只信 `bus-check` 实查(任何文档不写"当前线上 vX",契约快照版本仅 `PROTOCOL.md` 头部);多仓项目的 repo/契约/本地部署基线 app 关系只写在 `PROTOCOL.md` 的 `buildbeat-multirepo-map:v1`,不从自然语言猜;每个收敛后的**真实决策包**只在 `pm/decisions.md` 记一行并回写落点,验收条目/推导结论/部分对话进度不单独记拍板;换期必跑压缩仪式(NOW 底部 checklist),**NOW 永远是薄指针、禁堆流水**。
40
+ **⑩ Gate2 真渲染拍板** —— 设计拍板对象必须是真渲染可点原型(`bash scripts/design-preview.sh <期号>`);静态稿/截图只作参考。终签同样含真渲染走查。
41
+
42
+ > **元原则:能实查的不问人** —— 查代码 / 配置 / 部署平台能得到的事实,不拿去问用户、不信文档、不信上游转述(规则⑨与②的推广)。
43
+
44
+ ## 2.5 任务包与人批节奏
45
+
46
+ **任务包信封** —— 多步骤工作开工时,从用户目标与当期看板明确 `objective / in_scope / terminal_condition`。需求 ID、验收项、文档和原子 commit 可以细分,但只是追踪单位;默认一个工作包覆盖多个子项。一个工作包可由多个域按写边界接力,多个独立目标也可并行,但每个会话同时只认领一个工作包。只要仍有安全、可逆、在 `in_scope` 内且能推进 `objective` 的工作,会话就继续做。单个文档提交、reviewer 返回、status 回写或普通 P2 只报中间进展,**不得因此结束任务等用户说“继续”**。只在以下三种情况结束:目标带证据完成;遇到必须由人处理的真实阻塞;用户明确只要阶段性检查点。
47
+
48
+ **审批三级**:
49
+
50
+ 1. **STOP_NOW 立即停**:跨 Gate;扩大已批准范围或重开 non-goal;修改已冻结对外契约;部署/发布/花费/删除等不可逆外部动作;接受安全/合规风险;权威事实冲突且无法实查。停在动作前,一次给推荐方案、影响和最小问题。
51
+ 2. **BATCH_AT_GATE 门前批**:冻结前可逆草案选择,或批准目标内的默认值/阈值/失败态归类/实现语义。先写进当期看板「决策收件箱」,继续不依赖它的工作;到 Gate 或约定节奏一次提交**默认 2–5 个真实取舍**(确实只有 1 个就单项),每项带推荐值和后果。若阻塞关键路径,也要合并成一次提问,不得逐条连环问。
52
+ 3. **NO_APPROVAL 无需批**:能实查的事实、已批准信封内的派生约束、文案/归档/status/证据整理、普通 P2、不改变外部语义的可逆实现细节。自主完成并说明,不把告知包装成审批。
53
+
54
+ **人批预算** —— 默认每个工作包、每道 Gate 只有 1 个 `BATCH_AT_GATE` 请求;`STOP_NOW` 是越界例外。用户只回答一部分或要求解释时,保持同一决策包编号,补充说明并更新收件箱,不得包装成一轮新审批。只有当未决项真实阻塞关键路径时才回到同一包追问;否则继续范围内工作。
55
+
56
+ **决策包口径** —— 验收清单先拆成「人必须取舍的独立决策变量」和「由已选变量/现有契约推导的约束」;只把前者送人批。用户分轮回答时,未收敛项留在看板决策收件箱;收敛后按决策包在 `pm/decisions.md` 记一次,不得为 `3/14 → 11/14 → 14/14` 之类部分进度制造三条永久拍板。
57
+
58
+ ## 3. 红线(每个会话受约束)
59
+
60
+ 1. **凭据不入 git、不出本机**:文档只标位置不写值;本地 .env 必须 gitignore + 600 权限;机器闸 = gitleaks pre-commit 默认装(`scripts/pre-commit.sh`),报警即拦。
61
+ 2. **不 `git add -A`**:只 stage 自己域的具体文件;同持多仓按仓分别提交。
62
+ 3. **不未授权部署**、不 force-push、不 `--amend`、不 `--no-verify`。
63
+ 4. **每次部署完必更对应仓 `CHANGELOG.md`**;长连接服务部署带优雅下线(<PreStop/drain 机制>)。
64
+ 5. **写者≠审者**:review-ready 的里程碑候选必过独立 reviewer;写者自发现问题先收敛,不把未稳定候选反复送审。同一 candidate 复用结论,P0/P1 默认合并修复后只做一次 closure。