@warpgogol/forge 5.1.3 → 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 (116) hide show
  1. package/AGENTS.md +3 -1
  2. package/bin/cli.ts +4 -0
  3. package/dist/bin/cli.js +4 -0
  4. package/dist/bin/cli.js.map +1 -1
  5. package/dist/os/adr/adr.module.js +1 -1
  6. package/dist/os/adr/adr.module.js.map +1 -1
  7. package/dist/os/compass/compass.module.js +2 -2
  8. package/dist/os/compass/compass.module.js.map +1 -1
  9. package/dist/os/compass/handlers/compass-audit-handler.d.ts +3 -0
  10. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  11. package/dist/os/compass/handlers/compass-audit-handler.js +87 -8
  12. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  13. package/dist/os/core/core.module.js +1 -1
  14. package/dist/os/core/core.module.js.map +1 -1
  15. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  16. package/dist/os/mission/handlers/archive.js +22 -6
  17. package/dist/os/mission/handlers/archive.js.map +1 -1
  18. package/dist/os/program/lease.js +1 -1
  19. package/dist/os/program/lease.js.map +1 -1
  20. package/dist/os/queue/handlers/queue-validate.d.ts +4 -0
  21. package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -0
  22. package/dist/os/queue/handlers/queue-validate.js +62 -0
  23. package/dist/os/queue/handlers/queue-validate.js.map +1 -0
  24. package/dist/os/queue/index.d.ts +5 -0
  25. package/dist/os/queue/index.d.ts.map +1 -0
  26. package/dist/os/queue/index.js +18 -0
  27. package/dist/os/queue/index.js.map +1 -0
  28. package/dist/os/queue/manifest.d.ts +14 -0
  29. package/dist/os/queue/manifest.d.ts.map +1 -0
  30. package/dist/os/queue/manifest.js +110 -0
  31. package/dist/os/queue/manifest.js.map +1 -0
  32. package/dist/os/queue/queue.module.d.ts +3 -0
  33. package/dist/os/queue/queue.module.d.ts.map +1 -0
  34. package/dist/os/queue/queue.module.js +56 -0
  35. package/dist/os/queue/queue.module.js.map +1 -0
  36. package/dist/os/queue/types.d.ts +25 -0
  37. package/dist/os/queue/types.d.ts.map +1 -0
  38. package/dist/os/queue/types.js +28 -0
  39. package/dist/os/queue/types.js.map +1 -0
  40. package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
  41. package/dist/os/rfc/handlers/list-create.js +79 -32
  42. package/dist/os/rfc/handlers/list-create.js.map +1 -1
  43. package/dist/os/rfc/handlers/pipeline-status.d.ts +2 -7
  44. package/dist/os/rfc/handlers/pipeline-status.d.ts.map +1 -1
  45. package/dist/os/rfc/handlers/pipeline-status.js +15 -57
  46. package/dist/os/rfc/handlers/pipeline-status.js.map +1 -1
  47. package/dist/os/rfc/rfc.module.js +1 -1
  48. package/dist/os/rfc/rfc.module.js.map +1 -1
  49. package/dist/os/spec/spec.module.d.ts.map +1 -1
  50. package/dist/os/spec/spec.module.js +2 -0
  51. package/dist/os/spec/spec.module.js.map +1 -1
  52. package/dist/src/knowledge/index.d.ts +1 -0
  53. package/dist/src/knowledge/index.d.ts.map +1 -1
  54. package/dist/src/knowledge/index.js +1 -0
  55. package/dist/src/knowledge/index.js.map +1 -1
  56. package/dist/src/knowledge/sync.d.ts +24 -0
  57. package/dist/src/knowledge/sync.d.ts.map +1 -0
  58. package/dist/src/knowledge/sync.js +127 -0
  59. package/dist/src/knowledge/sync.js.map +1 -0
  60. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  61. package/dist/src/onboarding/doctor.js +7 -6
  62. package/dist/src/onboarding/doctor.js.map +1 -1
  63. package/dist/src/onboarding/init.d.ts.map +1 -1
  64. package/dist/src/onboarding/init.js +15 -8
  65. package/dist/src/onboarding/init.js.map +1 -1
  66. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  67. package/dist/src/onboarding/upgrade.js +42 -13
  68. package/dist/src/onboarding/upgrade.js.map +1 -1
  69. package/dist/src/pipeline-status.d.ts +62 -0
  70. package/dist/src/pipeline-status.d.ts.map +1 -0
  71. package/dist/src/pipeline-status.js +220 -0
  72. package/dist/src/pipeline-status.js.map +1 -0
  73. package/dist/src/types.d.ts +1 -2
  74. package/dist/src/types.d.ts.map +1 -1
  75. package/dist/src/types.js +1 -0
  76. package/dist/src/types.js.map +1 -1
  77. package/dist/src/utils/fs-atomic.js +1 -1
  78. package/dist/src/utils/fs-atomic.js.map +1 -1
  79. package/os/adr/adr.module.ts +1 -1
  80. package/os/compass/compass.module.ts +2 -2
  81. package/os/compass/handlers/compass-audit-handler.ts +116 -8
  82. package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
  83. package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
  84. package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
  85. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
  86. package/os/core/core.module.ts +1 -1
  87. package/os/mission/handlers/archive.test.ts +49 -0
  88. package/os/mission/handlers/archive.ts +21 -6
  89. package/os/program/lease.ts +1 -1
  90. package/os/queue/handlers/queue-validate.ts +83 -0
  91. package/os/queue/index.ts +19 -0
  92. package/os/queue/manifest.ts +183 -0
  93. package/os/queue/queue-validate.test.ts +177 -0
  94. package/os/queue/queue.module.ts +59 -0
  95. package/os/queue/types.ts +49 -0
  96. package/os/rfc/handlers/list-create.ts +83 -35
  97. package/os/rfc/handlers/pipeline-status.ts +24 -67
  98. package/os/rfc/handlers/validate-rules.test.ts +50 -0
  99. package/os/rfc/rfc-create-concurrent.test.ts +118 -0
  100. package/os/rfc/rfc-create-hint.test.ts +90 -0
  101. package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
  102. package/os/rfc/rfc.module.ts +1 -1
  103. package/os/spec/spec.module.ts +2 -0
  104. package/package.json +17 -1
  105. package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +46 -1
  106. package/src/knowledge/__tests__/sync.test.ts +227 -0
  107. package/src/knowledge/index.ts +7 -0
  108. package/src/knowledge/sync.ts +161 -0
  109. package/src/onboarding/doctor.ts +7 -6
  110. package/src/onboarding/init.ts +15 -8
  111. package/src/onboarding/upgrade.ts +47 -16
  112. package/src/pipeline-status.test.ts +192 -0
  113. package/src/pipeline-status.ts +319 -0
  114. package/src/tests/upgrade.test.ts +53 -0
  115. package/src/types.ts +2 -2
  116. package/src/utils/fs-atomic.ts +1 -1
@@ -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
  }
@@ -24,6 +24,7 @@ import fs from "node:fs";
24
24
  import path from "node:path";
25
25
  import { stringify as stringifyYaml, parse as parseYaml } from "yaml";
26
26
  import { FORGE_SKILLS, discoverPackSkills } from "../registry.ts";
27
+ import { syncKnowledgeFile } from "../knowledge/index.ts";
27
28
  import { defaultForgeConfig, loadForgeConfig, resolveForgeRoot, type ForgeConfig } from "../config/forge-config.ts";
28
29
  import { listStackProfiles, detectStack } from "../profiles/stack-profile.ts";
29
30
 
@@ -292,9 +293,11 @@ export function runInit(
292
293
  const kfSrcPath = path.join(skillSrcDir, kf);
293
294
  const kfDestPath = path.join(destDir, kf);
294
295
  if (fs.existsSync(kfSrcPath)) {
295
- const kfContent = fs.readFileSync(kfSrcPath, "utf8");
296
- fs.writeFileSync(kfDestPath, kfContent, "utf8");
297
- created.push(`${config.paths.skillsDir}/${skillName}/${kf}`);
296
+ // Append-only sync — never overwrite local knowledge entries
297
+ const syncResult = syncKnowledgeFile(kfSrcPath, kfDestPath);
298
+ if (syncResult.action === "copied" || syncResult.action === "merged") {
299
+ created.push(`${config.paths.skillsDir}/${skillName}/${kf}`);
300
+ }
298
301
  } else {
299
302
  errors.push(`Knowledge file '${kf}' declared in ${skillName} but not found in source`);
300
303
  }
@@ -362,9 +365,11 @@ export function runInit(
362
365
  const kfSrcPath = path.join(skillSrcDir, kf);
363
366
  const kfDestPath = path.join(destDir, kf);
364
367
  if (fs.existsSync(kfSrcPath)) {
365
- const kfContent = fs.readFileSync(kfSrcPath, "utf8");
366
- fs.writeFileSync(kfDestPath, kfContent, "utf8");
367
- created.push(`${config.paths.skillsDir}/${skillName}/${kf}`);
368
+ // Append-only sync — never overwrite local knowledge entries
369
+ const syncResult = syncKnowledgeFile(kfSrcPath, kfDestPath);
370
+ if (syncResult.action === "copied" || syncResult.action === "merged") {
371
+ created.push(`${config.paths.skillsDir}/${skillName}/${kf}`);
372
+ }
368
373
  } else {
369
374
  errors.push(`Knowledge file '${kf}' declared in ${skillName} but not found in source`);
370
375
  }
@@ -382,8 +387,10 @@ export function runInit(
382
387
  const sharedKnowledgeDestDir = path.join(agentsSkillsDir, "shared-knowledge");
383
388
  fs.mkdirSync(sharedKnowledgeDestDir, { recursive: true });
384
389
  const sharedKnowledgeDest = path.join(sharedKnowledgeDestDir, "learned-principles.md");
385
- fs.writeFileSync(sharedKnowledgeDest, fs.readFileSync(sharedKnowledgeSrc, "utf8"), "utf8");
386
- created.push(`${config.paths.skillsDir}/shared-knowledge/learned-principles.md`);
390
+ const syncResult = syncKnowledgeFile(sharedKnowledgeSrc, sharedKnowledgeDest);
391
+ if (syncResult.action === "copied" || syncResult.action === "merged") {
392
+ created.push(`${config.paths.skillsDir}/shared-knowledge/learned-principles.md`);
393
+ }
387
394
  }
388
395
 
389
396
  // 4. Create docs directories from config paths
@@ -39,6 +39,7 @@ import {
39
39
  type ForgeBindings,
40
40
  } from "../config/forge-config.ts";
41
41
  import { FORGE_SKILLS, discoverPackSkills } from "../registry.ts";
42
+ import { syncKnowledgeFile } from "../knowledge/index.ts";
42
43
  import { generateNestedAgentsMd } from "./nested-agents-generate.ts";
43
44
  import { scaffoldMemoryLayer } from "./memory-scaffold.ts";
44
45
  import type { SkippedSkill } from "./init.ts";
@@ -143,8 +144,9 @@ function syncForgeSkills(
143
144
  forgeRoot: string,
144
145
  skillsDir: string,
145
146
  dryRun: boolean,
146
- ): string[] {
147
+ ): { updated: string[]; knowledgeConflicts: string[] } {
147
148
  const updated: string[] = [];
149
+ const knowledgeConflicts: string[] = [];
148
150
  const agentsSkillsDir = path.join(workspaceRoot, skillsDir);
149
151
 
150
152
  for (const skill of FORGE_SKILLS) {
@@ -160,7 +162,7 @@ function syncForgeSkills(
160
162
  const content = fs.readFileSync(srcPath, "utf8");
161
163
  fs.writeFileSync(destPath, content, "utf8");
162
164
 
163
- // Sync knowledge files
165
+ // Sync knowledge files (append-only — never overwrite local entries)
164
166
  const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
165
167
  if (fmMatch) {
166
168
  try {
@@ -173,7 +175,10 @@ function syncForgeSkills(
173
175
  const kfSrcPath = path.join(skillSrcDir, kf);
174
176
  const kfDestPath = path.join(destDir, kf);
175
177
  if (fs.existsSync(kfSrcPath)) {
176
- fs.writeFileSync(kfDestPath, fs.readFileSync(kfSrcPath, "utf8"), "utf8");
178
+ const syncResult = syncKnowledgeFile(kfSrcPath, kfDestPath);
179
+ for (const id of syncResult.conflicts) {
180
+ knowledgeConflicts.push(`${skillName}/${kf}#${id}`);
181
+ }
177
182
  }
178
183
  }
179
184
  }
@@ -185,7 +190,7 @@ function syncForgeSkills(
185
190
  updated.push(skillName);
186
191
  }
187
192
 
188
- return updated;
193
+ return { updated, knowledgeConflicts };
189
194
  }
190
195
 
191
196
  function syncSharedKnowledge(
@@ -193,20 +198,24 @@ function syncSharedKnowledge(
193
198
  forgeRoot: string,
194
199
  skillsDir: string,
195
200
  dryRun: boolean,
196
- ): string[] {
201
+ ): { updated: string[]; knowledgeConflicts: string[] } {
197
202
  const updated: string[] = [];
203
+ const knowledgeConflicts: string[] = [];
198
204
  const srcPath = path.join(forgeRoot, "skills", "shared", "knowledge", "learned-principles.md");
199
- if (!fs.existsSync(srcPath)) return updated;
205
+ if (!fs.existsSync(srcPath)) return { updated, knowledgeConflicts };
200
206
 
201
207
  const destDir = path.join(workspaceRoot, skillsDir, "shared-knowledge");
202
208
  const destPath = path.join(destDir, "learned-principles.md");
203
209
 
204
210
  if (!dryRun) {
205
211
  fs.mkdirSync(destDir, { recursive: true });
206
- fs.writeFileSync(destPath, fs.readFileSync(srcPath, "utf8"), "utf8");
212
+ const syncResult = syncKnowledgeFile(srcPath, destPath);
213
+ for (const id of syncResult.conflicts) {
214
+ knowledgeConflicts.push(`shared-knowledge/learned-principles.md#${id}`);
215
+ }
207
216
  }
208
217
  updated.push("shared-knowledge");
209
- return updated;
218
+ return { updated, knowledgeConflicts };
210
219
  }
211
220
 
212
221
  function syncPackSkills(
@@ -214,9 +223,10 @@ function syncPackSkills(
214
223
  config: ForgeConfig,
215
224
  skillsDir: string,
216
225
  dryRun: boolean,
217
- ): { updated: string[]; skipped: SkippedSkill[] } {
226
+ ): { updated: string[]; skipped: SkippedSkill[]; knowledgeConflicts: string[] } {
218
227
  const updated: string[] = [];
219
228
  const skipped: SkippedSkill[] = [];
229
+ const knowledgeConflicts: string[] = [];
220
230
  const agentsSkillsDir = path.join(workspaceRoot, skillsDir);
221
231
  let packSkills: ReturnType<typeof discoverPackSkills> = [];
222
232
  try {
@@ -245,7 +255,7 @@ function syncPackSkills(
245
255
  const content = fs.readFileSync(srcPath, "utf8");
246
256
  fs.writeFileSync(destPath, content, "utf8");
247
257
 
248
- // Sync knowledge files
258
+ // Sync knowledge files (append-only — never overwrite local entries)
249
259
  const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
250
260
  if (fmMatch) {
251
261
  try {
@@ -258,7 +268,10 @@ function syncPackSkills(
258
268
  const kfSrcPath = path.join(skillSrcDir, kf);
259
269
  const kfDestPath = path.join(destDir, kf);
260
270
  if (fs.existsSync(kfSrcPath)) {
261
- fs.writeFileSync(kfDestPath, fs.readFileSync(kfSrcPath, "utf8"), "utf8");
271
+ const syncResult = syncKnowledgeFile(kfSrcPath, kfDestPath);
272
+ for (const id of syncResult.conflicts) {
273
+ knowledgeConflicts.push(`${skillName}/${kf}#${id}`);
274
+ }
262
275
  }
263
276
  }
264
277
  }
@@ -270,7 +283,7 @@ function syncPackSkills(
270
283
  updated.push(skillName);
271
284
  }
272
285
 
273
- return { updated, skipped };
286
+ return { updated, skipped, knowledgeConflicts };
274
287
  }
275
288
 
276
289
  function addMissingBindingDefaults(
@@ -467,7 +480,7 @@ export async function runUpgrade(
467
480
  }
468
481
 
469
482
  // Step 3: Sync forge skills
470
- const forgeSkillsUpdated = syncForgeSkills(
483
+ const forgeSkillsResult = syncForgeSkills(
471
484
  workspaceRoot,
472
485
  forgeRoot,
473
486
  config.paths.skillsDir,
@@ -483,14 +496,29 @@ export async function runUpgrade(
483
496
  );
484
497
 
485
498
  // Step 3c: Sync shared knowledge layer (RFC-0663)
486
- const sharedKnowledgeUpdated = syncSharedKnowledge(
499
+ const sharedKnowledgeResult = syncSharedKnowledge(
487
500
  workspaceRoot,
488
501
  forgeRoot,
489
502
  config.paths.skillsDir,
490
503
  isDryRun,
491
504
  );
492
505
 
493
- const skillsUpdated = [...forgeSkillsUpdated, ...packResult.updated, ...sharedKnowledgeUpdated];
506
+ const skillsUpdated = [
507
+ ...forgeSkillsResult.updated,
508
+ ...packResult.updated,
509
+ ...sharedKnowledgeResult.updated,
510
+ ];
511
+ const knowledgeConflicts = [
512
+ ...forgeSkillsResult.knowledgeConflicts,
513
+ ...packResult.knowledgeConflicts,
514
+ ...sharedKnowledgeResult.knowledgeConflicts,
515
+ ];
516
+ if (knowledgeConflicts.length > 0) {
517
+ context.logger.warn(
518
+ `forge.upgrade: ${knowledgeConflicts.length} knowledge entr${knowledgeConflicts.length === 1 ? "y" : "ies"} ` +
519
+ `diverge between package and project — local versions kept: ${knowledgeConflicts.join(", ")}`,
520
+ );
521
+ }
494
522
 
495
523
  // Step 3d: Scaffold memory layer (RFC-0664)
496
524
  const memoryScaffold = isDryRun ? { created: [], gitignoreUpdated: false, skipped: [] } : scaffoldMemoryLayer(workspaceRoot);
@@ -565,6 +593,9 @@ export async function runUpgrade(
565
593
  ? `[dry-run] forge.upgrade: would sync ${skillsUpdated.length} skill(s), add ${bindingsAdded.length} binding(s), update syncedVersion to ${toVersion}`
566
594
  : npmCheck.warning
567
595
  ? `[forge.upgrade] OK — ${skillsUpdated.length} skill(s) synced, ${bindingsAdded.length} binding(s) added, syncedVersion → ${toVersion} — ⚠ ${npmCheck.warning}`
568
- : `[forge.upgrade] OK — ${skillsUpdated.length} skill(s) synced, ${bindingsAdded.length} binding(s) added, syncedVersion → ${toVersion}`,
596
+ : `[forge.upgrade] OK — ${skillsUpdated.length} skill(s) synced, ${bindingsAdded.length} binding(s) added, syncedVersion → ${toVersion}` +
597
+ (knowledgeConflicts.length > 0
598
+ ? ` — ${knowledgeConflicts.length} knowledge conflict(s), local kept`
599
+ : ""),
569
600
  };
570
601
  }
@@ -0,0 +1,192 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+
6
+ import {
7
+ deriveQueueItemStatus,
8
+ deriveQueueReport,
9
+ resolveDocument,
10
+ computeRfcPipelineStages,
11
+ nextPipelineStep,
12
+ } from "./pipeline-status.ts";
13
+
14
+ let tmpDir: string;
15
+
16
+ beforeEach(async () => {
17
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "pipeline-status-test-"));
18
+ await fs.mkdir(path.join(tmpDir, "docs/rfcs"), { recursive: true });
19
+ await fs.mkdir(path.join(tmpDir, "docs/adrs"), { recursive: true });
20
+ await fs.mkdir(path.join(tmpDir, "docs/audits"), { recursive: true });
21
+ await fs.mkdir(path.join(tmpDir, "docs/plans"), { recursive: true });
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await fs.rm(tmpDir, { recursive: true, force: true });
26
+ });
27
+
28
+ async function writeRfc(id: string, frontmatter: string): Promise<void> {
29
+ await fs.writeFile(
30
+ path.join(tmpDir, `docs/rfcs/${id.toLowerCase()}-test.md`),
31
+ `---\nid: ${id}\n${frontmatter}---\n# ${id}\n`,
32
+ );
33
+ }
34
+
35
+ async function writeAdr(id: string, frontmatter: string): Promise<void> {
36
+ await fs.writeFile(
37
+ path.join(tmpDir, `docs/adrs/${id.toLowerCase()}-test.md`),
38
+ `---\nid: ${id}\n${frontmatter}---\n# ${id}\n`,
39
+ );
40
+ }
41
+
42
+ describe("resolveDocument", () => {
43
+ test("resolves an active RFC file", async () => {
44
+ await writeRfc("RFC-1001", "status: draft\n");
45
+ const doc = await resolveDocument(tmpDir, "RFC-1001");
46
+ expect(doc?.kind).toBe("rfc");
47
+ expect(doc?.frontmatter["status"]).toBe("draft");
48
+ });
49
+
50
+ test("resolves an archived RFC file", async () => {
51
+ await fs.mkdir(path.join(tmpDir, "docs/rfcs/archive/implemented"), { recursive: true });
52
+ await fs.writeFile(
53
+ path.join(tmpDir, "docs/rfcs/archive/implemented/rfc-1002-old.md"),
54
+ "---\nid: RFC-1002\nstatus: implemented\n---\n# RFC-1002\n",
55
+ );
56
+ const doc = await resolveDocument(tmpDir, "RFC-1002");
57
+ expect(doc?.file).toContain("archive/implemented");
58
+ expect(doc?.frontmatter["status"]).toBe("implemented");
59
+ });
60
+
61
+ test("returns undefined for malformed ids", async () => {
62
+ expect(await resolveDocument(tmpDir, "NOT-A-DOC")).toBeUndefined();
63
+ expect(await resolveDocument(tmpDir, "RFC-999")).toBeUndefined();
64
+ });
65
+ });
66
+
67
+ describe("deriveQueueItemStatus — derivation matrix (RFC-1140)", () => {
68
+ test("implemented status → implemented", async () => {
69
+ await writeRfc("RFC-1100", "status: implemented\n");
70
+ const r = await deriveQueueItemStatus(tmpDir, "RFC-1100");
71
+ expect(r?.status).toBe("implemented");
72
+ });
73
+
74
+ test("implementedAt set → implemented", async () => {
75
+ await writeRfc("RFC-1101", "status: accepted\nimplementedAt: 2026-09-01\n");
76
+ const r = await deriveQueueItemStatus(tmpDir, "RFC-1101");
77
+ expect(r?.status).toBe("implemented");
78
+ });
79
+
80
+ test("rejected → skipped", async () => {
81
+ await writeRfc("RFC-1102", "status: rejected\n");
82
+ const r = await deriveQueueItemStatus(tmpDir, "RFC-1102");
83
+ expect(r?.status).toBe("skipped");
84
+ });
85
+
86
+ test("superseded → skipped", async () => {
87
+ await writeRfc("RFC-1103", "status: superseded\n");
88
+ const r = await deriveQueueItemStatus(tmpDir, "RFC-1103");
89
+ expect(r?.status).toBe("skipped");
90
+ });
91
+
92
+ test("accepted RFC → in-progress/implement", async () => {
93
+ await writeRfc("RFC-1104", "status: accepted\n");
94
+ const r = await deriveQueueItemStatus(tmpDir, "RFC-1104");
95
+ expect(r?.status).toBe("in-progress");
96
+ expect(r?.pipelineStep).toBe("implement");
97
+ });
98
+
99
+ test("plan file exists → in-progress/implement", async () => {
100
+ await writeRfc("RFC-1105", "status: draft\nenhancedAt: 2026-09-01\n");
101
+ await fs.writeFile(
102
+ path.join(tmpDir, "docs/plans/plan-rfc-1105-test.md"),
103
+ "---\nrfcId: RFC-1105\n---\n",
104
+ );
105
+ const r = await deriveQueueItemStatus(tmpDir, "RFC-1105");
106
+ expect(r?.status).toBe("in-progress");
107
+ expect(r?.pipelineStep).toBe("implement");
108
+ });
109
+
110
+ test("enhancedAt without plan → in-progress/plan", async () => {
111
+ await writeRfc("RFC-1106", "status: draft\nenhancedAt: 2026-09-01\n");
112
+ const r = await deriveQueueItemStatus(tmpDir, "RFC-1106");
113
+ expect(r?.status).toBe("in-progress");
114
+ expect(r?.pipelineStep).toBe("plan");
115
+ });
116
+
117
+ test("audit file only → in-progress/enhance", async () => {
118
+ await writeRfc("RFC-1107", "status: draft\n");
119
+ await fs.writeFile(
120
+ path.join(tmpDir, "docs/audits/audit-rfc-1107-test.md"),
121
+ "# audit\n",
122
+ );
123
+ const r = await deriveQueueItemStatus(tmpDir, "RFC-1107");
124
+ expect(r?.status).toBe("in-progress");
125
+ expect(r?.pipelineStep).toBe("enhance");
126
+ });
127
+
128
+ test("no artifacts → pending", async () => {
129
+ await writeRfc("RFC-1108", "status: draft\n");
130
+ const r = await deriveQueueItemStatus(tmpDir, "RFC-1108");
131
+ expect(r?.status).toBe("pending");
132
+ expect(r?.pipelineStep).toBeUndefined();
133
+ });
134
+
135
+ test("non-terminal ADR → pending", async () => {
136
+ await writeAdr("ADR-0100", "status: accepted\n");
137
+ const r = await deriveQueueItemStatus(tmpDir, "ADR-0100");
138
+ expect(r?.status).toBe("pending");
139
+ });
140
+
141
+ test("unresolvable id → undefined", async () => {
142
+ expect(await deriveQueueItemStatus(tmpDir, "RFC-9999")).toBeUndefined();
143
+ });
144
+ });
145
+
146
+ describe("deriveQueueReport", () => {
147
+ test("preserves manifest order and picks the first actionable item as next", async () => {
148
+ await writeRfc("RFC-1200", "status: implemented\n");
149
+ await writeRfc("RFC-1201", "status: draft\n");
150
+ await writeRfc("RFC-1202", "status: draft\n");
151
+
152
+ const report = await deriveQueueReport(tmpDir, ["RFC-1200", "RFC-1201", "RFC-1202"]);
153
+
154
+ expect(report.items.map((i) => i.id)).toEqual(["RFC-1200", "RFC-1201", "RFC-1202"]);
155
+ expect(report.next).toBe("RFC-1201");
156
+ });
157
+
158
+ test("next is null when every item is terminal", async () => {
159
+ await writeRfc("RFC-1210", "status: implemented\n");
160
+ await writeRfc("RFC-1211", "status: rejected\n");
161
+
162
+ const report = await deriveQueueReport(tmpDir, ["RFC-1210", "RFC-1211"]);
163
+
164
+ expect(report.next).toBeNull();
165
+ });
166
+
167
+ test("in-progress items are actionable for next", async () => {
168
+ await writeRfc("RFC-1220", "status: draft\nenhancedAt: 2026-09-01\n");
169
+ await writeRfc("RFC-1221", "status: draft\n");
170
+
171
+ const report = await deriveQueueReport(tmpDir, ["RFC-1220", "RFC-1221"]);
172
+
173
+ expect(report.next).toBe("RFC-1220");
174
+ });
175
+ });
176
+
177
+ describe("computeRfcPipelineStages + nextPipelineStep", () => {
178
+ test("stages reflect artifact completion", () => {
179
+ const stages = computeRfcPipelineStages({
180
+ status: "draft",
181
+ enhancedAt: "2026-09-01",
182
+ auditFile: "docs/audits/audit-rfc-1.md",
183
+ });
184
+ expect(stages.map((s) => s.done)).toEqual([true, true, false, false]);
185
+ expect(nextPipelineStep("draft", stages)).toBe("plan");
186
+ });
187
+
188
+ test("terminal status yields null next step", () => {
189
+ const stages = computeRfcPipelineStages({ status: "implemented" });
190
+ expect(nextPipelineStep("implemented", stages)).toBeNull();
191
+ });
192
+ });