@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
@@ -6,7 +6,6 @@
6
6
  </non-goals>
7
7
  </MODULE_CONTRACT>
8
8
  <CHANGE_SUMMARY>
9
- <item>RFC-0661: added optional knowledge.budgets binding for hot/warm layer character budget overrides.</item>
10
9
  <item>RFC-0662: added optional knowledge.retentionDays and knowledge.staleDays bindings for compaction overrides.</item>
11
10
  <item>RFC-0704: added optional independentVersionPackages field to forgeConfigSchema for packages with autonomous npm versions.</item>
12
11
  <item>RFC-1097: step 6 — compass.migrate codemod run
@@ -15,14 +14,15 @@ Mechanical v1 to v2 header migration across the workspace: 942 files rewritten
15
14
  <item>RFC-1097: sweep — packages/forge + services clean
16
15
 
17
16
  Sweep batch 2: real KEY_DECISIONS on 10 files, expanded purposes (CONTRACT-02/PURPOSE-02), headers on mission/index + gen-upstreams, sanitizeItemText in summary.record (literal Compass tags corrupted history), excludedPaths for wrangler types, test-fixtures testPattern. forge+services now 0 diagnostics under --mode error.</item>
18
- <history>RFC-0391, RFC-0393, RFC-0537, RFC-0538, RFC-0539, RFC-0540, RFC-0543, RFC-0546, RFC-0639, RFC-0640, RFC-0643</history>
17
+ <item>RFC-1224: preserve operator forge.yaml content on upgrade, promote adrImplementStamp binding</item>
18
+ <history>RFC-0391, RFC-0393, RFC-0537, RFC-0538, RFC-0539, RFC-0540, RFC-0543, RFC-0546, RFC-0639, RFC-0640, RFC-0643, RFC-0661, RFC-1224</history>
19
19
  </CHANGE_SUMMARY>
20
20
  */
21
21
 
22
22
  import * as fs from "../utils/sync-fs.ts";
23
23
  import path from "node:path";
24
24
  import { z } from "zod";
25
- import { parse as parseYaml } from "yaml";
25
+ import { parse as parseYaml, parseDocument } from "yaml";
26
26
  import {
27
27
  TERMINOLOGY_DEFAULTS,
28
28
  compassPolicyOverridesSchema,
@@ -36,22 +36,27 @@ import { listStackProfiles, type StackProfile } from "../profiles/stack-profile.
36
36
 
37
37
  export const forgeBindingsSchema = z.object({
38
38
  schema: z.literal("forge/bindings@1"),
39
- commands: z.object({
40
- validateRfc: z.string().nullable().default(null),
41
- validateAdr: z.string().nullable().default(null),
42
- implementStamp: z.string().nullable().default(null),
43
- typecheck: z.string().nullable().default(null),
44
- test: z.string().nullable().default(null),
45
- scopedBuild: z.string().nullable().default(null),
46
- specValidate: z.string().nullable().default(null),
47
- sessionSave: z.string().nullable().default(null),
48
- // Semantic keys (RFC-0639) — domain-neutral, optional
49
- validate: z.string().nullable().default(null),
50
- produce: z.string().nullable().default(null),
51
- verify: z.string().nullable().default(null),
52
- preview: z.string().nullable().default(null),
53
- lint: z.string().nullable().default(null),
54
- }),
39
+ commands: z
40
+ .object({
41
+ validateRfc: z.string().nullable().default(null),
42
+ validateAdr: z.string().nullable().default(null),
43
+ implementStamp: z.string().nullable().default(null),
44
+ adrImplementStamp: z.string().nullable().default(null),
45
+ typecheck: z.string().nullable().default(null),
46
+ test: z.string().nullable().default(null),
47
+ scopedBuild: z.string().nullable().default(null),
48
+ specValidate: z.string().nullable().default(null),
49
+ sessionSave: z.string().nullable().default(null),
50
+ // Semantic keys (RFC-0639) — domain-neutral, optional
51
+ validate: z.string().nullable().default(null),
52
+ produce: z.string().nullable().default(null),
53
+ verify: z.string().nullable().default(null),
54
+ preview: z.string().nullable().default(null),
55
+ lint: z.string().nullable().default(null),
56
+ // RFC-1224: consumer-defined command keys (e.g. createRfc) stay in the
57
+ // parsed config so resolveBinding can resolve them.
58
+ })
59
+ .passthrough(),
55
60
  paths: z.object({
56
61
  invariantsFile: z.string().nullable().default(null),
57
62
  compassDocs: z.array(z.string()).default([]),
@@ -98,6 +103,7 @@ export interface ForgeBindings {
98
103
  validateRfc: string | null;
99
104
  validateAdr: string | null;
100
105
  implementStamp: string | null;
106
+ adrImplementStamp: string | null;
101
107
  typecheck: string | null;
102
108
  test: string | null;
103
109
  scopedBuild: string | null;
@@ -177,44 +183,48 @@ export interface ForgeMigrationAdapter {
177
183
  // Schema (forge/config@1)
178
184
  // ---------------------------------------------------------------------------
179
185
 
180
- export const forgeConfigSchema = z.object({
181
- schema: z.literal("forge/config@1"),
182
- project: z.object({
183
- name: z.string().min(1),
184
- stack: z.array(z.string()).default([]),
185
- packageManager: z.enum(["pnpm", "npm", "yarn", "bun", "none"]).default("pnpm"),
186
- // RFC-0640: optional domain field — absent means software-domain fallback
187
- domain: z.string().optional(),
188
- }),
189
- paths: z.object({
190
- rfcsDir: z.string().default("docs/rfcs"),
191
- adrsDir: z.string().default("docs/adrs"),
192
- plansDir: z.string().default("docs/plans"),
193
- auditsDir: z.string().default("docs/audits"),
194
- specsDir: z.string().default("docs/specs"),
195
- skillsDir: z.string().default(".agents/skills"),
196
- sessionsDir: z.string().default("docs/sessions"),
197
- }),
198
- /** Bindings contract (RFC-0393). Optional — absent means no bindings. */
199
- bindings: forgeBindingsSchema.optional(),
200
- /** Skill packs (RFC-0539). Optional — absent means no project-local packs. */
201
- skillPacks: z.array(forgeSkillPackSchema).optional(),
202
- /** Migration adapters (RFC-0546). Optional — absent means built-in adapters only. */
203
- migrationAdapters: z.array(forgeMigrationAdapterSchema).optional(),
204
- /** Forge sync metadata (RFC-0543). Optional — absent means never synced. */
205
- forge: z
206
- .object({
207
- syncedVersion: z.string().nullable().default(null),
208
- })
209
- .optional(),
210
- /** RFC-0643: profile id — when present, loadForgeConfig loads the corresponding profiles/<id>.yaml */
211
- // RFC-1118: accept the corrupted object form (whole StackProfile written by a
212
- // previous buggy serialize) so loadForgeConfig can recover the declared id —
213
- // a hard schema throw here would deadlock `forge upgrade`, the healing path.
214
- profile: z.union([z.string(), z.object({ id: z.string().min(1) }).passthrough()]).optional(),
215
- /** RFC-0704: packages with autonomous npm versions — ecosystem.commit skips platform bump when all staged files belong to these packages */
216
- independentVersionPackages: z.array(z.string()).optional(),
217
- });
186
+ export const forgeConfigSchema = z
187
+ .object({
188
+ schema: z.literal("forge/config@1"),
189
+ project: z.object({
190
+ name: z.string().min(1),
191
+ stack: z.array(z.string()).default([]),
192
+ packageManager: z.enum(["pnpm", "npm", "yarn", "bun", "none"]).default("pnpm"),
193
+ // RFC-0640: optional domain field — absent means software-domain fallback
194
+ domain: z.string().optional(),
195
+ }),
196
+ paths: z.object({
197
+ rfcsDir: z.string().default("docs/rfcs"),
198
+ adrsDir: z.string().default("docs/adrs"),
199
+ plansDir: z.string().default("docs/plans"),
200
+ auditsDir: z.string().default("docs/audits"),
201
+ specsDir: z.string().default("docs/specs"),
202
+ skillsDir: z.string().default(".agents/skills"),
203
+ sessionsDir: z.string().default("docs/sessions"),
204
+ }),
205
+ /** Bindings contract (RFC-0393). Optional — absent means no bindings. */
206
+ bindings: forgeBindingsSchema.optional(),
207
+ /** Skill packs (RFC-0539). Optional — absent means no project-local packs. */
208
+ skillPacks: z.array(forgeSkillPackSchema).optional(),
209
+ /** Migration adapters (RFC-0546). Optional — absent means built-in adapters only. */
210
+ migrationAdapters: z.array(forgeMigrationAdapterSchema).optional(),
211
+ /** Forge sync metadata (RFC-0543). Optional — absent means never synced. */
212
+ forge: z
213
+ .object({
214
+ syncedVersion: z.string().nullable().default(null),
215
+ })
216
+ .optional(),
217
+ /** RFC-0643: profile id — when present, loadForgeConfig loads the corresponding profiles/<id>.yaml */
218
+ // RFC-1118: accept the corrupted object form (whole StackProfile written by a
219
+ // previous buggy serialize) so loadForgeConfig can recover the declared id —
220
+ // a hard schema throw here would deadlock `forge upgrade`, the healing path.
221
+ profile: z.union([z.string(), z.object({ id: z.string().min(1) }).passthrough()]).optional(),
222
+ /** RFC-0704: packages with autonomous npm versions — ecosystem.commit skips platform bump when all staged files belong to these packages */
223
+ independentVersionPackages: z.array(z.string()).optional(),
224
+ // RFC-1224: consumer-owned top-level sections (e.g. `plugins:`) stay in the
225
+ // parsed config so full-serialization writes do not silently drop them.
226
+ })
227
+ .passthrough();
218
228
 
219
229
  export interface ForgeConfig {
220
230
  schema: "forge/config@1";
@@ -268,6 +278,10 @@ export const FORGE_CLI_BINDING_DEFAULTS: ForgeCliBindingDefault[] = [
268
278
  key: "commands.implementStamp",
269
279
  template: "forge rfc.implement.stamp --id {id} --implementation-commit {commit}",
270
280
  },
281
+ {
282
+ key: "commands.adrImplementStamp",
283
+ template: "forge adr.implement.stamp --id {id} --implementation-commit {commit}",
284
+ },
271
285
  { key: "commands.specValidate", template: "forge spec.validate --spec={id} --json" },
272
286
  { key: "commands.sessionSave", template: "forge session.save --json" },
273
287
  ];
@@ -302,6 +316,7 @@ export function applyCliBindingDefaults(pm: string): ForgeBindings["commands"] {
302
316
  validateRfc: null,
303
317
  validateAdr: null,
304
318
  implementStamp: null,
319
+ adrImplementStamp: null,
305
320
  typecheck: null,
306
321
  test: null,
307
322
  scopedBuild: null,
@@ -471,6 +486,36 @@ export function serializeForgeConfig(config: ForgeConfig): Record<string, unknow
471
486
  return out;
472
487
  }
473
488
 
489
+ // ---------------------------------------------------------------------------
490
+ // YAML document patching — RFC-1224
491
+ // ---------------------------------------------------------------------------
492
+
493
+ /**
494
+ * A single targeted write into forge.yaml: a dotted key path expressed as
495
+ * segments plus the value to set. `applyForgeYamlPatches` creates missing
496
+ * intermediate maps and preserves comments/formatting elsewhere in the file.
497
+ */
498
+ export interface ForgeYamlPatch {
499
+ path: string[];
500
+ value: unknown;
501
+ }
502
+
503
+ /**
504
+ * Apply targeted patches to a forge.yaml document without rewriting it.
505
+ * Returns the patched YAML text, or null when the document cannot be parsed —
506
+ * callers should fall back to full serialization in that case.
507
+ */
508
+ export function applyForgeYamlPatches(rawYaml: string, patches: ForgeYamlPatch[]): string | null {
509
+ const doc = parseDocument(rawYaml);
510
+ if (doc.errors.length > 0) {
511
+ return null;
512
+ }
513
+ for (const patch of patches) {
514
+ doc.setIn(patch.path, patch.value);
515
+ }
516
+ return doc.toString();
517
+ }
518
+
474
519
  // ---------------------------------------------------------------------------
475
520
  // Forge root resolver — the single place that decides monorepo vs npm-installed
476
521
  // ---------------------------------------------------------------------------
@@ -9,7 +9,6 @@
9
9
  </non-goals>
10
10
  </MODULE_CONTRACT>
11
11
  <CHANGE_SUMMARY>
12
- <item>RFC-0643: pass profileId to runInit so forge.yaml gets a `profile` field.</item>
13
12
  <item>RFC-0664: scaffold memory layer (.agents/memory/) after init.</item>
14
13
  <item>RFC-0877: in-place mode only — --in-place flag required, no subdirectory creation, name derived from folder, strict empty-directory check (only .git/ tolerated).</item>
15
14
  <item>RFC-1097: step 6 — compass.migrate codemod run
@@ -18,7 +17,8 @@ Mechanical v1 to v2 header migration across the workspace: 942 files rewritten
18
17
  <item>RFC-1097: sweep — packages/forge + services clean
19
18
 
20
19
  Sweep batch 2: real KEY_DECISIONS on 10 files, expanded purposes (CONTRACT-02/PURPOSE-02), headers on mission/index + gen-upstreams, sanitizeItemText in summary.record (literal Compass tags corrupted history), excludedPaths for wrangler types, test-fixtures testPattern. forge+services now 0 diagnostics under --mode error.</item>
21
- <history>RFC-0544, RFC-0548, RFC-0550, RFC-0640</history>
20
+ <item>RFC-1224: preserve operator forge.yaml content on upgrade, promote adrImplementStamp binding</item>
21
+ <history>RFC-0544, RFC-0548, RFC-0550, RFC-0640, RFC-0643, RFC-1224</history>
22
22
  </CHANGE_SUMMARY>
23
23
  */
24
24
 
@@ -32,6 +32,7 @@ import { scaffoldMemoryLayer } from "./memory-scaffold.ts";
32
32
  import {
33
33
  loadForgeConfig,
34
34
  serializeForgeConfig,
35
+ applyForgeYamlPatches,
35
36
  resolveForgeRoot,
36
37
  resolvePackageManager,
37
38
  applyCliBindingDefaults,
@@ -346,15 +347,30 @@ export async function runCreate(
346
347
  const forgeYamlPath = path.join(targetDir, "forge.yaml");
347
348
  if (await fio.exists(forgeYamlPath)) {
348
349
  try {
349
- const config = loadForgeConfig(targetDir);
350
350
  const pm = resolvePackageManager(packageManager);
351
- config.project.packageManager = pm;
352
- if (config.bindings) {
353
- config.bindings.commands = applyCliBindingDefaults(pm);
351
+ const commandDefaults = applyCliBindingDefaults(pm);
352
+ // RFC-1224: patch the document init just wrote instead of re-serializing —
353
+ // preserves comments/formatting and any schema-unknown sections.
354
+ const raw = await fio.readFile(forgeYamlPath);
355
+ const patched = applyForgeYamlPatches(raw, [
356
+ { path: ["project", "packageManager"], value: pm },
357
+ ...Object.entries(commandDefaults).map(([key, value]) => ({
358
+ path: ["bindings", "commands", key],
359
+ value,
360
+ })),
361
+ ]);
362
+ if (patched !== null) {
363
+ await fio.writeFile(forgeYamlPath, patched);
364
+ } else {
365
+ const config = loadForgeConfig(targetDir);
366
+ config.project.packageManager = pm;
367
+ if (config.bindings) {
368
+ config.bindings.commands = commandDefaults;
369
+ }
370
+ // RFC-1118: serializeForgeConfig writes the declared profile id verbatim —
371
+ // never the resolved StackProfile object, never drops an unresolvable id.
372
+ await fio.writeFile(forgeYamlPath, stringifyYaml(serializeForgeConfig(config)));
354
373
  }
355
- // RFC-1118: serializeForgeConfig writes the declared profile id verbatim —
356
- // never the resolved StackProfile object, never drops an unresolvable id.
357
- await fio.writeFile(forgeYamlPath, stringifyYaml(serializeForgeConfig(config)));
358
374
  } catch {
359
375
  // Post-processing is best-effort — init already wrote a valid config
360
376
  }
@@ -8,7 +8,6 @@ Checks for forge.yaml, AGENTS.md, PREFERENCES.md, .agents/skills/, docs/rfcs/,
8
8
  </non-goals>
9
9
  </MODULE_CONTRACT>
10
10
  <CHANGE_SUMMARY>
11
- <item>RFC-0664: added memory-layer health check (budget usage, gitignore coverage, daily-file leak risk).</item>
12
11
  <item>RFC-0704: added independent-version-packages check — validates that paths in independentVersionPackages exist and contain package.json.</item>
13
12
  <item>RFC-0941: added pack-manifests advisory check — validates forge.plugin.yaml existence and schema for each declared skill pack.</item>
14
13
  <item>RFC-1097: step 6 — compass.migrate codemod run
@@ -17,7 +16,9 @@ Mechanical v1 to v2 header migration across the workspace: 942 files rewritten
17
16
  <item>RFC-1097: sweep — packages/forge + services clean
18
17
 
19
18
  Sweep batch 2: real KEY_DECISIONS on 10 files, expanded purposes (CONTRACT-02/PURPOSE-02), headers on mission/index + gen-upstreams, sanitizeItemText in summary.record (literal Compass tags corrupted history), excludedPaths for wrangler types, test-fixtures testPattern. forge+services now 0 diagnostics under --mode error.</item>
20
- <history>RFC-0391, RFC-0393, RFC-0524, RFC-0539, RFC-0540, RFC-0611, RFC-0640, RFC-0660, RFC-0661, RFC-0663, RFC-0675</history>
19
+ <item>RFC-1224: preserve operator forge.yaml content on upgrade, promote adrImplementStamp binding</item>
20
+ <item>RFC-1226: skills-unmanaged check — warn on SKILL.md-bearing dirs without .forge-managed marker</item>
21
+ <history>RFC-0391, RFC-0393, RFC-0524, RFC-0539, RFC-0540, RFC-0611, RFC-0640, RFC-0660, RFC-0661, RFC-0663, RFC-0664, RFC-0675, RFC-1224</history>
21
22
  </CHANGE_SUMMARY>
22
23
  */
23
24
 
@@ -54,6 +55,7 @@ import { compactMemoryMd } from "./memory-compact.ts";
54
55
  import { checkInvariants } from "./invariant-engine.ts";
55
56
  import type { InvariantViolation } from "./invariant-engine.ts";
56
57
  import { checkNpmToken, workshopNeedsWarpgogolToken } from "./npm-token-check.ts";
58
+ import { SKILL_MARKER_FILE } from "./skill-markers.ts";
57
59
  import { execSync } from "../utils/sync-fs.ts";
58
60
  import * as fs from "../utils/sync-fs.ts";
59
61
  import type { ProfilePrerequisite } from "../profiles/profile-schema.ts";
@@ -142,6 +144,7 @@ const BINDING_COMMAND_KEYS = [
142
144
  "commands.validateRfc",
143
145
  "commands.validateAdr",
144
146
  "commands.implementStamp",
147
+ "commands.adrImplementStamp",
145
148
  "commands.typecheck",
146
149
  "commands.test",
147
150
  "commands.scopedBuild",
@@ -762,6 +765,49 @@ async function checkPackSkills(workspaceRoot: string, io: WorkspaceIO): Promise<
762
765
  };
763
766
  }
764
767
 
768
+ // ---------------------------------------------------------------------------
769
+ // Unmanaged skill directories (RFC-1226)
770
+ // ---------------------------------------------------------------------------
771
+
772
+ // Visibility only: a dir bearing SKILL.md without .forge-managed is
773
+ // consumer-owned per RFC-1154 — report it, never prune or block.
774
+ async function checkUnmanagedSkillDirs(workspaceRoot: string, io: WorkspaceIO): Promise<DoctorCheck> {
775
+ let config;
776
+ try {
777
+ config = loadForgeConfig(workspaceRoot);
778
+ } catch {
779
+ return { name: "skills-unmanaged", status: "pass", message: "No forge.yaml — unmanaged skill dir check skipped" };
780
+ }
781
+
782
+ const skillsDir = join(workspaceRoot, config.paths.skillsDir);
783
+ let entries;
784
+ try {
785
+ entries = await io.readdir(skillsDir);
786
+ } catch {
787
+ return { name: "skills-unmanaged", status: "pass", message: "skillsDir absent — nothing to scan" };
788
+ }
789
+
790
+ const unmanaged: string[] = [];
791
+ for (const entry of entries) {
792
+ if (!entry.isDirectory) continue;
793
+ const dir = join(skillsDir, entry.name);
794
+ // Support dirs (_shared/, knowledge-only dirs) are not skills — skip.
795
+ if (!(await io.exists(join(dir, "SKILL.md")))) continue;
796
+ if (await io.exists(join(dir, SKILL_MARKER_FILE))) continue;
797
+ unmanaged.push(entry.name);
798
+ }
799
+
800
+ if (unmanaged.length === 0) {
801
+ return { name: "skills-unmanaged", status: "pass", message: "all skill dirs forge-managed" };
802
+ }
803
+
804
+ return {
805
+ name: "skills-unmanaged",
806
+ status: "warn",
807
+ message: `${unmanaged.length} unmanaged skill dir(s): ${unmanaged.join(", ")} — consumer-owned (no ${SKILL_MARKER_FILE} marker). Port into a declared skillPacks pack to make them forge-managed, or leave as-is.`,
808
+ };
809
+ }
810
+
765
811
  // ---------------------------------------------------------------------------
766
812
  // Pack manifest diagnostics (RFC-0941)
767
813
  // ---------------------------------------------------------------------------
@@ -1455,6 +1501,9 @@ export async function runDoctor(
1455
1501
  const packManifestCheck = await checkPackManifests(workspaceRoot, fio);
1456
1502
  checks.push(packManifestCheck);
1457
1503
 
1504
+ // RFC-1226: List unmanaged skill dirs under skillsDir (visibility only)
1505
+ checks.push(await checkUnmanagedSkillDirs(workspaceRoot, fio));
1506
+
1458
1507
  // RFC-0704: Check independent version packages — paths must exist and contain package.json
1459
1508
  const independentCheck = await checkIndependentVersionPackages(workspaceRoot, fio);
1460
1509
  checks.push(independentCheck);
@@ -11,7 +11,6 @@ With --update-npm, also updates @warpgogol/forge from npm before syncing (skippe
11
11
  </non-goals>
12
12
  </MODULE_CONTRACT>
13
13
  <CHANGE_SUMMARY>
14
- <item>RFC-0611: added nested AGENTS.md generation after skill sync.</item>
15
14
  <item>RFC-0663: added syncSharedKnowledge step to sync shared knowledge layer to .agents/skills/shared-knowledge/.</item>
16
15
  <item>RFC-0664: added scaffoldMemoryLayer step to scaffold .agents/memory/ and .gitignore block.</item>
17
16
  <item>RFC-1097: step 6 — compass.migrate codemod run
@@ -20,7 +19,8 @@ Mechanical v1 to v2 header migration across the workspace: 942 files rewritten
20
19
  <item>RFC-1097: sweep — werkstatt-engine clean
21
20
 
22
21
  Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.</item>
23
- <history>RFC-0543</history>
22
+ <item>RFC-1224: preserve operator forge.yaml content on upgrade, promote adrImplementStamp binding</item>
23
+ <history>RFC-0543, RFC-0611, RFC-1224</history>
24
24
  </CHANGE_SUMMARY>
25
25
  */
26
26
 
@@ -35,10 +35,12 @@ import {
35
35
  resolveForgeRoot,
36
36
  loadForgeConfig,
37
37
  serializeForgeConfig,
38
+ applyForgeYamlPatches,
38
39
  resolvePmRunner,
39
40
  resolvePmInstall,
40
41
  type ForgeConfig,
41
42
  type ForgeBindings,
43
+ type ForgeYamlPatch,
42
44
  } from "../config/forge-config.ts";
43
45
  import { FORGE_SKILLS, discoverPackSkills } from "../registry.ts";
44
46
  import { syncKnowledgeFile } from "../knowledge/index.ts";
@@ -452,18 +454,38 @@ async function updateSyncedVersion(
452
454
  workspaceRoot: string,
453
455
  config: ForgeConfig,
454
456
  version: string,
457
+ bindingsAdded: { key: string; value: string }[],
455
458
  dryRun: boolean,
456
459
  io: WorkspaceIO,
460
+ warn: (msg: string) => void,
457
461
  ): Promise<void> {
458
462
  if (dryRun) return;
459
463
 
460
- // Set the syncedVersion on the in-memory config (bindings already updated)
464
+ // Keep the in-memory config truthful for downstream steps (doctor).
461
465
  config.forge = { syncedVersion: version };
462
466
 
463
- // Write the full config back to forge.yaml
467
+ // RFC-1224: patch the existing document — syncedVersion plus newly added
468
+ // binding defaults — instead of re-serializing, so consumer-owned keys,
469
+ // sections, comments and ordering survive the rewrite.
470
+ const patches: ForgeYamlPatch[] = [
471
+ { path: ["forge", "syncedVersion"], value: version },
472
+ ...bindingsAdded.map((b) => ({
473
+ path: ["bindings", ...b.key.split(".")],
474
+ value: b.value,
475
+ })),
476
+ ];
477
+ const forgeYamlPath = path.join(workspaceRoot, "forge.yaml");
478
+ const raw = await io.readFile(forgeYamlPath).catch(() => null);
479
+ const patched = raw === null ? null : applyForgeYamlPatches(raw, patches);
480
+ if (patched !== null) {
481
+ await io.writeFile(forgeYamlPath, patched);
482
+ return;
483
+ }
484
+ warn(
485
+ "forge.upgrade: forge.yaml patch failed — falling back to full serialization (schema-unknown keys may be dropped)",
486
+ );
464
487
  // RFC-1118: serializeForgeConfig writes the declared profile id verbatim —
465
488
  // never the resolved StackProfile object, never drops an unresolvable id.
466
- const forgeYamlPath = path.join(workspaceRoot, "forge.yaml");
467
489
  await io.writeFile(forgeYamlPath, stringifyYaml(serializeForgeConfig(config)));
468
490
  }
469
491
 
@@ -689,7 +711,15 @@ export async function runUpgrade(
689
711
 
690
712
  // Step 5: Update forge.syncedVersion
691
713
  if (!isDryRun) {
692
- await updateSyncedVersion(workspaceRoot, config, toVersion, false, fio);
714
+ await updateSyncedVersion(
715
+ workspaceRoot,
716
+ config,
717
+ toVersion,
718
+ bindingsAdded,
719
+ false,
720
+ fio,
721
+ (msg) => context.logger.warn(msg),
722
+ );
693
723
  }
694
724
 
695
725
  // Step 5b: Generate nested AGENTS.md (RFC-0611). RFC-1153: the write path
@@ -7,6 +7,8 @@
7
7
  </MODULE_CONTRACT>
8
8
  <CHANGE_SUMMARY>
9
9
  <item>RFC-0639: initial test suite for semantic command keys, terminology resolution, and applyCliBindingDefaults.</item>
10
+ <item>RFC-1224: adrImplementStamp schema/default coverage; consumer-defined command key passthrough.</item>
11
+ <item>RFC-1224: preserve operator forge.yaml content on upgrade, promote adrImplementStamp binding</item>
10
12
  </CHANGE_SUMMARY>
11
13
  */
12
14
 
@@ -174,12 +176,14 @@ describe("RFC-0639: Terminology schema change", () => {
174
176
  });
175
177
 
176
178
  describe("RFC-0639: applyCliBindingDefaults", () => {
177
- test("returns 13 keys with semantic keys as null", () => {
179
+ test("returns 14 keys with semantic keys as null", () => {
178
180
  const commands = applyCliBindingDefaults("pnpm");
179
181
  // CLI-backed keys are non-null
180
182
  expect(commands.validateRfc).not.toBeNull();
181
183
  expect(commands.validateAdr).not.toBeNull();
182
184
  expect(commands.implementStamp).not.toBeNull();
185
+ // RFC-1224: adr.implement.stamp promoted to a first-class default binding
186
+ expect(commands.adrImplementStamp).not.toBeNull();
183
187
  expect(commands.specValidate).not.toBeNull();
184
188
  expect(commands.sessionSave).not.toBeNull();
185
189
  // Software-domain keys are null
@@ -312,3 +316,51 @@ describe("RFC-0639: resolveTerminology", () => {
312
316
  expect(resolveTerminology(config, undefined, "source")).toBe("source file");
313
317
  });
314
318
  });
319
+
320
+ describe("RFC-1224: promoted + consumer-defined command keys", () => {
321
+ test("forgeBindingsSchema accepts adrImplementStamp", () => {
322
+ const result = forgeBindingsSchema.safeParse({
323
+ schema: "forge/bindings@1",
324
+ commands: {
325
+ adrImplementStamp:
326
+ "pnpm exec forge adr.implement.stamp --id {id} --implementation-commit {commit}",
327
+ },
328
+ paths: {
329
+ invariantsFile: null,
330
+ compassDocs: [],
331
+ reviewsDir: null,
332
+ handoffsDir: null,
333
+ sessionsDir: null,
334
+ },
335
+ });
336
+ expect(result.success).toBe(true);
337
+ if (result.success) {
338
+ expect(result.data.commands.adrImplementStamp).toBe(
339
+ "pnpm exec forge adr.implement.stamp --id {id} --implementation-commit {commit}",
340
+ );
341
+ }
342
+ });
343
+
344
+ test("forgeBindingsSchema retains consumer-defined command keys", () => {
345
+ const result = forgeBindingsSchema.safeParse({
346
+ schema: "forge/bindings@1",
347
+ commands: {
348
+ validateRfc: null,
349
+ createRfc: "pnpm exec forge rfc.create",
350
+ },
351
+ paths: {
352
+ invariantsFile: null,
353
+ compassDocs: [],
354
+ reviewsDir: null,
355
+ handoffsDir: null,
356
+ sessionsDir: null,
357
+ },
358
+ });
359
+ expect(result.success).toBe(true);
360
+ if (result.success) {
361
+ expect((result.data.commands as Record<string, unknown>)["createRfc"]).toBe(
362
+ "pnpm exec forge rfc.create",
363
+ );
364
+ }
365
+ });
366
+ });
@@ -4,6 +4,8 @@
4
4
  </MODULE_CONTRACT>
5
5
  <CHANGE_SUMMARY>
6
6
  <item>RFC-0540: initial doctor binding notices tests.</item>
7
+ <item>RFC-1224: adrImplementStamp added to notice inventory and fixtures.</item>
8
+ <item>RFC-1224: preserve operator forge.yaml content on upgrade, promote adrImplementStamp binding</item>
7
9
  </CHANGE_SUMMARY>
8
10
  */
9
11
 
@@ -58,7 +60,7 @@ async function setupMinimalProject(dir: string): Promise<void> {
58
60
  await writeFile(join(dir, "PREFERENCES.md"), "# Test", "utf8");
59
61
  }
60
62
 
61
- test("doctor emits defaultable-binding-null notices for all 5 forge-CLI bindings when null", async () => {
63
+ test("doctor emits defaultable-binding-null notices for all 6 forge-CLI bindings when null", async () => {
62
64
  await setupMinimalProject(tempDir);
63
65
  await writeForgeYaml(
64
66
  tempDir,
@@ -96,11 +98,12 @@ bindings:
96
98
 
97
99
  const result = await runDoctor({ argv: [], flags: {} }, mockContext(tempDir));
98
100
  const notices = result.data?.bindings?.notices ?? [];
99
- expect(notices).toHaveLength(5);
101
+ expect(notices).toHaveLength(6);
100
102
  const keys = notices.map((n) => n.key);
101
103
  expect(keys).toContain("commands.validateRfc");
102
104
  expect(keys).toContain("commands.validateAdr");
103
105
  expect(keys).toContain("commands.implementStamp");
106
+ expect(keys).toContain("commands.adrImplementStamp");
104
107
  expect(keys).toContain("commands.specValidate");
105
108
  expect(keys).toContain("commands.sessionSave");
106
109
  for (const notice of notices) {
@@ -131,6 +134,7 @@ bindings:
131
134
  validateRfc: "custom rfc validate command"
132
135
  validateAdr: "custom adr validate command"
133
136
  implementStamp: "custom stamp command"
137
+ adrImplementStamp: "custom adr stamp command"
134
138
  typecheck: null
135
139
  test: null
136
140
  scopedBuild: null
@@ -172,6 +176,7 @@ bindings:
172
176
  validateRfc: "npx forge rfc.validate --id {id} --json"
173
177
  validateAdr: "npx forge adr.validate --id {id} --json"
174
178
  implementStamp: "npx forge rfc.implement.stamp --id {id} --implementation-commit {commit}"
179
+ adrImplementStamp: "npx forge adr.implement.stamp --id {id} --implementation-commit {commit}"
175
180
  typecheck: null
176
181
  test: null
177
182
  scopedBuild: null