@deftai/directive-core 0.55.1 → 0.56.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 (74) hide show
  1. package/dist/cache/errors.d.ts +4 -1
  2. package/dist/cache/errors.js +8 -2
  3. package/dist/cache/fetch.d.ts +50 -0
  4. package/dist/cache/fetch.js +224 -3
  5. package/dist/cache/main.js +10 -5
  6. package/dist/cache/operations.js +6 -4
  7. package/dist/codebase/map-fresh.js +7 -1
  8. package/dist/deposit/copy-tree.d.ts +16 -0
  9. package/dist/deposit/copy-tree.js +58 -0
  10. package/dist/deposit/resolve-content.d.ts +28 -0
  11. package/dist/deposit/resolve-content.js +70 -0
  12. package/dist/doctor/checks.d.ts +6 -0
  13. package/dist/doctor/checks.js +37 -0
  14. package/dist/doctor/constants.d.ts +2 -0
  15. package/dist/doctor/constants.js +8 -0
  16. package/dist/engine-version.d.ts +3 -0
  17. package/dist/engine-version.js +23 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +3 -1
  20. package/dist/init-deposit/gitignore.d.ts +51 -0
  21. package/dist/init-deposit/gitignore.js +200 -0
  22. package/dist/init-deposit/index.d.ts +6 -0
  23. package/dist/init-deposit/index.js +6 -0
  24. package/dist/init-deposit/init-deposit.d.ts +44 -0
  25. package/dist/init-deposit/init-deposit.js +197 -0
  26. package/dist/init-deposit/legacy-detect.d.ts +61 -0
  27. package/dist/init-deposit/legacy-detect.js +223 -0
  28. package/dist/init-deposit/migrate.d.ts +88 -0
  29. package/dist/init-deposit/migrate.js +196 -0
  30. package/dist/init-deposit/refresh.d.ts +50 -0
  31. package/dist/init-deposit/refresh.js +292 -0
  32. package/dist/init-deposit/scaffold.d.ts +41 -0
  33. package/dist/init-deposit/scaffold.js +753 -0
  34. package/dist/legacy-bridge/bridge-drift.d.ts +70 -0
  35. package/dist/legacy-bridge/bridge-drift.js +171 -0
  36. package/dist/legacy-bridge/freeze-gate.d.ts +79 -0
  37. package/dist/legacy-bridge/freeze-gate.js +168 -0
  38. package/dist/legacy-bridge/index.d.ts +4 -0
  39. package/dist/legacy-bridge/index.js +4 -0
  40. package/dist/legacy-bridge/sot.d.ts +45 -0
  41. package/dist/legacy-bridge/sot.js +49 -0
  42. package/dist/preflight-cache/evaluate.d.ts +11 -0
  43. package/dist/preflight-cache/evaluate.js +63 -13
  44. package/dist/release-e2e/constants.d.ts +2 -0
  45. package/dist/release-e2e/constants.js +2 -0
  46. package/dist/release-e2e/flags.js +5 -1
  47. package/dist/release-e2e/legacy-bridge-leg.d.ts +134 -0
  48. package/dist/release-e2e/legacy-bridge-leg.js +460 -0
  49. package/dist/release-e2e/main.d.ts +2 -1
  50. package/dist/release-e2e/main.js +17 -1
  51. package/dist/release-e2e/npm-ops.d.ts +3 -1
  52. package/dist/release-e2e/npm-ops.js +5 -3
  53. package/dist/release-e2e/types.d.ts +9 -0
  54. package/dist/render/index.d.ts +1 -1
  55. package/dist/render/index.js +1 -1
  56. package/dist/render/project-render.js +4 -3
  57. package/dist/render/roadmap-render.d.ts +4 -2
  58. package/dist/render/roadmap-render.js +14 -7
  59. package/dist/swarm/routing-verify.js +2 -2
  60. package/dist/swarm/subagent-backend.d.ts +16 -0
  61. package/dist/swarm/subagent-backend.js +14 -0
  62. package/dist/triage/actions/candidates-log.d.ts +12 -1
  63. package/dist/triage/actions/candidates-log.js +44 -7
  64. package/dist/triage/actions/index.d.ts +25 -1
  65. package/dist/triage/actions/index.js +94 -3
  66. package/dist/triage/summary/index.d.ts +1 -3
  67. package/dist/triage/summary/index.js +6 -57
  68. package/dist/triage/summary/reconcilable.js +4 -33
  69. package/dist/triage/welcome/default-mode.d.ts +1 -0
  70. package/dist/triage/welcome/default-mode.js +6 -0
  71. package/dist/vbrief-validate/project-definition.js +4 -69
  72. package/dist/vbrief-validate/registry-status.d.ts +20 -0
  73. package/dist/vbrief-validate/registry-status.js +85 -0
  74. package/package.json +11 -2
@@ -0,0 +1,460 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { GO_BRIDGE_RELEASES_URL } from "../doctor/constants.js";
5
+ import { parseInstallManifest } from "../doctor/manifest.js";
6
+ import { buildLegacyRefusalMessage, detectLegacyLayout, } from "../init-deposit/legacy-detect.js";
7
+ import { isNpmManaged, NPM_MANAGED_SENTINEL_VALUE, runMigrate } from "../init-deposit/migrate.js";
8
+ import { buildInstallManifestText, CANONICAL_INSTALL_ROOT, writeAgentsMd, writeInstallManifest, } from "../init-deposit/scaffold.js";
9
+ // NOTE: the Tier-0 SoT lives at packages/core/src/legacy-bridge/sot.ts and is
10
+ // deliberately NOT edited here (operator-only freeze step). This leg only READS
11
+ // it via lastGoInstaller(). deft:last-go-installer -- the bridge tag is read
12
+ // from the SoT (lastGoInstaller()), never restated as a literal on this line.
13
+ import { lastGoInstaller } from "../legacy-bridge/sot.js";
14
+ import { defaultWhich, spawnText } from "../release/spawn.js";
15
+ import { emit } from "./flags.js";
16
+ /**
17
+ * legacy-bridge-leg.ts -- the opt-in Tier-1 pinned migration e2e leg (#1912).
18
+ *
19
+ * Exercises the two-stage legacy migration end-to-end:
20
+ * - Stage 1 (the frozen Go bridge installer): normalises a LEGACY on-disk
21
+ * layout (pre-v0.27 sentinel AGENTS.md / git-clone / submodule / orphan
22
+ * `.deft/VERSION` / legacy `deft/`-prefixed root) to the canonical-vendored
23
+ * `.deft/core` layout.
24
+ * - Stage 2 (the npm CLI): `directive migrate` stamps the deposit npm-managed,
25
+ * then `directive init`/`update` own the canonical-vendored payload, yielding
26
+ * a valid npm-hybrid deposit (canonical `.deft/core` + AGENTS.md
27
+ * managed-section + a `managed_by: 'npm'` install manifest).
28
+ *
29
+ * SEMANTICS -- pending-pin (the key #1912 contract):
30
+ * The bridge tag is the Tier-0 SoT `lastGoInstaller()` (story G). While it is
31
+ * `null` (unfrozen -- today), the leg cannot download/run a real frozen bridge
32
+ * binary, so it runs every fixture-shape + stage-2 assertion it can WITHOUT the
33
+ * network and SKIPS the real frozen-binary download with a clear
34
+ * `PENDING (SoT unfrozen)` advisory. The moment the operator freezes (pins the
35
+ * SoT), the same leg runs the real pinned bridge -> npm handoff.
36
+ *
37
+ * The leg mirrors the npm-ops leg (npm-ops.ts): a self-contained function
38
+ * returning `[ok, reason]`, invoked from main.ts behind the opt-in
39
+ * `--legacy-bridge` flag (default OFF -- the default `task release:e2e` budget is
40
+ * unaffected). It soft-skips (ok=true) when the npm channel is absent, symmetric
41
+ * to `rehearseNpmPublish`'s npm soft-skip.
42
+ *
43
+ * Refs #1912 (capstone), #1942 (deposit journey), #1941 (migrate), #1669 (Wave 5).
44
+ */
45
+ const NOOP_IO = { printf: () => { } };
46
+ /** The frozen Go bridge repo (the published release lives under deftai/directive). */
47
+ export const GO_BRIDGE_REPO = "deftai/directive";
48
+ /**
49
+ * The legacy layouts the leg provisions + asserts. Covers the four shapes the
50
+ * detector recognises (legacy-detect.ts): orphan `.deft/VERSION`, legacy
51
+ * `deft/`-prefixed root, git-clone / submodule, and a pre-v0.27 sentinel-only
52
+ * AGENTS.md. Each fixture lands in its own clean throwaway dir.
53
+ */
54
+ export const LEGACY_FIXTURES = [
55
+ {
56
+ kind: "orphan-deft-version",
57
+ provision: (dir) => {
58
+ mkdirSync(join(dir, ".deft"), { recursive: true });
59
+ writeFileSync(join(dir, ".deft", "VERSION"), "ref: 'v0.1.0'\nsha: 'legacy'\ntag: 'v0.1.0'\n", "utf8");
60
+ },
61
+ },
62
+ {
63
+ kind: "legacy-deft-prefixed",
64
+ provision: (dir) => {
65
+ mkdirSync(join(dir, "deft"), { recursive: true });
66
+ writeFileSync(join(dir, "deft", "VERSION"), "ref: 'v0.1.0'\n", "utf8");
67
+ writeFileSync(join(dir, "deft", "main.md"), "# Deft (legacy prefixed)\n", "utf8");
68
+ },
69
+ },
70
+ {
71
+ kind: "git-clone-or-submodule",
72
+ provision: (dir) => {
73
+ mkdirSync(dir, { recursive: true });
74
+ writeFileSync(join(dir, ".gitmodules"), '[submodule "deft"]\n\tpath = .deft\n\turl = https://github.com/deftai/directive.git\n', "utf8");
75
+ },
76
+ },
77
+ {
78
+ kind: "pre-v0.27-sentinel-agents-md",
79
+ provision: (dir) => {
80
+ mkdirSync(dir, { recursive: true });
81
+ writeFileSync(join(dir, "AGENTS.md"), "# AGENTS\n\n<!-- deft:managed-section -->\nLegacy pre-v0.27 sentinel-only body (no v2/v3 markers).\n", "utf8");
82
+ },
83
+ },
84
+ ];
85
+ /** Provision the named legacy fixture under `dir`. */
86
+ export function provisionLegacyFixture(dir, kind) {
87
+ const spec = LEGACY_FIXTURES.find((f) => f.kind === kind);
88
+ if (!spec) {
89
+ throw new Error(`unknown legacy fixture kind: ${kind}`);
90
+ }
91
+ spec.provision(dir);
92
+ }
93
+ /**
94
+ * Stage-0 fixture-shape assertions: every legacy fixture is classified as legacy
95
+ * with its expected kind, and the npm refuse-preflight surface (story P) would
96
+ * refuse it (we exercise the version-neutral two-step refusal message). This is
97
+ * the boundary that makes the two-stage migration load-bearing: the npm path
98
+ * NEVER migrates a legacy layout -- the frozen Go bridge does (stage 1) first.
99
+ */
100
+ export function assertLegacyFixturesDetectedAndRefused(workRoot) {
101
+ for (const spec of LEGACY_FIXTURES) {
102
+ const dir = join(workRoot, `legacy-${spec.kind}`);
103
+ provisionLegacyFixture(dir, spec.kind);
104
+ const detection = detectLegacyLayout(dir);
105
+ if (!detection.legacy) {
106
+ return [false, `legacy fixture ${spec.kind} was NOT detected as legacy`];
107
+ }
108
+ if (detection.kind !== spec.kind) {
109
+ return [
110
+ false,
111
+ `legacy fixture ${spec.kind} misclassified: detector returned ${detection.kind}`,
112
+ ];
113
+ }
114
+ for (const command of ["init", "update"]) {
115
+ const refusal = buildLegacyRefusalMessage(command, detection);
116
+ if (!refusal.includes("frozen final Go bridge")) {
117
+ return [
118
+ false,
119
+ `npm ${command} refusal for ${spec.kind} did not signpost the frozen Go bridge`,
120
+ ];
121
+ }
122
+ }
123
+ }
124
+ return [
125
+ true,
126
+ `${LEGACY_FIXTURES.length} legacy fixtures detected + npm init/update refuse-boundary asserted ` +
127
+ `(${LEGACY_FIXTURES.map((f) => f.kind).join(", ")})`,
128
+ ];
129
+ }
130
+ /**
131
+ * The canonical installer manifest fields the frozen Go bridge writes into
132
+ * `.deft/core/VERSION`. Field names + order come from the installer's
133
+ * `buildInstallManifestText` (the TS port of the Go `BuildInstallManifestText`),
134
+ * so this fixture is the same shape a real frozen-bridge run deposits.
135
+ */
136
+ export const CANONICAL_INSTALLER_MANIFEST_FIELDS = {
137
+ ref: "v0.0.1",
138
+ sha: "content-package",
139
+ tag: "v0.0.1",
140
+ installRoot: CANONICAL_INSTALL_ROOT,
141
+ fetchedAt: "2026-06-24T00:00:00Z",
142
+ fetchedBy: "frozen-go-bridge",
143
+ };
144
+ /**
145
+ * The install-manifest field keys a canonical installer VERSION carries, derived
146
+ * from the installer's own `buildInstallManifestText` output so the handshake
147
+ * expectation tracks the real installer field shape rather than restating it.
148
+ * `directive migrate` consumes this VERSION; a drift away from these keys is the
149
+ * handshake break this leg now asserts against.
150
+ */
151
+ export function installerManifestFieldKeys() {
152
+ return Object.keys(parseInstallManifest(buildInstallManifestText(CANONICAL_INSTALLER_MANIFEST_FIELDS)));
153
+ }
154
+ /**
155
+ * Assert an installer-shaped VERSION is migrate-acceptable: `parseInstallManifest`
156
+ * accepts it AND it carries every canonical installer manifest field
157
+ * ({@link installerManifestFieldKeys}) with a non-empty value. A drifted field
158
+ * shape (missing or renamed key, empty value) returns a handshake mismatch
159
+ * reason so the leg fails loudly rather than letting `directive migrate` stamp a
160
+ * non-installer VERSION npm-managed. This is the stage-2 handshake the #1912
161
+ * freeze hinges on (the installer VERSION the npm CLI accepts as canonical-ready).
162
+ */
163
+ export function assertInstallerVersionMigrateAcceptable(versionText) {
164
+ const manifest = parseInstallManifest(versionText);
165
+ if (Object.keys(manifest).length === 0) {
166
+ return [false, "handshake mismatch: installer VERSION parsed to an empty manifest"];
167
+ }
168
+ const required = installerManifestFieldKeys();
169
+ const missing = required.filter((key) => {
170
+ const value = manifest[key];
171
+ return value === undefined || value.trim() === "";
172
+ });
173
+ if (missing.length > 0) {
174
+ return [
175
+ false,
176
+ `handshake mismatch: installer VERSION missing migrate-required field(s): ${missing.join(", ")}`,
177
+ ];
178
+ }
179
+ return [true, `installer VERSION migrate-acceptable (${required.join(", ")})`];
180
+ }
181
+ /**
182
+ * Provision a canonical-vendored `.deft/core` deposit -- the shape the stage-1
183
+ * bridge produces from a legacy layout. Writes an installer-shaped VERSION (via
184
+ * the installer's own `writeInstallManifest`) unless `versionTextOverride` is
185
+ * supplied, in which case the raw override text is written verbatim so a test can
186
+ * drive a drifted VERSION through the stage-2 handshake. Optionally renders
187
+ * AGENTS.md when the agents-entry template is available (so the npm-hybrid
188
+ * end-state check can assert the managed-section the way `directive init`/`update`
189
+ * do). Returns the VERSION manifest path so the caller can assert the handshake.
190
+ */
191
+ export function provisionCanonicalVendoredDeposit(projectDir, agentsTemplatePath, versionTextOverride = null) {
192
+ const deftDir = join(projectDir, CANONICAL_INSTALL_ROOT);
193
+ mkdirSync(join(deftDir, "templates"), { recursive: true });
194
+ writeFileSync(join(deftDir, "main.md"), "# Deft\n", "utf8");
195
+ let manifestPath;
196
+ if (versionTextOverride !== null) {
197
+ // deftDir already exists: the `mkdirSync(join(deftDir, "templates"))` above
198
+ // created it as an ancestor (recursive). Just write the override VERSION.
199
+ manifestPath = join(deftDir, "VERSION");
200
+ writeFileSync(manifestPath, versionTextOverride, "utf8");
201
+ }
202
+ else {
203
+ manifestPath = writeInstallManifest(projectDir, deftDir, CANONICAL_INSTALLER_MANIFEST_FIELDS);
204
+ }
205
+ let agentsRendered = false;
206
+ if (agentsTemplatePath !== null && existsSync(agentsTemplatePath)) {
207
+ copyFileSync(agentsTemplatePath, join(deftDir, "templates", "agents-entry.md"));
208
+ writeAgentsMd(projectDir, deftDir, NOOP_IO);
209
+ agentsRendered = true;
210
+ }
211
+ return { deftDir, agentsRendered, manifestPath };
212
+ }
213
+ /**
214
+ * Stage-2 assertion: drive a canonical-vendored deposit through `directive
215
+ * migrate` (and the init/update AGENTS.md render) to a valid npm-hybrid deposit.
216
+ * Asserts migrate stamps the deposit npm-managed, is idempotent (already-hybrid
217
+ * on the second run), and leaves a `managed_by: 'npm'` manifest.
218
+ */
219
+ export function assertNpmHybridMigration(workRoot, agentsTemplatePath, seams = {}) {
220
+ const projectDir = join(workRoot, "post-bridge");
221
+ mkdirSync(projectDir, { recursive: true });
222
+ const { agentsRendered, manifestPath } = provisionCanonicalVendoredDeposit(projectDir, agentsTemplatePath, seams.versionTextOverride ?? null);
223
+ // Stage-2 handshake: the installer-shaped VERSION must be migrate-acceptable
224
+ // (parseInstallManifest accepts it AND it carries the canonical installer
225
+ // fields) BEFORE `directive migrate` stamps managed_by. migrate itself only
226
+ // appends the sentinel and would happily stamp a drifted VERSION, so asserting
227
+ // the installer field shape here is what makes the freeze handshake end-to-end
228
+ // rather than assumed. Runs regardless of the SoT pin (the pending-pin path
229
+ // still validates the handshake without any binary download).
230
+ const versionText = readFileSync(manifestPath, "utf8");
231
+ const [handshakeOk, handshakeReason] = assertInstallerVersionMigrateAcceptable(versionText);
232
+ if (!handshakeOk) {
233
+ return [false, `npm-hybrid FAIL: ${handshakeReason}`];
234
+ }
235
+ if (agentsRendered) {
236
+ const agents = readFileSync(join(projectDir, "AGENTS.md"), "utf8");
237
+ if (!agents.includes("deft:managed-section")) {
238
+ return [false, "npm-hybrid FAIL: rendered AGENTS.md missing the managed-section"];
239
+ }
240
+ }
241
+ const resolveEngine = seams.resolveEngine ?? (() => "npm-channel");
242
+ const nowIso = seams.nowIso;
243
+ const first = runMigrate(projectDir, { resolveEngine, nowIso });
244
+ if (first.outcome !== "migrated") {
245
+ return [
246
+ false,
247
+ `npm-hybrid FAIL: directive migrate expected 'migrated', got '${first.outcome}'`,
248
+ ];
249
+ }
250
+ const second = runMigrate(projectDir, { resolveEngine, nowIso });
251
+ if (second.outcome !== "already-hybrid") {
252
+ return [
253
+ false,
254
+ `npm-hybrid FAIL: directive migrate not idempotent (second run: '${second.outcome}')`,
255
+ ];
256
+ }
257
+ if (first.manifestPath === null) {
258
+ return [false, "npm-hybrid FAIL: migrate reported no manifest path"];
259
+ }
260
+ const manifest = parseInstallManifest(readFileSync(first.manifestPath, "utf8"));
261
+ if (!isNpmManaged(manifest)) {
262
+ return [false, "npm-hybrid FAIL: manifest is not stamped npm-managed after migrate"];
263
+ }
264
+ const agentsNote = agentsRendered ? " + AGENTS.md managed-section" : "";
265
+ return [
266
+ true,
267
+ `npm-hybrid deposit valid (canonical ${CANONICAL_INSTALL_ROOT}${agentsNote}; ` +
268
+ `installer VERSION migrate-acceptable; ` +
269
+ `directive migrate -> managed_by: '${NPM_MANAGED_SENTINEL_VALUE}', idempotent)`,
270
+ ];
271
+ }
272
+ /** Map a Node `process.platform` value to the Go release `GOOS` asset token. */
273
+ function goOsToken(platform) {
274
+ if (platform === "win32")
275
+ return "windows";
276
+ return platform; // "linux" / "darwin" line up with GOOS as-is
277
+ }
278
+ /** Map a Node `process.arch` value to the Go release `GOARCH` asset token. */
279
+ function goArchToken(arch) {
280
+ if (arch === "x64")
281
+ return "amd64";
282
+ return arch; // "arm64" lines up with GOARCH as-is
283
+ }
284
+ /**
285
+ * Pick the deft-install bridge binary matching the current host platform/arch.
286
+ *
287
+ * Go releases publish one asset per OS/arch (e.g. `deft-install-linux-amd64`,
288
+ * `deft-install-darwin-arm64`, `deft-install-windows-amd64.exe`). Selecting
289
+ * `assets[0]` alphabetically silently picks the wrong binary on a multi-asset
290
+ * release (darwin sorts before linux), producing an exec-format failure on a
291
+ * non-Darwin runner. This resolves the host-matching asset instead and reports
292
+ * a precise outcome when none matches:
293
+ * - single deft-install asset -> use it (single-binary releases, back-compat);
294
+ * - prefer an asset whose name carries BOTH the GOOS and GOARCH tokens;
295
+ * - else fall back to a GOOS-only match (releases that omit the arch suffix);
296
+ * - else `no-platform-match` so the caller fails loudly rather than guessing.
297
+ */
298
+ export function selectBridgeAsset(fileNames, platform = process.platform, arch = process.arch) {
299
+ const candidates = fileNames.filter((name) => /deft-install/i.test(name));
300
+ if (candidates.length === 0) {
301
+ return { kind: "none" };
302
+ }
303
+ if (candidates.length === 1) {
304
+ return { kind: "ok", name: candidates[0] };
305
+ }
306
+ const os = goOsToken(platform).toLowerCase();
307
+ const cpu = goArchToken(arch).toLowerCase();
308
+ const osArch = candidates.find((name) => {
309
+ const lower = name.toLowerCase();
310
+ return lower.includes(os) && lower.includes(cpu);
311
+ });
312
+ if (osArch !== undefined) {
313
+ return { kind: "ok", name: osArch };
314
+ }
315
+ const osOnly = candidates.find((name) => name.toLowerCase().includes(os));
316
+ if (osOnly !== undefined) {
317
+ return { kind: "ok", name: osOnly };
318
+ }
319
+ return { kind: "no-platform-match", candidates: [...candidates] };
320
+ }
321
+ /**
322
+ * The frozen-mode real handoff (reached ONLY once the operator pins the SoT):
323
+ * download the frozen Go bridge binary at the pinned tag and run it against a
324
+ * legacy fixture to normalise it to canonical-vendored. Soft-skips when `gh` is
325
+ * absent. Never builds/cuts a release -- it consumes an already-published one.
326
+ */
327
+ export function downloadAndRunFrozenBridge(pin, fixtureDir, seams = {}) {
328
+ if (seams.runFrozenBridge) {
329
+ return seams.runFrozenBridge(pin, fixtureDir);
330
+ }
331
+ const which = seams.which ?? seams.whichGh ?? defaultWhich;
332
+ const spawn = seams.spawnText ?? spawnText;
333
+ const gh = which("gh");
334
+ if (gh === null) {
335
+ return [true, `SKIP (gh not on PATH; cannot fetch the frozen bridge ${pin})`];
336
+ }
337
+ const mkdtemp = seams.mkdtemp ?? ((prefix) => mkdtempSync(join(tmpdir(), prefix)));
338
+ const rmTemp = seams.rmTemp ?? ((p) => rmSync(p, { recursive: true, force: true }));
339
+ const assetDir = mkdtemp("deft-frozen-bridge-");
340
+ // Clean up the downloaded-binary temp dir on every exit path (mirrors the
341
+ // workRoot try/finally in runLegacyBridgeLeg). Once the SoT is pinned this
342
+ // runs on every --legacy-bridge CI invocation, so a leaked assetDir would
343
+ // accumulate full Go-bridge binaries.
344
+ try {
345
+ const download = spawn(gh, ["release", "download", pin, "--repo", GO_BRIDGE_REPO, "--dir", assetDir, "--clobber"], { env: { ...process.env }, timeoutMs: 300_000 });
346
+ if (download.status !== 0) {
347
+ return [
348
+ false,
349
+ `gh release download ${pin} from ${GO_BRIDGE_REPO} failed: ${download.stderr.trim()}`,
350
+ ];
351
+ }
352
+ const selection = selectBridgeAsset(readdirSync(assetDir));
353
+ if (selection.kind === "none") {
354
+ return [
355
+ false,
356
+ `frozen bridge ${pin} downloaded but no deft-install asset found in ${assetDir} ` +
357
+ `(see ${GO_BRIDGE_RELEASES_URL})`,
358
+ ];
359
+ }
360
+ if (selection.kind === "no-platform-match") {
361
+ return [
362
+ false,
363
+ `frozen bridge ${pin} downloaded but no deft-install asset matches ` +
364
+ `${goOsToken(process.platform)}/${goArchToken(process.arch)} ` +
365
+ `(candidates: ${selection.candidates.join(", ")}; see ${GO_BRIDGE_RELEASES_URL})`,
366
+ ];
367
+ }
368
+ const binaryPath = join(assetDir, selection.name);
369
+ try {
370
+ chmodSync(binaryPath, 0o755);
371
+ }
372
+ catch {
373
+ // best-effort; non-fatal on platforms that ignore the bit
374
+ }
375
+ const run = spawn(binaryPath, ["--yes", "--upgrade", "--repo-root", fixtureDir], {
376
+ env: { ...process.env },
377
+ timeoutMs: 300_000,
378
+ });
379
+ if (run.status !== 0) {
380
+ return [false, `frozen bridge ${pin} run failed (exit ${run.status}): ${run.stderr.trim()}`];
381
+ }
382
+ if (detectLegacyLayout(fixtureDir).legacy) {
383
+ return [false, `frozen bridge ${pin} ran but the fixture is still a legacy layout`];
384
+ }
385
+ return [true, `frozen bridge ${pin} normalised the legacy fixture to canonical-vendored`];
386
+ }
387
+ finally {
388
+ rmTemp(assetDir);
389
+ }
390
+ }
391
+ /**
392
+ * Run the pinned legacy -> bridge -> npm-hybrid migration leg.
393
+ *
394
+ * Returns `[ok, reason]` like the other release-e2e legs. Soft-skips (ok=true)
395
+ * when npm is absent. While the SoT is null (pending-pin) it asserts the
396
+ * fixture-shape + stage-2 npm-hybrid checks and SKIPS the real frozen-binary
397
+ * download; once the SoT is pinned it additionally runs the real bridge handoff.
398
+ */
399
+ export function runLegacyBridgeLeg(projectRoot, seams = {}) {
400
+ const which = seams.which ?? seams.whichGh ?? defaultWhich;
401
+ if (which("npm") === null) {
402
+ return [
403
+ true,
404
+ "SKIP (npm not on PATH; Node-less operator) -- the npm-hybrid stage needs the npm channel",
405
+ ];
406
+ }
407
+ const pin = (seams.lastGoInstaller ?? lastGoInstaller)();
408
+ const frozen = pin !== null;
409
+ const agentsTemplatePath = seams.agentsTemplatePath !== undefined
410
+ ? seams.agentsTemplatePath
411
+ : join(projectRoot, "content", "templates", "agents-entry.md");
412
+ const mkdtemp = seams.mkdtemp ?? ((prefix) => mkdtempSync(join(tmpdir(), prefix)));
413
+ const rmTemp = seams.rmTemp ?? ((p) => rmSync(p, { recursive: true, force: true }));
414
+ const workRoot = mkdtemp("deft-legacy-bridge-");
415
+ try {
416
+ let [ok, reason] = assertLegacyFixturesDetectedAndRefused(workRoot);
417
+ emit(" legacy-bridge step: legacy fixtures + refuse-boundary", `${ok ? "OK" : "FAIL"} (${reason})`);
418
+ if (!ok) {
419
+ return [false, `legacy fixtures: ${reason}`];
420
+ }
421
+ // Stage 1: the frozen Go bridge normalises legacy -> canonical-vendored.
422
+ if (frozen && pin !== null) {
423
+ const fixtureDir = join(workRoot, "frozen-fixture");
424
+ provisionLegacyFixture(fixtureDir, "pre-v0.27-sentinel-agents-md");
425
+ const [bridgeOk, bridgeReason] = downloadAndRunFrozenBridge(pin, fixtureDir, seams);
426
+ emit(" legacy-bridge step: frozen bridge handoff", `${bridgeOk ? "OK" : "FAIL"} (${bridgeReason})`);
427
+ if (!bridgeOk) {
428
+ return [false, `frozen bridge: ${bridgeReason}`];
429
+ }
430
+ if (bridgeReason.startsWith("SKIP")) {
431
+ return [true, `frozen pin ${pin}: ${bridgeReason}; stage-1 deferred (gh absent)`];
432
+ }
433
+ }
434
+ else {
435
+ emit(" legacy-bridge step: frozen bridge handoff", "PENDING (SoT unfrozen) -- skipped the real frozen-binary download (pin: null)");
436
+ }
437
+ // Stage 2: directive migrate (+ init/update AGENTS.md render) -> npm-hybrid.
438
+ [ok, reason] = assertNpmHybridMigration(workRoot, agentsTemplatePath, seams);
439
+ emit(" legacy-bridge step: stage-2 npm-hybrid", `${ok ? "OK" : "FAIL"} (${reason})`);
440
+ if (!ok) {
441
+ return [false, `npm-hybrid: ${reason}`];
442
+ }
443
+ if (frozen) {
444
+ return [
445
+ true,
446
+ `frozen pin ${pin}: real legacy -> bridge -> npm-hybrid handoff verified ` +
447
+ "(legacy fixtures detected; frozen bridge normalised; stage-2 migrate npm-hybrid)",
448
+ ];
449
+ }
450
+ return [
451
+ true,
452
+ "PENDING (SoT unfrozen): legacy fixtures + npm refuse-boundary + stage-2 npm-hybrid shape " +
453
+ "asserted; skipped the real frozen-binary download (pin: null) until the operator freezes",
454
+ ];
455
+ }
456
+ finally {
457
+ rmTemp(workRoot);
458
+ }
459
+ }
460
+ //# sourceMappingURL=legacy-bridge-leg.js.map
@@ -1,4 +1,5 @@
1
+ import { type LegacyBridgeLegSeams } from "./legacy-bridge-leg.js";
1
2
  import type { E2EConfig, E2ESeams } from "./types.js";
2
- export declare function runE2e(config: E2EConfig, seams?: E2ESeams): number;
3
+ export declare function runE2e(config: E2EConfig, seams?: E2ESeams & LegacyBridgeLegSeams): number;
3
4
  export declare function cmdReleaseE2e(args: readonly string[], seams?: E2ESeams): number;
4
5
  //# sourceMappingURL=main.d.ts.map
@@ -2,6 +2,7 @@ import { resolveProjectRoot } from "../release/paths.js";
2
2
  import { EXIT_CONFIG_ERROR, EXIT_OK, EXIT_VIOLATION, RELEASE_E2E_HELP } from "./constants.js";
3
3
  import { emit, generateRepoSlug, parseE2EFlags } from "./flags.js";
4
4
  import { destroyTempRepo, provisionTempRepo } from "./gh-ops.js";
5
+ import { runLegacyBridgeLeg } from "./legacy-bridge-leg.js";
5
6
  import { runRehearsal } from "./rehearsal.js";
6
7
  export function runE2e(config, seams = {}) {
7
8
  const slug = config.repoSlug ?? generateRepoSlug(seams);
@@ -13,9 +14,23 @@ export function runE2e(config, seams = {}) {
13
14
  : "npm publish dry-run (4 packages) -> task release:rollback";
14
15
  emit("Rehearsal", "DRYRUN (would run pipeline-mirror rehearsal: clone -> push heads+tags -> task release -> " +
15
16
  `verify draft + tag -> ${npmPlan} against temp repo)`);
17
+ if (config.legacyBridge) {
18
+ emit("Legacy-bridge leg", "DRYRUN (would run the pinned legacy -> bridge -> npm-hybrid migration leg; reads the " +
19
+ "Tier-0 SoT lastGoInstaller(), pending-pin while null)");
20
+ }
16
21
  emit("Destroy temp repo", `DRYRUN (would run \`gh repo delete ${owner}/${slug} --yes\`)`);
17
22
  return EXIT_OK;
18
23
  }
24
+ // Opt-in #1912 leg: local + self-contained (no temp GitHub repo), so run it
25
+ // independently of the pipeline-mirror rehearsal and fold its result into rc.
26
+ let legacyBridgeRc = EXIT_OK;
27
+ if (config.legacyBridge) {
28
+ const [ok, reason] = runLegacyBridgeLeg(config.projectRoot, seams);
29
+ emit("Legacy-bridge leg", `${ok ? "OK" : "FAIL"} (${reason})`);
30
+ if (!ok) {
31
+ legacyBridgeRc = EXIT_VIOLATION;
32
+ }
33
+ }
19
34
  const [provisionOk, provisionReason] = provisionTempRepo(owner, slug, seams);
20
35
  if (!provisionOk) {
21
36
  emit(`Provision ${owner}/${slug}`, `FAIL (${provisionReason})`);
@@ -48,7 +63,7 @@ export function runE2e(config, seams = {}) {
48
63
  }
49
64
  }
50
65
  }
51
- return rehearsalRc;
66
+ return rehearsalRc !== EXIT_OK ? rehearsalRc : legacyBridgeRc;
52
67
  }
53
68
  export function cmdReleaseE2e(args, seams = {}) {
54
69
  const flags = parseE2EFlags(args);
@@ -71,6 +86,7 @@ export function cmdReleaseE2e(args, seams = {}) {
71
86
  dryRun: flags.dryRun,
72
87
  keepRepo: flags.keepRepo,
73
88
  skipNpm: flags.skipNpm,
89
+ legacyBridge: flags.legacyBridge,
74
90
  repoSlug: null,
75
91
  };
76
92
  return runE2e(config, seams);
@@ -21,7 +21,9 @@ export declare function alignNpmPackageVersions(cloneDir: string, version: strin
21
21
  * 2. Resolve pnpm (or `corepack pnpm`) and `pnpm install --frozen-lockfile`.
22
22
  * 3. `pnpm -w run build`; dist/ must exist for the dist-only files allowlist.
23
23
  * 4. Align the four package.json versions + resolve the workspace protocol.
24
- * 5. `npm publish --dry-run --access public` per package in dependency order.
24
+ * 5. `npm publish --dry-run --access public --tag e2e-rehearsal` per package
25
+ * in dependency order (#1925 bypasses implicit-`latest` when the rehearsal
26
+ * sentinel is below the highest published version).
25
27
  *
26
28
  * Returns [ok, reason] like verifyDraftRelease / verifyTag.
27
29
  */
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { defaultWhich, spawnText } from "../release/spawn.js";
4
- import { NPM_BUILD_TIMEOUT_SECONDS, NPM_INSTALL_TIMEOUT_SECONDS, NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS, NPM_PUBLISH_PACKAGES, } from "./constants.js";
4
+ import { NPM_BUILD_TIMEOUT_SECONDS, NPM_E2E_REHEARSAL_TAG, NPM_INSTALL_TIMEOUT_SECONDS, NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS, NPM_PUBLISH_PACKAGES, } from "./constants.js";
5
5
  /**
6
6
  * npm publish dry-run rehearsal (#1910) -- the TS port of the
7
7
  * scripts/release_e2e.py helpers of the same name. Mirrors
@@ -102,7 +102,9 @@ export function alignNpmPackageVersions(cloneDir, version) {
102
102
  * 2. Resolve pnpm (or `corepack pnpm`) and `pnpm install --frozen-lockfile`.
103
103
  * 3. `pnpm -w run build`; dist/ must exist for the dist-only files allowlist.
104
104
  * 4. Align the four package.json versions + resolve the workspace protocol.
105
- * 5. `npm publish --dry-run --access public` per package in dependency order.
105
+ * 5. `npm publish --dry-run --access public --tag e2e-rehearsal` per package
106
+ * in dependency order (#1925 bypasses implicit-`latest` when the rehearsal
107
+ * sentinel is below the highest published version).
106
108
  *
107
109
  * Returns [ok, reason] like verifyDraftRelease / verifyTag.
108
110
  */
@@ -135,7 +137,7 @@ export function rehearseNpmPublish(cloneDir, version, seams = {}) {
135
137
  }
136
138
  for (const pkg of NPM_PUBLISH_PACKAGES) {
137
139
  const pkgDir = join(cloneDir, "packages", pkg);
138
- [ok, reason] = runNpmStep([npmPath, "publish", "--dry-run", "--access", "public"], pkgDir, env, `npm publish --dry-run packages/${pkg}`, NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS, seams);
140
+ [ok, reason] = runNpmStep([npmPath, "publish", "--dry-run", "--access", "public", "--tag", NPM_E2E_REHEARSAL_TAG], pkgDir, env, `npm publish --dry-run --tag ${NPM_E2E_REHEARSAL_TAG} packages/${pkg}`, NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS, seams);
139
141
  if (!ok) {
140
142
  return [false, reason];
141
143
  }
@@ -6,6 +6,13 @@ export interface E2EConfig {
6
6
  keepRepo: boolean;
7
7
  /** When true, skip the npm publish dry-run rehearsal step (#1910). */
8
8
  skipNpm: boolean;
9
+ /**
10
+ * When true, run the opt-in pinned legacy->bridge->npm-hybrid migration leg
11
+ * (#1912). Default OFF (optional) so the field is absent on existing callers
12
+ * and the default `task release:e2e` budget is unaffected; mirrors `skipNpm`
13
+ * as an opt-in knob rather than a default-on step.
14
+ */
15
+ legacyBridge?: boolean;
9
16
  /** Optional override slug (test injection). If null, a fresh slug is generated per run. */
10
17
  repoSlug: string | null;
11
18
  }
@@ -16,6 +23,8 @@ export interface ParsedE2EFlags {
16
23
  keepRepo: boolean;
17
24
  projectRoot: string | null;
18
25
  skipNpm: boolean;
26
+ /** Opt-in: run the pinned legacy->bridge->npm-hybrid migration leg (#1912). */
27
+ legacyBridge: boolean;
19
28
  unknown: string[];
20
29
  }
21
30
  export type EntrypointFn = (argv: string[]) => number;
@@ -6,7 +6,7 @@ export { main as prdRenderMain, parsePrdArgv, renderPrd } from "./prd-render.js"
6
6
  export * as projectRender from "./project-render.js";
7
7
  export { flagStaleNarratives, main as projectRenderMain, renderProjectDefinition, scanLifecycleFolders, } from "./project-render.js";
8
8
  export * as roadmapRender from "./roadmap-render.js";
9
- export { checkDrift, generateRoadmapContent, main as roadmapRenderMain, renderRoadmap, } from "./roadmap-render.js";
9
+ export { checkDrift, generateRoadmapContent, main as roadmapRenderMain, renderRoadmap, renderRoadmapToBuffer, } from "./roadmap-render.js";
10
10
  export * as specRender from "./spec-render.js";
11
11
  export { main as specRenderMain, parseIncludeScopesFlag, renderSpec } from "./spec-render.js";
12
12
  export * as specValidate from "./spec-validate.js";
@@ -6,7 +6,7 @@ export { main as prdRenderMain, parsePrdArgv, renderPrd } from "./prd-render.js"
6
6
  export * as projectRender from "./project-render.js";
7
7
  export { flagStaleNarratives, main as projectRenderMain, renderProjectDefinition, scanLifecycleFolders, } from "./project-render.js";
8
8
  export * as roadmapRender from "./roadmap-render.js";
9
- export { checkDrift, generateRoadmapContent, main as roadmapRenderMain, renderRoadmap, } from "./roadmap-render.js";
9
+ export { checkDrift, generateRoadmapContent, main as roadmapRenderMain, renderRoadmap, renderRoadmapToBuffer, } from "./roadmap-render.js";
10
10
  export * as specRender from "./spec-render.js";
11
11
  export { main as specRenderMain, parseIncludeScopesFlag, renderSpec } from "./spec-render.js";
12
12
  export * as specValidate from "./spec-validate.js";
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { EMITTED_VBRIEF_VERSION } from "../vbrief-build/constants.js";
4
+ import { deriveRegistryItemStatus, registryMetadataReferencesFromScope, } from "../vbrief-validate/registry-status.js";
4
5
  import { PROJECT_LIFECYCLE_FOLDERS, SKELETON_NARRATIVES } from "./constants.js";
5
6
  import { splitCamel, splitWords } from "./text-utils.js";
6
7
  export function scanLifecycleFolders(vbriefDir) {
@@ -24,8 +25,8 @@ export function scanLifecycleFolders(vbriefDir) {
24
25
  const data = JSON.parse(readFileSync(full, "utf8"));
25
26
  const plan = (data.plan ?? {});
26
27
  const title = String(plan.title ?? vbriefFile.replace(/\.vbrief\.json$/, ""));
27
- const status = String(plan.status ?? folderName);
28
- const references = plan.references;
28
+ const status = deriveRegistryItemStatus(plan.status, folderName);
29
+ const references = registryMetadataReferencesFromScope(plan.references);
29
30
  const item = {
30
31
  id: vbriefFile.replace(/\.vbrief\.json$/, "").replace(/\.vbrief$/, ""),
31
32
  title,
@@ -35,7 +36,7 @@ export function scanLifecycleFolders(vbriefDir) {
35
36
  lifecycle_folder: folderName,
36
37
  },
37
38
  };
38
- if (Array.isArray(references) && references.length > 0) {
39
+ if (references.length > 0) {
39
40
  item.metadata.references = references;
40
41
  }
41
42
  items.push(item);
@@ -1,8 +1,10 @@
1
- /** Generate ROADMAP.md content (mirrors ``scripts/roadmap_render.generate_roadmap_content``). */
1
+ /** Single render-to-buffer entry used by both write and --check (mirrors ``scripts/roadmap_render.generate_roadmap_content``). */
2
+ export declare function renderRoadmapToBuffer(pendingDir: string, completedDir?: string): string;
3
+ /** @deprecated Prefer ``renderRoadmapToBuffer`` — kept for existing imports and parity harnesses. */
2
4
  export declare function generateRoadmapContent(pendingDir: string, completedDir?: string): string;
3
5
  export type RenderRoadmapResult = readonly [boolean, string];
4
6
  export declare function renderRoadmap(pendingDir: string, outPath: string, completedDir?: string): RenderRoadmapResult;
5
- export declare function checkDrift(pendingDir: string, roadmapPath: string): RenderRoadmapResult;
7
+ export declare function checkDrift(pendingDir: string, roadmapPath: string, completedDir?: string): RenderRoadmapResult;
6
8
  /** CLI entry (mirrors ``scripts/roadmap_render.main``). */
7
9
  export declare function main(argv: readonly string[]): number;
8
10
  //# sourceMappingURL=roadmap-render.d.ts.map