@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.
- package/AGENTS.md +3 -1
- package/bin/cli.ts +4 -0
- package/dist/bin/cli.js +4 -0
- package/dist/bin/cli.js.map +1 -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.js +1 -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/handlers/queue-validate.d.ts +4 -0
- package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -0
- package/dist/os/queue/handlers/queue-validate.js +62 -0
- package/dist/os/queue/handlers/queue-validate.js.map +1 -0
- package/dist/os/queue/index.d.ts +5 -0
- package/dist/os/queue/index.d.ts.map +1 -0
- package/dist/os/queue/index.js +18 -0
- package/dist/os/queue/index.js.map +1 -0
- package/dist/os/queue/manifest.d.ts +14 -0
- package/dist/os/queue/manifest.d.ts.map +1 -0
- package/dist/os/queue/manifest.js +110 -0
- package/dist/os/queue/manifest.js.map +1 -0
- package/dist/os/queue/queue.module.d.ts +3 -0
- package/dist/os/queue/queue.module.d.ts.map +1 -0
- package/dist/os/queue/queue.module.js +56 -0
- package/dist/os/queue/queue.module.js.map +1 -0
- package/dist/os/queue/types.d.ts +25 -0
- package/dist/os/queue/types.d.ts.map +1 -0
- package/dist/os/queue/types.js +28 -0
- package/dist/os/queue/types.js.map +1 -0
- 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/handlers/pipeline-status.d.ts +2 -7
- package/dist/os/rfc/handlers/pipeline-status.d.ts.map +1 -1
- package/dist/os/rfc/handlers/pipeline-status.js +15 -57
- package/dist/os/rfc/handlers/pipeline-status.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/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/doctor.d.ts.map +1 -1
- package/dist/src/onboarding/doctor.js +7 -6
- 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/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/pipeline-status.d.ts +62 -0
- package/dist/src/pipeline-status.d.ts.map +1 -0
- package/dist/src/pipeline-status.js +220 -0
- package/dist/src/pipeline-status.js.map +1 -0
- 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/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 +1 -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/handlers/queue-validate.ts +83 -0
- package/os/queue/index.ts +19 -0
- package/os/queue/manifest.ts +183 -0
- package/os/queue/queue-validate.test.ts +177 -0
- package/os/queue/queue.module.ts +59 -0
- package/os/queue/types.ts +49 -0
- package/os/rfc/handlers/list-create.ts +83 -35
- package/os/rfc/handlers/pipeline-status.ts +24 -67
- 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 +17 -1
- package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +46 -1
- 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/doctor.ts +7 -6
- package/src/onboarding/init.ts +15 -8
- package/src/onboarding/upgrade.ts +47 -16
- package/src/pipeline-status.test.ts +192 -0
- package/src/pipeline-status.ts +319 -0
- package/src/tests/upgrade.test.ts +53 -0
- package/src/types.ts +2 -2
- 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
|
+
}
|
package/src/onboarding/doctor.ts
CHANGED
|
@@ -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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
278
|
-
|
|
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
|
}
|
package/src/onboarding/init.ts
CHANGED
|
@@ -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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = [
|
|
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
|
+
});
|