@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.
- package/AGENTS.md +4 -1
- package/dist/os/adr/adr.module.js +1 -1
- package/dist/os/adr/adr.module.js.map +1 -1
- package/dist/os/compass/compass.module.js +2 -2
- package/dist/os/compass/compass.module.js.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.d.ts +3 -0
- package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.js +87 -8
- package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
- package/dist/os/core/core.module.d.ts.map +1 -1
- package/dist/os/core/core.module.js +15 -1
- package/dist/os/core/core.module.js.map +1 -1
- package/dist/os/mission/handlers/archive.d.ts.map +1 -1
- package/dist/os/mission/handlers/archive.js +22 -6
- package/dist/os/mission/handlers/archive.js.map +1 -1
- package/dist/os/program/lease.js +1 -1
- package/dist/os/program/lease.js.map +1 -1
- package/dist/os/queue/queue.module.js +1 -1
- package/dist/os/queue/queue.module.js.map +1 -1
- package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
- package/dist/os/rfc/handlers/list-create.js +79 -32
- package/dist/os/rfc/handlers/list-create.js.map +1 -1
- package/dist/os/rfc/rfc.module.js +1 -1
- package/dist/os/rfc/rfc.module.js.map +1 -1
- package/dist/os/spec/spec.module.d.ts.map +1 -1
- package/dist/os/spec/spec.module.js +2 -0
- package/dist/os/spec/spec.module.js.map +1 -1
- package/dist/src/config/forge-config.d.ts +9 -0
- package/dist/src/config/forge-config.d.ts.map +1 -1
- package/dist/src/config/forge-config.js +7 -0
- package/dist/src/config/forge-config.js.map +1 -1
- package/dist/src/knowledge/index.d.ts +1 -0
- package/dist/src/knowledge/index.d.ts.map +1 -1
- package/dist/src/knowledge/index.js +1 -0
- package/dist/src/knowledge/index.js.map +1 -1
- package/dist/src/knowledge/sync.d.ts +24 -0
- package/dist/src/knowledge/sync.d.ts.map +1 -0
- package/dist/src/knowledge/sync.js +127 -0
- package/dist/src/knowledge/sync.js.map +1 -0
- package/dist/src/onboarding/agents-generate.d.ts +2 -0
- package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
- package/dist/src/onboarding/agents-generate.js +14 -18
- package/dist/src/onboarding/agents-generate.js.map +1 -1
- package/dist/src/onboarding/doctor.d.ts +6 -0
- package/dist/src/onboarding/doctor.d.ts.map +1 -1
- package/dist/src/onboarding/doctor.js +124 -14
- package/dist/src/onboarding/doctor.js.map +1 -1
- package/dist/src/onboarding/init.d.ts.map +1 -1
- package/dist/src/onboarding/init.js +15 -8
- package/dist/src/onboarding/init.js.map +1 -1
- package/dist/src/onboarding/memory-compact.d.ts +22 -0
- package/dist/src/onboarding/memory-compact.d.ts.map +1 -0
- package/dist/src/onboarding/memory-compact.js +147 -0
- package/dist/src/onboarding/memory-compact.js.map +1 -0
- package/dist/src/onboarding/memory-scaffold.d.ts +1 -0
- package/dist/src/onboarding/memory-scaffold.d.ts.map +1 -1
- package/dist/src/onboarding/memory-scaffold.js +1 -1
- package/dist/src/onboarding/memory-scaffold.js.map +1 -1
- package/dist/src/onboarding/nested-agents-generate.d.ts.map +1 -1
- package/dist/src/onboarding/nested-agents-generate.js +1 -1
- package/dist/src/onboarding/nested-agents-generate.js.map +1 -1
- package/dist/src/onboarding/upgrade.d.ts.map +1 -1
- package/dist/src/onboarding/upgrade.js +42 -13
- package/dist/src/onboarding/upgrade.js.map +1 -1
- package/dist/src/onboarding/workspace-discovery.d.ts +1 -1
- package/dist/src/onboarding/workspace-discovery.d.ts.map +1 -1
- package/dist/src/onboarding/workspace-discovery.js +14 -2
- package/dist/src/onboarding/workspace-discovery.js.map +1 -1
- package/dist/src/types.d.ts +1 -2
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +1 -0
- package/dist/src/types.js.map +1 -1
- package/dist/src/utils/fs-atomic.js +1 -1
- package/dist/src/utils/fs-atomic.js.map +1 -1
- package/docs/reference/forge-yaml.md +12 -0
- package/os/adr/adr.module.ts +1 -1
- package/os/compass/compass.module.ts +2 -2
- package/os/compass/handlers/compass-audit-handler.ts +116 -8
- package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
- package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
- package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
- package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
- package/os/core/core.module.ts +16 -1
- package/os/mission/handlers/archive.test.ts +49 -0
- package/os/mission/handlers/archive.ts +21 -6
- package/os/program/lease.ts +1 -1
- package/os/queue/queue.module.ts +1 -1
- package/os/rfc/handlers/list-create.ts +83 -35
- package/os/rfc/handlers/validate-rules.test.ts +50 -0
- package/os/rfc/rfc-create-concurrent.test.ts +118 -0
- package/os/rfc/rfc-create-hint.test.ts +90 -0
- package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
- package/os/rfc/rfc.module.ts +1 -1
- package/os/spec/spec.module.ts +2 -0
- package/package.json +1 -1
- package/profiles/forge-shell.yaml +3 -0
- package/profiles/godot-game.yaml +3 -0
- package/profiles/phaser-game.yaml +3 -0
- package/profiles/site-workshop.yaml +567 -564
- package/src/config/forge-config.ts +11 -0
- package/src/knowledge/__tests__/sync.test.ts +227 -0
- package/src/knowledge/index.ts +7 -0
- package/src/knowledge/sync.ts +161 -0
- package/src/onboarding/agents-generate.ts +18 -18
- package/src/onboarding/doctor.ts +141 -15
- package/src/onboarding/init.ts +15 -8
- package/src/onboarding/memory-compact.ts +188 -0
- package/src/onboarding/memory-scaffold.ts +1 -1
- package/src/onboarding/nested-agents-generate.ts +5 -1
- package/src/onboarding/upgrade.ts +47 -16
- package/src/onboarding/workspace-discovery.ts +15 -1
- package/src/tests/agents-generate.test.ts +26 -3
- package/src/tests/doctor-dist-freshness.test.ts +110 -0
- package/src/tests/doctor-fix.test.ts +145 -0
- package/src/tests/doctor-nested-agents.test.ts +108 -0
- package/src/tests/memory-compact.test.ts +143 -0
- package/src/tests/upgrade.test.ts +53 -0
- package/src/tests/workspace-discovery.test.ts +28 -0
- package/src/types.ts +2 -2
- 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
|
+
});
|
package/src/knowledge/index.ts
CHANGED
|
@@ -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) —
|
|
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:
|
|
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
|
-
|
|
259
|
+
rootSkipped = true;
|
|
255
260
|
if (outputFormat === "pretty") {
|
|
256
|
-
logger.
|
|
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[] = [
|
|
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
|
}
|