@haiyangbg/buildbeat 2.0.1 → 3.0.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 (127) hide show
  1. package/CHANGELOG.md +24 -301
  2. package/README.en.md +6 -19
  3. package/README.md +6 -19
  4. package/SKILL.md +168 -219
  5. package/bin/buildbeat.js +14 -2
  6. package/docs/CAPABILITY-MATRIX.md +13 -55
  7. package/docs/README.md +14 -14
  8. package/docs/RELEASING.md +8 -9
  9. package/docs/v2/RFC-0001-product-definition.md +2 -0
  10. package/docs/v2/RFC-0003-workflow-policy.md +2 -0
  11. package/docs/v2/guide/00-how-to-talk.md +3 -3
  12. package/docs/v2/guide/01-quickstart.md +12 -12
  13. package/docs/v2/guide/03-policy-guide.md +1 -1
  14. package/docs/v2/guide/06-evidence-guide.md +3 -3
  15. package/docs/v2/guide/07-approval-guide.md +10 -10
  16. package/docs/v2/guide/10-recovery.md +6 -6
  17. package/docs/v2/guide/11-session-handoff.en.md +2 -2
  18. package/docs/v2/guide/11-session-handoff.md +2 -2
  19. package/docs/v2/guide/README.md +0 -6
  20. package/lessons.md +52 -71
  21. package/package.json +16 -8
  22. package/src/v2/cli/run.js +33 -23
  23. package/src/v2/engine/risk-preset.js +1 -1
  24. package/src/v2/runtime/notify.js +5 -5
  25. package/src/v2/runtime/overview.js +7 -7
  26. package/templates/ARCHITECTURE.md +1 -1
  27. package/templates/contracts/PROTOCOL.md +2 -10
  28. package/templates/gitignore.template +0 -3
  29. package/templates/pm/adr/README.md +1 -1
  30. package/templates/pm/decisions.md +4 -5
  31. package/templates/standards/CODE.md +1 -1
  32. package/templates/standards/DESIGN.md +1 -1
  33. package/templates/standards/REVIEW.md +2 -2
  34. package/templates/standards/STACK.md +2 -8
  35. package/templates/v2/AGENTS.md +18 -18
  36. package/templates/v2/BUILDBEAT.md +2 -3
  37. package/templates/v2/CLAUDE.md +1 -1
  38. package/templates/v2/run-config.example.yaml +1 -1
  39. package/templates/v2//346/214/207/346/214/245/345/217/260.md +6 -6
  40. package/bin/buildbeat-v2.js +0 -18
  41. package/bin/solobaton.js +0 -6
  42. package/docs/BuildBeat v2/357/274/232AI /345/216/237/347/224/237/350/275/257/344/273/266/344/272/244/344/273/230/346/216/247/345/210/266/345/271/263/351/235/242.md" +0 -2053
  43. package/docs/CHECKS.md +0 -326
  44. package/docs/CLI-PILOT-2026-08-23.md +0 -25
  45. package/docs/CLI-STRATEGY-2026-08.md +0 -55
  46. package/docs/CLI.md +0 -244
  47. package/docs/EXECUTION-PLAN.md +0 -487
  48. package/docs/LEGACY-V1.16-MIGRATION.md +0 -54
  49. package/docs/PHASE1-PILOT-2026-08-24.md +0 -32
  50. package/docs/PHASE2-BUILDBEAT-PILOT-2026-08-25.md +0 -75
  51. package/docs/PHASE2-PILOT-2026-08-25.md +0 -88
  52. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +0 -42
  53. package/docs/PHASE4-STABILITY-AUDIT-2026-08-25.md +0 -35
  54. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +0 -56
  55. package/docs/ROADMAP.md +0 -875
  56. package/docs/V1.21-RELEASE-EVIDENCE-2026-08-25.md +0 -55
  57. package/docs/V2-D2-DECISION-CARD.md +0 -37
  58. package/docs/V2-DECISIONS.md +0 -11
  59. package/docs/V2-ITERATION-01.md +0 -60
  60. package/docs/V2-ITERATION-02.md +0 -32
  61. package/docs/V2-ITERATION-03.md +0 -30
  62. package/docs/V2-ITERATION-04.md +0 -29
  63. package/docs/V2-ITERATION-05.md +0 -20
  64. package/docs/V2-ITERATION-06.md +0 -18
  65. package/docs/V2-ITERATION-07.md +0 -36
  66. package/docs/V2-ITERATION-08.md +0 -62
  67. package/docs/V2-PLAN.md +0 -335
  68. package/docs/V2-PROPOSAL.md +0 -319
  69. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +0 -41
  70. package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +0 -8
  71. package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +0 -8
  72. package/docs/V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md +0 -9
  73. package/docs/V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md +0 -10
  74. package/docs/V2.0.0-RELEASE-EVIDENCE-2026-09-05.md +0 -11
  75. package/docs/WP4.3-RELEASE-EVIDENCE-2026-08-25.md +0 -73
  76. package/docs/v2/M1-ACCEPTANCE-2026-08-28.md +0 -38
  77. package/docs/v2/M2-DOD-2026-08-28.md +0 -34
  78. package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +0 -46
  79. package/docs/v2/M4-PILOT-APP-2026-08-28.md +0 -44
  80. package/docs/v2/M4-SELFHOST-2026-08-28.md +0 -53
  81. package/docs/v2/guide/08-migration-v1.md +0 -72
  82. package/example/.buildbeat/manifest.json +0 -45
  83. package/example/AGENTS.md +0 -19
  84. package/example/ARCHITECTURE.md +0 -39
  85. package/example/BUILDBEAT.md +0 -17
  86. package/example/CLAUDE.md +0 -7
  87. package/example/README.md +0 -75
  88. package/example/contracts/PROTOCOL.md +0 -38
  89. package/example/pm/NOW.md +0 -22
  90. package/example/pm/adr/ADR-0001-local-first-sqlite.md +0 -25
  91. package/example/pm/adr/README.md +0 -7
  92. package/example/pm/archive//344/270/200/346/234/237/evidence/gate1.md +0 -5
  93. package/example/pm/archive//344/270/200/346/234/237/evidence/gate2.md +0 -5
  94. package/example/pm/archive//344/270/200/346/234/237/evidence/gate3.md +0 -5
  95. package/example/pm/archive//344/270/200/346/234/237/evidence/gate4.md +0 -5
  96. package/example/pm/archive//344/270/200/346/234/237/evidence/implementation.md +0 -5
  97. package/example/pm/decisions.md +0 -20
  98. package/example/pm/status//344/272/247/345/223/201.md +0 -20
  99. package/example/pm/status//345/205/250/346/240/210.md +0 -15
  100. package/example/pm/status//346/265/213/350/257/225.md +0 -15
  101. package/example/pm//344/270/200/346/234/237-/347/234/213/346/235/277.md +0 -97
  102. package/example/standards/CODE.md +0 -18
  103. package/example/standards/DESIGN.md +0 -34
  104. package/example/standards/REVIEW.md +0 -16
  105. package/example/standards/STACK.md +0 -31
  106. package/src/cli.js +0 -323
  107. package/src/constants.js +0 -202
  108. package/src/doctor.js +0 -267
  109. package/src/planner.js +0 -251
  110. package/src/project.js +0 -844
  111. package/src/upgrader.js +0 -1249
  112. package/src/v2/presets/risk/legacy-four-gates.yaml +0 -44
  113. package/src/writer.js +0 -534
  114. package/templates/.claude/agents/reviewer.md +0 -62
  115. package/templates/AGENTS.md +0 -85
  116. package/templates/BUILDBEAT.md +0 -13
  117. package/templates/CLAUDE.md +0 -7
  118. package/templates/pm/NOW.md +0 -26
  119. package/templates/pm/changes/README.md +0 -44
  120. package/templates/pm/status/README.md +0 -32
  121. package/templates/pm//345/275/223/346/234/237/347/234/213/346/235/277.md +0 -62
  122. package/templates/scripts/bus-check.sh +0 -1875
  123. package/templates/scripts/design-preview.sh +0 -44
  124. package/templates/scripts/drift-check.sh +0 -112
  125. package/templates/scripts/pre-commit.sh +0 -74
  126. package/templates/scripts/verify-status.sh +0 -105
  127. package/templates//346/214/207/346/214/245/345/217/260.md +0 -58
package/src/upgrader.js DELETED
@@ -1,1249 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import {
3
- chmodSync,
4
- closeSync,
5
- fsyncSync,
6
- lstatSync,
7
- mkdirSync,
8
- openSync,
9
- readFileSync,
10
- renameSync,
11
- rmdirSync,
12
- unlinkSync,
13
- writeFileSync,
14
- } from "node:fs";
15
- import path from "node:path";
16
-
17
- import {
18
- CLI_VERSION,
19
- FILE_POLICIES,
20
- GITIGNORE_BEGIN_MARKER,
21
- GITIGNORE_END_MARKER,
22
- GITIGNORE_MARKER_PAIRS,
23
- MANIFEST_PATH,
24
- MANIFEST_SCHEMA_VERSION,
25
- OUTPUT_SCHEMA_VERSION,
26
- SCAFFOLD_VERSION,
27
- } from "./constants.js";
28
- import { runDoctor } from "./doctor.js";
29
- import { inspectProject, validateManifest } from "./project.js";
30
- import { prepareScaffold, WriteError } from "./writer.js";
31
-
32
- const INTERNAL = Symbol("upgrade-context");
33
-
34
- function sha256(bytes) {
35
- return createHash("sha256").update(bytes).digest("hex");
36
- }
37
-
38
- function addUnique(items, item) {
39
- if (!items.some((existing) => existing.code === item.code && existing.path === item.path)) {
40
- items.push(item);
41
- }
42
- }
43
-
44
- function parseVersion(value) {
45
- const match = typeof value === "string"
46
- ? value.match(/^v(\d+)\.(\d+)(?:\.(\d+))?$/)
47
- : null;
48
- if (!match) {
49
- return null;
50
- }
51
- const parts = match.slice(1).map((part) => Number(part || 0));
52
- return parts.every((part) => Number.isSafeInteger(part)) ? parts : null;
53
- }
54
-
55
- function compareVersions(left, right) {
56
- for (let index = 0; index < 3; index += 1) {
57
- if (left[index] !== right[index]) {
58
- return left[index] < right[index] ? -1 : 1;
59
- }
60
- }
61
- return 0;
62
- }
63
-
64
- function relativeFilename(target, relative) {
65
- return path.join(target, ...relative.split("/"));
66
- }
67
-
68
- function readPathState(target, relative) {
69
- const filename = relativeFilename(target, relative);
70
- try {
71
- const stat = lstatSync(filename);
72
- if (!stat.isFile() || stat.isSymbolicLink()) {
73
- return { kind: "unsafe", filename, hash: null, bytes: null, mode: null };
74
- }
75
- const bytes = readFileSync(filename);
76
- return {
77
- kind: "file",
78
- filename,
79
- hash: sha256(bytes),
80
- bytes,
81
- mode: stat.mode & 0o777,
82
- };
83
- } catch (error) {
84
- if (error.code === "ENOENT") {
85
- return { kind: "missing", filename, hash: null, bytes: null, mode: null };
86
- }
87
- return { kind: "unsafe", filename, hash: null, bytes: null, mode: null };
88
- }
89
- }
90
-
91
- function targetParentsSafe(target, relative) {
92
- let current = target;
93
- for (const segment of relative.split("/").slice(0, -1)) {
94
- current = path.join(current, segment);
95
- try {
96
- const stat = lstatSync(current);
97
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
98
- return false;
99
- }
100
- } catch (error) {
101
- if (error.code === "ENOENT") {
102
- return true;
103
- }
104
- return false;
105
- }
106
- }
107
- return true;
108
- }
109
-
110
- function markerCount(bytes, marker) {
111
- const token = Buffer.from(marker, "utf8");
112
- let count = 0;
113
- let offset = 0;
114
- while (offset <= bytes.length - token.length) {
115
- const index = bytes.indexOf(token, offset);
116
- if (index === -1) {
117
- break;
118
- }
119
- count += 1;
120
- offset = index + token.length;
121
- }
122
- return count;
123
- }
124
-
125
- function extractOwnedFragment(bytes, integration) {
126
- for (const [beginMarker, endMarker] of GITIGNORE_MARKER_PAIRS) {
127
- const expectedPair =
128
- beginMarker === integration.beginMarker && endMarker === integration.endMarker;
129
- const beginCount = markerCount(bytes, beginMarker);
130
- const endCount = markerCount(bytes, endMarker);
131
- if (expectedPair) {
132
- if (beginCount !== 1 || endCount !== 1) {
133
- return { ok: false, reason: "missing-or-duplicate" };
134
- }
135
- } else if (beginCount !== 0 || endCount !== 0) {
136
- return { ok: false, reason: "ambiguous-marker-pairs" };
137
- }
138
- }
139
-
140
- const begin = Buffer.from(integration.beginMarker, "utf8");
141
- const end = Buffer.from(integration.endMarker, "utf8");
142
- const start = bytes.indexOf(begin);
143
- const endStart = bytes.indexOf(end, start + begin.length);
144
- if (start === -1 || endStart === -1 || endStart < start) {
145
- return { ok: false, reason: "invalid-marker-order" };
146
- }
147
- const beginLineEnd = start + begin.length;
148
- const beginHasLineStart = start === 0 || bytes[start - 1] === 0x0a;
149
- const beginHasLineEnd =
150
- bytes[beginLineEnd] === 0x0a ||
151
- (bytes[beginLineEnd] === 0x0d && bytes[beginLineEnd + 1] === 0x0a);
152
- const endHasLineStart = endStart > 0 && bytes[endStart - 1] === 0x0a;
153
- const rawEnd = endStart + end.length;
154
- const endHasLineEnd =
155
- bytes[rawEnd] === 0x0a ||
156
- (bytes[rawEnd] === 0x0d && bytes[rawEnd + 1] === 0x0a);
157
- if (!beginHasLineStart || !beginHasLineEnd || !endHasLineStart || !endHasLineEnd) {
158
- return { ok: false, reason: "markers-not-full-lines" };
159
- }
160
- let endExclusive = endStart + end.length;
161
- if (bytes[endExclusive] === 0x0d && bytes[endExclusive + 1] === 0x0a) {
162
- endExclusive += 2;
163
- } else if (bytes[endExclusive] === 0x0a) {
164
- endExclusive += 1;
165
- }
166
- return {
167
- ok: true,
168
- start,
169
- endExclusive,
170
- fragment: bytes.subarray(start, endExclusive),
171
- };
172
- }
173
-
174
- function versionGate(installedVersion, { major }) {
175
- const installed = parseVersion(installedVersion);
176
- const bundled = parseVersion(SCAFFOLD_VERSION);
177
- if (!installed || !bundled) {
178
- return {
179
- status: "invalid",
180
- eligible: false,
181
- upToDate: false,
182
- message: "Installed or bundled scaffold version is invalid.",
183
- };
184
- }
185
- const comparison = compareVersions(installed, bundled);
186
- if (comparison === 0) {
187
- return {
188
- status: "up-to-date",
189
- eligible: false,
190
- upToDate: true,
191
- message: `The installed scaffold already matches ${SCAFFOLD_VERSION}.`,
192
- };
193
- }
194
- if (comparison > 0) {
195
- return {
196
- status: "downgrade-blocked",
197
- eligible: false,
198
- upToDate: false,
199
- message: `Installed scaffold ${installedVersion} is newer than bundled ${SCAFFOLD_VERSION}; downgrade is not supported.`,
200
- };
201
- }
202
- if (installed[0] !== bundled[0] && !major) {
203
- return {
204
- status: "major-required",
205
- eligible: false,
206
- upToDate: false,
207
- message: `Upgrade ${installedVersion} -> ${SCAFFOLD_VERSION} crosses a major version; re-run with --major after review.`,
208
- };
209
- }
210
- return {
211
- status: installed[0] === bundled[0] ? "same-major" : "major-acknowledged",
212
- eligible: true,
213
- upToDate: false,
214
- message: `Mechanical upgrade ${installedVersion} -> ${SCAFFOLD_VERSION} is eligible.`,
215
- };
216
- }
217
-
218
- function basePlan({ target, preview, force, major }) {
219
- return {
220
- schemaVersion: OUTPUT_SCHEMA_VERSION,
221
- command: "upgrade",
222
- cliVersion: CLI_VERSION,
223
- scaffoldVersion: SCAFFOLD_VERSION,
224
- preview,
225
- writesPerformed: false,
226
- target: path.resolve(target),
227
- targetExists: false,
228
- layout: null,
229
- installedVersion: null,
230
- targetVersion: SCAFFOLD_VERSION,
231
- force,
232
- major,
233
- versionGate: {
234
- status: "unavailable",
235
- eligible: false,
236
- upToDate: false,
237
- message: "Upgrade prerequisites have not been established.",
238
- },
239
- operations: [],
240
- conflicts: [],
241
- blockers: [],
242
- warnings: [],
243
- writtenPaths: [],
244
- manifestPath: MANIFEST_PATH,
245
- doctor: null,
246
- nextAction:
247
- "After a successful upgrade, run the project bus-check and use an AI session for every reported semantic merge.",
248
- ready: false,
249
- upToDate: false,
250
- };
251
- }
252
-
253
- function structuralBlockers(plan, inspection) {
254
- plan.target = inspection.target;
255
- plan.targetExists = inspection.exists;
256
- plan.layout = inspection.installation.layout || inspection.manifest.layout || null;
257
- plan.installedVersion = inspection.manifest.scaffoldVersion || inspection.installation.version || null;
258
-
259
- if (!inspection.exists) {
260
- addUnique(plan.blockers, {
261
- code: "target.not_found",
262
- path: null,
263
- message: "Upgrade requires an existing project directory.",
264
- });
265
- return;
266
- }
267
- if (inspection.installation.state !== "installed") {
268
- addUnique(plan.blockers, {
269
- code: `install.${inspection.installation.state.replaceAll("-", "_")}`,
270
- path: inspection.installation.markerPath,
271
- message: "Upgrade requires one complete, unambiguous BuildBeat installation.",
272
- });
273
- } else if (inspection.installation.namespace !== "buildbeat") {
274
- addUnique(plan.blockers, {
275
- code: "install.legacy_namespace",
276
- path: inspection.installation.markerPath,
277
- message: "Legacy Solobaton installations require the manual migration or explicitly confirmed re-baselining path.",
278
- });
279
- }
280
-
281
- if (inspection.manifest.state !== "present") {
282
- addUnique(plan.blockers, {
283
- code: `manifest.${inspection.manifest.state.replaceAll("-", "_")}`,
284
- path: inspection.manifest.path,
285
- message: "Upgrade requires one readable canonical schema 2 manifest.",
286
- });
287
- } else {
288
- if (inspection.manifest.path !== MANIFEST_PATH || inspection.manifest.namespace !== "buildbeat") {
289
- addUnique(plan.blockers, {
290
- code: "manifest.legacy_namespace",
291
- path: inspection.manifest.path,
292
- message: "Mechanical upgrade does not infer ownership from a legacy manifest namespace.",
293
- });
294
- }
295
- if (inspection.manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
296
- addUnique(plan.blockers, {
297
- code: "manifest.schema_2_required",
298
- path: inspection.manifest.path,
299
- message: "Mechanical upgrade requires manifest schema 2; schema 1 remains diagnostic-only.",
300
- });
301
- }
302
- for (const issue of inspection.manifest.validationIssues || []) {
303
- addUnique(plan.blockers, {
304
- code: issue.code,
305
- path: inspection.manifest.path,
306
- message: issue.message,
307
- });
308
- }
309
- }
310
-
311
- if (inspection.gitWorktree.state === "not-initialized") {
312
- addUnique(plan.blockers, {
313
- code: "git.not_initialized",
314
- path: null,
315
- message: "Upgrade requires a target-root Git repository and a clean worktree.",
316
- });
317
- } else if (inspection.gitWorktree.state === "dirty") {
318
- addUnique(plan.blockers, {
319
- code: "git.dirty",
320
- path: null,
321
- message: `The target-root Git worktree has ${inspection.gitWorktree.changes} visible change(s).`,
322
- });
323
- } else if (inspection.gitWorktree.state === "unavailable") {
324
- addUnique(plan.blockers, {
325
- code: "git.status_unavailable",
326
- path: null,
327
- message: "The target-root Git status could not be read safely.",
328
- });
329
- }
330
-
331
- if (
332
- inspection.installation.state === "installed" &&
333
- inspection.manifest.state === "present" &&
334
- inspection.manifest.layout !== inspection.installation.layout
335
- ) {
336
- addUnique(plan.blockers, {
337
- code: "manifest.layout_mismatch",
338
- path: inspection.manifest.path,
339
- message: "Manifest layout does not match the detected installation layout.",
340
- });
341
- }
342
- if (
343
- inspection.installation.state === "installed" &&
344
- inspection.manifest.state === "present" &&
345
- inspection.installation.version !== inspection.manifest.scaffoldVersion
346
- ) {
347
- addUnique(plan.blockers, {
348
- code: "manifest.version_mismatch",
349
- path: inspection.manifest.path,
350
- message: "Manifest scaffoldVersion does not match the installed version marker.",
351
- });
352
- }
353
- }
354
-
355
- function publicOperation({ action, target, policy, status, current, baseline, candidate, forced = false }) {
356
- return {
357
- action,
358
- target,
359
- policy,
360
- status,
361
- currentSha256: current || null,
362
- baselineSha256: baseline || null,
363
- candidateSha256: candidate || null,
364
- forced,
365
- };
366
- }
367
-
368
- function planFiles(plan, context) {
369
- const { manifest, rendered, target } = context;
370
- const candidates = new Map(rendered.files.map((item) => [item.target, item]));
371
- const manifestEntries = Object.entries(manifest.files).sort(([left], [right]) =>
372
- left.localeCompare(right),
373
- );
374
-
375
- for (const [relative, record] of manifestEntries) {
376
- const candidate = candidates.get(relative) || null;
377
- if (record.policy === FILE_POLICIES.MERGE_ONLY) {
378
- addUnique(plan.blockers, {
379
- code: "manifest.merge_file_unsupported",
380
- path: relative,
381
- message: "Schema 2 merge-only ownership is supported only through integrations.gitignore.",
382
- });
383
- plan.operations.push(publicOperation({
384
- action: "preserve",
385
- target: relative,
386
- policy: record.policy,
387
- status: "unsupported-merge-file",
388
- baseline: record.baselineSha256,
389
- }));
390
- candidates.delete(relative);
391
- continue;
392
- }
393
-
394
- if (!candidate) {
395
- plan.operations.push(publicOperation({
396
- action: "retain",
397
- target: relative,
398
- policy: record.policy,
399
- status: "removed-upstream",
400
- baseline: record.baselineSha256,
401
- }));
402
- plan.warnings.push({
403
- code: "upgrade.removed_upstream",
404
- path: relative,
405
- message: "The path is no longer bundled; it will not be deleted or removed from the manifest.",
406
- });
407
- continue;
408
- }
409
- candidates.delete(relative);
410
-
411
- if (record.policy === FILE_POLICIES.PROJECT_OWNED) {
412
- plan.operations.push(publicOperation({
413
- action: "preserve",
414
- target: relative,
415
- policy: record.policy,
416
- status: candidate.policy === record.policy ? "project-owned" : "policy-change-preserved",
417
- baseline: record.baselineSha256,
418
- candidate: candidate.baselineSha256,
419
- }));
420
- if (candidate.baselineSha256 !== record.baselineSha256 || candidate.policy !== record.policy) {
421
- plan.warnings.push({
422
- code: "upgrade.project_owned_migration",
423
- path: relative,
424
- message: "Project-owned content has an upstream candidate; compare it in an AI session and merge semantically if useful.",
425
- });
426
- }
427
- continue;
428
- }
429
-
430
- if (candidate.policy !== FILE_POLICIES.REPLACE_IF_UNMODIFIED) {
431
- plan.operations.push(publicOperation({
432
- action: "preserve",
433
- target: relative,
434
- policy: record.policy,
435
- status: "policy-change-preserved",
436
- baseline: record.baselineSha256,
437
- candidate: candidate.baselineSha256,
438
- }));
439
- plan.warnings.push({
440
- code: "upgrade.policy_change",
441
- path: relative,
442
- message: "Upstream ownership policy changed; automatic upgrade will not escalate write authority.",
443
- });
444
- continue;
445
- }
446
-
447
- const current = readPathState(target, relative);
448
- if (current.kind === "unsafe") {
449
- addUnique(plan.blockers, {
450
- code: "path.unsafe",
451
- path: relative,
452
- message: "Managed path is not a readable regular file; --force cannot cross this boundary.",
453
- });
454
- plan.operations.push(publicOperation({
455
- action: "preserve",
456
- target: relative,
457
- policy: record.policy,
458
- status: "unsafe",
459
- baseline: record.baselineSha256,
460
- candidate: candidate.baselineSha256,
461
- }));
462
- continue;
463
- }
464
-
465
- if (current.kind === "file" && current.hash === record.baselineSha256) {
466
- const unchanged = current.hash === candidate.baselineSha256;
467
- plan.operations.push(publicOperation({
468
- action: unchanged ? "keep" : "replace",
469
- target: relative,
470
- policy: record.policy,
471
- status: unchanged ? "already-current" : "baseline-matched",
472
- current: current.hash,
473
- baseline: record.baselineSha256,
474
- candidate: candidate.baselineSha256,
475
- }));
476
- if (!unchanged) {
477
- context.fileWrites.push({
478
- action: "replace",
479
- target: relative,
480
- candidate,
481
- expectedKind: "file",
482
- expectedHash: current.hash,
483
- });
484
- }
485
- continue;
486
- }
487
-
488
- const conflict = {
489
- code: current.kind === "missing" ? "upgrade.managed_file_missing" : "upgrade.managed_file_changed",
490
- path: relative,
491
- message: current.kind === "missing"
492
- ? "A replace-if-unmodified path is missing."
493
- : "A replace-if-unmodified path differs from its installed baseline.",
494
- forceEligible: true,
495
- resolved: plan.force,
496
- };
497
- plan.conflicts.push(conflict);
498
- plan.operations.push(publicOperation({
499
- action: plan.force ? "replace" : "preserve",
500
- target: relative,
501
- policy: record.policy,
502
- status: current.kind === "missing" ? "missing-conflict" : "modified-conflict",
503
- current: current.hash,
504
- baseline: record.baselineSha256,
505
- candidate: candidate.baselineSha256,
506
- forced: plan.force,
507
- }));
508
- if (plan.force) {
509
- context.fileWrites.push({
510
- action: current.kind === "missing" ? "create" : "replace",
511
- target: relative,
512
- candidate,
513
- expectedKind: current.kind,
514
- expectedHash: current.hash,
515
- });
516
- plan.warnings.push({
517
- code: "upgrade.force_replace",
518
- path: relative,
519
- message: "--force will overwrite or restore this replace-if-unmodified path.",
520
- });
521
- }
522
- }
523
-
524
- for (const [relative, candidate] of [...candidates.entries()].sort(([left], [right]) =>
525
- left.localeCompare(right),
526
- )) {
527
- if (candidate.policy === FILE_POLICIES.PROJECT_OWNED) {
528
- plan.operations.push(publicOperation({
529
- action: "suggest",
530
- target: relative,
531
- policy: candidate.policy,
532
- status: "new-project-owned",
533
- candidate: candidate.baselineSha256,
534
- }));
535
- plan.warnings.push({
536
- code: "upgrade.project_owned_migration",
537
- path: relative,
538
- message: "New project-owned template is available; the CLI will not create it.",
539
- });
540
- continue;
541
- }
542
-
543
- if (candidate.policy !== FILE_POLICIES.REPLACE_IF_UNMODIFIED) {
544
- addUnique(plan.blockers, {
545
- code: "upgrade.unsupported_new_policy",
546
- path: relative,
547
- message: `New template policy ${candidate.policy} has no mechanical file-write contract.`,
548
- });
549
- continue;
550
- }
551
-
552
- const current = readPathState(target, relative);
553
- if (current.kind === "missing" && targetParentsSafe(target, relative)) {
554
- plan.operations.push(publicOperation({
555
- action: "create",
556
- target: relative,
557
- policy: candidate.policy,
558
- status: "new-managed-path",
559
- candidate: candidate.baselineSha256,
560
- }));
561
- context.fileWrites.push({
562
- action: "create",
563
- target: relative,
564
- candidate,
565
- expectedKind: "missing",
566
- expectedHash: null,
567
- });
568
- continue;
569
- }
570
-
571
- const unsafe = current.kind === "unsafe" || !targetParentsSafe(target, relative);
572
- plan.conflicts.push({
573
- code: unsafe ? "path.unsafe" : "upgrade.new_path_collision",
574
- path: relative,
575
- message: unsafe
576
- ? "A new managed path has an unsafe parent or destination."
577
- : "A new managed path collides with project content that has no BuildBeat baseline.",
578
- forceEligible: false,
579
- resolved: false,
580
- });
581
- plan.operations.push(publicOperation({
582
- action: "preserve",
583
- target: relative,
584
- policy: candidate.policy,
585
- status: unsafe ? "unsafe" : "new-path-collision",
586
- current: current.hash,
587
- candidate: candidate.baselineSha256,
588
- }));
589
- }
590
- }
591
-
592
- function planGitignore(plan, context) {
593
- const { manifest, rendered, target } = context;
594
- const integration = manifest.integrations.gitignore;
595
- if (integration === null) {
596
- plan.operations.push(publicOperation({
597
- action: "preserve",
598
- target: ".gitignore",
599
- policy: FILE_POLICIES.MERGE_ONLY,
600
- status: "unmanaged-integration",
601
- candidate: rendered.gitignore.baselineSha256,
602
- }));
603
- plan.warnings.push({
604
- code: "upgrade.gitignore_unmanaged",
605
- path: ".gitignore",
606
- message: "The manifest owns no gitignore fragment; upgrade will not infer or create one.",
607
- });
608
- return;
609
- }
610
-
611
- const host = readPathState(target, integration.path);
612
- if (host.kind !== "file") {
613
- plan.conflicts.push({
614
- code: "upgrade.gitignore_host_unsafe",
615
- path: integration.path,
616
- message: "The owned gitignore fragment has no safe regular host file.",
617
- forceEligible: false,
618
- resolved: false,
619
- });
620
- plan.operations.push(publicOperation({
621
- action: "preserve",
622
- target: integration.path,
623
- policy: FILE_POLICIES.MERGE_ONLY,
624
- status: "host-missing-or-unsafe",
625
- baseline: integration.baselineSha256,
626
- candidate: rendered.gitignore.baselineSha256,
627
- }));
628
- return;
629
- }
630
-
631
- const extracted = extractOwnedFragment(host.bytes, integration);
632
- if (!extracted.ok) {
633
- plan.conflicts.push({
634
- code: "upgrade.gitignore_markers_invalid",
635
- path: integration.path,
636
- message: "Owned gitignore markers are missing, duplicated, mixed, or out of order.",
637
- forceEligible: false,
638
- resolved: false,
639
- });
640
- plan.operations.push(publicOperation({
641
- action: "preserve",
642
- target: integration.path,
643
- policy: FILE_POLICIES.MERGE_ONLY,
644
- status: extracted.reason,
645
- baseline: integration.baselineSha256,
646
- candidate: rendered.gitignore.baselineSha256,
647
- }));
648
- return;
649
- }
650
-
651
- const currentHash = sha256(extracted.fragment);
652
- const candidateHash = rendered.gitignore.baselineSha256;
653
- if (currentHash === integration.baselineSha256) {
654
- const unchanged = currentHash === candidateHash;
655
- plan.operations.push(publicOperation({
656
- action: unchanged ? "keep" : "replace-fragment",
657
- target: integration.path,
658
- policy: FILE_POLICIES.MERGE_ONLY,
659
- status: unchanged ? "already-current" : "baseline-matched",
660
- current: currentHash,
661
- baseline: integration.baselineSha256,
662
- candidate: candidateHash,
663
- }));
664
- if (!unchanged) {
665
- context.gitignoreWrite = {
666
- path: integration.path,
667
- expectedHostHash: host.hash,
668
- bytes: Buffer.concat([
669
- host.bytes.subarray(0, extracted.start),
670
- rendered.gitignore.fragment,
671
- host.bytes.subarray(extracted.endExclusive),
672
- ]),
673
- mode: host.mode,
674
- };
675
- }
676
- return;
677
- }
678
-
679
- plan.conflicts.push({
680
- code: "upgrade.gitignore_fragment_changed",
681
- path: integration.path,
682
- message: "The owned gitignore fragment differs from its baseline.",
683
- forceEligible: true,
684
- resolved: plan.force,
685
- });
686
- plan.operations.push(publicOperation({
687
- action: plan.force ? "replace-fragment" : "preserve",
688
- target: integration.path,
689
- policy: FILE_POLICIES.MERGE_ONLY,
690
- status: "modified-conflict",
691
- current: currentHash,
692
- baseline: integration.baselineSha256,
693
- candidate: candidateHash,
694
- forced: plan.force,
695
- }));
696
- if (plan.force) {
697
- context.gitignoreWrite = {
698
- path: integration.path,
699
- expectedHostHash: host.hash,
700
- bytes: Buffer.concat([
701
- host.bytes.subarray(0, extracted.start),
702
- rendered.gitignore.fragment,
703
- host.bytes.subarray(extracted.endExclusive),
704
- ]),
705
- mode: host.mode,
706
- };
707
- plan.warnings.push({
708
- code: "upgrade.force_gitignore",
709
- path: integration.path,
710
- message: "--force will replace only the uniquely marked gitignore fragment.",
711
- });
712
- }
713
- }
714
-
715
- function buildContext(plan, inspection, now) {
716
- const manifestFilename = relativeFilename(inspection.target, MANIFEST_PATH);
717
- let manifestBytes;
718
- let manifest;
719
- try {
720
- manifestBytes = readFileSync(manifestFilename);
721
- manifest = JSON.parse(manifestBytes.toString("utf8"));
722
- } catch (error) {
723
- addUnique(plan.blockers, {
724
- code: "manifest.unreadable",
725
- path: MANIFEST_PATH,
726
- message: "The canonical manifest changed or became unreadable during planning.",
727
- });
728
- return null;
729
- }
730
- const issues = validateManifest(manifest, inspection.target);
731
- if (issues.length > 0 || manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
732
- for (const issue of issues) {
733
- addUnique(plan.blockers, { ...issue, path: MANIFEST_PATH });
734
- }
735
- return null;
736
- }
737
- const rendered = prepareScaffold({
738
- layout: manifest.layout,
739
- projectName: inspection.projectName.value,
740
- now,
741
- });
742
- return {
743
- target: inspection.target,
744
- inspection,
745
- manifest,
746
- manifestHash: sha256(manifestBytes),
747
- rendered,
748
- fileWrites: [],
749
- gitignoreWrite: null,
750
- };
751
- }
752
-
753
- export function buildUpgradePlan({
754
- target,
755
- preview = true,
756
- force = false,
757
- major = false,
758
- now = new Date(),
759
- }) {
760
- const plan = basePlan({ target, preview, force, major });
761
- let inspection;
762
- try {
763
- inspection = inspectProject(target, { includeDependencies: false });
764
- } catch (error) {
765
- addUnique(plan.blockers, {
766
- code: "target.unsafe",
767
- path: null,
768
- message: error.message,
769
- });
770
- return plan;
771
- }
772
- structuralBlockers(plan, inspection);
773
-
774
- if (
775
- inspection.manifest.state !== "present" ||
776
- inspection.manifest.path !== MANIFEST_PATH ||
777
- inspection.manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION ||
778
- (inspection.manifest.validationIssues || []).length > 0
779
- ) {
780
- plan.ready = false;
781
- return plan;
782
- }
783
-
784
- const gate = versionGate(inspection.manifest.scaffoldVersion, { major });
785
- plan.versionGate = gate;
786
- plan.upToDate = gate.upToDate;
787
- if (gate.status === "invalid") {
788
- addUnique(plan.blockers, {
789
- code: "version.invalid",
790
- path: MANIFEST_PATH,
791
- message: gate.message,
792
- });
793
- } else if (gate.status === "downgrade-blocked") {
794
- addUnique(plan.blockers, {
795
- code: "version.downgrade_blocked",
796
- path: MANIFEST_PATH,
797
- message: gate.message,
798
- });
799
- } else if (gate.status === "major-required") {
800
- addUnique(plan.blockers, {
801
- code: "version.major_required",
802
- path: MANIFEST_PATH,
803
- message: gate.message,
804
- });
805
- }
806
-
807
- if (gate.upToDate) {
808
- plan.ready = plan.blockers.length === 0;
809
- return plan;
810
- }
811
-
812
- const context = buildContext(plan, inspection, now);
813
- if (context === null) {
814
- plan.ready = false;
815
- return plan;
816
- }
817
- const markerRecord = context.manifest.files[inspection.installation.markerPath];
818
- if (
819
- inspection.installation.state === "installed" &&
820
- (!markerRecord || markerRecord.policy !== FILE_POLICIES.REPLACE_IF_UNMODIFIED)
821
- ) {
822
- addUnique(plan.blockers, {
823
- code: "manifest.marker_unmanaged",
824
- path: inspection.installation.markerPath,
825
- message: "The installed version marker must be manifest-owned as replace-if-unmodified.",
826
- });
827
- }
828
-
829
- planFiles(plan, context);
830
- planGitignore(plan, context);
831
- plan.operations.sort((left, right) => left.target.localeCompare(right.target));
832
- plan.conflicts.sort((left, right) => left.path.localeCompare(right.path));
833
- plan.warnings.sort((left, right) =>
834
- (left.path || "").localeCompare(right.path || "") || left.code.localeCompare(right.code),
835
- );
836
- plan.ready =
837
- gate.eligible &&
838
- plan.blockers.length === 0 &&
839
- plan.conflicts.every((conflict) => conflict.resolved);
840
- Object.defineProperty(plan, INTERNAL, { value: context, enumerable: false });
841
- return plan;
842
- }
843
-
844
- function assertRelativeTarget(relative) {
845
- if (
846
- typeof relative !== "string" ||
847
- relative.length === 0 ||
848
- relative.includes("\\") ||
849
- path.posix.isAbsolute(relative) ||
850
- path.win32.isAbsolute(relative) ||
851
- path.posix.normalize(relative) !== relative ||
852
- relative.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
853
- ) {
854
- throw new WriteError("path.unsafe", `Unsafe upgrade target path: ${relative}`);
855
- }
856
- }
857
-
858
- function ensureDirectory(directory, createdDirectories) {
859
- try {
860
- const stat = lstatSync(directory);
861
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
862
- throw new WriteError("path.unsafe", `Required directory is unsafe: ${directory}`);
863
- }
864
- return;
865
- } catch (error) {
866
- if (error instanceof WriteError || error.code !== "ENOENT") {
867
- throw error;
868
- }
869
- }
870
- const parent = path.dirname(directory);
871
- if (parent === directory) {
872
- throw new WriteError("path.unsafe", `Cannot create directory: ${directory}`);
873
- }
874
- ensureDirectory(parent, createdDirectories);
875
- try {
876
- mkdirSync(directory, { mode: 0o755 });
877
- createdDirectories.push(directory);
878
- } catch (error) {
879
- if (error.code === "EEXIST") {
880
- ensureDirectory(directory, createdDirectories);
881
- return;
882
- }
883
- throw error;
884
- }
885
- }
886
-
887
- function ensureTargetParent(target, relative, createdDirectories) {
888
- assertRelativeTarget(relative);
889
- ensureDirectory(path.dirname(relativeFilename(target, relative)), createdDirectories);
890
- }
891
-
892
- function fsyncDirectory(directory) {
893
- if (process.platform === "win32") {
894
- return;
895
- }
896
- const descriptor = openSync(directory, "r");
897
- try {
898
- fsyncSync(descriptor);
899
- } finally {
900
- closeSync(descriptor);
901
- }
902
- }
903
-
904
- function atomicWrite(filename, bytes, mode, { overwrite, nextTempId }) {
905
- const parent = path.dirname(filename);
906
- const basename = path.basename(filename);
907
- let descriptor = null;
908
- let temp = null;
909
- try {
910
- for (let attempt = 0; attempt < 100; attempt += 1) {
911
- const candidate = path.join(
912
- parent,
913
- `.${basename}.buildbeat-${process.pid}-${nextTempId()}-${attempt}.tmp`,
914
- );
915
- try {
916
- descriptor = openSync(candidate, "wx", mode);
917
- temp = candidate;
918
- break;
919
- } catch (error) {
920
- if (error.code !== "EEXIST") {
921
- throw error;
922
- }
923
- }
924
- }
925
- if (descriptor === null || temp === null) {
926
- throw new WriteError("write.temp_unavailable", `Could not allocate a temporary sibling for ${filename}.`);
927
- }
928
- writeFileSync(descriptor, bytes);
929
- fsyncSync(descriptor);
930
- closeSync(descriptor);
931
- descriptor = null;
932
- chmodSync(temp, mode);
933
-
934
- if (overwrite) {
935
- const current = lstatSync(filename);
936
- if (!current.isFile() || current.isSymbolicLink()) {
937
- throw new WriteError("path.unsafe", `Upgrade destination is no longer a regular file: ${filename}`);
938
- }
939
- } else {
940
- try {
941
- lstatSync(filename);
942
- throw new WriteError("upgrade.preflight_changed", `Upgrade destination appeared: ${filename}`);
943
- } catch (error) {
944
- if (error instanceof WriteError || error.code !== "ENOENT") {
945
- throw error;
946
- }
947
- }
948
- }
949
- renameSync(temp, filename);
950
- temp = null;
951
- fsyncDirectory(parent);
952
- } finally {
953
- if (descriptor !== null) {
954
- closeSync(descriptor);
955
- }
956
- if (temp !== null) {
957
- try {
958
- unlinkSync(temp);
959
- } catch (error) {
960
- if (error.code !== "ENOENT") {
961
- throw error;
962
- }
963
- }
964
- }
965
- }
966
- }
967
-
968
- function assertExpectedState(target, write) {
969
- const current = readPathState(target, write.target);
970
- if (current.kind !== write.expectedKind || current.hash !== write.expectedHash) {
971
- throw new WriteError(
972
- "upgrade.preflight_changed",
973
- `Managed path changed after planning: ${write.target}`,
974
- );
975
- }
976
- if (!targetParentsSafe(target, write.target)) {
977
- throw new WriteError("path.unsafe", `Managed path parent became unsafe: ${write.target}`);
978
- }
979
- }
980
-
981
- function preflightExpectations(context) {
982
- for (const write of context.fileWrites) {
983
- assertExpectedState(context.target, write);
984
- }
985
- if (context.gitignoreWrite) {
986
- const host = readPathState(context.target, context.gitignoreWrite.path);
987
- if (host.kind !== "file" || host.hash !== context.gitignoreWrite.expectedHostHash) {
988
- throw new WriteError(
989
- "upgrade.preflight_changed",
990
- `${context.gitignoreWrite.path} changed after planning.`,
991
- );
992
- }
993
- }
994
- const manifest = readPathState(context.target, MANIFEST_PATH);
995
- if (manifest.kind !== "file" || manifest.hash !== context.manifestHash) {
996
- throw new WriteError("upgrade.preflight_changed", "The lifecycle manifest changed after planning.");
997
- }
998
- }
999
-
1000
- function rollback({ createdFiles, createdDirectories, backups, nextTempId }) {
1001
- const failures = [];
1002
- for (const filename of [...createdFiles].reverse()) {
1003
- try {
1004
- unlinkSync(filename);
1005
- } catch (error) {
1006
- if (error.code !== "ENOENT") {
1007
- failures.push(`${filename}: ${error.code || error.message}`);
1008
- }
1009
- }
1010
- }
1011
- for (const backup of [...backups].reverse()) {
1012
- try {
1013
- atomicWrite(backup.filename, backup.bytes, backup.mode, {
1014
- overwrite: true,
1015
- nextTempId,
1016
- });
1017
- } catch (error) {
1018
- failures.push(`${backup.filename}: ${error.code || error.message}`);
1019
- }
1020
- }
1021
- for (const directory of [...createdDirectories].reverse()) {
1022
- try {
1023
- rmdirSync(directory);
1024
- } catch (error) {
1025
- if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") {
1026
- failures.push(`${directory}: ${error.code || error.message}`);
1027
- }
1028
- }
1029
- }
1030
- return failures;
1031
- }
1032
-
1033
- function nextManifest(context) {
1034
- const manifest = structuredClone(context.manifest);
1035
- manifest.scaffoldVersion = SCAFFOLD_VERSION;
1036
- manifest.cliVersion = CLI_VERSION;
1037
- for (const write of context.fileWrites) {
1038
- manifest.files[write.target] = {
1039
- policy: FILE_POLICIES.REPLACE_IF_UNMODIFIED,
1040
- baselineSha256: write.candidate.baselineSha256,
1041
- };
1042
- }
1043
- if (context.gitignoreWrite) {
1044
- manifest.integrations.gitignore = {
1045
- path: ".gitignore",
1046
- beginMarker: GITIGNORE_BEGIN_MARKER,
1047
- endMarker: GITIGNORE_END_MARKER,
1048
- baselineSha256: context.rendered.gitignore.baselineSha256,
1049
- };
1050
- }
1051
- const issues = validateManifest(manifest, context.target);
1052
- if (issues.length > 0) {
1053
- throw new WriteError(
1054
- "manifest.generated_invalid",
1055
- `Generated manifest is invalid: ${issues.map((issue) => issue.code).join(", ")}`,
1056
- );
1057
- }
1058
- return manifest;
1059
- }
1060
-
1061
- export function applyUpgrade(plan, { now = new Date(), faultInjector = null } = {}) {
1062
- if (plan.command !== "upgrade" || plan.preview || plan.writesPerformed) {
1063
- throw new WriteError("write.invalid_plan", "Only a non-preview upgrade plan can be applied.");
1064
- }
1065
- const fresh = buildUpgradePlan({
1066
- target: plan.target,
1067
- preview: false,
1068
- force: plan.force,
1069
- major: plan.major,
1070
- now,
1071
- });
1072
- if (fresh.upToDate && fresh.ready) {
1073
- return fresh;
1074
- }
1075
- if (!fresh.ready || !fresh[INTERNAL]) {
1076
- throw new WriteError(
1077
- "upgrade.preflight_blocked",
1078
- "Upgrade prerequisites, ownership checks, or conflicts changed before apply.",
1079
- );
1080
- }
1081
- const context = fresh[INTERNAL];
1082
- preflightExpectations(context);
1083
- const manifest = nextManifest(context);
1084
- const createdFiles = [];
1085
- const createdDirectories = [];
1086
- const backups = [];
1087
- const writtenPaths = [];
1088
- let tempId = 0;
1089
- const nextTempId = () => {
1090
- tempId += 1;
1091
- return tempId;
1092
- };
1093
- const maybeFault = (phase, relative) => {
1094
- if (typeof faultInjector === "function") {
1095
- faultInjector({ phase, path: relative, writes: writtenPaths.length });
1096
- }
1097
- };
1098
-
1099
- try {
1100
- for (const write of context.fileWrites) {
1101
- assertExpectedState(context.target, write);
1102
- ensureTargetParent(context.target, write.target, createdDirectories);
1103
- const filename = relativeFilename(context.target, write.target);
1104
- const current = readPathState(context.target, write.target);
1105
- if (current.kind === "file") {
1106
- backups.push({ filename, bytes: current.bytes, mode: current.mode });
1107
- }
1108
- atomicWrite(filename, write.candidate.content, write.candidate.mode, {
1109
- overwrite: current.kind === "file",
1110
- nextTempId,
1111
- });
1112
- if (current.kind === "missing") {
1113
- createdFiles.push(filename);
1114
- }
1115
- writtenPaths.push(write.target);
1116
- maybeFault("file", write.target);
1117
- }
1118
-
1119
- if (context.gitignoreWrite) {
1120
- const write = context.gitignoreWrite;
1121
- const current = readPathState(context.target, write.path);
1122
- if (current.kind !== "file" || current.hash !== write.expectedHostHash) {
1123
- throw new WriteError(
1124
- "upgrade.preflight_changed",
1125
- `${write.path} changed during the upgrade transaction.`,
1126
- );
1127
- }
1128
- backups.push({ filename: current.filename, bytes: current.bytes, mode: current.mode });
1129
- atomicWrite(current.filename, write.bytes, write.mode, {
1130
- overwrite: true,
1131
- nextTempId,
1132
- });
1133
- writtenPaths.push(write.path);
1134
- maybeFault("gitignore", write.path);
1135
- }
1136
-
1137
- const manifestFilename = relativeFilename(context.target, MANIFEST_PATH);
1138
- const manifestCurrent = readPathState(context.target, MANIFEST_PATH);
1139
- if (manifestCurrent.kind !== "file" || manifestCurrent.hash !== context.manifestHash) {
1140
- throw new WriteError(
1141
- "upgrade.preflight_changed",
1142
- "The lifecycle manifest changed during the upgrade transaction.",
1143
- );
1144
- }
1145
- backups.push({
1146
- filename: manifestFilename,
1147
- bytes: manifestCurrent.bytes,
1148
- mode: manifestCurrent.mode,
1149
- });
1150
- maybeFault("before-manifest", MANIFEST_PATH);
1151
- atomicWrite(
1152
- manifestFilename,
1153
- Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
1154
- manifestCurrent.mode,
1155
- { overwrite: true, nextTempId },
1156
- );
1157
- writtenPaths.push(MANIFEST_PATH);
1158
- maybeFault("manifest", MANIFEST_PATH);
1159
-
1160
- const doctor = runDoctor(context.target);
1161
- return {
1162
- ...fresh,
1163
- preview: false,
1164
- writesPerformed: true,
1165
- writtenPaths,
1166
- doctor,
1167
- nextAction:
1168
- `Run ${fresh.layout === "compact" ? "pm/scripts" : "scripts"}/bus-check.sh --strict; use an AI session for every semantic migration note.`,
1169
- ready: true,
1170
- };
1171
- } catch (error) {
1172
- const rollbackFailures = rollback({
1173
- createdFiles,
1174
- createdDirectories,
1175
- backups,
1176
- nextTempId,
1177
- });
1178
- if (rollbackFailures.length > 0) {
1179
- throw new WriteError(
1180
- "rollback.incomplete",
1181
- `Upgrade failed and rollback was incomplete: ${rollbackFailures.join("; ")}`,
1182
- error,
1183
- );
1184
- }
1185
- if (error instanceof WriteError) {
1186
- throw error;
1187
- }
1188
- throw new WriteError(
1189
- "upgrade.write_failed",
1190
- `Upgrade write failed and was rolled back: ${error.message}`,
1191
- error,
1192
- );
1193
- }
1194
- }
1195
-
1196
- export function formatUpgradePlan(plan) {
1197
- const title = plan.writesPerformed
1198
- ? "BuildBeat upgrade complete"
1199
- : `BuildBeat upgrade ${plan.preview ? "dry run" : "write plan"}`;
1200
- const lines = [
1201
- title,
1202
- `Target: ${plan.target}`,
1203
- `Version: ${plan.installedVersion || "unknown"} -> ${plan.targetVersion}`,
1204
- `Version gate: ${plan.versionGate.status}`,
1205
- `Layout: ${plan.layout || "unknown"}`,
1206
- `Force: ${plan.force ? "enabled" : "disabled"}; major transition: ${plan.major ? "acknowledged" : "not acknowledged"}`,
1207
- "",
1208
- ];
1209
- if (plan.upToDate) {
1210
- lines.push("No template upgrade is needed.");
1211
- }
1212
- for (const blocker of plan.blockers) {
1213
- lines.push(`BLOCKER ${blocker.code}${blocker.path ? ` [${blocker.path}]` : ""}: ${blocker.message}`);
1214
- }
1215
- for (const conflict of plan.conflicts) {
1216
- lines.push(
1217
- `${conflict.resolved ? "FORCED" : "CONFLICT"} ${conflict.code} [${conflict.path}]: ${conflict.message}`,
1218
- );
1219
- }
1220
- for (const warning of plan.warnings) {
1221
- lines.push(`WARNING ${warning.code}${warning.path ? ` [${warning.path}]` : ""}: ${warning.message}`);
1222
- }
1223
- if (plan.operations.length > 0) {
1224
- lines.push("", "Mechanical operations:");
1225
- for (const operation of plan.operations) {
1226
- lines.push(
1227
- `- ${operation.action} ${operation.target} (${operation.policy}; ${operation.status})${operation.forced ? " [forced]" : ""}`,
1228
- );
1229
- }
1230
- }
1231
- if (plan.writesPerformed) {
1232
- lines.push(
1233
- "",
1234
- `Written paths: ${plan.writtenPaths.length}`,
1235
- `Doctor: ${plan.doctor?.ok ? "no errors" : `${plan.doctor?.summary.errors ?? "unknown"} error(s)`}`,
1236
- plan.nextAction,
1237
- );
1238
- } else if (plan.conflicts.some((item) => !item.resolved)) {
1239
- lines.push(
1240
- "",
1241
- "No files changed. Open an AI session to compare the current files with the new templates and merge semantically, or review eligible conflicts before --force.",
1242
- );
1243
- } else if (plan.preview) {
1244
- lines.push("", "No files changed. This was a dry run.");
1245
- } else if (!plan.writesPerformed) {
1246
- lines.push("", "No files changed.");
1247
- }
1248
- return lines.join("\n");
1249
- }