@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
@@ -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
@@ -0,0 +1,110 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for the dist-freshness doctor check — warns when a forge
4
+ source checkout's dist/ is older than the newest src/ file (RFC-1151).</purpose>
5
+ </MODULE_CONTRACT>
6
+ <CHANGE_SUMMARY>
7
+ <item>RFC-1151: initial dist-freshness tests.</item>
8
+ </CHANGE_SUMMARY>
9
+ */
10
+
11
+ import { test, expect, beforeEach, afterEach } from "vitest";
12
+ import { mkdtemp, rm, mkdir, writeFile, utimes } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { runDoctor } from "../onboarding/doctor.ts";
16
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../types.ts";
17
+
18
+ let tempDir: string;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), "doctor-dist-test-"));
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await rm(tempDir, { recursive: true, force: true });
26
+ });
27
+
28
+ function makeContext(): ForgeRuntimeContext {
29
+ return {
30
+ workspaceRoot: tempDir,
31
+ // forgeRoot falls back to <workspaceRoot>/packages/forge when unresolvable
32
+ forgeRoot: join(tempDir, "packages", "forge"),
33
+ logger: {
34
+ section: () => {},
35
+ success: () => {},
36
+ warn: () => {},
37
+ error: () => {},
38
+ info: () => {},
39
+ },
40
+ dryRun: false,
41
+ outputFormat: "json",
42
+ };
43
+ }
44
+
45
+ const FORGE_YAML = `schema: "forge/config@1"
46
+ project:
47
+ name: test-project
48
+ stack: []
49
+ packageManager: pnpm
50
+ paths:
51
+ rfcsDir: docs/rfcs
52
+ adrsDir: docs/adrs
53
+ plansDir: docs/plans
54
+ auditsDir: docs/audits
55
+ specsDir: docs/specs
56
+ skillsDir: .agents/skills
57
+ sessionsDir: docs/sessions
58
+ `;
59
+
60
+ async function makeForgeCheckout(srcMtime: Date, distMtime: Date): Promise<void> {
61
+ const forgeRoot = join(tempDir, "packages", "forge");
62
+ await mkdir(join(forgeRoot, "src"), { recursive: true });
63
+ await mkdir(join(forgeRoot, "dist"), { recursive: true });
64
+ await writeFile(join(forgeRoot, "package.json"), JSON.stringify({ name: "@warpgogol/forge" }));
65
+ await writeFile(join(forgeRoot, "src", "index.ts"), "export {};\n");
66
+ await writeFile(join(forgeRoot, "dist", "index.js"), "export {};\n");
67
+ await utimes(join(forgeRoot, "src", "index.ts"), srcMtime, srcMtime);
68
+ await utimes(join(forgeRoot, "dist"), distMtime, distMtime);
69
+ }
70
+
71
+ test("dist-freshness warns when dist is older than newest src file", async () => {
72
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
73
+ const old = new Date("2026-01-01T00:00:00Z");
74
+ const recent = new Date("2026-09-01T00:00:00Z");
75
+ await makeForgeCheckout(recent, old);
76
+
77
+ const input: ForgeCommandInput = { argv: [], flags: {} };
78
+ const result = await runDoctor(input, makeContext());
79
+
80
+ const check = result.data!.checks.find((c) => c.name === "dist-freshness");
81
+ expect(check).toBeDefined();
82
+ expect(check!.status).toBe("warn");
83
+ expect(check!.message).toContain("pnpm run build");
84
+ });
85
+
86
+ test("dist-freshness passes when dist is absent (npm consumer layout)", async () => {
87
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
88
+ // No packages/forge at all — forgeRoot fallback dir doesn't exist
89
+ const input: ForgeCommandInput = { argv: [], flags: {} };
90
+ const result = await runDoctor(input, makeContext());
91
+
92
+ const check = result.data!.checks.find((c) => c.name === "dist-freshness");
93
+ expect(check).toBeDefined();
94
+ expect(check!.status).toBe("pass");
95
+ });
96
+
97
+ test("dist-freshness passes when dist is newer than src", async () => {
98
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
99
+ const old = new Date("2026-01-01T00:00:00Z");
100
+ const recent = new Date("2026-09-01T00:00:00Z");
101
+ await makeForgeCheckout(old, recent);
102
+
103
+ const input: ForgeCommandInput = { argv: [], flags: {} };
104
+ const result = await runDoctor(input, makeContext());
105
+
106
+ const check = result.data!.checks.find((c) => c.name === "dist-freshness");
107
+ expect(check).toBeDefined();
108
+ expect(check!.status).toBe("pass");
109
+ expect(check!.message).toContain("fresh");
110
+ });
@@ -0,0 +1,145 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for forge doctor --fix — safe remediations for warn/fail
4
+ checks: nested-AGENTS.md regeneration and memory-layer compaction (RFC-1151).</purpose>
5
+ </MODULE_CONTRACT>
6
+ <CHANGE_SUMMARY>
7
+ <item>RFC-1151: initial doctor --fix tests.</item>
8
+ </CHANGE_SUMMARY>
9
+ */
10
+
11
+ import { test, expect, beforeEach, afterEach } from "vitest";
12
+ import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { runDoctor } from "../onboarding/doctor.ts";
16
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../types.ts";
17
+
18
+ let tempDir: string;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), "doctor-fix-test-"));
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await rm(tempDir, { recursive: true, force: true });
26
+ });
27
+
28
+ function makeContext(dryRun = false): ForgeRuntimeContext {
29
+ return {
30
+ workspaceRoot: tempDir,
31
+ logger: {
32
+ section: () => {},
33
+ success: () => {},
34
+ warn: () => {},
35
+ error: () => {},
36
+ info: () => {},
37
+ },
38
+ dryRun,
39
+ outputFormat: "json",
40
+ };
41
+ }
42
+
43
+ const FORGE_YAML = `schema: "forge/config@1"
44
+ project:
45
+ name: test-project
46
+ stack: []
47
+ packageManager: pnpm
48
+ paths:
49
+ rfcsDir: docs/rfcs
50
+ adrsDir: docs/adrs
51
+ plansDir: docs/plans
52
+ auditsDir: docs/audits
53
+ specsDir: docs/specs
54
+ skillsDir: .agents/skills
55
+ sessionsDir: docs/sessions
56
+ `;
57
+
58
+ const GENERATED_MARKER =
59
+ "<!--\n GENERATED. Do not change this line unless the file contains project specific changes.\n-->\n";
60
+
61
+ async function makeStaleWorkspace(): Promise<void> {
62
+ await mkdir(join(tempDir, "packages", "my-pkg"), { recursive: true });
63
+ await writeFile(
64
+ join(tempDir, "packages", "my-pkg", "package.json"),
65
+ JSON.stringify({ name: "@test/my-pkg" }),
66
+ );
67
+ // Generated marker + outdated content → stale
68
+ await writeFile(
69
+ join(tempDir, "packages", "my-pkg", "AGENTS.md"),
70
+ `${GENERATED_MARKER}# Old content\n`,
71
+ "utf8",
72
+ );
73
+ }
74
+
75
+ test("doctor --fix regenerates stale nested AGENTS.md and subsequent run passes", async () => {
76
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
77
+ await makeStaleWorkspace();
78
+
79
+ const fixInput: ForgeCommandInput = { argv: [], flags: { fix: true } };
80
+ const fixResult = await runDoctor(fixInput, makeContext());
81
+
82
+ const fixEntry = fixResult.data!.fixes?.find((f) => f.check === "nested-AGENTS.md");
83
+ expect(fixEntry).toBeDefined();
84
+ expect(fixEntry!.action).toBe("fixed");
85
+
86
+ const regenerated = await readFile(
87
+ join(tempDir, "packages", "my-pkg", "AGENTS.md"),
88
+ "utf8",
89
+ );
90
+ expect(regenerated).toContain("`@test/my-pkg` — Agent Guide");
91
+ expect(regenerated).not.toContain("Old content");
92
+
93
+ // Subsequent doctor run: nested-AGENTS.md no longer reports this file stale
94
+ const verifyResult = await runDoctor({ argv: [], flags: {} }, makeContext());
95
+ const check = verifyResult.data!.checks.find((c) => c.name === "nested-AGENTS.md");
96
+ expect(check!.message).not.toContain("my-pkg/AGENTS.md stale");
97
+ });
98
+
99
+ test("doctor --fix does not modify hand-written nested AGENTS.md", async () => {
100
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
101
+ await mkdir(join(tempDir, "packages", "my-pkg"), { recursive: true });
102
+ await writeFile(
103
+ join(tempDir, "packages", "my-pkg", "package.json"),
104
+ JSON.stringify({ name: "@test/my-pkg" }),
105
+ );
106
+ const handwritten = "# Custom\nNo marker.\n";
107
+ await writeFile(join(tempDir, "packages", "my-pkg", "AGENTS.md"), handwritten, "utf8");
108
+
109
+ const result = await runDoctor({ argv: [], flags: { fix: true } }, makeContext());
110
+ expect(result.data!.fixes).toBeDefined();
111
+
112
+ const after = await readFile(join(tempDir, "packages", "my-pkg", "AGENTS.md"), "utf8");
113
+ expect(after).toBe(handwritten);
114
+ });
115
+
116
+ test("doctor --fix compacts over-budget MEMORY.md", async () => {
117
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
118
+ await mkdir(join(tempDir, ".agents", "memory"), { recursive: true });
119
+ const env = Array.from({ length: 30 }, (_, i) => `- env ${i} ${"x".repeat(200)}`).join("\n");
120
+ await writeFile(
121
+ join(tempDir, ".agents", "memory", "MEMORY.md"),
122
+ `# Project Memory\n\n## Current focus\n\n- keep\n\n## Environment notes\n\n${env}\n`,
123
+ "utf8",
124
+ );
125
+
126
+ const result = await runDoctor({ argv: [], flags: { fix: true } }, makeContext());
127
+ const fixEntry = result.data!.fixes?.find((f) => f.check === "memory-layer");
128
+ expect(fixEntry).toBeDefined();
129
+ expect(fixEntry!.action).toBe("fixed");
130
+
131
+ const after = await readFile(join(tempDir, ".agents", "memory", "MEMORY.md"), "utf8");
132
+ expect(after.length).toBeLessThanOrEqual(4096);
133
+ expect(after).toContain("- keep");
134
+ });
135
+
136
+ test("doctor without --fix produces no fixes array and does not mutate", async () => {
137
+ await writeFile(join(tempDir, "forge.yaml"), FORGE_YAML, "utf8");
138
+ await makeStaleWorkspace();
139
+
140
+ const result = await runDoctor({ argv: [], flags: {} }, makeContext());
141
+ expect(result.data!.fixes).toBeUndefined();
142
+
143
+ const after = await readFile(join(tempDir, "packages", "my-pkg", "AGENTS.md"), "utf8");
144
+ expect(after).toContain("Old content");
145
+ });
@@ -0,0 +1,108 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for the nested-AGENTS.md doctor check — consumer-declared
4
+ workspace discovery exclusions via bindings.workspaces.skipDirs (RFC-1150).</purpose>
5
+ </MODULE_CONTRACT>
6
+ <CHANGE_SUMMARY>
7
+ <item>RFC-1150: initial nested-AGENTS.md skipDirs tests.</item>
8
+ </CHANGE_SUMMARY>
9
+ */
10
+
11
+ import { test, expect, beforeEach, afterEach } from "vitest";
12
+ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { runDoctor } from "../onboarding/doctor.ts";
16
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../types.ts";
17
+
18
+ let tempDir: string;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), "doctor-nested-test-"));
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await rm(tempDir, { recursive: true, force: true });
26
+ });
27
+
28
+ function makeContext(): ForgeRuntimeContext {
29
+ return {
30
+ workspaceRoot: tempDir,
31
+ logger: {
32
+ section: () => {},
33
+ success: () => {},
34
+ warn: () => {},
35
+ error: () => {},
36
+ info: () => {},
37
+ },
38
+ dryRun: false,
39
+ outputFormat: "json",
40
+ };
41
+ }
42
+
43
+ function forgeYaml(skipDirs: string[]): string {
44
+ const skip = skipDirs.length > 0 ? `\n workspaces:\n skipDirs: [${skipDirs.join(", ")}]` : "";
45
+ return `schema: "forge/config@1"
46
+ project:
47
+ name: test-project
48
+ stack: []
49
+ packageManager: pnpm
50
+ paths:
51
+ rfcsDir: docs/rfcs
52
+ adrsDir: docs/adrs
53
+ plansDir: docs/plans
54
+ auditsDir: docs/audits
55
+ specsDir: docs/specs
56
+ skillsDir: .agents/skills
57
+ sessionsDir: docs/sessions
58
+ bindings:
59
+ schema: forge/bindings@1
60
+ commands:
61
+ validateRfc: null
62
+ validateAdr: null
63
+ implementStamp: null
64
+ typecheck: null
65
+ test: null
66
+ scopedBuild: null
67
+ specValidate: null
68
+ sessionSave: null
69
+ paths:
70
+ invariantsFile: null
71
+ compassDocs: []
72
+ reviewsDir: null
73
+ handoffsDir: null
74
+ sessionsDir: null${skip}
75
+ `;
76
+ }
77
+
78
+ test("nested-AGENTS.md check excludes dirs declared in workspaces.skipDirs", async () => {
79
+ await writeFile(join(tempDir, "forge.yaml"), forgeYaml(["builds"]), "utf8");
80
+ // Artifact dir that would otherwise be discovered as a workspace
81
+ await mkdir(join(tempDir, "builds", "out"), { recursive: true });
82
+ await writeFile(join(tempDir, "builds", "out", "package.json"), "{}");
83
+ // Real workspace without AGENTS.md — must still be reported missing
84
+ await mkdir(join(tempDir, "packages", "real-pkg"), { recursive: true });
85
+ await writeFile(join(tempDir, "packages", "real-pkg", "package.json"), "{}");
86
+
87
+ const input: ForgeCommandInput = { argv: [], flags: {} };
88
+ const result = await runDoctor(input, makeContext());
89
+
90
+ const check = result.data!.checks.find((c) => c.name === "nested-AGENTS.md");
91
+ expect(check).toBeDefined();
92
+ // missing-only reports pass (warn requires stale>0) — assert on message
93
+ expect(check!.message).toContain("packages/real-pkg");
94
+ expect(check!.message).not.toContain("builds");
95
+ });
96
+
97
+ test("nested-AGENTS.md check reports artifact dir as missing without skipDirs", async () => {
98
+ await writeFile(join(tempDir, "forge.yaml"), forgeYaml([]), "utf8");
99
+ await mkdir(join(tempDir, "builds", "out"), { recursive: true });
100
+ await writeFile(join(tempDir, "builds", "out", "package.json"), "{}");
101
+
102
+ const input: ForgeCommandInput = { argv: [], flags: {} };
103
+ const result = await runDoctor(input, makeContext());
104
+
105
+ const check = result.data!.checks.find((c) => c.name === "nested-AGENTS.md");
106
+ expect(check).toBeDefined();
107
+ expect(check!.message).toContain("builds");
108
+ });
@@ -0,0 +1,143 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Unit tests for forge memory.compact — mechanical MEMORY.md budget
4
+ enforcement via oldest-first Environment notes truncation (RFC-1151).</purpose>
5
+ </MODULE_CONTRACT>
6
+ <CHANGE_SUMMARY>
7
+ <item>RFC-1151: initial memory.compact tests.</item>
8
+ </CHANGE_SUMMARY>
9
+ */
10
+
11
+ import { test, expect, beforeEach, afterEach } from "vitest";
12
+ import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { runMemoryCompact, compactMemoryMd } from "../onboarding/memory-compact.ts";
16
+ import type { ForgeRuntimeContext } from "../types.ts";
17
+
18
+ let tempDir: string;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), "memory-compact-test-"));
22
+ await mkdir(join(tempDir, ".agents", "memory"), { recursive: true });
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await rm(tempDir, { recursive: true, force: true });
27
+ });
28
+
29
+ function makeContext(dryRun = false): ForgeRuntimeContext {
30
+ return {
31
+ workspaceRoot: tempDir,
32
+ logger: {
33
+ section: () => {},
34
+ success: () => {},
35
+ warn: () => {},
36
+ error: () => {},
37
+ info: () => {},
38
+ },
39
+ dryRun,
40
+ outputFormat: "json",
41
+ };
42
+ }
43
+
44
+ function memoryMd(envBullets: number, bulletSize = 200): string {
45
+ const env = Array.from(
46
+ { length: envBullets },
47
+ (_, i) => `- env note ${i} ${"x".repeat(bulletSize)}`,
48
+ ).join("\n");
49
+ return [
50
+ "# Project Memory",
51
+ "",
52
+ "## Current focus",
53
+ "",
54
+ "- keep me",
55
+ "",
56
+ "## Decisions in flight",
57
+ "",
58
+ "- keep me too",
59
+ "",
60
+ "## Environment notes",
61
+ "",
62
+ env,
63
+ "",
64
+ ].join("\n");
65
+ }
66
+
67
+ test("compactMemoryMd removes oldest Environment notes bullets until within budget", () => {
68
+ const content = memoryMd(20);
69
+ const budget = 2000;
70
+ const { lines, removedLines, fitsBudget } = compactMemoryMd(content, budget);
71
+
72
+ expect(fitsBudget).toBe(true);
73
+ expect(removedLines.length).toBeGreaterThan(0);
74
+ // Oldest-first: env note 0 removed before env note 19
75
+ expect(removedLines[0]).toContain("env note 0");
76
+ expect(lines.join("\n").length).toBeLessThanOrEqual(budget);
77
+ // Protected sections untouched
78
+ expect(lines.join("\n")).toContain("- keep me");
79
+ expect(lines.join("\n")).toContain("- keep me too");
80
+ });
81
+
82
+ test("compactMemoryMd returns empty removedLines when already within budget", () => {
83
+ const content = memoryMd(2, 10);
84
+ const { removedLines, fitsBudget } = compactMemoryMd(content, 4096);
85
+ expect(removedLines).toHaveLength(0);
86
+ expect(fitsBudget).toBe(true);
87
+ });
88
+
89
+ test("runMemoryCompact writes compacted file and reports removed count", async () => {
90
+ const content = memoryMd(30);
91
+ await writeFile(join(tempDir, ".agents", "memory", "MEMORY.md"), content, "utf8");
92
+
93
+ const result = await runMemoryCompact({ argv: [], flags: {} }, makeContext());
94
+ expect(result.exitCode).toBe(0);
95
+ expect(result.data?.status).toBe("pass");
96
+ expect(result.data?.removed).toBeGreaterThan(0);
97
+ expect(result.data?.remainingChars).toBeLessThanOrEqual(result.data!.budget);
98
+
99
+ const after = await readFile(join(tempDir, ".agents", "memory", "MEMORY.md"), "utf8");
100
+ expect(after.length).toBeLessThanOrEqual(result.data!.budget);
101
+ expect(after).toContain("- keep me");
102
+ });
103
+
104
+ test("runMemoryCompact dryRun reports without writing", async () => {
105
+ const content = memoryMd(30);
106
+ await writeFile(join(tempDir, ".agents", "memory", "MEMORY.md"), content, "utf8");
107
+
108
+ const result = await runMemoryCompact({ argv: [], flags: {} }, makeContext(true));
109
+ expect(result.exitCode).toBe(0);
110
+ expect(result.data?.removed).toBeGreaterThan(0);
111
+ expect(result.data?.removedLines?.length).toBe(result.data?.removed);
112
+
113
+ const after = await readFile(join(tempDir, ".agents", "memory", "MEMORY.md"), "utf8");
114
+ expect(after).toBe(content);
115
+ });
116
+
117
+ test("runMemoryCompact passes when MEMORY.md absent", async () => {
118
+ const result = await runMemoryCompact({ argv: [], flags: {} }, makeContext());
119
+ expect(result.exitCode).toBe(0);
120
+ expect(result.data?.removed).toBe(0);
121
+ });
122
+
123
+ test("runMemoryCompact fails when protected sections alone exceed budget", async () => {
124
+ // Huge Current focus — Environment notes empty, nothing removable
125
+ const content = [
126
+ "# Project Memory",
127
+ "",
128
+ "## Current focus",
129
+ "",
130
+ `- ${"y".repeat(6000)}`,
131
+ "",
132
+ "## Environment notes",
133
+ "",
134
+ "- small note",
135
+ "",
136
+ ].join("\n");
137
+ await writeFile(join(tempDir, ".agents", "memory", "MEMORY.md"), content, "utf8");
138
+
139
+ const result = await runMemoryCompact({ argv: [], flags: {} }, makeContext());
140
+ expect(result.exitCode).toBe(1);
141
+ expect(result.data?.status).toBe("fail");
142
+ expect(result.data?.errors[0]).toContain("manual editorial compaction");
143
+ });
@@ -431,3 +431,56 @@ test("forge.upgrade without --update-npm does not attempt npm install", async ()
431
431
  expect(result.data?.npmUpdated).toBe(false);
432
432
  expect(result.data?.npmUpdateSkipped).toBe(null);
433
433
  });
434
+
435
+ function qaLog(entries: { id: string; title: string; body: string }[]): string {
436
+ const blocks = entries.map(
437
+ (e) =>
438
+ `### ${e.id}: ${e.title}\n\n\`\`\`knowledge-entry\nid: ${e.id}\nlayer: L0\ncreated: 2026-08-03\nstatus: active\n\`\`\`\n\n${e.body}\n`,
439
+ );
440
+ return `<!-- knowledge-layer: L0 -->\n\n# Q&A Log (L0)\n\n${blocks.join("\n")}`;
441
+ }
442
+
443
+ test("forge.upgrade preserves project-accumulated knowledge entries (append-only merge)", async () => {
444
+ await setupForgeSource(tempDir, "0.2.0");
445
+ // Declare a knowledge file on the fixture skill
446
+ await writeFile(
447
+ join(tempDir, "packages", "forge", "skills", "fo", "fo-idea", "SKILL.md"),
448
+ "---\nname: fo-idea\nknowledge: [qa-log.md]\n---\n# fo-idea\n",
449
+ "utf8",
450
+ );
451
+ // Package ships K-0001 + new K-0003
452
+ await writeFile(
453
+ join(tempDir, "packages", "forge", "skills", "fo", "fo-idea", "qa-log.md"),
454
+ qaLog([
455
+ { id: "K-0001", title: "First", body: "Package body." },
456
+ { id: "K-0003", title: "Third", body: "New package entry." },
457
+ ]),
458
+ "utf8",
459
+ );
460
+ await setupConsumerForgeYaml(tempDir, null);
461
+
462
+ // Local copy already accumulated: edited K-0001 + project-only K-0002
463
+ await mkdir(join(tempDir, ".agents", "skills", "fo-idea"), { recursive: true });
464
+ await writeFile(
465
+ join(tempDir, ".agents", "skills", "fo-idea", "qa-log.md"),
466
+ qaLog([
467
+ { id: "K-0001", title: "First", body: "Locally edited body." },
468
+ { id: "K-0002", title: "Local record", body: "Project-accumulated entry." },
469
+ ]),
470
+ "utf8",
471
+ );
472
+
473
+ const result = await runUpgrade({ argv: [], flags: {} }, makeContext(tempDir, false));
474
+
475
+ expect(result.data?.status).toBe("pass");
476
+ const merged = await readFile(join(tempDir, ".agents", "skills", "fo-idea", "qa-log.md"), "utf8");
477
+ // Local entries preserved — the data-loss bug would have wiped K-0002
478
+ expect(merged).toContain("K-0002");
479
+ expect(merged).toContain("Project-accumulated entry.");
480
+ // Local version of the conflicting entry wins
481
+ expect(merged).toContain("Locally edited body.");
482
+ expect(merged).not.toContain("Package body.");
483
+ // New package entry appended
484
+ expect(merged).toContain("K-0003");
485
+ expect(merged).toContain("New package entry.");
486
+ });
@@ -104,6 +104,34 @@ test("discoverWorkspaces skips node_modules, .git, dist, .turbo, .cache, .agents
104
104
  expect(workspaces).toHaveLength(0);
105
105
  });
106
106
 
107
+ test("discoverWorkspaces skips missions, test-fixtures, .stryker-tmp", async () => {
108
+ // Pipeline-managed workpieces/archives, fixture workshops, and mutation-testing
109
+ // sandboxes are not project workspaces — their AGENTS.md is owned elsewhere.
110
+ for (const skip of ["missions", "test-fixtures", ".stryker-tmp"]) {
111
+ await mkdir(join(tempDir, skip, "sub"), { recursive: true });
112
+ await writeFile(join(tempDir, skip, "sub", "package.json"), "{}");
113
+ }
114
+
115
+ const workspaces = discoverWorkspaces(tempDir);
116
+ expect(workspaces).toHaveLength(0);
117
+ });
118
+
119
+ // RFC-1150: consumer-declared exclusions (bindings.workspaces.skipDirs)
120
+
121
+ test("discoverWorkspaces honors extraSkipDirs for consumer artifact dirs", async () => {
122
+ await mkdir(join(tempDir, "builds", "out"), { recursive: true });
123
+ await writeFile(join(tempDir, "builds", "out", "package.json"), "{}");
124
+ await mkdir(join(tempDir, "packages", "real-pkg"), { recursive: true });
125
+ await writeFile(join(tempDir, "packages", "real-pkg", "package.json"), "{}");
126
+
127
+ const without = discoverWorkspaces(tempDir);
128
+ expect(without.map((w) => w.path)).toContain(join("builds", "out"));
129
+
130
+ const withSkip = discoverWorkspaces(tempDir, undefined, ["builds"]);
131
+ expect(withSkip.map((w) => w.path)).not.toContain(join("builds", "out"));
132
+ expect(withSkip.map((w) => w.path)).toContain(join("packages", "real-pkg"));
133
+ });
134
+
107
135
  test("discoverWorkspaces detects generated AGENTS.md", async () => {
108
136
  await mkdir(join(tempDir, "packages", "my-pkg"), { recursive: true });
109
137
  await writeFile(join(tempDir, "packages", "my-pkg", "package.json"), "{}");