@warpgogol/forge 5.2.0 → 5.2.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 (120) hide show
  1. package/AGENTS.md +4 -1
  2. package/dist/os/adr/adr.module.js +1 -1
  3. package/dist/os/adr/adr.module.js.map +1 -1
  4. package/dist/os/compass/compass.module.js +2 -2
  5. package/dist/os/compass/compass.module.js.map +1 -1
  6. package/dist/os/compass/handlers/compass-audit-handler.d.ts +3 -0
  7. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  8. package/dist/os/compass/handlers/compass-audit-handler.js +87 -8
  9. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  10. package/dist/os/core/core.module.d.ts.map +1 -1
  11. package/dist/os/core/core.module.js +15 -1
  12. package/dist/os/core/core.module.js.map +1 -1
  13. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  14. package/dist/os/mission/handlers/archive.js +22 -6
  15. package/dist/os/mission/handlers/archive.js.map +1 -1
  16. package/dist/os/program/lease.js +1 -1
  17. package/dist/os/program/lease.js.map +1 -1
  18. package/dist/os/queue/queue.module.js +1 -1
  19. package/dist/os/queue/queue.module.js.map +1 -1
  20. package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
  21. package/dist/os/rfc/handlers/list-create.js +79 -32
  22. package/dist/os/rfc/handlers/list-create.js.map +1 -1
  23. package/dist/os/rfc/rfc.module.js +1 -1
  24. package/dist/os/rfc/rfc.module.js.map +1 -1
  25. package/dist/os/spec/spec.module.d.ts.map +1 -1
  26. package/dist/os/spec/spec.module.js +2 -0
  27. package/dist/os/spec/spec.module.js.map +1 -1
  28. package/dist/src/config/forge-config.d.ts +9 -0
  29. package/dist/src/config/forge-config.d.ts.map +1 -1
  30. package/dist/src/config/forge-config.js +7 -0
  31. package/dist/src/config/forge-config.js.map +1 -1
  32. package/dist/src/knowledge/index.d.ts +1 -0
  33. package/dist/src/knowledge/index.d.ts.map +1 -1
  34. package/dist/src/knowledge/index.js +1 -0
  35. package/dist/src/knowledge/index.js.map +1 -1
  36. package/dist/src/knowledge/sync.d.ts +24 -0
  37. package/dist/src/knowledge/sync.d.ts.map +1 -0
  38. package/dist/src/knowledge/sync.js +127 -0
  39. package/dist/src/knowledge/sync.js.map +1 -0
  40. package/dist/src/onboarding/agents-generate.d.ts +2 -0
  41. package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
  42. package/dist/src/onboarding/agents-generate.js +14 -18
  43. package/dist/src/onboarding/agents-generate.js.map +1 -1
  44. package/dist/src/onboarding/doctor.d.ts +6 -0
  45. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  46. package/dist/src/onboarding/doctor.js +124 -14
  47. package/dist/src/onboarding/doctor.js.map +1 -1
  48. package/dist/src/onboarding/init.d.ts.map +1 -1
  49. package/dist/src/onboarding/init.js +15 -8
  50. package/dist/src/onboarding/init.js.map +1 -1
  51. package/dist/src/onboarding/memory-compact.d.ts +22 -0
  52. package/dist/src/onboarding/memory-compact.d.ts.map +1 -0
  53. package/dist/src/onboarding/memory-compact.js +147 -0
  54. package/dist/src/onboarding/memory-compact.js.map +1 -0
  55. package/dist/src/onboarding/memory-scaffold.d.ts +1 -0
  56. package/dist/src/onboarding/memory-scaffold.d.ts.map +1 -1
  57. package/dist/src/onboarding/memory-scaffold.js +1 -1
  58. package/dist/src/onboarding/memory-scaffold.js.map +1 -1
  59. package/dist/src/onboarding/nested-agents-generate.d.ts.map +1 -1
  60. package/dist/src/onboarding/nested-agents-generate.js +1 -1
  61. package/dist/src/onboarding/nested-agents-generate.js.map +1 -1
  62. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  63. package/dist/src/onboarding/upgrade.js +42 -13
  64. package/dist/src/onboarding/upgrade.js.map +1 -1
  65. package/dist/src/onboarding/workspace-discovery.d.ts +1 -1
  66. package/dist/src/onboarding/workspace-discovery.d.ts.map +1 -1
  67. package/dist/src/onboarding/workspace-discovery.js +14 -2
  68. package/dist/src/onboarding/workspace-discovery.js.map +1 -1
  69. package/dist/src/types.d.ts +1 -2
  70. package/dist/src/types.d.ts.map +1 -1
  71. package/dist/src/types.js +1 -0
  72. package/dist/src/types.js.map +1 -1
  73. package/dist/src/utils/fs-atomic.js +1 -1
  74. package/dist/src/utils/fs-atomic.js.map +1 -1
  75. package/docs/reference/forge-yaml.md +12 -0
  76. package/os/adr/adr.module.ts +1 -1
  77. package/os/compass/compass.module.ts +2 -2
  78. package/os/compass/handlers/compass-audit-handler.ts +116 -8
  79. package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
  80. package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
  81. package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
  82. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
  83. package/os/core/core.module.ts +16 -1
  84. package/os/mission/handlers/archive.test.ts +49 -0
  85. package/os/mission/handlers/archive.ts +21 -6
  86. package/os/program/lease.ts +1 -1
  87. package/os/queue/queue.module.ts +1 -1
  88. package/os/rfc/handlers/list-create.ts +83 -35
  89. package/os/rfc/handlers/validate-rules.test.ts +50 -0
  90. package/os/rfc/rfc-create-concurrent.test.ts +118 -0
  91. package/os/rfc/rfc-create-hint.test.ts +90 -0
  92. package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
  93. package/os/rfc/rfc.module.ts +1 -1
  94. package/os/spec/spec.module.ts +2 -0
  95. package/package.json +1 -1
  96. package/profiles/forge-shell.yaml +3 -0
  97. package/profiles/godot-game.yaml +3 -0
  98. package/profiles/phaser-game.yaml +3 -0
  99. package/profiles/site-workshop.yaml +567 -564
  100. package/src/config/forge-config.ts +11 -0
  101. package/src/knowledge/__tests__/sync.test.ts +227 -0
  102. package/src/knowledge/index.ts +7 -0
  103. package/src/knowledge/sync.ts +161 -0
  104. package/src/onboarding/agents-generate.ts +18 -18
  105. package/src/onboarding/doctor.ts +141 -15
  106. package/src/onboarding/init.ts +15 -8
  107. package/src/onboarding/memory-compact.ts +188 -0
  108. package/src/onboarding/memory-scaffold.ts +1 -1
  109. package/src/onboarding/nested-agents-generate.ts +5 -1
  110. package/src/onboarding/upgrade.ts +47 -16
  111. package/src/onboarding/workspace-discovery.ts +15 -1
  112. package/src/tests/agents-generate.test.ts +26 -3
  113. package/src/tests/doctor-dist-freshness.test.ts +110 -0
  114. package/src/tests/doctor-fix.test.ts +145 -0
  115. package/src/tests/doctor-nested-agents.test.ts +108 -0
  116. package/src/tests/memory-compact.test.ts +143 -0
  117. package/src/tests/upgrade.test.ts +53 -0
  118. package/src/tests/workspace-discovery.test.ts +28 -0
  119. package/src/types.ts +2 -2
  120. package/src/utils/fs-atomic.ts +1 -1
@@ -83,6 +83,13 @@ export const forgeBindingsSchema = z.object({
83
83
  budget: z.number().int().positive().default(4096),
84
84
  })
85
85
  .optional(),
86
+ // RFC-1150: consumer-declared directory names excluded from workspace
87
+ // discovery (merged over the SKIP_DIRS defaults in workspace-discovery.ts).
88
+ workspaces: z
89
+ .object({
90
+ skipDirs: z.array(z.string()).default([]),
91
+ })
92
+ .optional(),
86
93
  });
87
94
 
88
95
  export interface ForgeBindings {
@@ -126,6 +133,10 @@ export interface ForgeBindings {
126
133
  memory?: {
127
134
  budget: number;
128
135
  };
136
+ // RFC-1150: consumer-declared workspace discovery exclusions
137
+ workspaces?: {
138
+ skipDirs: string[];
139
+ };
129
140
  }
130
141
 
131
142
  // ---------------------------------------------------------------------------
@@ -0,0 +1,227 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for the append-only knowledge sync (syncKnowledgeFile / planKnowledgeSync) — covers copy, merge by entry-ID union, conflict handling, skip semantics, and directory recursion.</purpose>
4
+ </MODULE_CONTRACT>
5
+ <CHANGE_SUMMARY>
6
+ <item>2026-09-24: initial tests for append-only knowledge sync.</item>
7
+ </CHANGE_SUMMARY>
8
+ */
9
+
10
+ import { test, expect, beforeEach, afterEach } from "vitest";
11
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { planKnowledgeSync, syncKnowledgeFile } from "../sync.ts";
15
+
16
+ let tempDir: string;
17
+
18
+ beforeEach(() => {
19
+ tempDir = mkdtempSync(join(tmpdir(), "forge-knowledge-sync-"));
20
+ });
21
+
22
+ afterEach(() => {
23
+ rmSync(tempDir, { recursive: true, force: true });
24
+ });
25
+
26
+ function entry(id: string, title: string, body: string, layer = "L0"): string {
27
+ const confirmations =
28
+ layer === "L2" ? `lastConfirmedAt: 2026-08-03\nconfirmations: 1\n` : "";
29
+ return `### ${id}: ${title}\n\n\`\`\`knowledge-entry\nid: ${id}\nlayer: ${layer}\ncreated: 2026-08-03\n${confirmations}status: active\n\`\`\`\n\n${body}\n`;
30
+ }
31
+
32
+ function knowledgeFile(layer: string, entries: string[]): string {
33
+ return `<!-- knowledge-layer: ${layer} -->\n\n# Knowledge (${layer})\n\n${entries.join("\n")}`;
34
+ }
35
+
36
+ test("copies file when destination does not exist", () => {
37
+ const src = join(tempDir, "src", "qa-log.md");
38
+ const dest = join(tempDir, "dest", "qa-log.md");
39
+ mkdirSync(join(tempDir, "src"), { recursive: true });
40
+ const content = knowledgeFile("L0", [entry("K-0001", "First", "Body one.")]);
41
+ writeFileSync(src, content, "utf8");
42
+
43
+ const result = syncKnowledgeFile(src, dest);
44
+
45
+ expect(result.action).toBe("copied");
46
+ expect(readFileSync(dest, "utf8")).toBe(content);
47
+ });
48
+
49
+ test("unchanged when contents are identical", () => {
50
+ const src = join(tempDir, "qa-log.md");
51
+ const dest = join(tempDir, "dest.md");
52
+ const content = knowledgeFile("L0", [entry("K-0001", "First", "Body one.")]);
53
+ writeFileSync(src, content, "utf8");
54
+ writeFileSync(dest, content, "utf8");
55
+
56
+ const result = syncKnowledgeFile(src, dest);
57
+
58
+ expect(result.action).toBe("unchanged");
59
+ });
60
+
61
+ test("merges: local entries preserved, new source entries appended", () => {
62
+ const src = join(tempDir, "qa-log.md");
63
+ const dest = join(tempDir, "dest.md");
64
+ writeFileSync(
65
+ src,
66
+ knowledgeFile("L0", [
67
+ entry("K-0001", "First", "Package body."),
68
+ entry("K-0002", "Second", "New package entry."),
69
+ ]),
70
+ "utf8",
71
+ );
72
+ writeFileSync(
73
+ dest,
74
+ knowledgeFile("L0", [
75
+ entry("K-0001", "First", "Locally edited body."),
76
+ entry("K-0057", "Local accumulation", "Project-specific record."),
77
+ ]),
78
+ "utf8",
79
+ );
80
+
81
+ const result = syncKnowledgeFile(src, dest);
82
+
83
+ expect(result.action).toBe("merged");
84
+ expect(result.appended).toEqual(["K-0002"]);
85
+ expect(result.conflicts).toEqual(["K-0001"]);
86
+
87
+ const merged = readFileSync(dest, "utf8");
88
+ // Local version of the conflicting entry wins
89
+ expect(merged).toContain("Locally edited body.");
90
+ expect(merged).not.toContain("Package body.");
91
+ // Local accumulated entry preserved
92
+ expect(merged).toContain("K-0057");
93
+ expect(merged).toContain("Project-specific record.");
94
+ // New source entry appended
95
+ expect(merged).toContain("K-0002");
96
+ expect(merged).toContain("New package entry.");
97
+ });
98
+
99
+ test("merge into empty template appends all source entries", () => {
100
+ const src = join(tempDir, "qa-log.md");
101
+ const dest = join(tempDir, "dest.md");
102
+ writeFileSync(
103
+ src,
104
+ knowledgeFile("L0", [entry("K-0001", "First", "Body one.")]),
105
+ "utf8",
106
+ );
107
+ // Destination is the shipped empty template (layer marker, no entries)
108
+ writeFileSync(dest, `<!-- knowledge-layer: L0 -->\n\n# Q&A Log (L0)\n`, "utf8");
109
+
110
+ const result = syncKnowledgeFile(src, dest);
111
+
112
+ expect(result.action).toBe("merged");
113
+ expect(result.appended).toEqual(["K-0001"]);
114
+ expect(readFileSync(dest, "utf8")).toContain("K-0001");
115
+ });
116
+
117
+ test("unchanged when source has no new entries (conflicts only)", () => {
118
+ const src = join(tempDir, "qa-log.md");
119
+ const dest = join(tempDir, "dest.md");
120
+ writeFileSync(src, knowledgeFile("L0", [entry("K-0001", "First", "Package body.")]), "utf8");
121
+ writeFileSync(
122
+ dest,
123
+ knowledgeFile("L0", [
124
+ entry("K-0001", "First", "Locally edited body."),
125
+ entry("K-0002", "Local extra", "Local only."),
126
+ ]),
127
+ "utf8",
128
+ );
129
+
130
+ const result = syncKnowledgeFile(src, dest);
131
+
132
+ expect(result.action).toBe("unchanged");
133
+ expect(result.conflicts).toEqual(["K-0001"]);
134
+ // File untouched — local content preserved byte-for-byte
135
+ expect(readFileSync(dest, "utf8")).toContain("Locally edited body.");
136
+ });
137
+
138
+ test("skips divergent non-cumulative destination (knowledge-adjacent)", () => {
139
+ const src = join(tempDir, "forge-about.md");
140
+ const dest = join(tempDir, "dest.md");
141
+ writeFileSync(src, "# Forge\n\nNew package template text.\n", "utf8");
142
+ const localContent = "# Forge\n\nLocally filled-in project description.\n";
143
+ writeFileSync(dest, localContent, "utf8");
144
+
145
+ const result = syncKnowledgeFile(src, dest);
146
+
147
+ expect(result.action).toBe("skipped");
148
+ expect(readFileSync(dest, "utf8")).toBe(localContent);
149
+ });
150
+
151
+ test("skips when destination is cumulative but source is not", () => {
152
+ const src = join(tempDir, "notes.md");
153
+ const dest = join(tempDir, "dest.md");
154
+ writeFileSync(src, "# Plain notes\n\nPackage rewrite.\n", "utf8");
155
+ const localContent = knowledgeFile("L0", [entry("K-0001", "First", "Local body.")]);
156
+ writeFileSync(dest, localContent, "utf8");
157
+
158
+ const result = syncKnowledgeFile(src, dest);
159
+
160
+ expect(result.action).toBe("skipped");
161
+ expect(readFileSync(dest, "utf8")).toBe(localContent);
162
+ });
163
+
164
+ test("recurses into declared directories", () => {
165
+ const srcDir = join(tempDir, "gallery");
166
+ const destDir = join(tempDir, "dest-gallery");
167
+ mkdirSync(srcDir, { recursive: true });
168
+ writeFileSync(join(srcDir, "a.md"), "A package\n", "utf8");
169
+ writeFileSync(join(srcDir, "b.md"), "B package\n", "utf8");
170
+ mkdirSync(destDir, { recursive: true });
171
+ writeFileSync(join(destDir, "b.md"), "B local edits\n", "utf8");
172
+
173
+ const result = syncKnowledgeFile(srcDir, destDir);
174
+
175
+ expect(result.action).toBe("copied");
176
+ expect(readFileSync(join(destDir, "a.md"), "utf8")).toBe("A package\n");
177
+ // Existing local file preserved (non-cumulative → skipped)
178
+ expect(readFileSync(join(destDir, "b.md"), "utf8")).toBe("B local edits\n");
179
+ });
180
+
181
+ test("planKnowledgeSync reports merged without writing", () => {
182
+ const src = join(tempDir, "qa-log.md");
183
+ const dest = join(tempDir, "dest.md");
184
+ writeFileSync(
185
+ src,
186
+ knowledgeFile("L0", [
187
+ entry("K-0001", "First", "Body one."),
188
+ entry("K-0002", "Second", "Body two."),
189
+ ]),
190
+ "utf8",
191
+ );
192
+ const localContent = knowledgeFile("L0", [entry("K-0001", "First", "Body one.")]);
193
+ writeFileSync(dest, localContent, "utf8");
194
+
195
+ const plan = planKnowledgeSync(src, dest);
196
+
197
+ expect(plan.action).toBe("merged");
198
+ expect(plan.appended).toEqual(["K-0002"]);
199
+ expect(plan.content).not.toBeNull();
200
+ // No write happened
201
+ expect(readFileSync(dest, "utf8")).toBe(localContent);
202
+ });
203
+
204
+ test("planKnowledgeSync reports unchanged for locally accumulated file", () => {
205
+ const src = join(tempDir, "qa-log.md");
206
+ const dest = join(tempDir, "dest.md");
207
+ writeFileSync(src, knowledgeFile("L0", [entry("K-0001", "First", "Body one.")]), "utf8");
208
+ writeFileSync(
209
+ dest,
210
+ knowledgeFile("L0", [
211
+ entry("K-0001", "First", "Body one."),
212
+ entry("K-0002", "Local", "Accumulated locally."),
213
+ ]),
214
+ "utf8",
215
+ );
216
+
217
+ const plan = planKnowledgeSync(src, dest);
218
+
219
+ // Local superset — nothing to merge, not stale
220
+ expect(plan.action).toBe("unchanged");
221
+ });
222
+
223
+ test("missing source returns unchanged", () => {
224
+ const result = syncKnowledgeFile(join(tempDir, "nope.md"), join(tempDir, "dest.md"));
225
+ expect(result.action).toBe("unchanged");
226
+ expect(existsSync(join(tempDir, "dest.md"))).toBe(false);
227
+ });
@@ -51,3 +51,10 @@ export {
51
51
  type DuplicatePair,
52
52
  type PromotionPlan,
53
53
  } from "./promote.ts";
54
+ export {
55
+ planKnowledgeSync,
56
+ syncKnowledgeFile,
57
+ type KnowledgeSyncAction,
58
+ type KnowledgeSyncResult,
59
+ type KnowledgeSyncPlan,
60
+ } from "./sync.ts";
@@ -0,0 +1,161 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Append-only sync for declared skill knowledge files. Copies files that are
4
+ missing at the destination, merges cumulative K-NNNN files by entry-ID union (local
5
+ entries always win), and never overwrites existing local content. Fixes the data-loss
6
+ bug where forge.upgrade wiped project-accumulated knowledge entries by overwriting
7
+ the synced copy with the package version.</purpose>
8
+ <non-goals>
9
+ <item>Do not validate entry metadata — SKILL-19/SKILL-20 handle schema checks.</item>
10
+ <item>Do not sync SKILL.md itself — callers handle that separately.</item>
11
+ <item>Do not propagate deletions — knowledge files are append-only; removing entries is a compaction concern (compact.ts), not a sync concern.</item>
12
+ </non-goals>
13
+ </MODULE_CONTRACT>
14
+ <CHANGE_SUMMARY>
15
+ <item>2026-09-24: initial append-only knowledge sync — merge by entry-ID union, local wins conflicts, skip divergent non-cumulative files, recurse into declared directories.</item>
16
+ </CHANGE_SUMMARY>
17
+ */
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { parseKnowledgeFile } from "./parse.ts";
22
+ import { serializeKnowledgeFile } from "./serialize.ts";
23
+ import type { ParsedKnowledgeFile } from "./schema.ts";
24
+
25
+ export type KnowledgeSyncAction = "copied" | "merged" | "unchanged" | "skipped";
26
+
27
+ export interface KnowledgeSyncResult {
28
+ action: KnowledgeSyncAction;
29
+ /** Entry IDs appended from the source file (merge only). */
30
+ appended: string[];
31
+ /** Entry IDs present in both files — the local version is always kept. */
32
+ conflicts: string[];
33
+ }
34
+
35
+ export interface KnowledgeSyncPlan extends KnowledgeSyncResult {
36
+ /** Serialized content to write at the destination, or null when no write is needed. */
37
+ content: string | null;
38
+ }
39
+
40
+ const ACTION_PRIORITY: Record<KnowledgeSyncAction, number> = {
41
+ merged: 3,
42
+ copied: 2,
43
+ skipped: 1,
44
+ unchanged: 0,
45
+ };
46
+
47
+ function aggregateInto(target: KnowledgeSyncResult, sub: KnowledgeSyncResult): void {
48
+ target.appended.push(...sub.appended);
49
+ target.conflicts.push(...sub.conflicts);
50
+ if (ACTION_PRIORITY[sub.action] > ACTION_PRIORITY[target.action]) {
51
+ target.action = sub.action;
52
+ }
53
+ }
54
+
55
+ function isCumulative(parsed: ParsedKnowledgeFile): boolean {
56
+ return !parsed.isKnowledgeAdjacent;
57
+ }
58
+
59
+ function planFileSync(srcPath: string, destPath: string): KnowledgeSyncPlan {
60
+ const none = { appended: [], conflicts: [] };
61
+ if (!fs.existsSync(srcPath)) {
62
+ return { action: "unchanged", content: null, ...none };
63
+ }
64
+
65
+ const srcContent = fs.readFileSync(srcPath, "utf8");
66
+ if (!fs.existsSync(destPath)) {
67
+ return { action: "copied", content: srcContent, ...none };
68
+ }
69
+
70
+ const destContent = fs.readFileSync(destPath, "utf8");
71
+ if (srcContent === destContent) {
72
+ return { action: "unchanged", content: null, ...none };
73
+ }
74
+
75
+ const srcParsed = parseKnowledgeFile(srcPath);
76
+ const destParsed = parseKnowledgeFile(destPath);
77
+
78
+ // Both files use the cumulative K-NNNN format → append-only merge:
79
+ // local entries always win; source entries with new IDs are appended.
80
+ if (isCumulative(srcParsed) && isCumulative(destParsed)) {
81
+ const destIds = new Set(destParsed.entries.map((e) => e.meta.id));
82
+ const appendedEntries = srcParsed.entries.filter((e) => !destIds.has(e.meta.id));
83
+ const conflicts = srcParsed.entries
84
+ .filter((e) => destIds.has(e.meta.id))
85
+ .map((e) => e.meta.id);
86
+
87
+ const destLegacyTexts = new Set(destParsed.legacySections.map((l) => l.text.trim()));
88
+ const appendedLegacy = srcParsed.legacySections.filter(
89
+ (l) => !destLegacyTexts.has(l.text.trim()),
90
+ );
91
+
92
+ if (appendedEntries.length === 0 && appendedLegacy.length === 0) {
93
+ return { action: "unchanged", content: null, appended: [], conflicts };
94
+ }
95
+
96
+ const merged: ParsedKnowledgeFile = {
97
+ path: destPath,
98
+ layer: destParsed.layer ?? srcParsed.layer,
99
+ preamble: destParsed.preamble || srcParsed.preamble,
100
+ entries: [...destParsed.entries, ...appendedEntries],
101
+ legacySections: [...destParsed.legacySections, ...appendedLegacy],
102
+ parseIssues: [],
103
+ isKnowledgeAdjacent: false,
104
+ };
105
+
106
+ return {
107
+ action: "merged",
108
+ content: serializeKnowledgeFile(merged),
109
+ appended: appendedEntries.map((e) => e.meta.id),
110
+ conflicts,
111
+ };
112
+ }
113
+
114
+ // Any other divergence means the local file was edited or uses a
115
+ // non-cumulative format — preserve it (append-only contract).
116
+ return { action: "skipped", content: null, ...none };
117
+ }
118
+
119
+ /**
120
+ * Compute the sync plan for a declared knowledge file (or directory) without
121
+ * writing anything. Used by doctor for stale detection.
122
+ */
123
+ export function planKnowledgeSync(srcPath: string, destPath: string): KnowledgeSyncPlan {
124
+ if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
125
+ const aggregate: KnowledgeSyncPlan = {
126
+ action: "unchanged",
127
+ appended: [],
128
+ conflicts: [],
129
+ content: null,
130
+ };
131
+ for (const entry of fs.readdirSync(srcPath)) {
132
+ const sub = planKnowledgeSync(path.join(srcPath, entry), path.join(destPath, entry));
133
+ aggregateInto(aggregate, sub);
134
+ }
135
+ return aggregate;
136
+ }
137
+ return planFileSync(srcPath, destPath);
138
+ }
139
+
140
+ /**
141
+ * Sync one declared knowledge file (or directory) from source to destination.
142
+ * Never overwrites existing local content — cumulative files merge by entry-ID
143
+ * union, divergent non-cumulative files are skipped.
144
+ */
145
+ export function syncKnowledgeFile(srcPath: string, destPath: string): KnowledgeSyncResult {
146
+ if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
147
+ const aggregate: KnowledgeSyncResult = { action: "unchanged", appended: [], conflicts: [] };
148
+ for (const entry of fs.readdirSync(srcPath)) {
149
+ const sub = syncKnowledgeFile(path.join(srcPath, entry), path.join(destPath, entry));
150
+ aggregateInto(aggregate, sub);
151
+ }
152
+ return aggregate;
153
+ }
154
+
155
+ const plan = planFileSync(srcPath, destPath);
156
+ if (plan.content !== null) {
157
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
158
+ fs.writeFileSync(destPath, plan.content, "utf8");
159
+ }
160
+ return { action: plan.action, appended: plan.appended, conflicts: plan.conflicts };
161
+ }
@@ -2,7 +2,7 @@
2
2
  <MODULE_CONTRACT>
3
3
  <purpose>forge.agents.generate — regenerates AGENTS.md deterministically from forge.yaml + skill registry. Carries the standard generated-file marker.</purpose>
4
4
  <non-goals>
5
- <item>Do not overwrite a hand-written AGENTS.md (no generated marker) — refuse with exit 1.</item>
5
+ <item>Do not overwrite a hand-written AGENTS.md (no generated marker) — skip with warning, continue nested (RFC-1150).</item>
6
6
  <item>Do not read or modify forge.yaml — only read it via loadForgeConfig.</item>
7
7
  </non-goals>
8
8
  </MODULE_CONTRACT>
@@ -215,6 +215,9 @@ interface AgentsGenerateResult {
215
215
  errors: string[];
216
216
  renderedFiles?: { [relPath: string]: string };
217
217
  details?: Array<{ path: string; domain?: string; register?: string; workspaceType?: string }>;
218
+ // RFC-1150: set when the root AGENTS.md was skipped (hand-written, no marker)
219
+ rootSkipped?: boolean;
220
+ rootSkipReason?: string;
218
221
  }
219
222
 
220
223
  export async function runAgentsGenerate(
@@ -247,26 +250,18 @@ export async function runAgentsGenerate(
247
250
 
248
251
  const agentsMdPath = path.join(workspaceRoot, "AGENTS.md");
249
252
 
250
- // Edit guard: refuse to overwrite a hand-written AGENTS.md (skipped in dryRun)
253
+ // Edit guard: never overwrite a hand-written AGENTS.md — skip the root file
254
+ // with a warning and continue to nested generation (RFC-1150, non-fatal).
255
+ let rootSkipped = false;
251
256
  if (!dryRun && fs.existsSync(agentsMdPath)) {
252
257
  const existing = fs.readFileSync(agentsMdPath, "utf8");
253
258
  if (!hasGeneratedMarker(existing)) {
254
- const msg = `AGENTS.md exists without a generated marker — refusing to overwrite a hand-written file. Delete or rename it first, then re-run forge.agents.generate.`;
259
+ rootSkipped = true;
255
260
  if (outputFormat === "pretty") {
256
- logger.error(msg);
261
+ logger.warn(
262
+ "AGENTS.md exists without a generated marker — skipping root file (hand-written). Nested AGENTS.md generation continues.",
263
+ );
257
264
  }
258
- return {
259
- data: {
260
- command: "forge.agents.generate",
261
- status: "fail",
262
- configPath: "forge.yaml",
263
- generated: [],
264
- skipped: [],
265
- errors: [msg],
266
- },
267
- exitCode: 1,
268
- summary: `forge.agents.generate: failed — hand-written AGENTS.md`,
269
- };
270
265
  }
271
266
  }
272
267
 
@@ -371,7 +366,7 @@ export async function runAgentsGenerate(
371
366
  const resolvedTerminology = resolveAllTerminology(config, profile);
372
367
  content = substituteTemplate(content, resolvedTerminology);
373
368
 
374
- const generated: string[] = ["AGENTS.md"];
369
+ const generated: string[] = [];
375
370
  const skipped: string[] = [];
376
371
  const renderedFiles: { [relPath: string]: string } = {};
377
372
  const details: Array<{ path: string; domain?: string; register?: string; workspaceType?: string }> = [];
@@ -383,8 +378,12 @@ export async function runAgentsGenerate(
383
378
 
384
379
  if (dryRun) {
385
380
  renderedFiles["AGENTS.md"] = content;
381
+ generated.push("AGENTS.md");
382
+ } else if (rootSkipped) {
383
+ skipped.push("AGENTS.md (hand-written)");
386
384
  } else {
387
385
  await writeFileIfChanged(agentsMdPath, content);
386
+ generated.push("AGENTS.md");
388
387
  if (outputFormat === "pretty") {
389
388
  logger.success(`Generated ${path.relative(workspaceRoot, agentsMdPath)}`);
390
389
  }
@@ -443,11 +442,12 @@ export async function runAgentsGenerate(
443
442
  skipped,
444
443
  errors: [],
445
444
  details,
445
+ ...(rootSkipped ? { rootSkipped: true, rootSkipReason: "hand-written" } : {}),
446
446
  ...(dryRun ? { renderedFiles } : {}),
447
447
  },
448
448
  exitCode: 0,
449
449
  summary: dryRun
450
450
  ? `forge.agents.generate: [dry-run] would generate ${generated.length} file(s), skip ${skipped.length}`
451
- : `forge.agents.generate: OK — ${generated.length} file(s) generated, ${skipped.length} skipped`,
451
+ : `forge.agents.generate: OK — ${generated.length} file(s) generated, ${skipped.length} skipped${rootSkipped ? " (root AGENTS.md hand-written — skipped)" : ""}`,
452
452
  };
453
453
  }