@warpgogol/forge 5.3.3 → 5.3.4

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 (65) hide show
  1. package/dist/os/compass/compass.module.js +1 -1
  2. package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
  3. package/dist/os/compass/handlers/compass-inventory.js +5 -2
  4. package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
  5. package/dist/os/compass/handlers/summary-record.d.ts +6 -1
  6. package/dist/os/compass/handlers/summary-record.d.ts.map +1 -1
  7. package/dist/os/compass/handlers/summary-record.js +14 -5
  8. package/dist/os/compass/handlers/summary-record.js.map +1 -1
  9. package/dist/os/compass/handlers/tests/forge-yaml-fixture.d.ts +18 -0
  10. package/dist/os/compass/handlers/tests/forge-yaml-fixture.d.ts.map +1 -0
  11. package/dist/os/compass/handlers/tests/forge-yaml-fixture.js +64 -0
  12. package/dist/os/compass/handlers/tests/forge-yaml-fixture.js.map +1 -0
  13. package/dist/os/compass/policy.d.ts +6 -1
  14. package/dist/os/compass/policy.d.ts.map +1 -1
  15. package/dist/os/compass/policy.js +8 -2
  16. package/dist/os/compass/policy.js.map +1 -1
  17. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  18. package/dist/os/mission/handlers/archive.js +1 -2
  19. package/dist/os/mission/handlers/archive.js.map +1 -1
  20. package/dist/os/rfc/handlers/validate.js +1 -1
  21. package/dist/src/config/forge-config.d.ts +21 -3
  22. package/dist/src/config/forge-config.d.ts.map +1 -1
  23. package/dist/src/config/forge-config.js +36 -7
  24. package/dist/src/config/forge-config.js.map +1 -1
  25. package/dist/src/onboarding/create.d.ts.map +1 -1
  26. package/dist/src/onboarding/create.js +26 -10
  27. package/dist/src/onboarding/create.js.map +1 -1
  28. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  29. package/dist/src/onboarding/doctor.js +49 -2
  30. package/dist/src/onboarding/doctor.js.map +1 -1
  31. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  32. package/dist/src/onboarding/upgrade.js +24 -8
  33. package/dist/src/onboarding/upgrade.js.map +1 -1
  34. package/dist/src/validators/skill-validate.d.ts.map +1 -1
  35. package/dist/src/validators/skill-validate.js +42 -15
  36. package/dist/src/validators/skill-validate.js.map +1 -1
  37. package/os/compass/compass.module.ts +1 -1
  38. package/os/compass/handlers/compass-inventory.ts +6 -3
  39. package/os/compass/handlers/summary-record.ts +14 -5
  40. package/os/compass/handlers/tests/compass-policy.test.ts +15 -18
  41. package/os/compass/handlers/tests/compass-v2-contract.test.ts +38 -0
  42. package/os/compass/handlers/tests/forge-yaml-fixture.ts +69 -0
  43. package/os/compass/handlers/tests/summary-record.test.ts +25 -0
  44. package/os/compass/policy.ts +8 -2
  45. package/os/mission/handlers/archive.ts +1 -2
  46. package/os/rfc/handlers/validate.ts +1 -1
  47. package/package.json +1 -1
  48. package/skills/fo/fo-compass-annotate/SKILL.md +1 -1
  49. package/skills/fo/fo-handoff/SKILL.md +2 -2
  50. package/skills/fo/fo-idea-audit/SKILL.md +1 -1
  51. package/skills/fo/fo-idea-create-rfc/SKILL.md +1 -1
  52. package/skills/fo/fo-idea-implement/SKILL.md +4 -3
  53. package/skills/fo/fo-session-retro/SKILL.md +1 -1
  54. package/skills/fo/fo-session-save/SKILL.md +1 -1
  55. package/src/config/forge-config.ts +102 -57
  56. package/src/onboarding/create.ts +25 -9
  57. package/src/onboarding/doctor.ts +51 -2
  58. package/src/onboarding/upgrade.ts +36 -6
  59. package/src/tests/bindings-schema.test.ts +53 -1
  60. package/src/tests/doctor-bindings.test.ts +7 -2
  61. package/src/tests/doctor-skills-unmanaged.test.ts +145 -0
  62. package/src/tests/forge-config.test.ts +106 -2
  63. package/src/tests/skill-validate.test.ts +180 -0
  64. package/src/tests/upgrade.test.ts +60 -0
  65. package/src/validators/skill-validate.ts +43 -16
@@ -0,0 +1,145 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for forge.doctor skills-unmanaged check (RFC-1226): SKILL.md-bearing dirs without .forge-managed are reported as warnings.</purpose>
4
+ </MODULE_CONTRACT>
5
+ <CHANGE_SUMMARY>
6
+ <item>RFC-1226: initial tests — AC-1 warn on unmanaged, AC-2 pass when all marked, AC-3 skip non-skill dirs, AC-4 skip without config/skillsDir.</item>
7
+ </CHANGE_SUMMARY>
8
+ */
9
+
10
+ import { test, expect, beforeEach, afterEach } from "vitest";
11
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { runDoctor } from "../onboarding/doctor.ts";
15
+ import { buildSkillMarkerContent } from "../onboarding/skill-markers.ts";
16
+ import type { ForgeRuntimeContext, ForgeLogger } from "../types.ts";
17
+
18
+ const mockLogger: ForgeLogger = {
19
+ section: () => {},
20
+ info: () => {},
21
+ warn: () => {},
22
+ error: () => {},
23
+ success: () => {},
24
+ };
25
+
26
+ const mockContext = (workspaceRoot: string): ForgeRuntimeContext => ({
27
+ workspaceRoot,
28
+ logger: mockLogger,
29
+ dryRun: false,
30
+ outputFormat: "json",
31
+ });
32
+
33
+ let tempDir: string;
34
+
35
+ beforeEach(async () => {
36
+ tempDir = await mkdtemp(join(tmpdir(), "forge-doctor-unmanaged-test-"));
37
+ });
38
+
39
+ afterEach(async () => {
40
+ await rm(tempDir, { recursive: true, force: true });
41
+ });
42
+
43
+ const FORGE_YAML = `schema: forge/config@1
44
+ project:
45
+ name: test
46
+ stack: []
47
+ packageManager: pnpm
48
+ paths:
49
+ rfcsDir: docs/rfcs
50
+ adrsDir: docs/adrs
51
+ plansDir: docs/plans
52
+ auditsDir: docs/audits
53
+ specsDir: docs/specs
54
+ skillsDir: .agents/skills
55
+ bindings:
56
+ schema: forge/bindings@1
57
+ commands:
58
+ validateRfc: null
59
+ validateAdr: null
60
+ implementStamp: null
61
+ typecheck: null
62
+ test: null
63
+ scopedBuild: null
64
+ specValidate: null
65
+ sessionSave: null
66
+ paths:
67
+ invariantsFile: null
68
+ compassDocs: []
69
+ reviewsDir: null
70
+ handoffsDir: null
71
+ sessionsDir: null
72
+ `;
73
+
74
+ const SKILL_MD = "---\nname: test-skill\ndescription: test\n---\n# Test\n";
75
+
76
+ async function writeSkill(dir: string, name: string, managed: boolean): Promise<void> {
77
+ const skillDir = join(dir, ".agents", "skills", name);
78
+ await mkdir(skillDir, { recursive: true });
79
+ await writeFile(join(skillDir, "SKILL.md"), SKILL_MD, "utf8");
80
+ if (managed) {
81
+ await writeFile(
82
+ join(skillDir, ".forge-managed"),
83
+ buildSkillMarkerContent(["SKILL.md"]),
84
+ "utf8",
85
+ );
86
+ }
87
+ }
88
+
89
+ function findCheck(result: Awaited<ReturnType<typeof runDoctor>>) {
90
+ const check = result.data?.checks.find((c) => c.name === "skills-unmanaged");
91
+ expect(check, "skills-unmanaged check must be present").toBeDefined();
92
+ return check!;
93
+ }
94
+
95
+ test("AC-1: skills-unmanaged warns and names SKILL.md-bearing dirs without .forge-managed", async () => {
96
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
97
+ await mkdir(join(tempDir, ".agents", "skills"), { recursive: true });
98
+ await writeSkill(tempDir, "fo-managed-skill", true);
99
+ await writeSkill(tempDir, "ask-matt", false);
100
+ await writeSkill(tempDir, "tdd", false);
101
+
102
+ const result = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
103
+ const check = findCheck(result);
104
+ expect(check.status).toBe("warn");
105
+ expect(check.message).toContain("2 unmanaged skill dir(s)");
106
+ expect(check.message).toContain("ask-matt");
107
+ expect(check.message).toContain("tdd");
108
+ expect(check.message).not.toContain("fo-managed-skill");
109
+ });
110
+
111
+ test("AC-2: skills-unmanaged passes when every SKILL.md-bearing dir carries .forge-managed", async () => {
112
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
113
+ await mkdir(join(tempDir, ".agents", "skills"), { recursive: true });
114
+ await writeSkill(tempDir, "fo-managed-skill", true);
115
+ await writeSkill(tempDir, "grilling", true);
116
+
117
+ const result = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
118
+ const check = findCheck(result);
119
+ expect(check.status).toBe("pass");
120
+ });
121
+
122
+ test("AC-3: skills-unmanaged excludes dirs without SKILL.md regardless of markers", async () => {
123
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
124
+ const sharedDir = join(tempDir, ".agents", "skills", "_shared");
125
+ await mkdir(sharedDir, { recursive: true });
126
+ await writeFile(join(sharedDir, "notes.md"), "# support files\n", "utf8");
127
+ const knowledgeDir = join(tempDir, ".agents", "skills", "knowledge");
128
+ await mkdir(knowledgeDir, { recursive: true });
129
+ await writeFile(join(knowledgeDir, "learned-principles.md"), "# L2\n", "utf8");
130
+
131
+ const result = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
132
+ const check = findCheck(result);
133
+ expect(check.status).toBe("pass");
134
+ });
135
+
136
+ test("AC-4: skills-unmanaged passes without scanning when forge.yaml or skillsDir is absent", async () => {
137
+ // No forge.yaml at all
138
+ const noConfig = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
139
+ expect(findCheck(noConfig).status).toBe("pass");
140
+
141
+ // forge.yaml present, skillsDir absent
142
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
143
+ const noSkillsDir = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
144
+ expect(findCheck(noSkillsDir).status).toBe("pass");
145
+ });
@@ -6,6 +6,8 @@
6
6
  <item>RFC-0391: initial config tests.</item>
7
7
  <item>RFC-0538: added compass binding section tests.</item>
8
8
  <item>RFC-0540: added CLI binding defaults, pm-runner, and package-manager-aware defaultForgeConfig tests.</item>
9
+ <item>RFC-1224: adrImplementStamp binding, schema passthrough retention, applyForgeYamlPatches tests.</item>
10
+ <item>RFC-1224: preserve operator forge.yaml content on upgrade, promote adrImplementStamp binding</item>
9
11
  </CHANGE_SUMMARY>
10
12
  */
11
13
 
@@ -20,6 +22,7 @@ import {
20
22
  forgeConfigSchema,
21
23
  forgeBindingsSchema,
22
24
  resolveBinding,
25
+ applyForgeYamlPatches,
23
26
  FORGE_CLI_BINDING_DEFAULTS,
24
27
  resolvePmRunner,
25
28
  applyCliBindingDefaults,
@@ -63,12 +66,13 @@ test("resolvePmRunner falls back to npx for unknown", () => {
63
66
  expect(resolvePmRunner("")).toBe("npx");
64
67
  });
65
68
 
66
- test("FORGE_CLI_BINDING_DEFAULTS has 5 entries with correct keys", () => {
67
- expect(FORGE_CLI_BINDING_DEFAULTS).toHaveLength(5);
69
+ test("FORGE_CLI_BINDING_DEFAULTS has 6 entries with correct keys", () => {
70
+ expect(FORGE_CLI_BINDING_DEFAULTS).toHaveLength(6);
68
71
  const keys = FORGE_CLI_BINDING_DEFAULTS.map((e) => e.key);
69
72
  expect(keys).toContain("commands.validateRfc");
70
73
  expect(keys).toContain("commands.validateAdr");
71
74
  expect(keys).toContain("commands.implementStamp");
75
+ expect(keys).toContain("commands.adrImplementStamp");
72
76
  expect(keys).toContain("commands.specValidate");
73
77
  expect(keys).toContain("commands.sessionSave");
74
78
  });
@@ -134,6 +138,9 @@ test("applyCliBindingDefaults returns forge-CLI bindings non-null and stack bind
134
138
  expect(commands.validateRfc).not.toBeNull();
135
139
  expect(commands.validateAdr).not.toBeNull();
136
140
  expect(commands.implementStamp).not.toBeNull();
141
+ expect(commands.adrImplementStamp).toBe(
142
+ "pnpm exec forge adr.implement.stamp --id {id} --implementation-commit {commit}",
143
+ );
137
144
  expect(commands.specValidate).not.toBeNull();
138
145
  expect(commands.sessionSave).not.toBeNull();
139
146
  expect(commands.typecheck).toBeNull();
@@ -418,3 +425,100 @@ bindings:
418
425
  expect(config.bindings?.compass?.fileExtensions).toEqual([".ts", ".astro"]);
419
426
  expect(config.bindings?.compass?.testPatterns).toEqual(["*.test.ts", "*.spec.ts"]);
420
427
  });
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // RFC-1224: schema passthrough + forge.yaml document patching
431
+ // ---------------------------------------------------------------------------
432
+
433
+ test("RFC-1224: schema retains adrImplementStamp and consumer-defined command keys", async () => {
434
+ const yaml = `schema: forge/config@1
435
+ project:
436
+ name: consumer-project
437
+ stack: []
438
+ packageManager: pnpm
439
+ paths:
440
+ rfcsDir: docs/rfcs
441
+ adrsDir: docs/adrs
442
+ plansDir: docs/plans
443
+ auditsDir: docs/audits
444
+ specsDir: docs/specs
445
+ skillsDir: .agents/skills
446
+ sessionsDir: docs/sessions
447
+ bindings:
448
+ schema: forge/bindings@1
449
+ commands:
450
+ validateRfc: null
451
+ adrImplementStamp: pnpm exec forge adr.implement.stamp --id {id} --implementation-commit {commit}
452
+ createRfc: pnpm exec forge rfc.create
453
+ manifestGenerate: pnpm exec forge manifest.generate
454
+ paths:
455
+ invariantsFile: null
456
+ compassDocs: []
457
+ reviewsDir: null
458
+ handoffsDir: null
459
+ sessionsDir: null
460
+ plugins:
461
+ - name: my-plugin
462
+ `;
463
+ await writeFile(join(tempDir, "forge.yaml"), yaml, "utf8");
464
+ const config = loadForgeConfig(tempDir);
465
+
466
+ // Promoted key resolves through the typed interface
467
+ expect(config.bindings?.commands.adrImplementStamp).toBe(
468
+ "pnpm exec forge adr.implement.stamp --id {id} --implementation-commit {commit}",
469
+ );
470
+ // Consumer-defined keys stay resolvable via passthrough
471
+ expect(resolveBinding(config, "commands.createRfc")).toBe("pnpm exec forge rfc.create");
472
+ expect(resolveBinding(config, "commands.manifestGenerate")).toBe(
473
+ "pnpm exec forge manifest.generate",
474
+ );
475
+ // Unknown top-level sections survive the schema parse
476
+ expect((config as unknown as Record<string, unknown>)["plugins"]).toEqual([
477
+ { name: "my-plugin" },
478
+ ]);
479
+ });
480
+
481
+ test("RFC-1224: applyForgeYamlPatches preserves comments and consumer content", () => {
482
+ const raw = `schema: forge/config@1
483
+ # operator comment — must survive
484
+ project:
485
+ name: consumer-project
486
+ bindings:
487
+ schema: forge/bindings@1
488
+ commands:
489
+ validateRfc: null # inline comment
490
+ customKey: keep-me
491
+ plugins:
492
+ - name: my-plugin
493
+ forge:
494
+ syncedVersion: 0.1.0
495
+ `;
496
+ const patched = applyForgeYamlPatches(raw, [
497
+ { path: ["forge", "syncedVersion"], value: "0.2.0" },
498
+ { path: ["bindings", "commands", "adrImplementStamp"], value: "forge adr.implement.stamp" },
499
+ ]);
500
+
501
+ expect(patched).not.toBeNull();
502
+ expect(patched).toContain("syncedVersion: 0.2.0");
503
+ expect(patched).toContain("adrImplementStamp: forge adr.implement.stamp");
504
+ expect(patched).toContain("customKey: keep-me");
505
+ expect(patched).toContain("# operator comment — must survive");
506
+ expect(patched).toContain("# inline comment");
507
+ expect(patched).toContain("my-plugin");
508
+ });
509
+
510
+ test("RFC-1224: applyForgeYamlPatches creates missing intermediate maps", () => {
511
+ const patched = applyForgeYamlPatches("schema: forge/config@1\n", [
512
+ { path: ["forge", "syncedVersion"], value: "0.2.0" },
513
+ { path: ["bindings", "commands", "adrImplementStamp"], value: "x" },
514
+ ]);
515
+ expect(patched).toContain("syncedVersion: 0.2.0");
516
+ expect(patched).toContain("adrImplementStamp: x");
517
+ });
518
+
519
+ test("RFC-1224: applyForgeYamlPatches returns null on invalid YAML", () => {
520
+ const patched = applyForgeYamlPatches("a: [unclosed\n : :", [
521
+ { path: ["a"], value: 1 },
522
+ ]);
523
+ expect(patched).toBeNull();
524
+ });
@@ -259,3 +259,183 @@ describe("SKILL-21: knowledge layer token budget warnings (RFC-0661)", () => {
259
259
  expect(skill21Warnings).toEqual([]);
260
260
  });
261
261
  });
262
+
263
+ describe("RFC-1225: forge root + workspace config resolution", () => {
264
+ function createConsumerWorkspace(): { tmpDir: string; forgePkg: string } {
265
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forge-skill-consumer-"));
266
+ const forgePkg = path.join(tmpDir, "node_modules", "@warpgogol", "forge");
267
+ fs.mkdirSync(forgePkg, { recursive: true });
268
+ fs.writeFileSync(
269
+ path.join(forgePkg, "package.json"),
270
+ JSON.stringify({ name: "@warpgogol/forge", version: "9.9.9" }),
271
+ "utf8",
272
+ );
273
+ return { tmpDir, forgePkg };
274
+ }
275
+
276
+ test("AC-1: consumer layout validates skills under node_modules/@warpgogol/forge", async () => {
277
+ const { FORGE_SKILLS } = await import("../registry.ts");
278
+ const { tmpDir, forgePkg } = createConsumerWorkspace();
279
+ try {
280
+ // Materialize one registry skill inside the installed package copy —
281
+ // the others stay absent and produce SKILL-01, which is honest.
282
+ const entry = FORGE_SKILLS.find((s) => !(s.knowledge && s.knowledge.length > 0))!;
283
+ const skillPath = path.join(forgePkg, entry.path);
284
+ fs.mkdirSync(path.dirname(skillPath), { recursive: true });
285
+ fs.writeFileSync(
286
+ skillPath,
287
+ `---\nname: ${entry.name}\ndescription: Fixture skill for consumer root resolution\ninvocation: ${entry.invocation}\ncategory: ${entry.category}\nconcerns: ${entry.concerns}\ndependsOn: []\nlanguagePolicy: ref(PREFERENCES.md)\n---\n\nBefore starting, read \`PREFERENCES.md\` at the repository root.\n`,
288
+ "utf8",
289
+ );
290
+
291
+ const result = runSkillValidate({}, { workspaceRoot: tmpDir });
292
+ // The materialized skill was found → no SKILL-01 for it. Under the old
293
+ // hardcoded <workspaceRoot>/packages/forge path it could never exist.
294
+ const skill01 = result.violations.filter(
295
+ (v) => v.rule === "SKILL-01" && v.skill === entry.name,
296
+ );
297
+ expect(skill01).toEqual([]);
298
+ } finally {
299
+ fs.rmSync(tmpDir, { recursive: true, force: true });
300
+ }
301
+ });
302
+
303
+ test("AC-2: unresolvable forge root fails with a single SKILL-00", () => {
304
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forge-skill-noroot-"));
305
+ try {
306
+ const result = runSkillValidate({}, { workspaceRoot: tmpDir });
307
+ expect(result.status).toBe("fail");
308
+ expect(result.violations).toHaveLength(1);
309
+ expect(result.violations[0].rule).toBe("SKILL-00");
310
+ expect(result.violations[0].message).toContain("forge root not resolvable");
311
+ } finally {
312
+ fs.rmSync(tmpDir, { recursive: true, force: true });
313
+ }
314
+ });
315
+
316
+ test("AC-3: pack discovery reads forge.yaml from the workspace root", () => {
317
+ const { tmpDir } = createConsumerWorkspace();
318
+ try {
319
+ fs.writeFileSync(
320
+ path.join(tmpDir, "forge.yaml"),
321
+ `schema: forge/config@1
322
+ project:
323
+ name: consumer
324
+ stack: []
325
+ packageManager: pnpm
326
+ paths:
327
+ rfcsDir: docs/rfcs
328
+ adrsDir: docs/adrs
329
+ plansDir: docs/plans
330
+ auditsDir: docs/audits
331
+ specsDir: docs/specs
332
+ skillsDir: .agents/skills
333
+ sessionsDir: docs/sessions
334
+ skillPacks:
335
+ - prefix: wg
336
+ dir: packs/wg-skills
337
+ `,
338
+ "utf8",
339
+ );
340
+ const packDir = path.join(tmpDir, "packs", "wg-skills");
341
+ fs.mkdirSync(path.join(packDir, "wg-broken"), { recursive: true });
342
+ fs.writeFileSync(path.join(packDir, "forge.plugin.yaml"), "id: wg-skills\nversion: 1.0.0\n");
343
+ // Deliberately invalid pack skill — discovery proves itself by flagging it.
344
+ fs.writeFileSync(path.join(packDir, "wg-broken", "SKILL.md"), "# no frontmatter\n");
345
+
346
+ const result = runSkillValidate({}, { workspaceRoot: tmpDir });
347
+ const packViolations = result.violations.filter(
348
+ (v) => v.pack === "wg" && v.skill === "wg-broken",
349
+ );
350
+ expect(packViolations.length).toBeGreaterThan(0);
351
+ expect(packViolations.some((v) => v.rule === "SKILL-01")).toBe(true);
352
+ } finally {
353
+ fs.rmSync(tmpDir, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ test("AC-4: monorepo invocation resolves the real forge package root — no SKILL-01", async () => {
358
+ const { resolveForgeRoot } = await import("../config/forge-config.ts");
359
+ const root = process.cwd();
360
+ // cwd may be the repo root (packages/forge layout) or packages/forge
361
+ // itself (scoped test runs) — both must resolve to a root with skills/.
362
+ const forgeRoot = resolveForgeRoot(root);
363
+ expect(fs.existsSync(path.join(forgeRoot, "skills"))).toBe(true);
364
+ const result = runSkillValidate({}, { workspaceRoot: root });
365
+ const skill01 = result.violations.filter((v) => v.rule === "SKILL-01" && !v.pack);
366
+ expect(skill01).toEqual([]);
367
+ });
368
+ });
369
+
370
+ describe("RFC-1227: SKILL-17 comment-region exemption", () => {
371
+ // Access the checker through a fabricated skill on disk? checkSkill17 is not
372
+ // exported — exercise it via a consumer fixture containing a registry skill
373
+ // whose SKILL.md carries the content under test.
374
+ async function fixtureRun(bodyLines: string[]): Promise<ReturnType<typeof runSkillValidate>> {
375
+ const { FORGE_SKILLS } = await import("../registry.ts");
376
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forge-skill17-"));
377
+ const forgePkg = path.join(tmpDir, "node_modules", "@warpgogol", "forge");
378
+ fs.mkdirSync(forgePkg, { recursive: true });
379
+ fs.writeFileSync(
380
+ path.join(forgePkg, "package.json"),
381
+ JSON.stringify({ name: "@warpgogol/forge", version: "9.9.9" }),
382
+ "utf8",
383
+ );
384
+ const entry = FORGE_SKILLS.find((s) => !(s.knowledge && s.knowledge.length > 0))!;
385
+ const skillPath = path.join(forgePkg, entry.path);
386
+ fs.mkdirSync(path.dirname(skillPath), { recursive: true });
387
+ fs.writeFileSync(
388
+ skillPath,
389
+ [
390
+ "---",
391
+ `name: ${entry.name}`,
392
+ "description: Fixture skill for SKILL-17 scope tests",
393
+ `invocation: ${entry.invocation}`,
394
+ `category: ${entry.category}`,
395
+ `concerns: ${entry.concerns}`,
396
+ "dependsOn: []",
397
+ "languagePolicy: ref(PREFERENCES.md)",
398
+ "---",
399
+ ...bodyLines,
400
+ "Before starting, read `PREFERENCES.md` at the repository root.",
401
+ ].join("\n"),
402
+ "utf8",
403
+ );
404
+ try {
405
+ return runSkillValidate({}, { workspaceRoot: tmpDir });
406
+ } finally {
407
+ fs.rmSync(tmpDir, { recursive: true, force: true });
408
+ }
409
+ }
410
+
411
+ test("AC-1: RFC ids inside HTML comment regions produce no SKILL-17 violation", async () => {
412
+ const result = await fixtureRun([
413
+ "<!--",
414
+ "<CHANGE_SUMMARY>",
415
+ " <item>RFC-1097: sweep — SKILL.md headers + classification fixes</item>",
416
+ "</CHANGE_SUMMARY>",
417
+ "-->",
418
+ "Run `forge doctor` to inspect bindings. <!-- operator note: see RFC-0553 -->",
419
+ ]);
420
+ const idViolations = result.violations.filter((v) => v.rule === "SKILL-17");
421
+ expect(idViolations).toEqual([]);
422
+ });
423
+
424
+ test("AC-2: RFC ids in instruction text still violate SKILL-17", async () => {
425
+ const result = await fixtureRun([
426
+ "Stamp the implementation via `rfc.implement.stamp` (RFC-0756).",
427
+ ]);
428
+ const idViolations = result.violations.filter((v) => v.rule === "SKILL-17");
429
+ expect(idViolations.length).toBeGreaterThan(0);
430
+ expect(idViolations[0].message).toContain("RFC-0756");
431
+ });
432
+
433
+ test("AC-4: the file-level disable marker still suppresses all SKILL-17 violations", async () => {
434
+ const result = await fixtureRun([
435
+ "<!-- skill-lint-disable SKILL-17 -->",
436
+ "Referencing RFC-0996 in body text is suppressed by the marker.",
437
+ ]);
438
+ const idViolations = result.violations.filter((v) => v.rule === "SKILL-17");
439
+ expect(idViolations).toEqual([]);
440
+ });
441
+ });
@@ -5,6 +5,8 @@
5
5
  <CHANGE_SUMMARY>
6
6
  <item>RFC-0543: initial upgrade handler and VERSION sourcing tests.</item>
7
7
  <item>RFC-0552: add test for Forge-vs-pack skill name conflict detection in upgrade.</item>
8
+ <item>RFC-1224: forge.yaml preservation test — consumer keys, comments, and unknown sections survive upgrade rewrites.</item>
9
+ <item>RFC-1224: preserve operator forge.yaml content on upgrade, promote adrImplementStamp binding</item>
8
10
  </CHANGE_SUMMARY>
9
11
  */
10
12
 
@@ -21,6 +23,7 @@ import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-i
21
23
  import { runInit } from "../onboarding/init.ts";
22
24
  import {
23
25
  loadForgeConfig,
26
+ resolveBinding,
24
27
  FORGE_CLI_BINDING_DEFAULTS,
25
28
  resolvePmRunner,
26
29
  } from "../config/forge-config.ts";
@@ -206,6 +209,63 @@ test("forge.upgrade never overwrites a non-null operator-set binding", async ()
206
209
  expect(addedKeys).toContain("commands.implementStamp");
207
210
  expect(addedKeys).toContain("commands.specValidate");
208
211
  expect(addedKeys).toContain("commands.sessionSave");
212
+ expect(addedKeys).toContain("commands.adrImplementStamp");
213
+ });
214
+
215
+ test("RFC-1224: forge.upgrade preserves consumer-owned keys and comments in forge.yaml", async () => {
216
+ await setupForgeSource(tempDir, "0.2.0");
217
+ // Hand-write forge.yaml the way an operator would: a consumer-defined command
218
+ // binding, a schema-unknown top-level section, and comments.
219
+ const yaml = `schema: forge/config@1
220
+ # operator comment — must survive upgrade
221
+ project:
222
+ name: consumer-project
223
+ stack: []
224
+ packageManager: pnpm
225
+ paths:
226
+ rfcsDir: docs/rfcs
227
+ adrsDir: docs/adrs
228
+ plansDir: docs/plans
229
+ auditsDir: docs/audits
230
+ specsDir: docs/specs
231
+ skillsDir: .agents/skills
232
+ sessionsDir: docs/sessions
233
+ bindings:
234
+ schema: forge/bindings@1
235
+ commands:
236
+ validateRfc: pnpm exec forge rfc.validate --id {id} --json # inline comment
237
+ customRegistryCmd: custom registry command
238
+ paths:
239
+ invariantsFile: null
240
+ compassDocs: []
241
+ reviewsDir: null
242
+ handoffsDir: null
243
+ sessionsDir: null
244
+ plugins:
245
+ - name: my-plugin
246
+ forge:
247
+ syncedVersion: 0.1.0
248
+ `;
249
+ await writeFile(join(tempDir, "forge.yaml"), yaml, "utf8");
250
+
251
+ const result = await runUpgrade({ argv: [], flags: {} }, makeContext(tempDir, false));
252
+
253
+ expect(result.data?.status).toBe("pass");
254
+ const written = await readFile(join(tempDir, "forge.yaml"), "utf8");
255
+ // Synced version updated, operator content untouched
256
+ expect(written).toContain("syncedVersion: 0.2.0");
257
+ expect(written).toContain("customRegistryCmd: custom registry command");
258
+ expect(written).toContain("plugins:");
259
+ expect(written).toContain("my-plugin");
260
+ expect(written).toContain("# operator comment — must survive upgrade");
261
+ expect(written).toContain("# inline comment");
262
+ // Newly promoted forge default is added in place
263
+ expect(written).toContain("adrImplementStamp:");
264
+
265
+ const config = loadForgeConfig(tempDir);
266
+ expect(config.forge?.syncedVersion).toBe("0.2.0");
267
+ // Consumer-defined keys stay resolvable after passthrough parsing
268
+ expect(resolveBinding(config, "commands.customRegistryCmd")).toBe("custom registry command");
209
269
  });
210
270
 
211
271
  test("forge.upgrade refuses when forge.yaml is missing", async () => {
@@ -6,6 +6,8 @@
6
6
  </non-goals>
7
7
  </MODULE_CONTRACT>
8
8
  <CHANGE_SUMMARY>
9
+ <item>RFC-1225: forgeRoot resolves via resolveForgeRoot (monorepo or npm-installed package); unresolvable root returns a single SKILL-00; pack discovery loads forge.yaml from workspaceRoot.</item>
10
+ <item>RFC-1227: SKILL-17 exempts HTML comment regions (file metadata written by the platform's own changelog tooling); pack-skill loop drops SKILL-17 (RFC-0553 scoped it to shipped canonical skills only).</item>
9
11
  <item>RFC-0642: added SKILL-18 — forge skill instruction lines must not reference software-specific binding keys (typecheck, scopedBuild, test); use semantic keys (validate, produce, verify) instead.</item>
10
12
  <item>RFC-0660: added SKILL-19 (knowledge entry schema validity) and SKILL-20 (entry identifier uniqueness) for structured knowledge files.</item>
11
13
  <item>RFC-0661: added SKILL-21 (knowledge layer token budget warnings), refactored warning handling — warnings go to separate `warnings` array, not `violations`.</item>
@@ -67,14 +69,35 @@ const PREFERENCES_PATTERN = /Read `PREFERENCES\.md`/i;
67
69
  export function runSkillValidate(_input: unknown, context: unknown): SkillValidateResult {
68
70
  const ctx = context as { workspaceRoot?: string };
69
71
  const workspaceRoot = ctx?.workspaceRoot ?? process.cwd();
70
- const forgeRoot = path.join(workspaceRoot, "packages", "forge");
72
+
73
+ // RFC-1225: skill sources live under the forge package root — monorepo
74
+ // packages/forge or the consumer's node_modules/@warpgogol/forge — while
75
+ // forge.yaml lives under the workspace root. An unresolvable forge root
76
+ // surfaces as a single SKILL-00, never N cascading SKILL-01 ghosts.
77
+ let forgeRoot: string;
78
+ try {
79
+ forgeRoot = resolveForgeRoot(workspaceRoot);
80
+ } catch (err) {
81
+ return {
82
+ command: "forge.skill.validate",
83
+ status: "fail",
84
+ violations: [
85
+ {
86
+ skill: "*",
87
+ rule: "SKILL-00",
88
+ message: `forge root not resolvable: ${(err as Error).message}`,
89
+ },
90
+ ],
91
+ warnings: [],
92
+ };
93
+ }
71
94
  const skillsDir = path.join(forgeRoot, "skills");
72
95
 
73
- // RFC-0539: Load config to discover declared skill packs
96
+ // RFC-0539: Load config to discover declared skill packs — from the
97
+ // workspace root, where forge.yaml actually lives (RFC-1225).
74
98
  let packSkills: PackSkillEntry[] = [];
75
99
  try {
76
- const forgeConfigRoot = resolveForgeRoot(workspaceRoot);
77
- const config = loadForgeConfig(forgeConfigRoot);
100
+ const config = loadForgeConfig(workspaceRoot, forgeRoot);
78
101
  packSkills = discoverPackSkills(workspaceRoot, config);
79
102
  } catch {
80
103
  // Config not found or invalid — skip pack skill validation
@@ -493,14 +516,10 @@ export function runSkillValidate(_input: unknown, context: unknown): SkillValida
493
516
  });
494
517
  }
495
518
 
496
- // SKILL-17: No internal platform RFC/ADR id references or platform names (RFC-0553)
497
- {
498
- const skill17Violations = checkSkill17(entry.name, content);
499
- for (const v of skill17Violations) {
500
- v.pack = entry.pack;
501
- }
502
- violations.push(...skill17Violations);
503
- }
519
+ // SKILL-17 is intentionally absent here: RFC-0553 scopes the rule to
520
+ // shipped canonical skills (packages/forge/skills) — pack skills are
521
+ // project-domain by design (same asymmetry as SKILL-18). The check was
522
+ // dead code until RFC-1225 enabled pack validation.
504
523
  }
505
524
 
506
525
  // Check: every SKILL.md on disk has a registry entry
@@ -642,12 +661,20 @@ function checkSkill17(skillName: string, content: string): Violation[] {
642
661
  return result;
643
662
  }
644
663
 
645
- const lines = content.split(/\r?\n/);
646
-
647
- for (const line of lines) {
648
- if (line.includes(SKILL17_DISABLE_MARKER)) {
664
+ const rawLines = content.split(/\r?\n/);
665
+ // RFC-1227: HTML comment regions (MODULE_CONTRACT, CHANGE_SUMMARY, inline
666
+ // notes) are file metadata, not agent instructions — the platform's own
667
+ // change-summarizer writes RFC ids there by convention. Blank them
668
+ // line-stably so instruction text keeps full enforcement and reported
669
+ // line numbers stay correct.
670
+ const stripped = content.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "));
671
+ const lines = stripped.split(/\r?\n/);
672
+
673
+ for (let i = 0; i < lines.length; i++) {
674
+ if (rawLines[i].includes(SKILL17_DISABLE_MARKER)) {
649
675
  continue;
650
676
  }
677
+ const line = lines[i];
651
678
 
652
679
  for (const pattern of SKILL17_ID_PATTERNS) {
653
680
  const re = new RegExp(pattern.source, pattern.flags);