@warpgogol/forge 5.2.0 → 5.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/AGENTS.md +2 -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.js +1 -1
  11. package/dist/os/core/core.module.js.map +1 -1
  12. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  13. package/dist/os/mission/handlers/archive.js +22 -6
  14. package/dist/os/mission/handlers/archive.js.map +1 -1
  15. package/dist/os/program/lease.js +1 -1
  16. package/dist/os/program/lease.js.map +1 -1
  17. package/dist/os/queue/queue.module.js +1 -1
  18. package/dist/os/queue/queue.module.js.map +1 -1
  19. package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
  20. package/dist/os/rfc/handlers/list-create.js +79 -32
  21. package/dist/os/rfc/handlers/list-create.js.map +1 -1
  22. package/dist/os/rfc/rfc.module.js +1 -1
  23. package/dist/os/rfc/rfc.module.js.map +1 -1
  24. package/dist/os/spec/spec.module.d.ts.map +1 -1
  25. package/dist/os/spec/spec.module.js +2 -0
  26. package/dist/os/spec/spec.module.js.map +1 -1
  27. package/dist/src/knowledge/index.d.ts +1 -0
  28. package/dist/src/knowledge/index.d.ts.map +1 -1
  29. package/dist/src/knowledge/index.js +1 -0
  30. package/dist/src/knowledge/index.js.map +1 -1
  31. package/dist/src/knowledge/sync.d.ts +24 -0
  32. package/dist/src/knowledge/sync.d.ts.map +1 -0
  33. package/dist/src/knowledge/sync.js +127 -0
  34. package/dist/src/knowledge/sync.js.map +1 -0
  35. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  36. package/dist/src/onboarding/doctor.js +7 -6
  37. package/dist/src/onboarding/doctor.js.map +1 -1
  38. package/dist/src/onboarding/init.d.ts.map +1 -1
  39. package/dist/src/onboarding/init.js +15 -8
  40. package/dist/src/onboarding/init.js.map +1 -1
  41. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  42. package/dist/src/onboarding/upgrade.js +42 -13
  43. package/dist/src/onboarding/upgrade.js.map +1 -1
  44. package/dist/src/types.d.ts +1 -2
  45. package/dist/src/types.d.ts.map +1 -1
  46. package/dist/src/types.js +1 -0
  47. package/dist/src/types.js.map +1 -1
  48. package/dist/src/utils/fs-atomic.js +1 -1
  49. package/dist/src/utils/fs-atomic.js.map +1 -1
  50. package/os/adr/adr.module.ts +1 -1
  51. package/os/compass/compass.module.ts +2 -2
  52. package/os/compass/handlers/compass-audit-handler.ts +116 -8
  53. package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
  54. package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
  55. package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
  56. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
  57. package/os/core/core.module.ts +1 -1
  58. package/os/mission/handlers/archive.test.ts +49 -0
  59. package/os/mission/handlers/archive.ts +21 -6
  60. package/os/program/lease.ts +1 -1
  61. package/os/queue/queue.module.ts +1 -1
  62. package/os/rfc/handlers/list-create.ts +83 -35
  63. package/os/rfc/handlers/validate-rules.test.ts +50 -0
  64. package/os/rfc/rfc-create-concurrent.test.ts +118 -0
  65. package/os/rfc/rfc-create-hint.test.ts +90 -0
  66. package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
  67. package/os/rfc/rfc.module.ts +1 -1
  68. package/os/spec/spec.module.ts +2 -0
  69. package/package.json +1 -1
  70. package/src/knowledge/__tests__/sync.test.ts +227 -0
  71. package/src/knowledge/index.ts +7 -0
  72. package/src/knowledge/sync.ts +161 -0
  73. package/src/onboarding/doctor.ts +7 -6
  74. package/src/onboarding/init.ts +15 -8
  75. package/src/onboarding/upgrade.ts +47 -16
  76. package/src/tests/upgrade.test.ts +53 -0
  77. package/src/types.ts +2 -2
  78. package/src/utils/fs-atomic.ts +1 -1
@@ -0,0 +1,90 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1139 AC-1: the next-steps hint printed by rfc.create must be an
5
+ executable command — correct binary (werkstatt, not forge), correct
6
+ subcommand (run), and correct flags (--id, not --file).
7
+ </purpose>
8
+ </MODULE_CONTRACT>
9
+ */
10
+
11
+ import { describe, expect, it, beforeEach, afterEach } from "vitest";
12
+ import * as fs from "node:fs/promises";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+ import { runRfcCreate } from "./handlers/list-create.ts";
16
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
17
+
18
+ const RFC_DIR = "docs/rfcs";
19
+
20
+ const TEMPLATE = `---
21
+ id: RFC-0000
22
+ title: "Template"
23
+ status: draft
24
+ kind: command
25
+ scope: workspace
26
+ createdAt: YYYY-MM-DD
27
+ updatedAt: YYYY-MM-DD
28
+ satisfies: []
29
+ ---
30
+
31
+ # RFC-0000: Template
32
+ `;
33
+
34
+ function makeContext(root: string): ForgeRuntimeContext {
35
+ return {
36
+ workspaceRoot: root,
37
+ logger: {
38
+ info() {},
39
+ warn() {},
40
+ error() {},
41
+ success() {},
42
+ section() {},
43
+ getEvents: () => [],
44
+ },
45
+ outputFormat: "json",
46
+ dryRun: false,
47
+ } as unknown as ForgeRuntimeContext;
48
+ }
49
+
50
+ function makeInput(flags: Record<string, unknown>): ForgeCommandInput {
51
+ return { argv: [], flags } as unknown as ForgeCommandInput;
52
+ }
53
+
54
+ describe("rfc.create — RFC-1139 next-steps hint accuracy", () => {
55
+ let root: string;
56
+ let rfcDir: string;
57
+
58
+ beforeEach(async () => {
59
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-hint-"));
60
+ rfcDir = path.join(root, RFC_DIR);
61
+ await fs.mkdir(rfcDir, { recursive: true });
62
+ await fs.writeFile(path.join(rfcDir, "rfc-0000-template.md"), TEMPLATE, "utf8");
63
+ });
64
+
65
+ afterEach(async () => {
66
+ await fs.rm(root, { recursive: true, force: true });
67
+ });
68
+
69
+ it("AC-1: next-steps hint is an executable werkstatt command with --id", async () => {
70
+ const result = await runRfcCreate(
71
+ makeInput({ title: "Hint Test", kind: "command", scope: "workspace" }),
72
+ makeContext(root),
73
+ );
74
+
75
+ const steps = result.nextSteps ?? [];
76
+ expect(steps.length).toBeGreaterThan(0);
77
+
78
+ for (const step of steps) {
79
+ const match = step.action.match(/pnpm exec (\S+) run ([\w.]+)/);
80
+ expect(match, `hint "${step.action}" must contain a runnable command`).not.toBeNull();
81
+ const [, binary, command] = match!;
82
+ // forge has no `run` subcommand — werkstatt does
83
+ expect(binary).toBe("werkstatt");
84
+ expect(command).toBe("rfc.validate");
85
+ // rfc.validate takes --id, not --file
86
+ expect(step.action).toContain("--id RFC-");
87
+ expect(step.action).not.toContain("--file");
88
+ }
89
+ });
90
+ });
@@ -0,0 +1,83 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1139 AC-4: read-only commands must not write to the working tree —
5
+ no ambient regeneration of tracked generated files as a side effect.
6
+ Guards against future read-path writers.
7
+ </purpose>
8
+ </MODULE_CONTRACT>
9
+ */
10
+
11
+ import { describe, expect, it, beforeEach, afterEach } from "vitest";
12
+ import * as fs from "node:fs/promises";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+ import { runRfcList } from "./handlers/list-create.ts";
16
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
17
+
18
+ function makeContext(root: string): ForgeRuntimeContext {
19
+ return {
20
+ workspaceRoot: root,
21
+ logger: {
22
+ info() {},
23
+ warn() {},
24
+ error() {},
25
+ success() {},
26
+ section() {},
27
+ getEvents: () => [],
28
+ },
29
+ outputFormat: "json",
30
+ dryRun: false,
31
+ } as unknown as ForgeRuntimeContext;
32
+ }
33
+
34
+ function makeInput(flags: Record<string, unknown> = {}): ForgeCommandInput {
35
+ return { argv: [], flags } as unknown as ForgeCommandInput;
36
+ }
37
+
38
+ async function snapshotTree(root: string): Promise<Map<string, string>> {
39
+ const snapshot = new Map<string, string>();
40
+ async function walk(dir: string): Promise<void> {
41
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
42
+ const full = path.join(dir, entry.name);
43
+ if (entry.isDirectory()) {
44
+ await walk(full);
45
+ } else if (entry.isFile()) {
46
+ snapshot.set(path.relative(root, full), await fs.readFile(full, "utf8"));
47
+ }
48
+ }
49
+ }
50
+ await walk(root);
51
+ return snapshot;
52
+ }
53
+
54
+ describe("read-only commands — RFC-1139 no side-effect writes", () => {
55
+ let root: string;
56
+
57
+ beforeEach(async () => {
58
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-readonly-"));
59
+ const rfcDir = path.join(root, "docs", "rfcs");
60
+ await fs.mkdir(rfcDir, { recursive: true });
61
+ await fs.writeFile(
62
+ path.join(rfcDir, "rfc-0001-test.md"),
63
+ `---\nid: RFC-0001\ntitle: "Test"\nstatus: draft\n---\n\n# RFC-0001\n`,
64
+ "utf8",
65
+ );
66
+ });
67
+
68
+ afterEach(async () => {
69
+ await fs.rm(root, { recursive: true, force: true });
70
+ });
71
+
72
+ it("AC-4: rfc.list leaves the working tree byte-identical", async () => {
73
+ const before = await snapshotTree(root);
74
+ const result = await runRfcList(makeInput(), makeContext(root));
75
+ expect(result.data?.status).toBe("ok");
76
+ const after = await snapshotTree(root);
77
+
78
+ expect([...after.keys()].sort()).toEqual([...before.keys()].sort());
79
+ for (const [file, content] of before) {
80
+ expect(after.get(file), `${file} modified by read-only command`).toBe(content);
81
+ }
82
+ });
83
+ });
@@ -396,7 +396,7 @@ const {
396
396
  "RFC-0476: the exclusive atomic path for accepted → implemented transitions. " +
397
397
  "Verifies preconditions (accepted status, checked+evidenced criteria, clean tree, " +
398
398
  "reachable RFC-referencing commit, passing probe evidence) then atomically sets " +
399
- "status: implemented, implementedAt, and updatedAt. Use --dry-run to preview.",
399
+ "status: implemented, implementedAt, and updatedAt. Use --dry-run to preview. Required flags: `--id`.",
400
400
  scope: "workspace",
401
401
  mutatesState: true,
402
402
  writes: ["docs/rfcs/**/*.md"],
@@ -103,6 +103,7 @@ const { runSpecValidate } = await import("./spec-validate.ts");
103
103
  "List all living feature specs in docs/specs/live/. " +
104
104
  "Returns domain, title, lastMergedRfc, updatedAt, and historyCount for each spec.",
105
105
  scope: "workspace",
106
+ flags: {},
106
107
  reads: ["docs/specs/live/*.md"],
107
108
  execute: runSpecLiveList,
108
109
  },
@@ -128,6 +129,7 @@ const { runSpecValidate } = await import("./spec-validate.ts");
128
129
  "V-LS-03 (lastMergedRfc is archived), V-LS-04 (history entries are archived), " +
129
130
  "V-LS-05 (no duplicate domains).",
130
131
  scope: "workspace",
132
+ flags: {},
131
133
  reads: ["docs/specs/live/*.md", "docs/rfcs/**/*.md"],
132
134
  execute: runSpecLiveValidate,
133
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warpgogol/forge",
3
- "version": "5.2.0",
3
+ "version": "5.2.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
+ }
@@ -42,6 +42,7 @@ import { TERMINOLOGY_DEFAULTS } from "../profiles/profile-schema.ts";
42
42
  import type { ProfileWorkspaceType } from "../profiles/profile-schema.ts";
43
43
  import { runProfileValidate } from "./profile-validate.ts";
44
44
  import { parseKnowledgeFile } from "../knowledge/index.ts";
45
+ import { planKnowledgeSync } from "../knowledge/index.ts";
45
46
  import type { ParsedKnowledgeFile } from "../knowledge/index.ts";
46
47
  import { computeLayerBudgets, resolveKnowledgeBudgets, DEFAULT_KNOWLEDGE_BUDGETS } from "../knowledge/budgets.ts";
47
48
  import { detectDuplicatePrinciples } from "../knowledge/index.ts";
@@ -243,9 +244,10 @@ async function checkStaleKnowledgeFiles(
243
244
  const destExists = await pathExists(destPath);
244
245
 
245
246
  if (srcExists && destExists) {
246
- const srcContent = await readFile(srcPath, "utf8").catch(() => "");
247
- const destContent = await readFile(destPath, "utf8").catch(() => "");
248
- if (srcContent !== destContent) {
247
+ // Append-only contract: local copies legitimately accumulate entries.
248
+ // Stale = source has entries the local copy lacks (sync would merge).
249
+ const plan = planKnowledgeSync(srcPath, destPath);
250
+ if (plan.action === "merged") {
249
251
  stale.push(`${skill.name}/${kf}`);
250
252
  }
251
253
  }
@@ -274,9 +276,8 @@ async function checkStaleKnowledgeFiles(
274
276
  const destExists = await pathExists(destPath);
275
277
 
276
278
  if (srcExists && destExists) {
277
- const srcContent = await readFile(srcPath, "utf8").catch(() => "");
278
- const destContent = await readFile(destPath, "utf8").catch(() => "");
279
- if (srcContent !== destContent) {
279
+ const plan = planKnowledgeSync(srcPath, destPath);
280
+ if (plan.action === "merged") {
280
281
  stale.push(`${skill.name}/${kf}`);
281
282
  }
282
283
  }