@warpgogol/forge 5.2.0 → 5.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/AGENTS.md +4 -1
  2. package/dist/os/adr/adr.module.js +1 -1
  3. package/dist/os/adr/adr.module.js.map +1 -1
  4. package/dist/os/compass/compass.module.js +2 -2
  5. package/dist/os/compass/compass.module.js.map +1 -1
  6. package/dist/os/compass/handlers/compass-audit-handler.d.ts +3 -0
  7. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  8. package/dist/os/compass/handlers/compass-audit-handler.js +87 -8
  9. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  10. package/dist/os/core/core.module.d.ts.map +1 -1
  11. package/dist/os/core/core.module.js +15 -1
  12. package/dist/os/core/core.module.js.map +1 -1
  13. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  14. package/dist/os/mission/handlers/archive.js +22 -6
  15. package/dist/os/mission/handlers/archive.js.map +1 -1
  16. package/dist/os/program/lease.js +1 -1
  17. package/dist/os/program/lease.js.map +1 -1
  18. package/dist/os/queue/queue.module.js +1 -1
  19. package/dist/os/queue/queue.module.js.map +1 -1
  20. package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
  21. package/dist/os/rfc/handlers/list-create.js +79 -32
  22. package/dist/os/rfc/handlers/list-create.js.map +1 -1
  23. package/dist/os/rfc/rfc.module.js +1 -1
  24. package/dist/os/rfc/rfc.module.js.map +1 -1
  25. package/dist/os/spec/spec.module.d.ts.map +1 -1
  26. package/dist/os/spec/spec.module.js +2 -0
  27. package/dist/os/spec/spec.module.js.map +1 -1
  28. package/dist/src/config/forge-config.d.ts +9 -0
  29. package/dist/src/config/forge-config.d.ts.map +1 -1
  30. package/dist/src/config/forge-config.js +7 -0
  31. package/dist/src/config/forge-config.js.map +1 -1
  32. package/dist/src/knowledge/index.d.ts +1 -0
  33. package/dist/src/knowledge/index.d.ts.map +1 -1
  34. package/dist/src/knowledge/index.js +1 -0
  35. package/dist/src/knowledge/index.js.map +1 -1
  36. package/dist/src/knowledge/sync.d.ts +24 -0
  37. package/dist/src/knowledge/sync.d.ts.map +1 -0
  38. package/dist/src/knowledge/sync.js +127 -0
  39. package/dist/src/knowledge/sync.js.map +1 -0
  40. package/dist/src/onboarding/agents-generate.d.ts +2 -0
  41. package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
  42. package/dist/src/onboarding/agents-generate.js +14 -18
  43. package/dist/src/onboarding/agents-generate.js.map +1 -1
  44. package/dist/src/onboarding/doctor.d.ts +6 -0
  45. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  46. package/dist/src/onboarding/doctor.js +124 -14
  47. package/dist/src/onboarding/doctor.js.map +1 -1
  48. package/dist/src/onboarding/init.d.ts.map +1 -1
  49. package/dist/src/onboarding/init.js +15 -8
  50. package/dist/src/onboarding/init.js.map +1 -1
  51. package/dist/src/onboarding/memory-compact.d.ts +22 -0
  52. package/dist/src/onboarding/memory-compact.d.ts.map +1 -0
  53. package/dist/src/onboarding/memory-compact.js +147 -0
  54. package/dist/src/onboarding/memory-compact.js.map +1 -0
  55. package/dist/src/onboarding/memory-scaffold.d.ts +1 -0
  56. package/dist/src/onboarding/memory-scaffold.d.ts.map +1 -1
  57. package/dist/src/onboarding/memory-scaffold.js +1 -1
  58. package/dist/src/onboarding/memory-scaffold.js.map +1 -1
  59. package/dist/src/onboarding/nested-agents-generate.d.ts.map +1 -1
  60. package/dist/src/onboarding/nested-agents-generate.js +1 -1
  61. package/dist/src/onboarding/nested-agents-generate.js.map +1 -1
  62. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  63. package/dist/src/onboarding/upgrade.js +42 -13
  64. package/dist/src/onboarding/upgrade.js.map +1 -1
  65. package/dist/src/onboarding/workspace-discovery.d.ts +1 -1
  66. package/dist/src/onboarding/workspace-discovery.d.ts.map +1 -1
  67. package/dist/src/onboarding/workspace-discovery.js +14 -2
  68. package/dist/src/onboarding/workspace-discovery.js.map +1 -1
  69. package/dist/src/types.d.ts +1 -2
  70. package/dist/src/types.d.ts.map +1 -1
  71. package/dist/src/types.js +1 -0
  72. package/dist/src/types.js.map +1 -1
  73. package/dist/src/utils/fs-atomic.js +1 -1
  74. package/dist/src/utils/fs-atomic.js.map +1 -1
  75. package/docs/reference/forge-yaml.md +12 -0
  76. package/os/adr/adr.module.ts +1 -1
  77. package/os/compass/compass.module.ts +2 -2
  78. package/os/compass/handlers/compass-audit-handler.ts +116 -8
  79. package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
  80. package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
  81. package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
  82. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
  83. package/os/core/core.module.ts +16 -1
  84. package/os/mission/handlers/archive.test.ts +49 -0
  85. package/os/mission/handlers/archive.ts +21 -6
  86. package/os/program/lease.ts +1 -1
  87. package/os/queue/queue.module.ts +1 -1
  88. package/os/rfc/handlers/list-create.ts +83 -35
  89. package/os/rfc/handlers/validate-rules.test.ts +50 -0
  90. package/os/rfc/rfc-create-concurrent.test.ts +118 -0
  91. package/os/rfc/rfc-create-hint.test.ts +90 -0
  92. package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
  93. package/os/rfc/rfc.module.ts +1 -1
  94. package/os/spec/spec.module.ts +2 -0
  95. package/package.json +1 -1
  96. package/profiles/forge-shell.yaml +3 -0
  97. package/profiles/godot-game.yaml +3 -0
  98. package/profiles/phaser-game.yaml +3 -0
  99. package/profiles/site-workshop.yaml +567 -564
  100. package/src/config/forge-config.ts +11 -0
  101. package/src/knowledge/__tests__/sync.test.ts +227 -0
  102. package/src/knowledge/index.ts +7 -0
  103. package/src/knowledge/sync.ts +161 -0
  104. package/src/onboarding/agents-generate.ts +18 -18
  105. package/src/onboarding/doctor.ts +141 -15
  106. package/src/onboarding/init.ts +15 -8
  107. package/src/onboarding/memory-compact.ts +188 -0
  108. package/src/onboarding/memory-scaffold.ts +1 -1
  109. package/src/onboarding/nested-agents-generate.ts +5 -1
  110. package/src/onboarding/upgrade.ts +47 -16
  111. package/src/onboarding/workspace-discovery.ts +15 -1
  112. package/src/tests/agents-generate.test.ts +26 -3
  113. package/src/tests/doctor-dist-freshness.test.ts +110 -0
  114. package/src/tests/doctor-fix.test.ts +145 -0
  115. package/src/tests/doctor-nested-agents.test.ts +108 -0
  116. package/src/tests/memory-compact.test.ts +143 -0
  117. package/src/tests/upgrade.test.ts +53 -0
  118. package/src/tests/workspace-discovery.test.ts +28 -0
  119. package/src/types.ts +2 -2
  120. package/src/utils/fs-atomic.ts +1 -1
@@ -35,18 +35,20 @@ import { resolveForgeRoot, loadForgeConfig, resolveBinding, resolveTerminology,
35
35
  import { FORGE_SKILLS, discoverPackSkills } from "../registry.ts";
36
36
  import { discoverWorkspaces } from "./workspace-discovery.ts";
37
37
  import { buildNestedAgentsMd, selectNestedTemplate } from "./nested-agents-templates.ts";
38
- import { readPackageInfo } from "./nested-agents-generate.ts";
38
+ import { readPackageInfo, generateNestedAgentsMd } from "./nested-agents-generate.ts";
39
39
  import { listStackProfiles, type StackProfile } from "../profiles/stack-profile.ts";
40
40
  import { resolveAllTerminology } from "../profiles/terminology-utils.ts";
41
41
  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";
48
49
  import type { DuplicatePair } from "../knowledge/index.ts";
49
- import { checkMemoryLayerHealth } from "./memory-scaffold.ts";
50
+ import { checkMemoryLayerHealth, resolveMemoryBudget } from "./memory-scaffold.ts";
51
+ import { compactMemoryMd } from "./memory-compact.ts";
50
52
  import { checkInvariants } from "./invariant-engine.ts";
51
53
  import type { InvariantViolation } from "./invariant-engine.ts";
52
54
  import { checkNpmToken, workshopNeedsWarpgogolToken } from "./npm-token-check.ts";
@@ -54,6 +56,12 @@ import { execSync } from "node:child_process";
54
56
  import fs from "node:fs";
55
57
  import type { ProfilePrerequisite } from "../profiles/profile-schema.ts";
56
58
 
59
+ interface DoctorFixResult {
60
+ check: string;
61
+ action: "fixed" | "skipped" | "failed";
62
+ detail: string;
63
+ }
64
+
57
65
  interface DoctorCheck {
58
66
  name: string;
59
67
  status: "pass" | "fail" | "warn";
@@ -243,9 +251,10 @@ async function checkStaleKnowledgeFiles(
243
251
  const destExists = await pathExists(destPath);
244
252
 
245
253
  if (srcExists && destExists) {
246
- const srcContent = await readFile(srcPath, "utf8").catch(() => "");
247
- const destContent = await readFile(destPath, "utf8").catch(() => "");
248
- if (srcContent !== destContent) {
254
+ // Append-only contract: local copies legitimately accumulate entries.
255
+ // Stale = source has entries the local copy lacks (sync would merge).
256
+ const plan = planKnowledgeSync(srcPath, destPath);
257
+ if (plan.action === "merged") {
249
258
  stale.push(`${skill.name}/${kf}`);
250
259
  }
251
260
  }
@@ -274,9 +283,8 @@ async function checkStaleKnowledgeFiles(
274
283
  const destExists = await pathExists(destPath);
275
284
 
276
285
  if (srcExists && destExists) {
277
- const srcContent = await readFile(srcPath, "utf8").catch(() => "");
278
- const destContent = await readFile(destPath, "utf8").catch(() => "");
279
- if (srcContent !== destContent) {
286
+ const plan = planKnowledgeSync(srcPath, destPath);
287
+ if (plan.action === "merged") {
280
288
  stale.push(`${skill.name}/${kf}`);
281
289
  }
282
290
  }
@@ -825,7 +833,11 @@ async function checkNestedAgentsMd(
825
833
  };
826
834
  }
827
835
 
828
- const workspaces = discoverWorkspaces(workspaceRoot, workspaceTypes);
836
+ const workspaces = discoverWorkspaces(
837
+ workspaceRoot,
838
+ workspaceTypes,
839
+ config.bindings?.workspaces?.skipDirs,
840
+ );
829
841
 
830
842
  if (workspaces.length === 0) {
831
843
  return {
@@ -1208,9 +1220,10 @@ function checkPrerequisites(prerequisites: ProfilePrerequisite[]): DoctorCheck {
1208
1220
  export async function runDoctor(
1209
1221
  input: ForgeCommandInput,
1210
1222
  context: ForgeRuntimeContext,
1211
- ): Promise<ForgeCommandResult<{ command: string; checks: DoctorCheck[]; allPass: boolean; forbiddenImports: ForbiddenImport[]; bindings: BindingValidation; domain?: DomainReport }>> {
1212
- const { workspaceRoot, logger, outputFormat } = context;
1223
+ ): Promise<ForgeCommandResult<{ command: string; checks: DoctorCheck[]; allPass: boolean; forbiddenImports: ForbiddenImport[]; bindings: BindingValidation; domain?: DomainReport; fixes?: DoctorFixResult[] }>> {
1224
+ const { workspaceRoot, logger, outputFormat, dryRun } = context;
1213
1225
  const strict = input.flags["strict"] === true;
1226
+ const fix = input.flags["fix"] === true;
1214
1227
  const checks: DoctorCheck[] = [];
1215
1228
 
1216
1229
  // Check forge.yaml
@@ -1417,6 +1430,9 @@ export async function runDoctor(
1417
1430
  // RFC-0664: Check memory layer health (budget, gitignore coverage, leak risk)
1418
1431
  checks.push(checkMemoryLayer(workspaceRoot));
1419
1432
 
1433
+ // RFC-1151: Check dist freshness — stale dist/ silently shadows src/ in source checkouts
1434
+ checks.push(checkDistFreshness(forgeRoot));
1435
+
1420
1436
  // RFC-0539: Check pack skills — stale/missing copies and config validation
1421
1437
  const packCheck = await checkPackSkills(workspaceRoot);
1422
1438
  checks.push(packCheck);
@@ -1434,8 +1450,8 @@ export async function runDoctor(
1434
1450
  // RFC-0640 fix: previously gated by isSoftwareDomain, which skipped the check
1435
1451
  // entirely for non-software domains. Now runs for all domains — profile-driven
1436
1452
  // wsTypes replace hardcoded detection when present.
1453
+ let wsTypes: ProfileWorkspaceType[] | undefined;
1437
1454
  {
1438
- let wsTypes: ProfileWorkspaceType[] | undefined;
1439
1455
  try {
1440
1456
  const config = loadForgeConfig(workspaceRoot);
1441
1457
  // RFC-0643: prefer config.profile (loaded by loadForgeConfig)
@@ -1471,6 +1487,15 @@ export async function runDoctor(
1471
1487
  const allPass = finalChecks.every((c) => c.status === "pass");
1472
1488
  const hasFails = finalChecks.some((c) => c.status === "fail");
1473
1489
 
1490
+ // RFC-1151: --fix applies registered safe remediations for warn/fail checks
1491
+ const fixes: DoctorFixResult[] = [];
1492
+ if (fix) {
1493
+ for (const check of finalChecks) {
1494
+ if (check.status === "pass") continue;
1495
+ fixes.push(await applyDoctorRemediation(check, workspaceRoot, wsTypes, dryRun === true));
1496
+ }
1497
+ }
1498
+
1474
1499
  if (outputFormat === "pretty") {
1475
1500
  logger.section(`Forge Doctor — ${workspaceRoot}`);
1476
1501
  for (const check of finalChecks) {
@@ -1478,6 +1503,10 @@ export async function runDoctor(
1478
1503
  const fn = check.status === "pass" ? logger.success : check.status === "warn" ? logger.warn : logger.error;
1479
1504
  fn(`${icon} ${check.name}: ${check.message}`);
1480
1505
  }
1506
+ for (const f of fixes) {
1507
+ const fn = f.action === "fixed" ? logger.success : f.action === "failed" ? logger.error : logger.info;
1508
+ fn(` fix ${f.check}: ${f.action} — ${f.detail}`);
1509
+ }
1481
1510
  if (allPass) {
1482
1511
  logger.success("All checks passed — forge is properly configured.");
1483
1512
  } else if (hasFails) {
@@ -1487,11 +1516,108 @@ export async function runDoctor(
1487
1516
  }
1488
1517
  }
1489
1518
 
1519
+ const fixFailed = fixes.some((f) => f.action === "failed");
1520
+
1490
1521
  return {
1491
- data: { command: "forge.doctor", checks: finalChecks, allPass, forbiddenImports, bindings: bindingsResult, domain: domainReport },
1492
- exitCode: hasFails ? 1 : 0,
1522
+ data: { command: "forge.doctor", checks: finalChecks, allPass, forbiddenImports, bindings: bindingsResult, domain: domainReport, ...(fix ? { fixes } : {}) },
1523
+ exitCode: hasFails || fixFailed ? 1 : 0,
1493
1524
  summary: allPass
1494
1525
  ? "forge.doctor: all checks passed"
1495
- : `forge.doctor: ${finalChecks.filter((c) => c.status === "fail").length} fail(s), ${finalChecks.filter((c) => c.status === "warn").length} warn(s)`,
1526
+ : `forge.doctor: ${finalChecks.filter((c) => c.status === "fail").length} fail(s), ${finalChecks.filter((c) => c.status === "warn").length} warn(s)${fix ? `, ${fixes.filter((f) => f.action === "fixed").length} fixed` : ""}`,
1496
1527
  };
1497
1528
  }
1529
+
1530
+ // RFC-1151: dist-freshness — a stale dist/ silently shadows src/ because
1531
+ // bin/cli.js prefers dist when present. Warn-only; rebuild is a pnpm script,
1532
+ // not forge's call. Pass (skip) when src/ or dist/ is absent — npm consumers
1533
+ // ship dist-only, so this activates only in source checkouts.
1534
+ function checkDistFreshness(forgeRoot: string): DoctorCheck {
1535
+ const srcDir = join(forgeRoot, "src");
1536
+ const distDir = join(forgeRoot, "dist");
1537
+ if (!fs.existsSync(srcDir) || !fs.existsSync(distDir)) {
1538
+ return {
1539
+ name: "dist-freshness",
1540
+ status: "pass",
1541
+ message: "no src+dist pair — not a source checkout",
1542
+ };
1543
+ }
1544
+ const newestSrc = newestMtime(srcDir);
1545
+ const distMtime = fs.statSync(distDir).mtimeMs;
1546
+ if (newestSrc > distMtime) {
1547
+ return {
1548
+ name: "dist-freshness",
1549
+ status: "warn",
1550
+ message: `dist/ older than newest src/ file — run 'pnpm run build' in ${forgeRoot}`,
1551
+ };
1552
+ }
1553
+ return { name: "dist-freshness", status: "pass", message: "dist/ is fresh" };
1554
+ }
1555
+
1556
+ function newestMtime(dir: string): number {
1557
+ let newest = 0;
1558
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) {
1559
+ if (!entry.isFile()) continue;
1560
+ const m = fs.statSync(join(entry.parentPath, entry.name)).mtimeMs;
1561
+ if (m > newest) newest = m;
1562
+ }
1563
+ return newest;
1564
+ }
1565
+
1566
+ // RFC-1151: remediation dispatch — check name → safe fix action.
1567
+ // Remediations reuse existing generators; hand-written files are never touched
1568
+ // (generated-marker edit guard inside generateNestedAgentsMd).
1569
+ async function applyDoctorRemediation(
1570
+ check: DoctorCheck,
1571
+ workspaceRoot: string,
1572
+ workspaceTypes: ProfileWorkspaceType[] | undefined,
1573
+ dryRun: boolean,
1574
+ ): Promise<DoctorFixResult> {
1575
+ switch (check.name) {
1576
+ case "nested-AGENTS.md": {
1577
+ try {
1578
+ const config = loadForgeConfig(workspaceRoot);
1579
+ const res = await generateNestedAgentsMd(workspaceRoot, config, dryRun, workspaceTypes);
1580
+ return {
1581
+ check: check.name,
1582
+ action: res.generated.length > 0 ? "fixed" : "skipped",
1583
+ detail: `${res.generated.length} file(s) ${dryRun ? "would be " : ""}regenerated, ${res.skipped.length} skipped`,
1584
+ };
1585
+ } catch (err) {
1586
+ return { check: check.name, action: "failed", detail: (err as Error).message };
1587
+ }
1588
+ }
1589
+ case "memory-layer": {
1590
+ try {
1591
+ const memoryPath = join(workspaceRoot, ".agents", "memory", "MEMORY.md");
1592
+ if (!fs.existsSync(memoryPath)) {
1593
+ return { check: check.name, action: "skipped", detail: "MEMORY.md absent" };
1594
+ }
1595
+ const content = fs.readFileSync(memoryPath, "utf8");
1596
+ const budget = resolveMemoryBudget(workspaceRoot);
1597
+ const { lines, removedLines, fitsBudget } = compactMemoryMd(content, budget);
1598
+ if (!fitsBudget) {
1599
+ return {
1600
+ check: check.name,
1601
+ action: "failed",
1602
+ detail: "still over budget after Environment notes truncation — manual editorial compaction required",
1603
+ };
1604
+ }
1605
+ if (removedLines.length === 0) {
1606
+ return { check: check.name, action: "skipped", detail: "within budget" };
1607
+ }
1608
+ if (!dryRun) {
1609
+ fs.writeFileSync(memoryPath, lines.join("\n"), "utf8");
1610
+ }
1611
+ return {
1612
+ check: check.name,
1613
+ action: "fixed",
1614
+ detail: `${dryRun ? "would remove" : "removed"} ${removedLines.length} bullet(s)`,
1615
+ };
1616
+ } catch (err) {
1617
+ return { check: check.name, action: "failed", detail: (err as Error).message };
1618
+ }
1619
+ }
1620
+ default:
1621
+ return { check: check.name, action: "skipped", detail: "no-remediation" };
1622
+ }
1623
+ }
@@ -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
@@ -0,0 +1,188 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>forge memory.compact — mechanically enforce the MEMORY.md character budget
4
+ (RFC-0664) by removing oldest Environment notes bullets until the file fits.
5
+ Invoked standalone and by forge doctor --fix for the memory-layer check (RFC-1151).</purpose>
6
+ <non-goals>
7
+ <item>Never touch "## Current focus" or "## Decisions in flight" — those need editorial judgment.</item>
8
+ <item>No semantic rewriting — bullets are removed whole, never edited or merged.</item>
9
+ <item>No archiving into daily/ logs — removed content is recoverable via git history.</item>
10
+ </non-goals>
11
+ </MODULE_CONTRACT>
12
+ <CHANGE_SUMMARY>
13
+ <item>RFC-1151: initial memory.compact — oldest-first Environment notes truncation.</item>
14
+ </CHANGE_SUMMARY>
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import type { ForgeCommandInput, ForgeCommandResult, ForgeRuntimeContext } from "../types.ts";
20
+ import { resolveMemoryBudget } from "./memory-scaffold.ts";
21
+
22
+ const MEMORY_MD_PATH = path.join(".agents", "memory", "MEMORY.md");
23
+ const COMPACT_SECTION = "## Environment notes";
24
+ const PROTECTED_SECTIONS = new Set(["## Current focus", "## Decisions in flight"]);
25
+
26
+ export interface MemoryCompactResult {
27
+ command: "forge.memory.compact";
28
+ status: "pass" | "fail";
29
+ removed: number;
30
+ remainingChars: number;
31
+ budget: number;
32
+ removedLines?: string[];
33
+ errors: string[];
34
+ }
35
+
36
+ interface Section {
37
+ header: string;
38
+ start: number; // line index of the "## ..." header
39
+ end: number; // line index one past the section's last line
40
+ }
41
+
42
+ function parseSections(lines: string[]): Section[] {
43
+ const sections: Section[] = [];
44
+ for (let i = 0; i < lines.length; i++) {
45
+ if (lines[i].startsWith("## ")) {
46
+ if (sections.length > 0) sections[sections.length - 1].end = i;
47
+ sections.push({ header: lines[i].trim(), start: i, end: lines.length });
48
+ }
49
+ }
50
+ return sections;
51
+ }
52
+
53
+ function isBullet(line: string): boolean {
54
+ return /^\s*-\s+/.test(line);
55
+ }
56
+
57
+ /**
58
+ * Remove oldest bullets from `## Environment notes` until the file fits the
59
+ * budget. Returns the new line list and the removed bullet lines.
60
+ * Exported for reuse by `forge doctor --fix` (RFC-1151).
61
+ */
62
+ export function compactMemoryMd(
63
+ content: string,
64
+ budget: number,
65
+ ): { lines: string[]; removedLines: string[]; fitsBudget: boolean } {
66
+ if (content.length <= budget) {
67
+ return { lines: content.split("\n"), removedLines: [], fitsBudget: true };
68
+ }
69
+
70
+ const lines = content.split("\n");
71
+ const sections = parseSections(lines);
72
+ const target = sections.find((s) => s.header === COMPACT_SECTION);
73
+
74
+ const removedLines: string[] = [];
75
+ if (target) {
76
+ // Sanity: never remove from protected sections even if headers drift.
77
+ if (PROTECTED_SECTIONS.has(target.header)) {
78
+ return { lines, removedLines, fitsBudget: false };
79
+ }
80
+ // Remove bullet lines oldest-first (document order) within the section.
81
+ let i = target.start + 1;
82
+ while (i < target.end && lines.join("\n").length > budget) {
83
+ if (isBullet(lines[i])) {
84
+ removedLines.push(lines[i]);
85
+ lines.splice(i, 1);
86
+ target.end--;
87
+ // Recompute section ends after splice — only the target shrinks.
88
+ } else {
89
+ i++;
90
+ }
91
+ }
92
+ }
93
+
94
+ const fitsBudget = lines.join("\n").length <= budget;
95
+ return { lines, removedLines, fitsBudget };
96
+ }
97
+
98
+ export async function runMemoryCompact(
99
+ _input: ForgeCommandInput,
100
+ context: ForgeRuntimeContext,
101
+ ): Promise<ForgeCommandResult<MemoryCompactResult>> {
102
+ const { workspaceRoot, logger, outputFormat, dryRun } = context;
103
+ const memoryPath = path.join(workspaceRoot, MEMORY_MD_PATH);
104
+ const budget = resolveMemoryBudget(workspaceRoot);
105
+
106
+ const fail = (msg: string): ForgeCommandResult<MemoryCompactResult> => ({
107
+ data: {
108
+ command: "forge.memory.compact",
109
+ status: "fail",
110
+ removed: 0,
111
+ remainingChars: 0,
112
+ budget,
113
+ errors: [msg],
114
+ },
115
+ exitCode: 1,
116
+ summary: `forge.memory.compact: failed — ${msg}`,
117
+ });
118
+
119
+ if (!fs.existsSync(memoryPath)) {
120
+ return {
121
+ data: {
122
+ command: "forge.memory.compact",
123
+ status: "pass",
124
+ removed: 0,
125
+ remainingChars: 0,
126
+ budget,
127
+ errors: [],
128
+ },
129
+ exitCode: 0,
130
+ summary: "forge.memory.compact: OK — MEMORY.md absent, nothing to compact",
131
+ };
132
+ }
133
+
134
+ const content = fs.readFileSync(memoryPath, "utf8");
135
+ if (content.length <= budget) {
136
+ return {
137
+ data: {
138
+ command: "forge.memory.compact",
139
+ status: "pass",
140
+ removed: 0,
141
+ remainingChars: content.length,
142
+ budget,
143
+ errors: [],
144
+ },
145
+ exitCode: 0,
146
+ summary: `forge.memory.compact: OK — already within budget (${content.length}/${budget} chars)`,
147
+ };
148
+ }
149
+
150
+ const { lines, removedLines, fitsBudget } = compactMemoryMd(content, budget);
151
+ const remainingChars = lines.join("\n").length;
152
+
153
+ if (!fitsBudget) {
154
+ return fail(
155
+ `still over budget after removing ${removedLines.length} Environment notes bullet(s) ` +
156
+ `(${remainingChars}/${budget} chars) — manual editorial compaction required`,
157
+ );
158
+ }
159
+
160
+ if (!dryRun) {
161
+ fs.writeFileSync(memoryPath, lines.join("\n"), "utf8");
162
+ }
163
+
164
+ if (outputFormat === "pretty") {
165
+ for (const line of removedLines) {
166
+ logger.info(`removed: ${line.slice(0, 80)}`);
167
+ }
168
+ logger.success(
169
+ `${dryRun ? "[dry-run] would remove" : "removed"} ${removedLines.length} bullet(s) — ${remainingChars}/${budget} chars`,
170
+ );
171
+ }
172
+
173
+ return {
174
+ data: {
175
+ command: "forge.memory.compact",
176
+ status: "pass",
177
+ removed: removedLines.length,
178
+ remainingChars,
179
+ budget,
180
+ removedLines: dryRun ? removedLines : undefined,
181
+ errors: [],
182
+ },
183
+ exitCode: 0,
184
+ summary: dryRun
185
+ ? `forge.memory.compact: [dry-run] would remove ${removedLines.length} bullet(s) — ${remainingChars}/${budget} chars`
186
+ : `forge.memory.compact: OK — removed ${removedLines.length} bullet(s), ${remainingChars}/${budget} chars`,
187
+ };
188
+ }
@@ -138,7 +138,7 @@ export function scaffoldMemoryLayer(workspaceRoot: string): MemoryScaffoldResult
138
138
  // Health check
139
139
  // ---------------------------------------------------------------------------
140
140
 
141
- function resolveMemoryBudget(workspaceRoot: string): number {
141
+ export function resolveMemoryBudget(workspaceRoot: string): number {
142
142
  try {
143
143
  const config = loadForgeConfig(workspaceRoot);
144
144
  if (config.bindings?.memory?.budget !== undefined && config.bindings?.memory?.budget !== null) {
@@ -56,7 +56,11 @@ export async function generateNestedAgentsMd(
56
56
  dryRun: boolean,
57
57
  workspaceTypes?: ProfileWorkspaceType[],
58
58
  ): Promise<NestedGenerateResult> {
59
- const workspaces = discoverWorkspaces(workspaceRoot, workspaceTypes);
59
+ const workspaces = discoverWorkspaces(
60
+ workspaceRoot,
61
+ workspaceTypes,
62
+ config.bindings?.workspaces?.skipDirs,
63
+ );
60
64
  const generated: string[] = [];
61
65
  const skipped: string[] = [];
62
66
  const renderedFiles: { [relPath: string]: string } = {};
@@ -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
  }