Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
34eff23
feat(schema): accept take: section in a unify plan
llima Oct 3, 2026
24ccfd0
feat(unify): derive section runs, spans and values from a plan
llima Oct 3, 2026
f3cf548
fix(unify): refuse a section span whose anchor is inside a hunk
llima Oct 3, 2026
ece867a
feat(unify): prove a section extraction before any write
llima Oct 3, 2026
fb5218b
feat(unify): apply take: section and check markers against the plan
llima Oct 3, 2026
2b43aad
feat(unify): pre-fill a section name in a saved plan
llima Oct 3, 2026
fcc1ff8
refactor(unify): share the section span helpers
llima Oct 3, 2026
673c1d8
refactor(forge): share the manifest schema bump between import and unify
llima Oct 3, 2026
fb325a5
feat(unify): add section rows and edits to checkParamWrites
llima Oct 3, 2026
21d4f5d
fix(unify): bump the manifest whenever a run adds markers
llima Oct 3, 2026
cb34661
feat(cli): write and report section extractions in forge unify
llima Oct 3, 2026
2d07421
test(golden): round trip a section extraction across three workspaces
llima Oct 3, 2026
2306ac0
test(golden): assert what a re-import after take: section writes
llima Oct 3, 2026
95ce350
docs(readme): document take: section
llima Oct 3, 2026
c743a69
test(cli): pin the already-in-place line and the manifest in a late f…
llima Oct 3, 2026
f25aacb
fix(unify): span a section run from its first hunk to its last, inser…
llima Oct 3, 2026
e2c09a3
fix(unify): leave the base body untouched on a reuse-only section plan
llima Oct 3, 2026
ecf42e8
test(golden): sync kiro in the take: section round trip
llima Oct 3, 2026
170f7b5
test(cli): refusals of take: section leave the Forge untouched
llima Oct 3, 2026
bbd2a18
chore(unify): drop dead code and an unused import; document the place…
llima Oct 3, 2026
ce5cd84
test(unify): pin the placeholder guard of the section proof
llima Oct 3, 2026
b0eb5b0
fix(unify): refuse an empty section span that touches another one
llima Oct 3, 2026
ff96788
refactor(unify): one helper for the S5 wordings; pin the no-lines wor…
llima Oct 3, 2026
6a72b07
docs(readme): word the schema bump and the reuse refusal as shipped
llima Oct 3, 2026
4cec057
fix(unify): refuse an empty section span inside another at S7
llima Oct 3, 2026
a507f3b
fix(unify): accept two empty sections one base line apart
llima Oct 3, 2026
88442b3
test(cli): pin the S5 wording for a plan without lines
llima Oct 3, 2026
55575f5
chore(unify): document why the marker order is total
llima Oct 3, 2026
a1fb3f5
test(unify): assert S7 for two empty sections at one position
llima Oct 3, 2026
8556bc8
chore: update version
llima Oct 3, 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
12 changes: 9 additions & 3 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.7.1",
"version": "0.8.0",
"description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.",
"license": "MIT",
"type": "module",
Expand Down
63 changes: 54 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { promises as fs } from "node:fs";
import YAML from "yaml";
import { importClaudeCode } from "./importers/claude-code.js";
import { loadWorkspace, plan, readLock, status, apply, resolveForge, type FileStatus, type SectionLayer } from "./core/sync.js";
import { sectionKey } from "./core/resolve.js";
import { canonicalValue } from "./core/sections.js";
import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
import { diffIngredients, listVariants, profileOf, type Distance, type IngredientDiff } from "./core/variants.js";
Expand All @@ -27,7 +28,7 @@ import { HUNK_CLASSES, UnifyPlanSchema, type HunkClass, type HunkSuggestion, typ
process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE") process.exit(0); });

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

/* ---------------------------------------------------------------- import */
program
Expand Down Expand Up @@ -267,12 +268,12 @@ forge

forge
.command("unify")
.description("Resolve one variant back into its base through a reviewable plan, taking each hunk from a side or turning it into a {{param}}. Writes to the Forge")
.description("Resolve one variant back into its base through a reviewable plan, taking each hunk from a side or turning it into a {{param}} or a section. Writes to the Forge")
.argument("<type/name>", "base ingredient (rule/workflow)")
.requiredOption("--profile <p>", "which variant to resolve")
.option("--take <side>", "resolve every decision to base or variant")
.option("--plan <file>", "apply the decisions in this plan file (a hunk may be take: param with its params list)")
.option("--save-plan <file>", "write a plan with every decision deferred, each hunk annotated with its suggested class (which --plan ignores) and value hunks pre-filled with params, to a new file outside the Forge, and stop")
.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)")
.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")
.option("--forge <dir>", "Forge directory (instead of --workspace)")
.option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
.option("--json", "machine-readable output", false)
Expand Down Expand Up @@ -368,12 +369,21 @@ forge
}
}

const result = await applyPlan(base, variant, diff, toApply, { discardVariantMeta: o.take === "base" });
// Get the profile's current sections for the base's key (spec 12 §6.5).
const baseKey = sectionKey(base.meta);
const profileSections = (() => {
const p = f.profiles.get(o.profile);
return p && Object.hasOwn(p.sections, baseKey) ? p.sections[baseKey] : {};
})();

const result = await applyPlan(base, variant, diff, toApply, { discardVariantMeta: o.take === "base", profileSections });
// Ruling 33, dry pass: every recipe `ingredients` rewrite the cascade will make is checked before
// the first byte is written, so a refusal (an aliased reference) leaves the Forge untouched.
const cascadeFiles = result.resolved ? await checkRecipeCascade(f, base.ref, variant.ref) : [];
// Spec 09: the Forge-level rows and both YAML edits of a parameter extraction, rendered before any write.
const paramWrites = result.params.length ? await checkParamWrites(f, base, variant, o.profile, result.params) : null;
// Spec 09 & 12: the Forge-level rows and YAML edits for parameter and section extractions, rendered before any write.
const paramWrites = result.params.length || result.sections.length
? await checkParamWrites(f, base, variant, o.profile, result.params, result.sections)
: null;

// Ruling 37: "git is the undo" only holds for files git actually has. The whole-repo clean
// check above cannot see ignored files (a Forge its enclosing repo ignores, an ignored file in
Expand All @@ -391,12 +401,15 @@ forge

// Order: merged files, then the recipe cascade, then removal of the variant directory
// (Ruling 21) — a late failure leaves the variant in place, never a recipe naming a removed ingredient.
// Spec 12 §6.7: the manifest is first when it moves to schema: 2; each prefix is emission-neutral.
const journal: WriteJournal = [];
let touched: string[] = [];
let cascade: RecipeCascadeResult = { rewritten: [], identicalToSibling: [] };
let variantRemoved: string | null = null;
try {
// Spec 09 §6.5: declarations, then the template, then the profile's values — each prefix emission-neutral.
// Spec 12 §6.7 step 1: the manifest (schema: 2), FIRST when it needs to move.
if (paramWrites?.manifest) await writeParamFile(paramWrites.manifest, journal);
// Spec 09 §6.5 / Spec 12 §6.7 step 2: declarations, then the template, then the profile's values — each prefix emission-neutral.
if (paramWrites?.ingredientYaml) await writeParamFile(paramWrites.ingredientYaml, journal);
touched = await writeUnified(base, result, journal);
if (paramWrites?.ingredientYaml) touched = [...touched, "ingredient.yaml"].sort();
Expand Down Expand Up @@ -442,6 +455,13 @@ forge
`craftar.local.yaml) now overrides ${base.ref} too; unify cannot reach workspaces`,
);
}
// Spec 12 W3: a new section's name may be cited by a workspace's overrides.sections that was inert until now.
for (const s of result.sections.filter((sec) => !sec.existing)) {
warnings.push(
`${s.name} is now a section of ${base.ref} — a workspace that sets overrides.sections.${s.key}.${s.name} (craftar.yaml or ` +
`craftar.local.yaml) now applies there; unify cannot reach workspaces`,
);
}
if (variantRemoved) {
warnings.push(
`${variant.ref} was removed — a workspace that disables it in overrides.ingredients.disable (craftar.yaml or craftar.local.yaml) ` +
Expand Down Expand Up @@ -469,6 +489,17 @@ forge
metaDiffers: result.metaDiffers,
// Spec 09 §4.5: only what this run wrote — [] when every key was already declared and valued.
params: result.params.filter((e) => paramWrites?.written.includes(e.key)).map((e) => ({ key: e.key, default: e.default, value: e.value })),
// Spec 12 §4.5: sections with line counts; [] when no section hunk.
sections: result.sections.map((s) => ({
key: s.key,
name: s.name,
file: s.file,
existing: s.existing,
defaultLines: s.default === null ? null : numLines(s.default),
valueLines: numLines(s.value),
written: paramWrites?.sectionsWritten.includes(s.name) ?? false,
})),
manifestEdited: paramWrites?.manifest !== null && paramWrites?.manifest !== undefined,
profileEdited: paramWrites?.profile ? path.relative(f.root, paramWrites.profile.abs).split(path.sep).join("/") : null,
warnings,
},
Expand All @@ -479,12 +510,21 @@ forge
}

console.log(pc.bold(`craftar forge unify ${ref} ↔ ${o.profile}`));
// Spec 12 §4.4: manifest line first, if edited
if (paramWrites?.manifest) console.log(` forge craftar.forge.yaml edited (schema: 2)`);
for (const p of touched) console.log(` ${result.write[p] !== undefined || p === "ingredient.yaml" ? pc.green("~") : pc.magenta("-")} ${p}`);
for (const e of result.params) {
const line = `param ${e.key} — default ${JSON.stringify(e.default)} (${base.ref}) · ${JSON.stringify(e.value)} (profile ${o.profile})`;
// Same filter as --json params: a key already declared and valued is named as such, not as written.
console.log(` ${line}${paramWrites?.written.includes(e.key) ? "" : " — already in place"}`);
}
// Spec 12 §4.4: one line per section, after the params
for (const s of result.sections) {
const defCount = s.existing ? "existing" : `default ${lineCount(s.default!)} (${base.ref})`;
const valCount = `${lineCount(s.value)} (profile ${o.profile})`;
const inPlace = paramWrites?.sectionsWritten.includes(s.name) ? "" : " — already in place";
console.log(` section ${s.key} ${s.name} — ${defCount} · ${valCount}${inPlace}`);
}
if (paramWrites?.profile) console.log(` ${pc.green("~")} ${path.relative(f.root, paramWrites.profile.abs).split(path.sep).join("/")}`);
console.log(` resolved ${result.resolved ? pc.green("yes") : pc.yellow("no")} · unresolved ${result.unresolved}`);
if (variantRemoved) console.log(` ${pc.magenta("removed variant")} ${variantRemoved}`);
Expand Down Expand Up @@ -653,10 +693,15 @@ function describeSuggestion(s: HunkSuggestion): string {

/** A section value as the import report shows it: `empty`, or its line count once canonical (spec 11 §4.3). */
function lineCount(value: string): string {
const n = canonicalValue(value).split("\n").length - 1;
const n = numLines(value);
return n === 0 ? "empty" : `${n} line${n === 1 ? "" : "s"}`;
}

/** Numeric line count of a canonical section value, for --json output. */
function numLines(value: string): number {
return canonicalValue(value).split("\n").length - 1;
}

function describeDistance(d: Distance): string {
if (d.identicalAfterNormalization) return "identical after normalization";
if (d.sameBodyDifferentMeta) return "meta only";
Expand Down
45 changes: 45 additions & 0 deletions src/core/manifest-edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { isDeepStrictEqual } from "node:util";
import YAML from "yaml";
import { FORGE_SCHEMA_SECTIONS, ForgeManifestSchema } from "../schema/index.js";
import { stripBom } from "./text.js";
import { editYamlText } from "./yaml-edit.js";

/**
* `craftar.forge.yaml` with `schema: 2` (spec 11 §6.14; spec 12 §6.7), edited in place through the
* round-trip gate, or null when it already declares 2. Throws `<command>: cannot edit craftar.forge.yaml
* in place (<why>) — set schema: 2 by hand, commit, and re-run`.
*/
export function manifestWithSections(raw: string, command: "import" | "unify"): string | null {
const refuse = (why: string) =>
new Error(`${command}: cannot edit craftar.forge.yaml in place (${why}) — set schema: ${FORGE_SCHEMA_SECTIONS} by hand, commit, and re-run`);

let before: unknown;
try {
before = YAML.parse(stripBom(raw));
} catch (e) {
throw refuse(`it does not parse: ${(e as Error).message}`);
}

// Unreachable while forgeBefore/loadForge loads the manifest through its schema first; kept so the edit never assumes it.
if (before === null || typeof before !== "object" || Array.isArray(before)) {
throw refuse("it is not a YAML mapping");
}

if ((before as { schema?: unknown }).schema === FORGE_SCHEMA_SECTIONS) {
return null;
}

let content: string;
try {
content = editYamlText(raw, { command, label: "craftar.forge.yaml", keys: ["schema"] }, (doc) => doc.set("schema", FORGE_SCHEMA_SECTIONS));
} catch (e) {
throw refuse((e as Error).message.replace(/^.*in place \((.*)\) — .*$/s, "$1"));
}

const after = YAML.parse(stripBom(content));
if (!ForgeManifestSchema.safeParse(after).success || !isDeepStrictEqual(after, { ...before, schema: FORGE_SCHEMA_SECTIONS })) {
throw refuse(`the edit does not read back as the original with exactly schema: ${FORGE_SCHEMA_SECTIONS}`);
}

return content;
}
Loading
Loading