Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
63e82dc
test(agents-md): pin the AGENTS.md size budget
llima Oct 18, 2026
d22d5f6
feat(agents-md): measure AGENTS.md against agents-md.maxBytes
llima Oct 18, 2026
6191104
feat(cli): sync --check, status and diff --exit-code fail above an ex…
llima Oct 18, 2026
0ffd54a
feat(doctor): add agents-md-size check
llima Oct 18, 2026
4f8e2bc
docs(readme): document agents-md.maxBytes parameter
llima Oct 18, 2026
4179e85
fix(cli): properly chain if-else in sync --check budget handling
llima Oct 18, 2026
1b4b4ec
fix(doctor): agents-md-size says the size against its budget
llima Oct 18, 2026
09f7dca
refactor(agents-md): one budget parser, no stale comments
llima Oct 18, 2026
7826869
docs(readme): the AGENTS.md budget in the commands table and Upgrading
llima Oct 18, 2026
5dcb72a
fix(agents-md): one wording for the budget, reported by one doctor check
llima Oct 18, 2026
9620150
fix(agents-md): the budget names the scoped rules AGENTS.md embeds
llima Oct 18, 2026
b2a9ea3
fix(cli): diff always prints the budget block; only the exit depends …
llima Oct 18, 2026
87dc6d9
fix(extract): agents-md.maxBytes is an emitter's param, never extracted
llima Oct 18, 2026
f743462
docs(readme): the budget's surfaces as they ship
llima Oct 18, 2026
100830e
fix(agents-md): the budget warning reuses the error's and doctor's wo…
llima Oct 18, 2026
73cf0bb
docs(readme): the budget's Upgrading heading is the next-version plac…
llima Oct 18, 2026
a5d08e7
fix(cli): status --json says the budget in its object, as it says an …
llima Oct 18, 2026
da329d1
docs(readme): which views show the AGENTS.md budget, and which do not
llima Oct 18, 2026
02c186e
docs(readme): craftar workspaces lists the budget warning without cha…
llima Oct 18, 2026
4918f83
docs(readme): where craftar workspaces lists the budget warning
llima Oct 18, 2026
38a860b
chore: update version
llima Oct 18, 2026
82ef5a7
fix(readme): restore D17/D19 sentence in drift row lost during rebase
llima Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "craftar",
"version": "0.31.0",
"version": "0.32.0",
"description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.",
"license": "MIT",
"type": "module",
Expand Down
35 changes: 25 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { launchRefusal, pinOf } from "./core/mcp-protocol.js";
import { serve } from "./mcp.js";
import { pruneCache, type PruneResult as CachePruneResult } from "./core/cache.js";
import { resolveHome } from "./core/home-lock.js";
import { loadWorkspace, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE, type ApplyResult, type FetchMode, type FileState, type FileStatus, type LoadOptions, type MergedConfig, type Plan, type SectionLayer, type UnsetParam, type Workspace } from "./core/sync.js";
import { loadWorkspace, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE, type ApplyResult, type FetchMode, type FileState, type FileStatus, type LoadOptions, type MergedConfig, type Plan, type SectionLayer, type UnsetParam, type Workspace } from "./core/sync.js";
import type { Lock, Registry } from "./schema/index.js";
import { sectionKey, type ParamLayer } from "./core/resolve.js";
import { catalogueContext, listRecipes, listIngredients, checkType, withLoadWarnings, type CatalogueContext, type ContextSource } from "./core/catalogue.js";
Expand Down Expand Up @@ -69,7 +69,7 @@ process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE
const DIFF_EXIT_CODE_FETCH_MODE: FetchMode = "sync";

const program = new Command();
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.31.0");
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.32.0");

/* ---------------------------------------------------------------- import */
/** The words of one output-path gate (`gateOutsideForge`), declared before the commands that run at load: what is written, by which command, and its two refusals' endings. */
Expand Down Expand Up @@ -168,7 +168,7 @@ program
/* ---------------------------------------------------------------- status */
program
.command("status")
.description("Show what sync would do: new, update, drift, orphan, collision; exits 1 when a declared parameter a file cites has no value")
.description("Show what sync would do: new, update, drift, orphan, collision; exits 1 when a declared parameter a file cites has no value, or when AGENTS.md is over an explicit budget")
.option("--workspace <dir>", "workspace root", ".")
.option("--json", "machine-readable output", false)
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
Expand All @@ -182,11 +182,13 @@ program
if (o.json) {
console.log(JSON.stringify(report, null, 2));
// exitCode, not process.exit: a piped object is not cut (spec 29 §4.1)
if (unset.length) process.exitCode = 1;
// The object carries both reasons (unsetParams, the budget line in warnings): nothing on stderr
if (unset.length || agentsMdOverBudget(p)) process.exitCode = 1;
return;
}
printStatus(st, p.warnings, ws.config.profile, p.resolution.recipes, false, forgeLine(ws, lock));
if (refusalBlock(unset)) process.exitCode = 1;
if (budgetRefusalBlock(p)) process.exitCode = 1;
});

/**
Expand All @@ -198,6 +200,16 @@ function refusalBlock(unset: UnsetParam[], opts: { thenSync?: boolean } = {}): b
return unset.length > 0;
}

/**
* The AGENTS.md budget refusal for a command that goes on to print or exit by itself: the block on stderr,
* without exiting. Says whether there was one.
*/
function budgetRefusalBlock(p: Plan): boolean {
const over = agentsMdOverBudget(p);
if (over) errorLine(agentsMdRefusal(over));
return over !== null;
}

/**
* A writing sync on a planned workspace, then its report: `apply`, the registration of spec 21 (unless
* `CRAFTAR_NO_REGISTRY`), and the lines `sync` prints. `sync` and `init` both call it (spec 23 §5.2).
Expand Down Expand Up @@ -384,9 +396,9 @@ program
/* ---------------------------------------------------------------- sync */
program
.command("sync")
.description("Generate the harness for every target from the Forge and update craftar.lock; a writing sync also records the workspace in $CRAFTAR_HOME/registry.json (see craftar workspaces); refuses, writing nothing, when a declared parameter a file cites has no value")
.description("Generate the harness for every target from the Forge and update craftar.lock; a writing sync also records the workspace in $CRAFTAR_HOME/registry.json (see craftar workspaces); refuses, writing nothing, when a declared parameter a file cites has no value; with --check, also exits 1 when AGENTS.md is over an explicit budget")
.option("--workspace <dir>", "workspace root", ".")
.option("--check", "exit 1 when the workspace is out of date or has drift (CI mode)", false)
.option("--check", "exit 1 when the workspace is out of date or has drift, or when AGENTS.md is over an explicit budget (CI mode)", false)
.option("--dry-run", "show the plan, write nothing", false)
.option("--overwrite-drift", "regenerate files that were hand-edited (their edits are lost)", false)
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
Expand All @@ -402,8 +414,9 @@ program
if (bad.length) console.log(pc.red(`\n${bad.length} file(s) out of sync`));
// A refused sync is not "in sync", whatever the files say: the listing, then the block, and the exit is
// exitCode's, so nothing queued is cut (spec 29 §4.1)
if (refusalBlock(unset)) process.exitCode = 1;
else if (bad.length) process.exit(1);
// Both blocks are printed; either one, or a file out of sync, fails the check.
const refused = [refusalBlock(unset), budgetRefusalBlock(p)].some(Boolean);
if (refused || bad.length) process.exitCode = 1;
else console.log(pc.green("\nworkspace in sync"));
return;
}
Expand All @@ -419,9 +432,9 @@ program
/* ---------------------------------------------------------------- diff */
program
.command("diff")
.description("Unified diff between the files on disk and what the Forge would generate, orphans included (files the Forge no longer produces); also says when a declared parameter a file cites has no value, which sync refuses")
.description("Unified diff between the files on disk and what the Forge would generate, orphans included (files the Forge no longer produces); also says when a declared parameter a file cites has no value, which sync refuses, and when AGENTS.md is over an explicit budget")
.option("--workspace <dir>", "workspace root", ".")
.option("--exit-code", "exit 1 when there are differences (exactly when `sync --check` would fail); a [path] that names no file craftar manages becomes an error; with a remote Forge, a failed fetch exits 1 as `sync --check` does (--offline to use the cached copy)", false)
.option("--exit-code", "exit 1 when there are differences (exactly when `sync --check` would fail), including when AGENTS.md is over an explicit budget; a [path] that names no file craftar manages becomes an error; with a remote Forge, a failed fetch exits 1 as `sync --check` does (--offline to use the cached copy)", false)
.argument("[path]", "limit to one file")
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
.action(async (only, o) => {
Expand All @@ -442,6 +455,8 @@ program
else if (o.exitCode) process.exitCode = 1;
// Spec 29 §4.1: with no [path], --exit-code fails exactly when `sync --check` does — a refused sync included
if (refusalBlock(unsetDeclared(p)) && o.exitCode && !only) process.exitCode = 1;
// The AGENTS.md budget, as the refusal above: always printed, failing only --exit-code with no [path]
if (budgetRefusalBlock(p) && o.exitCode && !only) process.exitCode = 1;
});

const DIFF_PAINT = { paint: { same: pc.dim, del: pc.red, add: pc.green } };
Expand Down
27 changes: 26 additions & 1 deletion src/core/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { FULL_SHA, cacheKey, classifyForge, inspectCache, type CacheSnapshot } f
import { dockerVersion, dockerFinding, dockerRunner as defaultDockerRunner, fragmentPaths, type DockerRunner } from "./services.js";
import { paramsFor } from "./resolve.js";
import { SchemaError, errorText } from "./schema-fault.js";
import { LOCK_FILE, declaredWithoutValue, loadForgeFor, plan, readLock, readWorkspaceConfig, status, type LoadOptions, type MergedConfig, type Plan, type Workspace } from "./sync.js";
import { LOCK_FILE, declaredWithoutValue, loadForgeFor, plan, readLock, readWorkspaceConfig, status, type LoadOptions, type MergedConfig, type Plan, type Workspace, AGENTS_MD_BUDGET_KEY, agentsMdOverBudgetMessage, agentsMdOverDefaultMessage, AGENTS_MD_BUDGET_FIX, AGENTS_MD_SHRINK } from "./sync.js";
import { outName } from "../emitters/shared.js";
import { declaredTemplates, DOTNET_MISSING, DOTNET_MISSING_FIX, dotnetVersion as defaultDotnetVersion, feedMessage, notInstalled, profileLabel, unpinnedSources, type Declared } from "./templates.js";
import { capture, execProgram } from "./proc.js";
Expand Down Expand Up @@ -256,6 +256,8 @@ async function workspaceChecks(
const declaredUnset = declaredWithoutValue(p);
const owned = new Set<string>();
for (const mp of p.missingParams) if (declaredUnset.has(mp.key)) owned.add(mp.warning);
// Also exclude the AGENTS.md budget warning — it is reported by agents-md-size
if (p.agentsMd?.warning) owned.add(p.agentsMd.warning);
const own = p.warnings.slice(ws.warnings.length).filter((x) => !owned.has(x));
if (own.length === 0) w("plan", "ok", `${p.files.length} files`);
for (const x of own) w("plan", "warn", x, null);
Expand All @@ -272,6 +274,29 @@ async function workspaceChecks(
);
}

// agents-md-size: only when the plan writes AGENTS.md (the target is in use)
if (p.agentsMd !== null) {
const { bytes, maxBytes, explicit } = p.agentsMd;
// maxBytes 0 means budget is off
if (maxBytes === 0) {
w("agents-md-size", "ok", `AGENTS.md is ${bytes} bytes; the budget is off (${AGENTS_MD_BUDGET_KEY}: 0)`);
} else if (bytes <= maxBytes) {
// Within budget
w("agents-md-size", "ok", `AGENTS.md is ${bytes} bytes, within the budget of ${maxBytes}`);
} else if (explicit) {
// Over explicit budget: error — use shared message and fix
w("agents-md-size", "error", agentsMdOverBudgetMessage({ bytes, maxBytes }), AGENTS_MD_BUDGET_FIX);
} else {
// Over default budget: warn
w(
"agents-md-size",
"warn",
agentsMdOverDefaultMessage({ bytes, maxBytes }),
`set ${AGENTS_MD_BUDGET_KEY} to fail sync --check above it; to shrink the file: ${AGENTS_MD_SHRINK}`,
);
}
}

const unset = mcpUnset(p, env, ws.forge.manifest.schema);
if (unset.length === 0) w("mcp-env", "ok", "every variable a written MCP server expects is set");
for (const { server, name } of unset) w("mcp-env", "warn", `server "${server}" expects ${name}, not set`, `export ${name}`);
Expand Down
3 changes: 2 additions & 1 deletion src/core/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { INTEGRATIONS_PREFIX } from "./integrations.js";
import { changedRegions } from "./classify.js";
import { TEXT_EXT } from "../emitters/claude-code.js";
import { KIRO_TEXT_EXT } from "../emitters/kiro.js";
import { AGENTS_MD_BUDGET_KEY } from "../emitters/agents-md.js";
import type { Hunk } from "./diff.js";
import { stripBom, toLf } from "./text.js";

Expand Down Expand Up @@ -31,7 +32,7 @@ const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g;
* the block gives (spec 33 §5.2).
*/
export function reservedKey(key: string, schema: number): boolean {
if (key === "kiro.banner" || key in Object.prototype) return true;
if (key === "kiro.banner" || key === AGENTS_MD_BUDGET_KEY || key in Object.prototype) return true;
return schema >= FORGE_SCHEMA_INTEGRATIONS && key.startsWith(INTEGRATIONS_PREFIX);
}

Expand Down
Loading
Loading