@warpgogol/forge 5.2.3 → 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 (51) hide show
  1. package/AGENTS.md +2 -0
  2. package/dist/os/core/core.module.d.ts.map +1 -1
  3. package/dist/os/core/core.module.js +14 -0
  4. package/dist/os/core/core.module.js.map +1 -1
  5. package/dist/src/config/forge-config.d.ts +9 -0
  6. package/dist/src/config/forge-config.d.ts.map +1 -1
  7. package/dist/src/config/forge-config.js +7 -0
  8. package/dist/src/config/forge-config.js.map +1 -1
  9. package/dist/src/onboarding/agents-generate.d.ts +2 -0
  10. package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
  11. package/dist/src/onboarding/agents-generate.js +14 -18
  12. package/dist/src/onboarding/agents-generate.js.map +1 -1
  13. package/dist/src/onboarding/doctor.d.ts +6 -0
  14. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  15. package/dist/src/onboarding/doctor.js +117 -8
  16. package/dist/src/onboarding/doctor.js.map +1 -1
  17. package/dist/src/onboarding/memory-compact.d.ts +22 -0
  18. package/dist/src/onboarding/memory-compact.d.ts.map +1 -0
  19. package/dist/src/onboarding/memory-compact.js +147 -0
  20. package/dist/src/onboarding/memory-compact.js.map +1 -0
  21. package/dist/src/onboarding/memory-scaffold.d.ts +1 -0
  22. package/dist/src/onboarding/memory-scaffold.d.ts.map +1 -1
  23. package/dist/src/onboarding/memory-scaffold.js +1 -1
  24. package/dist/src/onboarding/memory-scaffold.js.map +1 -1
  25. package/dist/src/onboarding/nested-agents-generate.d.ts.map +1 -1
  26. package/dist/src/onboarding/nested-agents-generate.js +1 -1
  27. package/dist/src/onboarding/nested-agents-generate.js.map +1 -1
  28. package/dist/src/onboarding/workspace-discovery.d.ts +1 -1
  29. package/dist/src/onboarding/workspace-discovery.d.ts.map +1 -1
  30. package/dist/src/onboarding/workspace-discovery.js +14 -2
  31. package/dist/src/onboarding/workspace-discovery.js.map +1 -1
  32. package/docs/reference/forge-yaml.md +12 -0
  33. package/os/core/core.module.ts +15 -0
  34. package/package.json +1 -1
  35. package/profiles/forge-shell.yaml +3 -0
  36. package/profiles/godot-game.yaml +3 -0
  37. package/profiles/phaser-game.yaml +3 -0
  38. package/profiles/site-workshop.yaml +567 -564
  39. package/src/config/forge-config.ts +11 -0
  40. package/src/onboarding/agents-generate.ts +18 -18
  41. package/src/onboarding/doctor.ts +134 -9
  42. package/src/onboarding/memory-compact.ts +188 -0
  43. package/src/onboarding/memory-scaffold.ts +1 -1
  44. package/src/onboarding/nested-agents-generate.ts +5 -1
  45. package/src/onboarding/workspace-discovery.ts +15 -1
  46. package/src/tests/agents-generate.test.ts +26 -3
  47. package/src/tests/doctor-dist-freshness.test.ts +110 -0
  48. package/src/tests/doctor-fix.test.ts +145 -0
  49. package/src/tests/doctor-nested-agents.test.ts +108 -0
  50. package/src/tests/memory-compact.test.ts +143 -0
  51. package/src/tests/workspace-discovery.test.ts +28 -0
@@ -83,6 +83,13 @@ export const forgeBindingsSchema = z.object({
83
83
  budget: z.number().int().positive().default(4096),
84
84
  })
85
85
  .optional(),
86
+ // RFC-1150: consumer-declared directory names excluded from workspace
87
+ // discovery (merged over the SKIP_DIRS defaults in workspace-discovery.ts).
88
+ workspaces: z
89
+ .object({
90
+ skipDirs: z.array(z.string()).default([]),
91
+ })
92
+ .optional(),
86
93
  });
87
94
 
88
95
  export interface ForgeBindings {
@@ -126,6 +133,10 @@ export interface ForgeBindings {
126
133
  memory?: {
127
134
  budget: number;
128
135
  };
136
+ // RFC-1150: consumer-declared workspace discovery exclusions
137
+ workspaces?: {
138
+ skipDirs: string[];
139
+ };
129
140
  }
130
141
 
131
142
  // ---------------------------------------------------------------------------
@@ -2,7 +2,7 @@
2
2
  <MODULE_CONTRACT>
3
3
  <purpose>forge.agents.generate — regenerates AGENTS.md deterministically from forge.yaml + skill registry. Carries the standard generated-file marker.</purpose>
4
4
  <non-goals>
5
- <item>Do not overwrite a hand-written AGENTS.md (no generated marker) — refuse with exit 1.</item>
5
+ <item>Do not overwrite a hand-written AGENTS.md (no generated marker) — skip with warning, continue nested (RFC-1150).</item>
6
6
  <item>Do not read or modify forge.yaml — only read it via loadForgeConfig.</item>
7
7
  </non-goals>
8
8
  </MODULE_CONTRACT>
@@ -215,6 +215,9 @@ interface AgentsGenerateResult {
215
215
  errors: string[];
216
216
  renderedFiles?: { [relPath: string]: string };
217
217
  details?: Array<{ path: string; domain?: string; register?: string; workspaceType?: string }>;
218
+ // RFC-1150: set when the root AGENTS.md was skipped (hand-written, no marker)
219
+ rootSkipped?: boolean;
220
+ rootSkipReason?: string;
218
221
  }
219
222
 
220
223
  export async function runAgentsGenerate(
@@ -247,26 +250,18 @@ export async function runAgentsGenerate(
247
250
 
248
251
  const agentsMdPath = path.join(workspaceRoot, "AGENTS.md");
249
252
 
250
- // Edit guard: refuse to overwrite a hand-written AGENTS.md (skipped in dryRun)
253
+ // Edit guard: never overwrite a hand-written AGENTS.md — skip the root file
254
+ // with a warning and continue to nested generation (RFC-1150, non-fatal).
255
+ let rootSkipped = false;
251
256
  if (!dryRun && fs.existsSync(agentsMdPath)) {
252
257
  const existing = fs.readFileSync(agentsMdPath, "utf8");
253
258
  if (!hasGeneratedMarker(existing)) {
254
- const msg = `AGENTS.md exists without a generated marker — refusing to overwrite a hand-written file. Delete or rename it first, then re-run forge.agents.generate.`;
259
+ rootSkipped = true;
255
260
  if (outputFormat === "pretty") {
256
- logger.error(msg);
261
+ logger.warn(
262
+ "AGENTS.md exists without a generated marker — skipping root file (hand-written). Nested AGENTS.md generation continues.",
263
+ );
257
264
  }
258
- return {
259
- data: {
260
- command: "forge.agents.generate",
261
- status: "fail",
262
- configPath: "forge.yaml",
263
- generated: [],
264
- skipped: [],
265
- errors: [msg],
266
- },
267
- exitCode: 1,
268
- summary: `forge.agents.generate: failed — hand-written AGENTS.md`,
269
- };
270
265
  }
271
266
  }
272
267
 
@@ -371,7 +366,7 @@ export async function runAgentsGenerate(
371
366
  const resolvedTerminology = resolveAllTerminology(config, profile);
372
367
  content = substituteTemplate(content, resolvedTerminology);
373
368
 
374
- const generated: string[] = ["AGENTS.md"];
369
+ const generated: string[] = [];
375
370
  const skipped: string[] = [];
376
371
  const renderedFiles: { [relPath: string]: string } = {};
377
372
  const details: Array<{ path: string; domain?: string; register?: string; workspaceType?: string }> = [];
@@ -383,8 +378,12 @@ export async function runAgentsGenerate(
383
378
 
384
379
  if (dryRun) {
385
380
  renderedFiles["AGENTS.md"] = content;
381
+ generated.push("AGENTS.md");
382
+ } else if (rootSkipped) {
383
+ skipped.push("AGENTS.md (hand-written)");
386
384
  } else {
387
385
  await writeFileIfChanged(agentsMdPath, content);
386
+ generated.push("AGENTS.md");
388
387
  if (outputFormat === "pretty") {
389
388
  logger.success(`Generated ${path.relative(workspaceRoot, agentsMdPath)}`);
390
389
  }
@@ -443,11 +442,12 @@ export async function runAgentsGenerate(
443
442
  skipped,
444
443
  errors: [],
445
444
  details,
445
+ ...(rootSkipped ? { rootSkipped: true, rootSkipReason: "hand-written" } : {}),
446
446
  ...(dryRun ? { renderedFiles } : {}),
447
447
  },
448
448
  exitCode: 0,
449
449
  summary: dryRun
450
450
  ? `forge.agents.generate: [dry-run] would generate ${generated.length} file(s), skip ${skipped.length}`
451
- : `forge.agents.generate: OK — ${generated.length} file(s) generated, ${skipped.length} skipped`,
451
+ : `forge.agents.generate: OK — ${generated.length} file(s) generated, ${skipped.length} skipped${rootSkipped ? " (root AGENTS.md hand-written — skipped)" : ""}`,
452
452
  };
453
453
  }
@@ -35,7 +35,7 @@ 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";
@@ -47,7 +47,8 @@ import type { ParsedKnowledgeFile } from "../knowledge/index.ts";
47
47
  import { computeLayerBudgets, resolveKnowledgeBudgets, DEFAULT_KNOWLEDGE_BUDGETS } from "../knowledge/budgets.ts";
48
48
  import { detectDuplicatePrinciples } from "../knowledge/index.ts";
49
49
  import type { DuplicatePair } from "../knowledge/index.ts";
50
- import { checkMemoryLayerHealth } from "./memory-scaffold.ts";
50
+ import { checkMemoryLayerHealth, resolveMemoryBudget } from "./memory-scaffold.ts";
51
+ import { compactMemoryMd } from "./memory-compact.ts";
51
52
  import { checkInvariants } from "./invariant-engine.ts";
52
53
  import type { InvariantViolation } from "./invariant-engine.ts";
53
54
  import { checkNpmToken, workshopNeedsWarpgogolToken } from "./npm-token-check.ts";
@@ -55,6 +56,12 @@ import { execSync } from "node:child_process";
55
56
  import fs from "node:fs";
56
57
  import type { ProfilePrerequisite } from "../profiles/profile-schema.ts";
57
58
 
59
+ interface DoctorFixResult {
60
+ check: string;
61
+ action: "fixed" | "skipped" | "failed";
62
+ detail: string;
63
+ }
64
+
58
65
  interface DoctorCheck {
59
66
  name: string;
60
67
  status: "pass" | "fail" | "warn";
@@ -826,7 +833,11 @@ async function checkNestedAgentsMd(
826
833
  };
827
834
  }
828
835
 
829
- const workspaces = discoverWorkspaces(workspaceRoot, workspaceTypes);
836
+ const workspaces = discoverWorkspaces(
837
+ workspaceRoot,
838
+ workspaceTypes,
839
+ config.bindings?.workspaces?.skipDirs,
840
+ );
830
841
 
831
842
  if (workspaces.length === 0) {
832
843
  return {
@@ -1209,9 +1220,10 @@ function checkPrerequisites(prerequisites: ProfilePrerequisite[]): DoctorCheck {
1209
1220
  export async function runDoctor(
1210
1221
  input: ForgeCommandInput,
1211
1222
  context: ForgeRuntimeContext,
1212
- ): Promise<ForgeCommandResult<{ command: string; checks: DoctorCheck[]; allPass: boolean; forbiddenImports: ForbiddenImport[]; bindings: BindingValidation; domain?: DomainReport }>> {
1213
- 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;
1214
1225
  const strict = input.flags["strict"] === true;
1226
+ const fix = input.flags["fix"] === true;
1215
1227
  const checks: DoctorCheck[] = [];
1216
1228
 
1217
1229
  // Check forge.yaml
@@ -1418,6 +1430,9 @@ export async function runDoctor(
1418
1430
  // RFC-0664: Check memory layer health (budget, gitignore coverage, leak risk)
1419
1431
  checks.push(checkMemoryLayer(workspaceRoot));
1420
1432
 
1433
+ // RFC-1151: Check dist freshness — stale dist/ silently shadows src/ in source checkouts
1434
+ checks.push(checkDistFreshness(forgeRoot));
1435
+
1421
1436
  // RFC-0539: Check pack skills — stale/missing copies and config validation
1422
1437
  const packCheck = await checkPackSkills(workspaceRoot);
1423
1438
  checks.push(packCheck);
@@ -1435,8 +1450,8 @@ export async function runDoctor(
1435
1450
  // RFC-0640 fix: previously gated by isSoftwareDomain, which skipped the check
1436
1451
  // entirely for non-software domains. Now runs for all domains — profile-driven
1437
1452
  // wsTypes replace hardcoded detection when present.
1453
+ let wsTypes: ProfileWorkspaceType[] | undefined;
1438
1454
  {
1439
- let wsTypes: ProfileWorkspaceType[] | undefined;
1440
1455
  try {
1441
1456
  const config = loadForgeConfig(workspaceRoot);
1442
1457
  // RFC-0643: prefer config.profile (loaded by loadForgeConfig)
@@ -1472,6 +1487,15 @@ export async function runDoctor(
1472
1487
  const allPass = finalChecks.every((c) => c.status === "pass");
1473
1488
  const hasFails = finalChecks.some((c) => c.status === "fail");
1474
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
+
1475
1499
  if (outputFormat === "pretty") {
1476
1500
  logger.section(`Forge Doctor — ${workspaceRoot}`);
1477
1501
  for (const check of finalChecks) {
@@ -1479,6 +1503,10 @@ export async function runDoctor(
1479
1503
  const fn = check.status === "pass" ? logger.success : check.status === "warn" ? logger.warn : logger.error;
1480
1504
  fn(`${icon} ${check.name}: ${check.message}`);
1481
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
+ }
1482
1510
  if (allPass) {
1483
1511
  logger.success("All checks passed — forge is properly configured.");
1484
1512
  } else if (hasFails) {
@@ -1488,11 +1516,108 @@ export async function runDoctor(
1488
1516
  }
1489
1517
  }
1490
1518
 
1519
+ const fixFailed = fixes.some((f) => f.action === "failed");
1520
+
1491
1521
  return {
1492
- data: { command: "forge.doctor", checks: finalChecks, allPass, forbiddenImports, bindings: bindingsResult, domain: domainReport },
1493
- 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,
1494
1524
  summary: allPass
1495
1525
  ? "forge.doctor: all checks passed"
1496
- : `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` : ""}`,
1497
1527
  };
1498
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
+ }
@@ -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 } = {};
@@ -36,6 +36,13 @@ const SKIP_DIRS = new Set([
36
36
  ".turbo",
37
37
  ".cache",
38
38
  ".agents",
39
+ // Pipeline-managed mission workpieces and archives — their AGENTS.md is
40
+ // generated by the site pipeline, not by forge.agents.generate.
41
+ "missions",
42
+ // Test fixture workshops — not real project workspaces.
43
+ "test-fixtures",
44
+ // Stryker mutation-testing sandboxes — transient temp dirs.
45
+ ".stryker-tmp",
39
46
  ]);
40
47
 
41
48
  export function detectWorkspaceType(
@@ -110,8 +117,15 @@ function matchesWorkspaceType(dirPath: string, wt: ProfileWorkspaceType): boolea
110
117
  export function discoverWorkspaces(
111
118
  workspaceRoot: string,
112
119
  workspaceTypes?: ProfileWorkspaceType[],
120
+ extraSkipDirs?: readonly string[],
113
121
  ): WorkspaceDir[] {
114
122
  const results: WorkspaceDir[] = [];
123
+ // RFC-1150: consumer-declared exclusions (bindings.workspaces.skipDirs)
124
+ // merge over the SKIP_DIRS defaults.
125
+ const skipDirs =
126
+ extraSkipDirs && extraSkipDirs.length > 0
127
+ ? new Set([...SKIP_DIRS, ...extraSkipDirs])
128
+ : SKIP_DIRS;
115
129
 
116
130
  function scanDir(dirPath: string, depth: number): void {
117
131
  if (depth > 5) return;
@@ -125,7 +139,7 @@ export function discoverWorkspaces(
125
139
 
126
140
  for (const entry of entries) {
127
141
  if (!entry.isDirectory()) continue;
128
- if (SKIP_DIRS.has(entry.name)) continue;
142
+ if (skipDirs.has(entry.name)) continue;
129
143
 
130
144
  const childPath = path.join(dirPath, entry.name);
131
145
  const type = detectWorkspaceType(childPath, workspaceTypes);
@@ -224,13 +224,36 @@ test("agents-generate is idempotent — running twice produces the same content"
224
224
  expect(second).toBe(first);
225
225
  });
226
226
 
227
- test("agents-generate refuses to overwrite hand-written AGENTS.md", async () => {
227
+ // RFC-1150: hand-written root guard is non-fatal — skip root, continue nested
228
+
229
+ test("agents-generate skips hand-written root and still generates nested", async () => {
228
230
  await makeForgeYaml(tempDir);
229
231
  await writeFile(join(tempDir, "AGENTS.md"), "# Hand-written\nNo marker.\n", "utf8");
232
+ await mkdir(join(tempDir, "packages", "my-pkg"), { recursive: true });
233
+ await writeFile(
234
+ join(tempDir, "packages", "my-pkg", "package.json"),
235
+ JSON.stringify({ name: "@test/my-pkg" }),
236
+ );
230
237
 
231
238
  const result = await runAgentsGenerate({ argv: [], flags: {} }, makeContext(tempDir));
232
- expect(result.exitCode).toBe(1);
233
- expect(result.data?.status).toBe("fail");
239
+ expect(result.exitCode).toBe(0);
240
+ expect(result.data?.status).toBe("pass");
241
+ expect(result.data?.rootSkipped).toBe(true);
242
+ expect(result.data?.rootSkipReason).toBe("hand-written");
243
+ expect(result.data?.skipped).toContain("AGENTS.md (hand-written)");
244
+ expect(result.data?.generated).toContain("packages/my-pkg/AGENTS.md");
245
+ });
246
+
247
+ test("agents-generate does not modify hand-written root AGENTS.md", async () => {
248
+ await makeForgeYaml(tempDir);
249
+ const original = "# Hand-written\nNo marker.\n";
250
+ await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
251
+
252
+ const result = await runAgentsGenerate({ argv: [], flags: {} }, makeContext(tempDir));
253
+ expect(result.exitCode).toBe(0);
254
+
255
+ const after = await readFile(join(tempDir, "AGENTS.md"), "utf8");
256
+ expect(after).toBe(original);
234
257
  });
235
258
 
236
259
  // RFC-0611: nested AGENTS.md generation tests