craftar 0.15.0 → 0.17.0

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/dist/cli.js CHANGED
@@ -6,7 +6,9 @@ import YAML from "yaml";
6
6
  import { importClaudeCode } from "./importers/claude-code.js";
7
7
  import { classifyForge } from "./core/remote.js";
8
8
  import { forget, listWorkspaces, prune, register, registryFile } from "./core/registry.js";
9
+ import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState } from "./core/impact.js";
9
10
  import { runDoctor } from "./core/doctor.js";
11
+ import { pruneCache } from "./core/cache.js";
10
12
  import { resolveHome } from "./core/home-lock.js";
11
13
  import { loadWorkspace, plan, readLock, status, apply, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
12
14
  import { resolve, sectionKey } from "./core/resolve.js";
@@ -16,9 +18,9 @@ import { canonicalValue } from "./core/sections.js";
16
18
  import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
17
19
  import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
18
20
  import { hashNormalized, toLf, stripBom } from "./core/text.js";
19
- import { exists, gitDirty, gitIsRepo, gitUnheld } from "./core/forge.js";
21
+ import { exists, gitDirty, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
20
22
  import { fingerprintDir } from "./core/fingerprint.js";
21
- import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, } from "./core/unify.js";
23
+ import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, pruneCandidates, pruneRecipes, note, } from "./core/unify.js";
22
24
  import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
23
25
  import { planInit } from "./core/init.js";
24
26
  import { editRecipesText, planRecipeEdit, recipeDiffLine } from "./core/recipe-edit.js";
@@ -33,7 +35,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
33
35
  */
34
36
  const DIFF_EXIT_CODE_FETCH_MODE = "sync";
35
37
  const program = new Command();
36
- program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.15.0");
38
+ program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.17.0");
37
39
  /* ---------------------------------------------------------------- import */
38
40
  program
39
41
  .command("import")
@@ -869,6 +871,94 @@ forge
869
871
  const orphanNote = orphans.length ? `, ${orphans.length} orphan${orphans.length === 1 ? "" : "s"}` : "";
870
872
  console.log(`\n ${groups.length} base${groups.length === 1 ? "" : "s"} with variants, ${total} variant${total === 1 ? "" : "s"} total${orphanNote}`);
871
873
  });
874
+ forge
875
+ .command("impact")
876
+ .description("List every workspace registered on this machine that reads this Forge — by its path, by a git remote of this clone, or through another clone of the same remote — with what its next sync would do against the Forge as it is now. Read-only; no fetch")
877
+ .option("--forge <dir>", "Forge directory (instead of --workspace)")
878
+ .option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
879
+ .option("--json", "machine-readable output", false)
880
+ .action(async (o) => {
881
+ const f = await resolveForge({
882
+ forge: o.forge,
883
+ workspace: o.workspace,
884
+ refuseRemote: (url) => `the Forge of this workspace is remote (${url}) — clone it and pass --forge <dir>`,
885
+ });
886
+ let fw;
887
+ try {
888
+ fw = await forgeWorkspaces(craftarHome(), f.root);
889
+ }
890
+ catch (e) {
891
+ fail(e instanceof Error ? e.message : String(e));
892
+ }
893
+ warnStderr(fw.warnings);
894
+ const planned = await planAll(fw.workspaces, f);
895
+ const results = await Promise.all(planned.map((p) => nextSync(p)));
896
+ // Refine the registry state: "partial" when any entry is missing or stage-config error (spec 25 §3)
897
+ const registryState = refineRegistryState(fw.state, planned);
898
+ if (o.json) {
899
+ const workspaces = fw.workspaces.map((ws, i) => {
900
+ const r = results[i];
901
+ return {
902
+ path: ws.entry.path,
903
+ profile: ws.entry.profile,
904
+ match: ws.match,
905
+ via: ws.via,
906
+ ref: ws.ref,
907
+ state: r.state,
908
+ counts: r.counts,
909
+ error: r.error,
910
+ };
911
+ });
912
+ console.log(JSON.stringify({ forge: fw.realForge, registry: registryState, workspaces }, null, 2));
913
+ return;
914
+ }
915
+ // Text output
916
+ const parts = [];
917
+ const byMatch = {};
918
+ for (const ws of fw.workspaces)
919
+ byMatch[ws.match] = (byMatch[ws.match] ?? 0) + 1;
920
+ if (byMatch.path)
921
+ parts.push(`${byMatch.path} by path`);
922
+ if (byMatch.remote)
923
+ parts.push(`${byMatch.remote} by remote`);
924
+ if (byMatch.clone)
925
+ parts.push(`${byMatch.clone} by clone`);
926
+ if (fw.state === "none") {
927
+ console.log(`craftar forge impact — ${fw.realForge}`);
928
+ console.log(` no registered workspace reads this Forge on this machine`);
929
+ return;
930
+ }
931
+ if (fw.state === "off") {
932
+ console.log(`craftar forge impact — ${fw.realForge}`);
933
+ console.log(` the registry is off (CRAFTAR_NO_REGISTRY)`);
934
+ return;
935
+ }
936
+ const n = fw.workspaces.length;
937
+ console.log(`craftar forge impact — ${fw.realForge} · ${n} registered workspace${n === 1 ? "" : "s"} (${parts.join(", ")})`);
938
+ const maxPath = Math.max(...fw.workspaces.map((ws) => ws.entry.path.length));
939
+ const maxProfile = Math.max(...fw.workspaces.map((ws) => ws.entry.profile.length));
940
+ for (let i = 0; i < fw.workspaces.length; i++) {
941
+ const ws = fw.workspaces[i];
942
+ const r = results[i];
943
+ let stateStr;
944
+ if (r.state === "unchanged") {
945
+ stateStr = "unchanged";
946
+ }
947
+ else if (r.state === "changed") {
948
+ // Counts in NEXT_SYNC order
949
+ const countParts = NEXT_SYNC_STATES.map((k) => (r.counts[k] ? `${r.counts[k]} ${k}` : "")).filter(Boolean);
950
+ stateStr = countParts.join(", ");
951
+ }
952
+ else if (r.state === "missing") {
953
+ stateStr = "missing";
954
+ }
955
+ else {
956
+ stateStr = `error: ${r.error}`;
957
+ }
958
+ const suffix = matchSuffix(ws);
959
+ console.log(` ${ws.entry.path.padEnd(maxPath)} ${ws.entry.profile.padEnd(maxProfile)} ${stateStr}${suffix}`);
960
+ }
961
+ });
872
962
  forge
873
963
  .command("diff")
874
964
  .description("Show the distance and the differences between a base ingredient and each of its variants, each hunk with a suggested class (evolution, value, block) that never decides anything. Read-only")
@@ -930,10 +1020,16 @@ forge
930
1020
  .option("--take <side>", "resolve every decision to base or variant")
931
1021
  .option("--plan <file>", "apply the decisions in this plan file (a hunk may be take: param with its params list, or take: section with its section name)")
932
1022
  .option("--save-plan <file>", "write a plan with every decision deferred, each hunk annotated with its suggested class (which --plan ignores), value hunks pre-filled with params, block hunks pre-filled with a section name, and hunks touching an existing section with its name, to a new file outside the Forge, and stop")
1023
+ .option("--no-impact", "skip planning the registered workspaces of this Forge before and after the writes (the warnings then keep their previous text)")
1024
+ .option("--prune-recipes", "delete each suffixed recipe the cascade leaves identical to its sibling and repoint the profiles that name it — only when every workspace registered on this machine plans byte for byte the same; needs the impact passes")
933
1025
  .option("--forge <dir>", "Forge directory (instead of --workspace)")
934
1026
  .option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
935
1027
  .option("--json", "machine-readable output", false)
936
1028
  .action(async (ref, o) => {
1029
+ // Spec 25 §4.4: --prune-recipes with --no-impact is refused before anything is read
1030
+ if (o.pruneRecipes && !o.impact) {
1031
+ fail("--prune-recipes needs the impact passes — drop --no-impact");
1032
+ }
937
1033
  const frontEnds = [o.take, o.plan, o.savePlan].filter((v) => v !== undefined);
938
1034
  if (frontEnds.length !== 1)
939
1035
  fail("pass exactly one of --take, --plan or --save-plan");
@@ -1048,6 +1144,10 @@ forge
1048
1144
  // Ruling 33, dry pass: every recipe `ingredients` rewrite the cascade will make is checked before
1049
1145
  // the first byte is written, so a refusal (an aliased reference) leaves the Forge untouched.
1050
1146
  const cascadeFiles = result.resolved ? await checkRecipeCascade(f, base.ref, variant.ref) : [];
1147
+ // Spec 25 §4.4: prune candidates, computed in the dry pass
1148
+ const candidates = o.pruneRecipes && result.resolved
1149
+ ? await pruneCandidates(f, base.ref, variant.ref, o.profile)
1150
+ : [];
1051
1151
  // Spec 09 & 12: the Forge-level rows and YAML edits for parameter and section extractions, rendered before any write.
1052
1152
  const paramWrites = result.params.length || result.sections.length
1053
1153
  ? await checkParamWrites(f, base, variant, o.profile, result.params, result.sections)
@@ -1056,7 +1156,9 @@ forge
1056
1156
  // check above cannot see ignored files (a Forge its enclosing repo ignores, an ignored file in
1057
1157
  // a variant) nor untracked ones under `status.showUntrackedFiles=no` — so every path this run
1058
1158
  // will overwrite or delete is checked on its own, before the first write.
1059
- const mustHold = [base.dir, ...(result.resolved ? [variant.dir, ...cascadeFiles] : []), ...(paramWrites?.mustHold ?? [])];
1159
+ // Spec 25 §4.4: prune candidates' profile files join mustHold
1160
+ const candidateProfiles = candidates.flatMap((c) => c.profiles.map((p) => p.abs));
1161
+ const mustHold = [base.dir, ...(result.resolved ? [variant.dir, ...cascadeFiles] : []), ...(paramWrites?.mustHold ?? []), ...candidateProfiles];
1060
1162
  const unheld = await gitUnheld(f.root, mustHold);
1061
1163
  if (unheld.length) {
1062
1164
  // Name every such path (the first few, then a count), so one run shows the whole problem.
@@ -1066,6 +1168,25 @@ forge
1066
1168
  lines.push(` … and ${unheld.length - SHOWN} more`);
1067
1169
  fail(`unify can only change files git can restore — ${unheld.length} path(s) under ${f.root} are not:\n${lines.join("\n")}`);
1068
1170
  }
1171
+ let impactState = "skipped";
1172
+ let impactEntries = [];
1173
+ let impactBefore = [];
1174
+ let impactWarnings = [];
1175
+ let impactUnreadableReason = null;
1176
+ if (o.impact) {
1177
+ try {
1178
+ const fw = await forgeWorkspaces(craftarHome(), f.root);
1179
+ impactEntries = fw.workspaces;
1180
+ impactWarnings = fw.warnings;
1181
+ impactBefore = await planAll(fw.workspaces, f);
1182
+ // Refine the state: "partial" when any before entry is missing or stage-config error
1183
+ impactState = refineRegistryState(fw.state, impactBefore);
1184
+ }
1185
+ catch (e) {
1186
+ impactState = "unreadable";
1187
+ impactUnreadableReason = e instanceof Error ? e.message : String(e);
1188
+ }
1189
+ }
1069
1190
  // Order: merged files, then the recipe cascade, then removal of the variant directory
1070
1191
  // (Ruling 21) — a late failure leaves the variant in place, never a recipe naming a removed ingredient.
1071
1192
  // Spec 12 §6.7: the manifest is first when it moves to schema: 2; each prefix is emission-neutral.
@@ -1097,8 +1218,72 @@ forge
1097
1218
  // writing began: name what was touched and how git undoes it, and still exit 1.
1098
1219
  throw new Error(lateFailure(e, f.root, journal), { cause: e });
1099
1220
  }
1221
+ // Spec 25 §4.2: after pass — planAll against the Forge as unify left it, then impactOf per entry
1222
+ let impactAfter = [];
1223
+ let impactResults = [];
1224
+ if (o.impact && impactState !== "skipped" && impactState !== "unreadable" && impactEntries.length > 0) {
1225
+ let afterForge = f;
1226
+ try {
1227
+ afterForge = await loadForge(f.root);
1228
+ }
1229
+ catch (e) {
1230
+ // If loadForge fails, treat every entry as error with the actual message
1231
+ const msg = e instanceof Error ? e.message : String(e);
1232
+ for (let i = 0; i < impactEntries.length; i++) {
1233
+ impactAfter.push({ kind: "error", stage: "plan", message: msg });
1234
+ }
1235
+ }
1236
+ if (impactAfter.length === 0) {
1237
+ impactAfter = await planAll(impactEntries, afterForge);
1238
+ }
1239
+ for (let i = 0; i < impactEntries.length; i++) {
1240
+ impactResults.push(impactOf(impactBefore[i], impactAfter[i]));
1241
+ }
1242
+ }
1243
+ // Spec 25 §4.4: prune step — after unify's writes and the after pass, prove and apply prunes
1244
+ let pruneResult = { pruned: [], kept: [] };
1245
+ if (o.pruneRecipes && candidates.length > 0) {
1246
+ // Map the state: "skipped" cannot happen here (--prune-recipes requires --impact)
1247
+ const pruneState = impactState === "unreadable" ? "unreadable" : impactState;
1248
+ try {
1249
+ pruneResult = await pruneRecipes(f.root, candidates, {
1250
+ state: pruneState,
1251
+ entries: impactEntries,
1252
+ after: impactAfter,
1253
+ });
1254
+ }
1255
+ catch (e) {
1256
+ // A throw in pruneRecipes (e.g. loadForge fails) after unify's writes: name what was touched.
1257
+ throw new Error(lateFailure(e, f.root, journal), { cause: e });
1258
+ }
1259
+ // Apply the prune writes
1260
+ for (const p of pruneResult.pruned) {
1261
+ try {
1262
+ // Write profile files first, then delete the recipe file
1263
+ for (const w of p.writes) {
1264
+ if (w.content !== null) {
1265
+ await note(journal, w.abs);
1266
+ await fs.writeFile(w.abs, w.content);
1267
+ }
1268
+ else {
1269
+ journal.push({ abs: w.abs, created: false });
1270
+ await fs.rm(w.abs, { force: true });
1271
+ }
1272
+ }
1273
+ }
1274
+ catch (e) {
1275
+ throw new Error(lateFailure(e, f.root, journal), { cause: e });
1276
+ }
1277
+ }
1278
+ }
1100
1279
  // Ruling 28: a metadata difference leaves the variant in place; say which fields and why.
1101
1280
  const warnings = [];
1281
+ // Spec 25 §4.2: warning for unreadable registry
1282
+ if (impactUnreadableReason !== null) {
1283
+ warnings.push(`the registry could not be read (${impactUnreadableReason}) — no workspace was checked`);
1284
+ }
1285
+ // Spec 25 §4.2: push warnings from forgeWorkspaces (credential lines)
1286
+ warnings.push(...impactWarnings);
1102
1287
  if (result.metaDiffers.length) {
1103
1288
  warnings.push(`ingredient.yaml differs in ${result.metaDiffers.join(", ")} — unify cannot merge ingredient.yaml, so ${variant.ref} stays; ` +
1104
1289
  `resolve it by hand, or use --take base to discard the variant`);
@@ -1106,30 +1291,142 @@ forge
1106
1291
  // Ruling 42: a suffixed recipe left identical to its sibling is reported, never deleted — a
1107
1292
  // workspace's recipes.add or an extends chain may name the sibling, and removing the suffixed
1108
1293
  // one would shift recipe order or param precedence there.
1294
+ // Spec 25 §4.4: only printed for candidates NOT pruned; without --prune-recipes, ends with a hint
1109
1295
  const suffix = `--${o.profile}`;
1296
+ const prunedRecipes = new Set(pruneResult.pruned.map((p) => p.recipe));
1110
1297
  for (const rn of cascade.identicalToSibling) {
1298
+ // Skip if this was pruned
1299
+ if (prunedRecipes.has(rn))
1300
+ continue;
1111
1301
  const sibling = rn.slice(0, -suffix.length);
1112
- warnings.push(`recipe ${rn} is now identical to ${sibling} — it can be removed by hand after repointing the profiles, extends and ` +
1302
+ const baseText = `recipe ${rn} is now identical to ${sibling} — it can be removed by hand after repointing the profiles, extends and ` +
1113
1303
  `workspace recipes lists that name it; unify does not, because a workspace or an extends chain may also name ` +
1114
- `${sibling} and the recipe order or param precedence would change`);
1304
+ `${sibling} and the recipe order or param precedence would change`;
1305
+ // Without --prune-recipes: add the hint to rerun with it
1306
+ const warning = o.pruneRecipes
1307
+ ? baseText
1308
+ : `${baseText} — or restore the Forge with git and rerun this unify with --prune-recipes`;
1309
+ warnings.push(warning);
1115
1310
  }
1116
- // Ruling 38: `resolve()` matches overrides.ingredients.disable by ref, so once the variant ref
1117
- // is gone a workspace that disabled it gets the base back, enabled, with no error.
1118
- // Spec 09 W1: what a parameter extraction cannot check from inside the Forge (W2 went with spec 10).
1311
+ // Spec 25 §4.3: helper to build conditional warnings based on registry state and concerned workspaces
1312
+ const unchecked = impactBefore.filter((p) => p.kind === "missing" || (p.kind === "error" && p.stage === "config")).length;
1313
+ const reach = (text, test) => {
1314
+ // --no-impact → today's text
1315
+ if (!o.impact)
1316
+ return `${text}; unify cannot reach workspaces`;
1317
+ const c = concerned(impactBefore, impactEntries, test);
1318
+ // concerned non-empty → name them
1319
+ if (c.concerned.length > 0) {
1320
+ const names = c.concerned.map((w) => `${w.path} (${w.file})`).join(", ");
1321
+ const uncheckedNote = c.unchecked > 0 ? ` (the registry could not check ${c.unchecked} workspace${c.unchecked > 1 ? "s" : ""})` : "";
1322
+ return `${text} — concerned: ${names}${uncheckedNote}`;
1323
+ }
1324
+ // none concerned and registry state read → no warning
1325
+ if (impactState === "read")
1326
+ return null;
1327
+ // otherwise → today's text plus suffix by state
1328
+ let stateSuffix;
1329
+ switch (impactState) {
1330
+ case "partial":
1331
+ stateSuffix = ` (the registry could not check ${unchecked} workspace${unchecked > 1 ? "s" : ""})`;
1332
+ break;
1333
+ case "none":
1334
+ stateSuffix = " (no workspace of this Forge is registered on this machine)";
1335
+ break;
1336
+ case "off":
1337
+ stateSuffix = " (the registry is off)";
1338
+ break;
1339
+ case "unreadable":
1340
+ stateSuffix = " (the registry could not be read)";
1341
+ break;
1342
+ default:
1343
+ stateSuffix = "";
1344
+ }
1345
+ return `${text}; unify cannot reach workspaces${stateSuffix}`;
1346
+ };
1347
+ // Spec 09 W1: a new parameter may override a workspace's overrides.params
1119
1348
  for (const e of result.params.filter((p) => !p.reused)) {
1120
- warnings.push(`${e.key} is now a parameter of ${base.ref} — a workspace that sets overrides.params.${e.key} (craftar.yaml or ` +
1121
- `craftar.local.yaml) now overrides ${base.ref} too; unify cannot reach workspaces`);
1349
+ const text = `${e.key} is now a parameter of ${base.ref} — a workspace that sets overrides.params.${e.key} (craftar.yaml or ` +
1350
+ `craftar.local.yaml) now overrides ${base.ref} too`;
1351
+ const testW1 = (doc) => {
1352
+ if (typeof doc !== "object" || doc === null)
1353
+ return false;
1354
+ const d = doc;
1355
+ if (typeof d.overrides !== "object" || d.overrides === null)
1356
+ return false;
1357
+ const ov = d.overrides;
1358
+ if (typeof ov.params !== "object" || ov.params === null)
1359
+ return false;
1360
+ return Object.hasOwn(ov.params, e.key);
1361
+ };
1362
+ const warn = reach(text, testW1);
1363
+ if (warn !== null)
1364
+ warnings.push(warn);
1122
1365
  }
1123
- // Spec 12 W3: a new section's name may be cited by a workspace's overrides.sections that was inert until now.
1366
+ // Spec 12 W3: a new section may be filled by a workspace's overrides.sections
1124
1367
  for (const s of result.sections.filter((sec) => !sec.existing)) {
1125
- warnings.push(`${s.name} is now a section of ${base.ref} — a workspace that sets overrides.sections.${s.key}.${s.name} (craftar.yaml or ` +
1126
- `craftar.local.yaml) now applies there; unify cannot reach workspaces`);
1368
+ const text = `${s.name} is now a section of ${base.ref} — a workspace that sets overrides.sections.${s.key}.${s.name} (craftar.yaml or ` +
1369
+ `craftar.local.yaml) now applies there`;
1370
+ const testW3 = (doc) => {
1371
+ if (typeof doc !== "object" || doc === null)
1372
+ return false;
1373
+ const d = doc;
1374
+ if (typeof d.overrides !== "object" || d.overrides === null)
1375
+ return false;
1376
+ const ov = d.overrides;
1377
+ if (typeof ov.sections !== "object" || ov.sections === null)
1378
+ return false;
1379
+ const sec = ov.sections;
1380
+ if (!Object.hasOwn(sec, s.key))
1381
+ return false;
1382
+ const keyObj = sec[s.key];
1383
+ if (typeof keyObj !== "object" || keyObj === null)
1384
+ return false;
1385
+ return Object.hasOwn(keyObj, s.name);
1386
+ };
1387
+ const warn = reach(text, testW3);
1388
+ if (warn !== null)
1389
+ warnings.push(warn);
1127
1390
  }
1391
+ // Ruling 38: a removed variant may be disabled by a workspace's overrides.ingredients.disable
1128
1392
  if (variantRemoved) {
1129
- warnings.push(`${variant.ref} was removed — a workspace that disables it in overrides.ingredients.disable (craftar.yaml or craftar.local.yaml) ` +
1130
- `must now name ${base.ref}, or the base comes back enabled; unify cannot reach workspaces`);
1393
+ const text = `${variant.ref} was removed — a workspace that disables it in overrides.ingredients.disable (craftar.yaml or craftar.local.yaml) ` +
1394
+ `must now name ${base.ref}, or the base comes back enabled`;
1395
+ const testDisable = (doc) => {
1396
+ if (typeof doc !== "object" || doc === null)
1397
+ return false;
1398
+ const d = doc;
1399
+ if (typeof d.overrides !== "object" || d.overrides === null)
1400
+ return false;
1401
+ const ov = d.overrides;
1402
+ if (typeof ov.ingredients !== "object" || ov.ingredients === null)
1403
+ return false;
1404
+ const ing = ov.ingredients;
1405
+ if (!Array.isArray(ing.disable))
1406
+ return false;
1407
+ return ing.disable.includes(variant.ref);
1408
+ };
1409
+ const warn = reach(text, testDisable);
1410
+ if (warn !== null)
1411
+ warnings.push(warn);
1412
+ }
1413
+ // Spec 25 §4.4: when at least one recipe was pruned, add coverage warning
1414
+ if (pruneResult.pruned.length > 0) {
1415
+ const n = impactEntries.length;
1416
+ warnings.push(`pruned against the ${n} workspace${n === 1 ? "" : "s"} registered on this machine — a workspace synced elsewhere (CI, another machine, CRAFTAR_NO_REGISTRY) is not covered`);
1131
1417
  }
1132
1418
  if (o.json) {
1419
+ // Spec 25 §4.2: impact array in JSON output
1420
+ const impact = impactResults.map((r, i) => ({
1421
+ path: impactEntries[i].entry.path,
1422
+ profile: impactEntries[i].entry.profile,
1423
+ match: impactEntries[i].match,
1424
+ via: impactEntries[i].via,
1425
+ ref: impactEntries[i].ref,
1426
+ state: r.state,
1427
+ files: r.files,
1428
+ error: r.error,
1429
+ }));
1133
1430
  return console.log(JSON.stringify({
1134
1431
  base: base.ref,
1135
1432
  profile: o.profile,
@@ -1143,6 +1440,8 @@ forge
1143
1440
  recipes: {
1144
1441
  rewritten: cascade.rewritten,
1145
1442
  identicalToSibling: cascade.identicalToSibling,
1443
+ pruned: pruneResult.pruned.map((p) => ({ recipe: p.recipe, sibling: p.sibling, profiles: p.profiles })),
1444
+ kept: pruneResult.kept,
1146
1445
  },
1147
1446
  metaDiffers: result.metaDiffers,
1148
1447
  // Spec 09 §4.5: only what this run wrote — [] when every key was already declared and valued.
@@ -1160,6 +1459,8 @@ forge
1160
1459
  manifestEdited: paramWrites?.manifest !== null && paramWrites?.manifest !== undefined,
1161
1460
  profileEdited: paramWrites?.profile ? path.relative(f.root, paramWrites.profile.abs).split(path.sep).join("/") : null,
1162
1461
  warnings,
1462
+ // Spec 25 §4.2: always present, [] when no workspace was checked
1463
+ impact,
1163
1464
  }, null, 2));
1164
1465
  }
1165
1466
  console.log(pc.bold(`craftar forge unify ${ref} ↔ ${o.profile}`));
@@ -1190,9 +1491,70 @@ forge
1190
1491
  if (cascade.identicalToSibling.length) {
1191
1492
  console.log(` recipes now identical to a sibling: ${cascade.identicalToSibling.join(", ")}`);
1192
1493
  }
1494
+ // Spec 25 §4.4: pruned and kept recipes, after the identical-to-sibling line
1495
+ for (const p of pruneResult.pruned) {
1496
+ console.log(` pruned recipe ${p.recipe} → ${p.sibling} (${p.profiles.join(", ")})`);
1497
+ }
1498
+ for (const k of pruneResult.kept) {
1499
+ console.log(` kept recipe ${k.recipe} — ${k.reason}`);
1500
+ }
1193
1501
  for (const w of warnings)
1194
1502
  console.log(` ${pc.yellow("warn")} ${w}`);
1195
- console.log(` next: run \`craftar status --workspace <dir>\` in a workspace on profile ${o.profile} to see what moved`);
1503
+ // Spec 25 §4.2: impact lines, one per workspace, with suffix format
1504
+ for (let i = 0; i < impactResults.length; i++) {
1505
+ const ws = impactEntries[i];
1506
+ const r = impactResults[i];
1507
+ let stateStr;
1508
+ if (r.state === "no-effect") {
1509
+ stateStr = "no effect";
1510
+ }
1511
+ else if (r.state === "changed") {
1512
+ const n = r.files.length;
1513
+ stateStr = n === 1 ? `1 file changes — ${r.files[0]}` : `${n} files change — ${r.files.join(", ")}`;
1514
+ }
1515
+ else if (r.state === "missing") {
1516
+ stateStr = "missing";
1517
+ }
1518
+ else {
1519
+ stateStr = `error: ${r.error}`;
1520
+ }
1521
+ const impactSuffix = matchSuffix(ws);
1522
+ console.log(` impact: ${ws.entry.path} (${ws.entry.profile}) ${stateStr}${impactSuffix}`);
1523
+ }
1524
+ // Spec 25 §4.2: the next: hint is printed only when no workspace was checked
1525
+ // (every entry missing/stage-config error, or no entries, or --no-impact, or unreadable)
1526
+ const someChecked = impactBefore.some((p) => p.kind !== "missing" && !(p.kind === "error" && p.stage === "config"));
1527
+ if (!someChecked) {
1528
+ console.log(` next: run \`craftar status --workspace <dir>\` in a workspace on profile ${o.profile} to see what moved`);
1529
+ }
1530
+ });
1531
+ /* ---------------------------------------------------------------- cache ---------------------------------------------------------------- */
1532
+ const cache = program.command("cache").description("The per-machine Forge cache in $CRAFTAR_HOME/forges/");
1533
+ cache
1534
+ .command("prune")
1535
+ .description("Remove the Forge cache entries nothing uses — no registered workspace and not this one — the entries whose first fetch never completed, and trees unused for 14 days; --dry-run only shows. Never touches a workspace, a lock or the registry; no network")
1536
+ // No commander default: an explicit --workspace without craftar.yaml exits 1, the current directory without one runs (spec 26 §4.1).
1537
+ .option("--dry-run", "show what would be removed; remove nothing", false)
1538
+ .option("--workspace <dir>", "the workspace whose Forge counts as used besides the registry (default: the current directory, when it holds craftar.yaml)")
1539
+ .option("--json", "machine-readable output", false)
1540
+ .action(async (o) => {
1541
+ let workspace = null;
1542
+ if (o.workspace !== undefined) {
1543
+ if (!(await exists(path.join(o.workspace, WORKSPACE_FILE))))
1544
+ fail(`no ${WORKSPACE_FILE} in ${path.resolve(o.workspace)} — run cache prune inside a workspace, or without --workspace`);
1545
+ workspace = o.workspace;
1546
+ }
1547
+ else if (await exists(path.join(process.cwd(), WORKSPACE_FILE)))
1548
+ workspace = process.cwd();
1549
+ const result = await pruneCache(craftarHome(), {
1550
+ dryRun: o.dryRun,
1551
+ workspace,
1552
+ registryOff: registryOff(),
1553
+ });
1554
+ if (o.json)
1555
+ console.log(JSON.stringify(result.report, null, 2));
1556
+ else
1557
+ printPrune(result);
1196
1558
  });
1197
1559
  program.parseAsync().catch((e) => fail(e instanceof Error ? e.message : String(e)));
1198
1560
  /* ---------------------------------------------------------------- helpers */
@@ -1224,6 +1586,65 @@ function printDoctor(r) {
1224
1586
  console.log(` ${paint(c.level)} ${c.id.padEnd(13)} ${c.message}${suffix(c)}`);
1225
1587
  console.log(`summary: ${r.summary.ok} ok, ${r.summary.warn} warn, ${r.summary.error} error`);
1226
1588
  }
1589
+ /** The text report of `craftar cache prune` (spec 26 §4.4) — presentation, not contract. */
1590
+ function printPrune(result) {
1591
+ const mb = (b) => `${(b / (1024 * 1024)).toFixed(1)} MB`;
1592
+ if (result.header === null) {
1593
+ console.log("nothing to prune");
1594
+ for (const w of result.report.warnings)
1595
+ console.log(` ${pc.yellow("warn")} ${w}`);
1596
+ return;
1597
+ }
1598
+ const { forges, entries, bytes } = result.header;
1599
+ console.log(pc.bold(`craftar cache prune — ${forges} (${entries} ${entries === 1 ? "entry" : "entries"}, ${mb(bytes)})`));
1600
+ // Compute label width: for entries/leftovers, just the key; for trees, `<key>/trees/<first 10 of commit>…`.
1601
+ const labelOf = (r) => {
1602
+ const p = r.path;
1603
+ if ("kind" in r && r.kind === "tree") {
1604
+ // forges/<key>/trees/<commit> → <key>/trees/<commit10>…
1605
+ const parts = p.split("/");
1606
+ return `${parts[1]}/trees/${parts[3].slice(0, 10)}…`;
1607
+ }
1608
+ // forges/<key> or forges/~removing-<...> → last segment
1609
+ return p.split("/").pop();
1610
+ };
1611
+ const whyRemoved = (r) => {
1612
+ if (r.reason === "orphan")
1613
+ return "orphan: nothing names it";
1614
+ if (r.reason === "incomplete")
1615
+ return "incomplete: a fetch never completed";
1616
+ if (r.reason === "unused")
1617
+ return "unused for 14 days or more";
1618
+ return "leftover of an interrupted prune";
1619
+ };
1620
+ const whyKept = (k) => {
1621
+ if (k.reason === "named")
1622
+ return `named by ${k.namedBy.join(", ")}`;
1623
+ if (k.reason === "busy")
1624
+ return "in use";
1625
+ if (k.reason === "held-open")
1626
+ return "held open";
1627
+ return "not checked";
1628
+ };
1629
+ // Compute column widths W (label) and V (why) across removed and kept rows.
1630
+ const allLabels = [...result.report.removed, ...result.report.kept].map(labelOf);
1631
+ const removedWhys = result.report.removed.map(whyRemoved);
1632
+ const keptWhys = result.report.kept.map(whyKept);
1633
+ const W = Math.max(1, ...allLabels.map((l) => l.length));
1634
+ const V = Math.max(1, ...removedWhys.map((w) => w.length), ...keptWhys.map((w) => w.length));
1635
+ const verb = result.report.dryRun ? "would remove" : "removed";
1636
+ for (const r of result.report.removed) {
1637
+ console.log(` ${verb.padEnd(12)} ${labelOf(r).padEnd(W)} ${whyRemoved(r).padEnd(V)} ${mb(r.bytes)}`);
1638
+ }
1639
+ for (const k of result.report.kept) {
1640
+ console.log(` ${"kept".padEnd(12)} ${labelOf(k).padEnd(W)} ${whyKept(k)}`);
1641
+ }
1642
+ for (const w of result.report.warnings) {
1643
+ console.log(` ${pc.yellow("warn")} ${w}`);
1644
+ }
1645
+ const freedVerb = result.report.dryRun ? "would free" : "freed";
1646
+ console.log(`${freedVerb} ${mb(result.report.freedBytes)}`);
1647
+ }
1227
1648
  /** `CRAFTAR_NO_REGISTRY` — "no registry", read like NO_COLOR: any non-empty value turns it off (spec 21 §4.1). */
1228
1649
  function registryOff() {
1229
1650
  return Boolean(process.env.CRAFTAR_NO_REGISTRY);
@@ -1272,6 +1693,20 @@ function warnStderr(warnings) {
1272
1693
  for (const w of warnings)
1273
1694
  console.error(`${pc.yellow("warn")} ${w}`);
1274
1695
  }
1696
+ /**
1697
+ * Spec 25 §4.1–§4.2: the suffix format for a workspace that is not a path match.
1698
+ * Used by both `forge impact` and `forge unify` so the format is shared.
1699
+ */
1700
+ function matchSuffix(ws) {
1701
+ let suffix = "";
1702
+ if (ws.match === "remote")
1703
+ suffix = ` · after push (${ws.via})`;
1704
+ if (ws.match === "clone")
1705
+ suffix = ` · after push and pull (${ws.via})`;
1706
+ if (ws.ref)
1707
+ suffix += `, pins ${ws.ref}`;
1708
+ return suffix;
1709
+ }
1275
1710
  /** The header line naming a remote Forge (spec 13 §4.1); null for a path Forge, whose headers keep their shape. */
1276
1711
  function forgeLine(ws, lock) {
1277
1712
  const o = ws.origin;