@warpgogol/forge 5.1.1 → 5.1.3

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 (106) hide show
  1. package/AGENTS.md +21 -2
  2. package/dist/os/adr/handlers/implement-stamp.d.ts.map +1 -1
  3. package/dist/os/adr/handlers/implement-stamp.js +32 -2
  4. package/dist/os/adr/handlers/implement-stamp.js.map +1 -1
  5. package/dist/os/adr/types.d.ts +2 -1
  6. package/dist/os/adr/types.d.ts.map +1 -1
  7. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  8. package/dist/os/compass/handlers/compass-audit-handler.js +3 -3
  9. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  10. package/dist/os/compass/handlers/compass-change-summary-handler.d.ts.map +1 -1
  11. package/dist/os/compass/handlers/compass-change-summary-handler.js +1 -1
  12. package/dist/os/compass/handlers/compass-change-summary-handler.js.map +1 -1
  13. package/dist/os/compass/handlers/compass-inventory-handler.d.ts.map +1 -1
  14. package/dist/os/compass/handlers/compass-inventory-handler.js +1 -1
  15. package/dist/os/compass/handlers/compass-inventory-handler.js.map +1 -1
  16. package/dist/os/compass/handlers/compass-inventory.d.ts +1 -1
  17. package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
  18. package/dist/os/compass/handlers/compass-inventory.js +20 -5
  19. package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
  20. package/dist/os/compass/handlers/compass-migrate-handler.d.ts.map +1 -1
  21. package/dist/os/compass/handlers/compass-migrate-handler.js +1 -0
  22. package/dist/os/compass/handlers/compass-migrate-handler.js.map +1 -1
  23. package/dist/os/compass/handlers/compass-migrate.d.ts +2 -0
  24. package/dist/os/compass/handlers/compass-migrate.d.ts.map +1 -1
  25. package/dist/os/compass/handlers/compass-migrate.js +1 -1
  26. package/dist/os/compass/handlers/compass-migrate.js.map +1 -1
  27. package/dist/os/compass/handlers/resolve-scan-root.js +1 -1
  28. package/dist/os/core/handlers/pinned-validate.d.ts.map +1 -1
  29. package/dist/os/core/handlers/pinned-validate.js +26 -6
  30. package/dist/os/core/handlers/pinned-validate.js.map +1 -1
  31. package/dist/os/naming/naming-convention.d.ts.map +1 -1
  32. package/dist/os/naming/naming-convention.js +2 -0
  33. package/dist/os/naming/naming-convention.js.map +1 -1
  34. package/dist/os/rfc/frontmatter-io.d.ts.map +1 -1
  35. package/dist/os/rfc/frontmatter-io.js +1 -0
  36. package/dist/os/rfc/frontmatter-io.js.map +1 -1
  37. package/dist/os/rfc/handlers/validate-rules.d.ts.map +1 -1
  38. package/dist/os/rfc/handlers/validate-rules.js +6 -1
  39. package/dist/os/rfc/handlers/validate-rules.js.map +1 -1
  40. package/dist/os/werkstatt/handlers/workspace-deps.d.ts.map +1 -1
  41. package/dist/os/werkstatt/handlers/workspace-deps.js +4 -3
  42. package/dist/os/werkstatt/handlers/workspace-deps.js.map +1 -1
  43. package/dist/src/config/forge-config.d.ts +8 -1
  44. package/dist/src/config/forge-config.d.ts.map +1 -1
  45. package/dist/src/config/forge-config.js +52 -5
  46. package/dist/src/config/forge-config.js.map +1 -1
  47. package/dist/src/onboarding/create.d.ts.map +1 -1
  48. package/dist/src/onboarding/create.js +9 -10
  49. package/dist/src/onboarding/create.js.map +1 -1
  50. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  51. package/dist/src/onboarding/doctor.js +80 -0
  52. package/dist/src/onboarding/doctor.js.map +1 -1
  53. package/dist/src/onboarding/init.d.ts +1 -0
  54. package/dist/src/onboarding/init.d.ts.map +1 -1
  55. package/dist/src/onboarding/init.js +11 -0
  56. package/dist/src/onboarding/init.js.map +1 -1
  57. package/dist/src/onboarding/npm-token-check.d.ts +40 -0
  58. package/dist/src/onboarding/npm-token-check.d.ts.map +1 -0
  59. package/dist/src/onboarding/npm-token-check.js +104 -0
  60. package/dist/src/onboarding/npm-token-check.js.map +1 -0
  61. package/dist/src/onboarding/scaffold-project.d.ts.map +1 -1
  62. package/dist/src/onboarding/scaffold-project.js +10 -0
  63. package/dist/src/onboarding/scaffold-project.js.map +1 -1
  64. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  65. package/dist/src/onboarding/upgrade.js +4 -2
  66. package/dist/src/onboarding/upgrade.js.map +1 -1
  67. package/dist/src/profiles/profile-schema.d.ts +2 -0
  68. package/dist/src/profiles/profile-schema.d.ts.map +1 -1
  69. package/dist/src/profiles/profile-schema.js +1 -0
  70. package/dist/src/profiles/profile-schema.js.map +1 -1
  71. package/dist/src/profiles/stack-profile.d.ts +1 -0
  72. package/dist/src/profiles/stack-profile.d.ts.map +1 -1
  73. package/dist/src/profiles/stack-profile.js +2 -0
  74. package/dist/src/profiles/stack-profile.js.map +1 -1
  75. package/os/adr/handlers/implement-stamp.ts +34 -2
  76. package/os/adr/types.ts +3 -1
  77. package/os/compass/handlers/compass-audit-handler.ts +3 -0
  78. package/os/compass/handlers/compass-change-summary-handler.ts +1 -0
  79. package/os/compass/handlers/compass-inventory-handler.ts +1 -0
  80. package/os/compass/handlers/compass-inventory.ts +20 -6
  81. package/os/compass/handlers/compass-migrate-handler.ts +1 -0
  82. package/os/compass/handlers/compass-migrate.ts +9 -2
  83. package/os/compass/handlers/resolve-scan-root.ts +1 -1
  84. package/os/core/handlers/pinned-validate.ts +33 -7
  85. package/os/naming/naming-convention.ts +2 -0
  86. package/os/rfc/frontmatter-io.ts +1 -0
  87. package/os/rfc/handlers/validate-rules.ts +8 -1
  88. package/os/werkstatt/handlers/workspace-deps.ts +4 -3
  89. package/package.json +1 -1
  90. package/profiles/site-workshop.yaml +654 -0
  91. package/skills/fo/fo-idea-create-rfc/acceptance-criteria-standard.md +1 -1
  92. package/src/config/forge-config.ts +59 -5
  93. package/src/onboarding/create.ts +9 -9
  94. package/src/onboarding/doctor-token-check.test.ts +124 -0
  95. package/src/onboarding/doctor.ts +87 -0
  96. package/src/onboarding/init.ts +20 -1
  97. package/src/onboarding/npm-token-check.ts +140 -0
  98. package/src/onboarding/scaffold-project.ts +11 -0
  99. package/src/onboarding/site-workshop-profile.test.ts +84 -0
  100. package/src/onboarding/upgrade.ts +4 -1
  101. package/src/profiles/profile-schema.ts +2 -0
  102. package/src/profiles/stack-profile.ts +2 -0
  103. package/src/tests/adr-implement-stamp.test.ts +150 -0
  104. package/src/tests/doctor-profile-id-known.test.ts +237 -0
  105. package/src/tests/pinned-validate.test.ts +134 -0
  106. package/src/tests/profile-schema.test.ts +1 -1
@@ -197,7 +197,10 @@ export const forgeConfigSchema = z.object({
197
197
  })
198
198
  .optional(),
199
199
  /** RFC-0643: profile id — when present, loadForgeConfig loads the corresponding profiles/<id>.yaml */
200
- profile: z.string().optional(),
200
+ // RFC-1118: accept the corrupted object form (whole StackProfile written by a
201
+ // previous buggy serialize) so loadForgeConfig can recover the declared id —
202
+ // a hard schema throw here would deadlock `forge upgrade`, the healing path.
203
+ profile: z.union([z.string(), z.object({ id: z.string().min(1) }).passthrough()]).optional(),
201
204
  /** RFC-0704: packages with autonomous npm versions — ecosystem.commit skips platform bump when all staged files belong to these packages */
202
205
  independentVersionPackages: z.array(z.string()).optional(),
203
206
  });
@@ -230,6 +233,10 @@ export interface ForgeConfig {
230
233
  forge?: { syncedVersion: string | null };
231
234
  /** RFC-0643: loaded stack profile — present when forge.yaml has a `profile` field */
232
235
  profile?: StackProfile;
236
+ /** RFC-1118: raw `profile:` id string from forge.yaml — preserved verbatim even when unresolvable */
237
+ profileDeclaredId?: string;
238
+ /** RFC-1118: resolution outcome for the declared `profile:` id */
239
+ profileResolution?: "resolved" | "unknown" | "absent" | "catalog-unavailable";
233
240
  /** RFC-0704: packages with autonomous npm versions — ecosystem.commit skips platform bump when all staged files belong to these packages */
234
241
  independentVersionPackages?: string[];
235
242
  }
@@ -382,21 +389,45 @@ export function loadForgeConfig(workspaceRoot: string, forgeRootOverride?: strin
382
389
  }
383
390
 
384
391
  const rawData = result.data as Record<string, unknown>;
385
- const profileId = rawData["profile"] as string | undefined;
392
+ // RFC-1118: recover the declared id even when a previous buggy serialize wrote
393
+ // the whole StackProfile object into forge.yaml (object with an `id` field).
394
+ const rawProfile = rawData["profile"];
395
+ const profileId =
396
+ typeof rawProfile === "string"
397
+ ? rawProfile
398
+ : rawProfile && typeof rawProfile === "object" && "id" in rawProfile
399
+ ? String((rawProfile as { id: unknown }).id)
400
+ : undefined;
386
401
 
387
402
  // RFC-0643: load stack profile when `profile` field is present in forge.yaml
388
403
  let loadedProfile: StackProfile | undefined;
404
+ let catalogAvailable = false;
389
405
  if (profileId) {
390
406
  try {
391
407
  const forgeRoot = forgeRootOverride ?? resolveForgeRoot(workspaceRoot);
392
- const profiles = listStackProfiles(forgeRoot);
393
- loadedProfile = profiles.find((p) => p.id === profileId);
408
+ // RFC-1118: listStackProfiles returns [] on a missing profiles/ dir — an
409
+ // unreadable catalog (forge not installed, fallback path) must surface as
410
+ // "catalog-unavailable", not "unknown" (which would fail doctor spuriously).
411
+ if (fs.existsSync(path.join(forgeRoot, "profiles"))) {
412
+ const profiles = listStackProfiles(forgeRoot);
413
+ catalogAvailable = true;
414
+ loadedProfile = profiles.find((p) => p.id === profileId);
415
+ }
394
416
  } catch {
395
- // forge root not resolvable — profile not loaded
417
+ // forge root not resolvable — catalog unavailable, resolvability undetermined
396
418
  }
397
419
  }
398
420
 
399
421
  const config = { ...rawData } as unknown as ForgeConfig;
422
+ // RFC-1118: preserve the declared id and its resolution status — never silently drop it
423
+ config.profileDeclaredId = profileId;
424
+ config.profileResolution = !profileId
425
+ ? "absent"
426
+ : loadedProfile
427
+ ? "resolved"
428
+ : catalogAvailable
429
+ ? "unknown"
430
+ : "catalog-unavailable";
400
431
  if (loadedProfile) {
401
432
  config.profile = loadedProfile;
402
433
  } else {
@@ -406,6 +437,29 @@ export function loadForgeConfig(workspaceRoot: string, forgeRootOverride?: strin
406
437
  return config;
407
438
  }
408
439
 
440
+ // ---------------------------------------------------------------------------
441
+ // Serializer — RFC-1118: forge.yaml stores the declared profile id (string),
442
+ // never the resolved StackProfile object, and never drops a declared id.
443
+ // ---------------------------------------------------------------------------
444
+
445
+ export function serializeForgeConfig(config: ForgeConfig): Record<string, unknown> {
446
+ const out = { ...config } as unknown as Record<string, unknown>;
447
+ // Runtime-only fields must not be persisted
448
+ delete out["profileDeclaredId"];
449
+ delete out["profileResolution"];
450
+ // Prefer the preserved declared id; fall back to a raw string `profile`
451
+ // (hand-built configs that never went through loadForgeConfig).
452
+ const declared =
453
+ config.profileDeclaredId ??
454
+ (typeof config.profile === "string" ? (config.profile as unknown as string) : undefined);
455
+ if (declared) {
456
+ out["profile"] = declared;
457
+ } else {
458
+ delete out["profile"];
459
+ }
460
+ return out;
461
+ }
462
+
409
463
  // ---------------------------------------------------------------------------
410
464
  // Forge root resolver — the single place that decides monorepo vs npm-installed
411
465
  // ---------------------------------------------------------------------------
@@ -32,6 +32,7 @@ import { runAgentsGenerate } from "./agents-generate.ts";
32
32
  import { scaffoldMemoryLayer } from "./memory-scaffold.ts";
33
33
  import {
34
34
  loadForgeConfig,
35
+ serializeForgeConfig,
35
36
  resolveForgeRoot,
36
37
  resolvePackageManager,
37
38
  applyCliBindingDefaults,
@@ -93,6 +94,9 @@ function loadProfileDomainFields(profileId: string, forgeRoot: string): InitDoma
93
94
  if (profile.compass) {
94
95
  fields.compass = profile.compass;
95
96
  }
97
+ if (profile.nextSteps) {
98
+ fields.nextSteps = profile.nextSteps;
99
+ }
96
100
  return fields;
97
101
  } catch {
98
102
  return {};
@@ -348,13 +352,9 @@ export async function runCreate(
348
352
  if (config.bindings) {
349
353
  config.bindings.commands = applyCliBindingDefaults(pm);
350
354
  }
351
- // RFC-0643: strip loaded profile object before serializing — forge.yaml stores profile id (string), not the full profile
352
- const configToSerialize = { ...config } as unknown as Record<string, unknown>;
353
- if (typeof configToSerialize["profile"] === "object") {
354
- const profileObj = configToSerialize["profile"] as { id?: string };
355
- configToSerialize["profile"] = profileObj.id ?? configToSerialize["profile"];
356
- }
357
- fs.writeFileSync(forgeYamlPath, stringifyYaml(configToSerialize), "utf8");
355
+ // RFC-1118: serializeForgeConfig writes the declared profile id verbatim —
356
+ // never the resolved StackProfile object, never drops an unresolvable id.
357
+ fs.writeFileSync(forgeYamlPath, stringifyYaml(serializeForgeConfig(config)), "utf8");
358
358
  } catch {
359
359
  // Post-processing is best-effort — init already wrote a valid config
360
360
  }
@@ -387,9 +387,9 @@ export async function runCreate(
387
387
  }
388
388
  }
389
389
 
390
- // 13. Write NEXT_STEPS.md (RFC-0550)
390
+ // 13. Write NEXT_STEPS.md (RFC-0550); RFC-1125: profile may override via nextSteps field
391
391
  const nextStepsPath = path.join(targetDir, "NEXT_STEPS.md");
392
- const nextStepsContent = `# Next Steps
392
+ const nextStepsContent = initDomainFields.nextSteps ?? `# Next Steps
393
393
 
394
394
  Your Forge project is ready. The next step is mandatory: run \`/forge-bootstrap\` to configure your project interactively.
395
395
 
@@ -0,0 +1,124 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for the npm-token probe (RFC-1125, AC-2) — .npmrc parsing, token presence detection, and the fail-fast fixHint contract used by forge create and forge.doctor.</purpose>
4
+ </MODULE_CONTRACT>
5
+ <CHANGE_SUMMARY>
6
+ <item>RFC-1125: initial npm-token probe tests (AC-2 evidence).</item>
7
+ </CHANGE_SUMMARY>
8
+ */
9
+
10
+ import { test, expect, beforeEach, afterEach } from "vitest";
11
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import {
15
+ checkNpmToken,
16
+ readNpmrcTokenStatus,
17
+ workshopNeedsWarpgogolToken,
18
+ } from "./npm-token-check.ts";
19
+
20
+ let tempDir: string;
21
+
22
+ beforeEach(async () => {
23
+ tempDir = await mkdtemp(join(tmpdir(), "npm-token-check-"));
24
+ });
25
+
26
+ afterEach(async () => {
27
+ await rm(tempDir, { recursive: true, force: true });
28
+ });
29
+
30
+ const NPMRC_WITH_TOKEN = `@warpgogol:registry=https://registry.npmjs.org/
31
+ //registry.npmjs.org/:_authToken=npm_real_token_123
32
+ `;
33
+
34
+ const NPMRC_PLACEHOLDER = `@warpgogol:registry=https://registry.npmjs.org/
35
+ //registry.npmjs.org/:_authToken=YOUR_NPM_TOKEN
36
+ `;
37
+
38
+ const NPMRC_ENV_REF = `@warpgogol:registry=https://registry.npmjs.org/
39
+ //registry.npmjs.org/:_authToken=\${NPM_TOKEN}
40
+ `;
41
+
42
+ // --- readNpmrcTokenStatus ---
43
+
44
+ test("detects registry and literal token from .npmrc", async () => {
45
+ await writeFile(join(tempDir, ".npmrc"), NPMRC_WITH_TOKEN);
46
+ const status = readNpmrcTokenStatus(tempDir, {});
47
+ expect(status.registry).toBe("https://registry.npmjs.org/");
48
+ expect(status.tokenPresent).toBe(true);
49
+ });
50
+
51
+ test("YOUR_NPM_TOKEN placeholder does not count as a token", async () => {
52
+ await writeFile(join(tempDir, ".npmrc"), NPMRC_PLACEHOLDER);
53
+ const status = readNpmrcTokenStatus(tempDir, {});
54
+ expect(status.tokenPresent).toBe(false);
55
+ });
56
+
57
+ test("NPM_TOKEN env satisfies the probe even with placeholder .npmrc", async () => {
58
+ await writeFile(join(tempDir, ".npmrc"), NPMRC_PLACEHOLDER);
59
+ const status = readNpmrcTokenStatus(tempDir, { NPM_TOKEN: "npm_env_token" });
60
+ expect(status.tokenPresent).toBe(true);
61
+ });
62
+
63
+ test("${NPM_TOKEN} reference resolves against env", async () => {
64
+ await writeFile(join(tempDir, ".npmrc"), NPMRC_ENV_REF);
65
+ expect(readNpmrcTokenStatus(tempDir, { NPM_TOKEN: "x" }).tokenPresent).toBe(true);
66
+ expect(readNpmrcTokenStatus(tempDir, {}).tokenPresent).toBe(false);
67
+ });
68
+
69
+ test("missing .npmrc → no registry, no token", () => {
70
+ const status = readNpmrcTokenStatus(tempDir, {});
71
+ expect(status.registry).toBeNull();
72
+ expect(status.tokenPresent).toBe(false);
73
+ });
74
+
75
+ test("detects _authToken when it is not the last line (scaffolded .npmrc shape)", async () => {
76
+ // Regression: scaffolded .npmrc appends dangerously-allow-all-builds and
77
+ // ignore-workspace-root-check AFTER the token line — the regex must match mid-file.
78
+ await writeFile(
79
+ join(tempDir, ".npmrc"),
80
+ `@warpgogol:registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=npm_real_token\ndangerously-allow-all-builds=true\nignore-workspace-root-check=true\n`,
81
+ );
82
+ const status = readNpmrcTokenStatus(tempDir, {});
83
+ expect(status.tokenPresent).toBe(true);
84
+ });
85
+
86
+ // --- workshopNeedsWarpgogolToken ---
87
+
88
+ test("needs token when .npmrc declares @warpgogol scope", async () => {
89
+ await writeFile(join(tempDir, ".npmrc"), NPMRC_PLACEHOLDER);
90
+ expect(workshopNeedsWarpgogolToken(tempDir, [])).toBe(true);
91
+ });
92
+
93
+ test("needs token when install pulls @warpgogol packages", () => {
94
+ expect(workshopNeedsWarpgogolToken(tempDir, ["pnpm add -wD @warpgogol/forge"])).toBe(true);
95
+ });
96
+
97
+ test("skips probe when no @warpgogol usage", () => {
98
+ expect(workshopNeedsWarpgogolToken(tempDir, ["pnpm add -wD prettier"])).toBe(false);
99
+ });
100
+
101
+ // --- checkNpmToken ---
102
+
103
+ test("canFetch=true → fixHint is null", async () => {
104
+ await writeFile(join(tempDir, ".npmrc"), NPMRC_WITH_TOKEN);
105
+ const result = await checkNpmToken(tempDir, { fetchVersion: () => true, env: {} });
106
+ expect(result.canFetch).toBe(true);
107
+ expect(result.fixHint).toBeNull();
108
+ });
109
+
110
+ test("canFetch=false with token → fixHint mentions expiry/revocation", async () => {
111
+ await writeFile(join(tempDir, ".npmrc"), NPMRC_WITH_TOKEN);
112
+ const result = await checkNpmToken(tempDir, { fetchVersion: () => false, env: {} });
113
+ expect(result.canFetch).toBe(false);
114
+ expect(result.fixHint).toContain("expired or revoked");
115
+ });
116
+
117
+ test("canFetch=false without token → fixHint names NPM_TOKEN and .npmrc", async () => {
118
+ await writeFile(join(tempDir, ".npmrc"), NPMRC_PLACEHOLDER);
119
+ const result = await checkNpmToken(tempDir, { fetchVersion: () => false, env: {} });
120
+ expect(result.canFetch).toBe(false);
121
+ // AC-2: the fixHint must be actionable — name the exact remediation path
122
+ expect(result.fixHint).toContain("NPM_TOKEN");
123
+ expect(result.fixHint).toContain(".npmrc");
124
+ });
@@ -49,7 +49,9 @@ import type { DuplicatePair } from "../knowledge/index.ts";
49
49
  import { checkMemoryLayerHealth } from "./memory-scaffold.ts";
50
50
  import { checkInvariants } from "./invariant-engine.ts";
51
51
  import type { InvariantViolation } from "./invariant-engine.ts";
52
+ import { checkNpmToken, workshopNeedsWarpgogolToken } from "./npm-token-check.ts";
52
53
  import { execSync } from "node:child_process";
54
+ import fs from "node:fs";
53
55
  import type { ProfilePrerequisite } from "../profiles/profile-schema.ts";
54
56
 
55
57
  interface DoctorCheck {
@@ -1023,6 +1025,72 @@ function checkDomainInfo(domainReport: DomainReport): DoctorCheck {
1023
1025
  };
1024
1026
  }
1025
1027
 
1028
+ // RFC-1118: fail-closed diagnostic for an unresolvable `profile:` id.
1029
+ // A declared id absent from the installed catalog is a hard failure —
1030
+ // never silently dropped. An unreadable catalog is a warning, not a fail.
1031
+ function checkProfileIdKnown(workspaceRoot: string, forgeRoot: string): DoctorCheck {
1032
+ const forgeYamlPath = join(workspaceRoot, "forge.yaml");
1033
+ if (!fs.existsSync(forgeYamlPath)) {
1034
+ return {
1035
+ name: "profile-id-known",
1036
+ status: "pass",
1037
+ message: "no forge.yaml — nothing to resolve",
1038
+ };
1039
+ }
1040
+ let config: ReturnType<typeof loadForgeConfig>;
1041
+ try {
1042
+ config = loadForgeConfig(workspaceRoot, forgeRoot);
1043
+ } catch (err) {
1044
+ // forge.yaml exists but is unreadable/schema-invalid — a real defect, fail-closed
1045
+ return {
1046
+ name: "profile-id-known",
1047
+ status: "fail",
1048
+ message: `forge.yaml unreadable: ${(err as Error).message.split("\n")[0]}`,
1049
+ };
1050
+ }
1051
+
1052
+ switch (config.profileResolution) {
1053
+ case "unknown": {
1054
+ let version = "unknown";
1055
+ try {
1056
+ const pkg = JSON.parse(fs.readFileSync(join(forgeRoot, "package.json"), "utf8")) as {
1057
+ version?: string;
1058
+ };
1059
+ version = pkg.version ?? "unknown";
1060
+ } catch {
1061
+ // version unreadable — message still names the declared id
1062
+ }
1063
+ return {
1064
+ name: "profile-id-known",
1065
+ status: "fail",
1066
+ message:
1067
+ `profile "${config.profileDeclaredId}" not in installed @warpgogol/forge v${version} catalog — ` +
1068
+ `renamed/removed id or forge upgrade required`,
1069
+ };
1070
+ }
1071
+ case "catalog-unavailable":
1072
+ return {
1073
+ name: "profile-id-known",
1074
+ status: "warn",
1075
+ message:
1076
+ `profile "${config.profileDeclaredId}" declared but the installed profile catalog cannot be read ` +
1077
+ `(forge root unresolvable) — resolvability undetermined`,
1078
+ };
1079
+ case "resolved":
1080
+ return {
1081
+ name: "profile-id-known",
1082
+ status: "pass",
1083
+ message: `profile "${config.profileDeclaredId}" resolved`,
1084
+ };
1085
+ default:
1086
+ return {
1087
+ name: "profile-id-known",
1088
+ status: "pass",
1089
+ message: "no profile declared",
1090
+ };
1091
+ }
1092
+ }
1093
+
1026
1094
  // RFC-0664: Memory layer health check
1027
1095
  function checkMemoryLayer(workspaceRoot: string): DoctorCheck {
1028
1096
  const health = checkMemoryLayerHealth(workspaceRoot);
@@ -1205,6 +1273,22 @@ export async function runDoctor(
1205
1273
  : "docs/adrs/ directory not found — run 'forge create' to create ADR directory",
1206
1274
  });
1207
1275
 
1276
+ // RFC-1125: re-verify the @warpgogol npm token post-install when the workshop
1277
+ // uses the private scope (.npmrc declares it or package.json has @warpgogol deps)
1278
+ const pkgJsonPath = join(workspaceRoot, "package.json");
1279
+ const pkgHasWarpgogol =
1280
+ fs.existsSync(pkgJsonPath) && fs.readFileSync(pkgJsonPath, "utf8").includes("@warpgogol/");
1281
+ if (workshopNeedsWarpgogolToken(workspaceRoot, []) || pkgHasWarpgogol) {
1282
+ const tokenCheck = await checkNpmToken(workspaceRoot);
1283
+ checks.push({
1284
+ name: "npm-token (@warpgogol)",
1285
+ status: tokenCheck.canFetch ? "pass" : "fail",
1286
+ message: tokenCheck.canFetch
1287
+ ? `npm token fetches @warpgogol/werkstatt-engine (${tokenCheck.registry ?? "default registry"})`
1288
+ : tokenCheck.fixHint!,
1289
+ });
1290
+ }
1291
+
1208
1292
  // Check git branch is 'main' (not 'master')
1209
1293
  checks.push(checkGitBranch(workspaceRoot));
1210
1294
 
@@ -1222,6 +1306,9 @@ export async function runDoctor(
1222
1306
  // RFC-0640: Domain info check
1223
1307
  checks.push(checkDomainInfo(domainReport));
1224
1308
 
1309
+ // RFC-1118: profile identity check — fail-closed on unresolvable declared id
1310
+ checks.push(checkProfileIdKnown(workspaceRoot, forgeRoot));
1311
+
1225
1312
  // RFC-0675: Enforce invariants using the invariant engine
1226
1313
  if (domainReport.invariants.length > 0) {
1227
1314
  const profiles = listStackProfiles(forgeRoot);
@@ -49,6 +49,7 @@ export interface InitDomainFields {
49
49
  terminology?: Record<string, string>;
50
50
  semanticBindings?: Record<string, string | null>;
51
51
  profileId?: string;
52
+ nextSteps?: string;
52
53
  compass?: {
53
54
  fileExtensions?: string[];
54
55
  testPatterns?: string[];
@@ -60,7 +61,11 @@ export function runInit(
60
61
  context: unknown,
61
62
  domainFields?: InitDomainFields,
62
63
  ): InitResult {
63
- const ctx = context as { workspaceRoot?: string; forgeRoot?: string };
64
+ const ctx = context as {
65
+ workspaceRoot?: string;
66
+ forgeRoot?: string;
67
+ logger?: { warn?: (msg: string) => void };
68
+ };
64
69
  const workspaceRoot = ctx?.workspaceRoot ?? process.cwd();
65
70
  const contextForgeRoot = ctx?.forgeRoot;
66
71
 
@@ -115,6 +120,20 @@ export function runInit(
115
120
  } catch {
116
121
  config = defaultForgeConfig(path.basename(workspaceRoot));
117
122
  }
123
+ // RFC-1118: a declared `profile:` is never overwritten by re-detection.
124
+ // If --from detection disagrees with the declared id, surface a warning —
125
+ // the declared id stays verbatim in forge.yaml.
126
+ if (
127
+ detection?.profile &&
128
+ config.profileDeclaredId &&
129
+ detection.profile !== config.profileDeclaredId
130
+ ) {
131
+ const msg =
132
+ `profile: declared id "${config.profileDeclaredId}" preserved — ` +
133
+ `detected "${detection.profile}" not written (forge.yaml already exists)`;
134
+ skipped.push(msg);
135
+ ctx?.logger?.warn?.(msg);
136
+ }
118
137
  } else {
119
138
  const projectName = path.basename(workspaceRoot);
120
139
  config = defaultForgeConfig(projectName);
@@ -0,0 +1,140 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Non-interactive npm-token probe for the private @warpgogol registry scope (RFC-1125) — parses .npmrc for the @warpgogol scope registry and auth token presence, probes the registry via `npm view @warpgogol/werkstatt-engine version` (injectable for tests) before pnpm install runs in forge create, re-verifies post-install in forge.doctor, and returns a structured NpmTokenCheck with an actionable fixHint on failure.</purpose>
4
+ <non-goals>
5
+ <item>Do not prompt the operator — the probe is non-interactive (create.ts forbids prompts).</item>
6
+ <item>Do not accept tokens via CLI flags — tokens must not appear in argv/history.</item>
7
+ </non-goals>
8
+ </MODULE_CONTRACT>
9
+ <CHANGE_SUMMARY>
10
+ <item>RFC-1125: initial npm-token probe — checkNpmToken + readNpmrcTokenStatus, wired into scaffold-project (pre-install fail-fast) and doctor (post-install re-verify).</item>
11
+ </CHANGE_SUMMARY>
12
+ */
13
+
14
+ import { execSync } from "node:child_process";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ /** Result of the npm-token probe (RFC-1125). */
19
+ export interface NpmTokenCheck {
20
+ /** Registry URL from `.npmrc` `@warpgogol:registry=` (null when undeclared). */
21
+ registry: string | null;
22
+ /** A usable token is configured — NPM_TOKEN env or a real _authToken value. */
23
+ tokenPresent: boolean;
24
+ /** `npm view @warpgogol/werkstatt-engine version` succeeded from workspaceRoot. */
25
+ canFetch: boolean;
26
+ /** Actionable remediation when canFetch is false; null on success. */
27
+ fixHint: string | null;
28
+ }
29
+
30
+ /** Injectable dependencies for testing. */
31
+ export interface NpmTokenProbeDeps {
32
+ /** Override the registry probe — return true when the fetch succeeds. */
33
+ fetchVersion?: (cwd: string) => boolean;
34
+ /** Override the environment (defaults to process.env). */
35
+ env?: Record<string, string | undefined>;
36
+ }
37
+
38
+ const WARPGOGOL_SCOPE = "@warpgogol";
39
+ const PROBE_PACKAGE = "@warpgogol/werkstatt-engine";
40
+ const PROBE_TIMEOUT_MS = 15_000;
41
+ const PLACEHOLDER_TOKENS = new Set(["YOUR_NPM_TOKEN", ""]);
42
+
43
+ /**
44
+ * Parse the workspace `.npmrc` for the @warpgogol scope: registry URL and
45
+ * whether a usable auth token is configured. A token counts as present when
46
+ * NPM_TOKEN is set in the env, or when `_authToken` holds a real value
47
+ * (not the YOUR_NPM_TOKEN placeholder, not an unset ${VAR} reference).
48
+ */
49
+ export function readNpmrcTokenStatus(
50
+ workspaceRoot: string,
51
+ env: Record<string, string | undefined> = process.env,
52
+ ): { registry: string | null; tokenPresent: boolean } {
53
+ const npmrcPath = path.join(workspaceRoot, ".npmrc");
54
+ const npmrc = fs.existsSync(npmrcPath) ? fs.readFileSync(npmrcPath, "utf8") : "";
55
+
56
+ const registryMatch = npmrc.match(/^@warpgogol:registry=(\S+)\s*$/m);
57
+ const registry = registryMatch?.[1] ?? null;
58
+
59
+ // Token sources, in precedence order:
60
+ // 1. NPM_TOKEN env (agent-driven installs; also satisfies ${NPM_TOKEN} refs)
61
+ if (env.NPM_TOKEN && env.NPM_TOKEN.trim().length > 0) {
62
+ return { registry, tokenPresent: true };
63
+ }
64
+
65
+ // 2. Literal _authToken in .npmrc for the scope's registry host.
66
+ // `m` flag required — the token line is never last in a scaffolded .npmrc
67
+ // (dangerously-allow-all-builds + appended ignore-workspace-root-check follow it).
68
+ const tokenMatch = npmrc.match(/_authToken=(\S+)[ \t]*$/m);
69
+ if (tokenMatch) {
70
+ const value = tokenMatch[1];
71
+ // ${VAR} references resolve at install time — count as present only when set
72
+ const varRef = value.match(/^\$\{(\w+)\}$/);
73
+ if (varRef) {
74
+ const envValue = env[varRef[1]];
75
+ return { registry, tokenPresent: !!envValue && envValue.trim().length > 0 };
76
+ }
77
+ return { registry, tokenPresent: !PLACEHOLDER_TOKENS.has(value) };
78
+ }
79
+
80
+ return { registry, tokenPresent: false };
81
+ }
82
+
83
+ /**
84
+ * Whether the workspace needs the @warpgogol token at all — true when `.npmrc`
85
+ * declares the scope or any install command pulls `@warpgogol/` packages.
86
+ * Profiles without @warpgogol deps skip the probe entirely.
87
+ */
88
+ export function workshopNeedsWarpgogolToken(workspaceRoot: string, installCmds: string[]): boolean {
89
+ const npmrcPath = path.join(workspaceRoot, ".npmrc");
90
+ if (fs.existsSync(npmrcPath)) {
91
+ const npmrc = fs.readFileSync(npmrcPath, "utf8");
92
+ if (npmrc.includes(`${WARPGOGOL_SCOPE}:registry`) || npmrc.includes(`${WARPGOGOL_SCOPE}/`)) {
93
+ return true;
94
+ }
95
+ }
96
+ return installCmds.some((cmd) => cmd.includes(`${WARPGOGOL_SCOPE}/`));
97
+ }
98
+
99
+ function defaultFetchVersion(cwd: string): boolean {
100
+ try {
101
+ execSync(`npm view ${PROBE_PACKAGE} version`, {
102
+ cwd,
103
+ stdio: "pipe",
104
+ timeout: PROBE_TIMEOUT_MS,
105
+ });
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function buildFixHint(tokenPresent: boolean): string {
113
+ const base =
114
+ `npm token for the @warpgogol scope cannot fetch ${PROBE_PACKAGE}. ` +
115
+ `Set NPM_TOKEN env or replace YOUR_NPM_TOKEN in .npmrc with a valid npm read token ` +
116
+ `(issued at workshop provisioning — see NEXT_STEPS.md §Token lifecycle).`;
117
+ return tokenPresent
118
+ ? `${base} A token is configured but was rejected — it may be expired or revoked; request a fresh one.`
119
+ : base;
120
+ }
121
+
122
+ /**
123
+ * Run the npm-token probe: parse `.npmrc`, then verify the registry actually
124
+ * serves the package. Non-interactive — never prompts, never reads argv.
125
+ */
126
+ export async function checkNpmToken(
127
+ workspaceRoot: string,
128
+ deps: NpmTokenProbeDeps = {},
129
+ ): Promise<NpmTokenCheck> {
130
+ const env = deps.env ?? process.env;
131
+ const fetchVersion = deps.fetchVersion ?? defaultFetchVersion;
132
+ const { registry, tokenPresent } = readNpmrcTokenStatus(workspaceRoot, env);
133
+ const canFetch = fetchVersion(workspaceRoot);
134
+ return {
135
+ registry,
136
+ tokenPresent,
137
+ canFetch,
138
+ fixHint: canFetch ? null : buildFixHint(tokenPresent),
139
+ };
140
+ }
@@ -22,6 +22,7 @@ import fs from "node:fs";
22
22
  import path from "node:path";
23
23
  import { execSync } from "node:child_process";
24
24
  import { listStackProfiles, type StackProfile } from "../profiles/stack-profile.ts";
25
+ import { checkNpmToken, workshopNeedsWarpgogolToken } from "./npm-token-check.ts";
25
26
  import { resolveForgeRoot } from "../config/forge-config.ts";
26
27
  import type {
27
28
  ForgeCommandInput,
@@ -166,6 +167,16 @@ export async function runScaffoldProject(
166
167
  }
167
168
  }
168
169
 
170
+ // RFC-1125: non-interactive npm-token probe — fail fast before pnpm install when
171
+ // the @warpgogol scope is needed but the token cannot fetch @warpgogol/werkstatt-engine.
172
+ // Runs after .npmrc is materialized so the probe sees the final token state.
173
+ if (profile.install.length > 0 && workshopNeedsWarpgogolToken(workspaceRoot, profile.install)) {
174
+ const tokenCheck = await checkNpmToken(workspaceRoot);
175
+ if (!tokenCheck.canFetch) {
176
+ return fail("forge.scaffold", profileId, tokenCheck.fixHint!, errors, outputFormat, logger);
177
+ }
178
+ }
179
+
169
180
  // Run ALL install commands AFTER all files are created (atomicity: no partial state if install fails)
170
181
  // Root workspace install commands
171
182
  if (outputFormat === "pretty" && profile.install.length > 0) {