@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.
- package/AGENTS.md +2 -0
- package/dist/os/core/core.module.d.ts.map +1 -1
- package/dist/os/core/core.module.js +14 -0
- package/dist/os/core/core.module.js.map +1 -1
- package/dist/src/config/forge-config.d.ts +9 -0
- package/dist/src/config/forge-config.d.ts.map +1 -1
- package/dist/src/config/forge-config.js +7 -0
- package/dist/src/config/forge-config.js.map +1 -1
- package/dist/src/onboarding/agents-generate.d.ts +2 -0
- package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
- package/dist/src/onboarding/agents-generate.js +14 -18
- package/dist/src/onboarding/agents-generate.js.map +1 -1
- package/dist/src/onboarding/doctor.d.ts +6 -0
- package/dist/src/onboarding/doctor.d.ts.map +1 -1
- package/dist/src/onboarding/doctor.js +117 -8
- package/dist/src/onboarding/doctor.js.map +1 -1
- package/dist/src/onboarding/memory-compact.d.ts +22 -0
- package/dist/src/onboarding/memory-compact.d.ts.map +1 -0
- package/dist/src/onboarding/memory-compact.js +147 -0
- package/dist/src/onboarding/memory-compact.js.map +1 -0
- package/dist/src/onboarding/memory-scaffold.d.ts +1 -0
- package/dist/src/onboarding/memory-scaffold.d.ts.map +1 -1
- package/dist/src/onboarding/memory-scaffold.js +1 -1
- package/dist/src/onboarding/memory-scaffold.js.map +1 -1
- package/dist/src/onboarding/nested-agents-generate.d.ts.map +1 -1
- package/dist/src/onboarding/nested-agents-generate.js +1 -1
- package/dist/src/onboarding/nested-agents-generate.js.map +1 -1
- package/dist/src/onboarding/workspace-discovery.d.ts +1 -1
- package/dist/src/onboarding/workspace-discovery.d.ts.map +1 -1
- package/dist/src/onboarding/workspace-discovery.js +14 -2
- package/dist/src/onboarding/workspace-discovery.js.map +1 -1
- package/docs/reference/forge-yaml.md +12 -0
- package/os/core/core.module.ts +15 -0
- package/package.json +1 -1
- package/profiles/forge-shell.yaml +3 -0
- package/profiles/godot-game.yaml +3 -0
- package/profiles/phaser-game.yaml +3 -0
- package/profiles/site-workshop.yaml +567 -564
- package/src/config/forge-config.ts +11 -0
- package/src/onboarding/agents-generate.ts +18 -18
- package/src/onboarding/doctor.ts +134 -9
- package/src/onboarding/memory-compact.ts +188 -0
- package/src/onboarding/memory-scaffold.ts +1 -1
- package/src/onboarding/nested-agents-generate.ts +5 -1
- package/src/onboarding/workspace-discovery.ts +15 -1
- package/src/tests/agents-generate.test.ts +26 -3
- package/src/tests/doctor-dist-freshness.test.ts +110 -0
- package/src/tests/doctor-fix.test.ts +145 -0
- package/src/tests/doctor-nested-agents.test.ts +108 -0
- package/src/tests/memory-compact.test.ts +143 -0
- 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) —
|
|
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:
|
|
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
|
-
|
|
259
|
+
rootSkipped = true;
|
|
255
260
|
if (outputFormat === "pretty") {
|
|
256
|
-
logger.
|
|
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[] = [
|
|
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
|
}
|
package/src/onboarding/doctor.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
233
|
-
expect(result.data?.status).toBe("
|
|
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
|