craftar 0.27.2 → 0.28.1

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Craft, sync and convert AI-coding workspace harnesses — rules, agents, commands, skills, MCP servers — across every client workspace you maintain and every AI coder each team uses.
4
4
 
5
- > Status: **phase 1 in progress** — phase 0, the foundations, is done. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) and `forge promote` (a profile's variant becomes the base) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`<!-- craftar:section <name> -->` … `<!-- /craftar:section -->`), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` or above (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. `craftar cache prune` removes the cache entries and trees nothing uses. An MCP ingredient can declare `authEnv`, the environment variables its server needs (names only, never a value), and `craftar doctor` checks them; with `claude-code`, `sync` writes an example file listing them. A recipe can bring **local services**: a `service` ingredient is a Docker Compose fragment that `sync` writes under `.craftar/`, and `craftar services up | down | status` run Compose on what the lock records (see *Local services*). A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` (see *Integrations*), since `0.27.0`. `craftar templates list | install` shows and runs the profile's `templates` block — `install` runs `dotnet new install` per entry into the user's template store; nothing installs implicitly. Everything else in the spec (`craftar ui`, `craftar mcp`) is not built yet.
5
+ > Status: **phase 1 in progress** — phase 0, the foundations, is done. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) and `forge promote` (a profile's variant becomes the base) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`<!-- craftar:section <name> -->` … `<!-- /craftar:section -->`), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` or above (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. `craftar cache prune` removes the cache entries and trees nothing uses. An MCP ingredient can declare `authEnv`, the environment variables its server needs (names only, never a value), and `craftar doctor` checks them; with `claude-code`, `sync` writes an example file listing them. A recipe can bring **local services**: a `service` ingredient is a Docker Compose fragment that `sync` writes under `.craftar/`, and `craftar services up | down | status` run Compose on what the lock records (see *Local services*). A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above (see *Integrations*), since `0.27.0`. `craftar templates list | install` shows and runs the profile's `templates` block — `install` runs `dotnet new install` per entry into the user's template store; nothing installs implicitly. An MCP `server` takes the params its ingredient declares, in a Forge that declares `schema: 4` (see *Server params*), since `0.28.0`. Everything else in the spec (`craftar ui`, `craftar mcp`) is not built yet.
6
6
 
7
7
  ## The idea in one paragraph
8
8
 
@@ -69,17 +69,17 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
69
69
  | Command | What it does |
70
70
  |---|---|
71
71
  | `craftar init [--forge <dir\|url>] [--profile <name>] [--ref <ref>] [--targets <a,b>] [--add-recipe <name>]... [--remove-recipe <name>]... [--replace] [--no-sync] [--offline] [--workspace .]` | Starts a workspace: writes a `craftar.yaml` holding `forge` (a directory as the path from the workspace, POSIX; a URL as given), `ref` (a remote Forge only), `profile`, `recipes` (only the lists the recipe flags leave non-empty) and `targets` (only with `--targets`; otherwise the workspace follows the profile's), then runs the first `sync` — which also registers the workspace. **In a terminal** (stdin and stdout both a TTY), `--forge` and `--profile` are optional: what is missing is asked for — the Forge (offering the one last synced on this machine, from the registry), a remote Forge's ref, the profile (from the Forge's list), then the recipes and the targets the profile gives, to keep or adjust; a flag given is never asked for. One confirmation before anything is written, showing what the first sync would do. Ctrl-C, the end of input, or `no` at the confirmation: nothing written, exit 1 (`init cancelled — nothing written`). The run ends with an `again craftar init …` line: the same run as flags, for CI. Off a terminal (a pipe, CI), a missing flag is refused, exit 1, naming the flag; where the terminal does not report a TTY, the flags are required. With both flags given nothing is asked, in a terminal or not. The file is written only once the configuration loads (a remote Forge is fetched as `sync` fetches it; `--offline` reads the cache), resolves and plans, and the sync applies that same plan. `--remove-recipe` and `--add-recipe` go through `remove recipe` / `add recipe`'s core (removes first, then adds; `--replace` swaps a slot holder); a call whose every name changes nothing prints its reasons as a `note`. `--workspace` is created when missing. Files already on disk and not in a lock are adopted when equal to the plan and skipped as a `collision` otherwise, never overwritten, with one warning pointing at `craftar import`. `--no-sync` writes `craftar.yaml` only and prints what the first `sync` would do. When a declared parameter a file cites has no value (see `sync`), `craftar.yaml` is still written — the workspace's `overrides.params` is a place for the value — but the first `sync` is not run: the refusal is printed (`error: N declared parameter(s) have no value — craftar.yaml written, sync not run`, with `then run craftar sync` in its fix line), exit 1; in a terminal the confirmation says `first sync: refused — …` and asks only `Write craftar.yaml`; `--no-sync` prints `next sync: refused — …` and exits 0. Refused, with nothing written and no directory created: an existing `craftar.yaml`; a `--workspace` that is a file, or under one; credentials in `--forge` (the value is never printed); `--ref` with a directory Forge; an unknown target; a recipe both added and removed, or any refusal of `add recipe` / `remove recipe`; a `craftar.local.yaml` that sets `forge` or `profile`, or a key a given flag would write (`ref`, `recipes`, `targets`) — its value would replace the flag's; a Forge, profile or plan that does not load. |
72
- | `craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config] [--report <file.md>] [--prefer-kiro <rule,…>]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering — and, with `--prefer-kiro`, the text of the rules it names). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `<profile>-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml` (a manifest already at 2 or 3 is left as it is). A reused base takes the place its `<name>--<profile>` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `<name>--<profile>` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `<recipe>--<profile>`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections <names>` when a section value filled the render), `inferred`, `sectioned`, `param <key>: <old> → <new>`, `section <key> <name>: <old> → <new>`, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. In a schema 3 Forge, what the profile's `integrations` block alone provides is **not imported** — not compared, not written — and is listed (`left to recipe <r> (integrations.<slot>): …`, and under `## Left to an integration recipe` in the `--report` file); `craftar status` then shows whether the workspace's copies differ (`drift`, `collision`); move an agent attached to a scoped rule into the integration recipe with its rule, or `base` splits on import. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand), and — in a schema 3 Forge — a profile whose `integrations` block is malformed or, when the block holds a `kind`, that does not resolve. Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `authEnv` is never inferred; a variant import rewrites keeps the one it declares, a new variant takes its base's, and a declaration a profile loses is reported. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. `--report <file.md>` also writes a Markdown report, after the Forge is written: what was created, reused, made a variant or rejected, the import's warnings, and the Kiro side — *Kiro collisions*, every `.kiro/` file that differs from what `sync` would generate for the imported profile (`status`' `collision` state against no lock, so a difference of line endings, BOM or JSON formatting is not one), each with its ingredient and the line counts of the two sides, a rule's steering mirror that outgrew its rule marked `steering bigger than the rule`; and *Unsourced Kiro files*, the files under `.kiro/steering/`, `.kiro/agents/`, `.kiro/skills/` and `.kiro/settings/mcp.json` that the plan for the imported profile does not write — listed, never imported. The report holds **paths and line counts, never content** (a `.kiro/` mirror is not scanned for secrets), and names an inferred parameter's key, not its value; a warning, a rejection reason or the `not computed` line is written with every parameter and section value this run accepted, replaced or inferred as `"…"`, and a variant is named against its base, without its reason — the summary on the terminal keeps them. It describes the profile as imported: of the workspace's own `craftar.yaml` and `craftar.local.yaml` only `overrides.params` and `overrides.sections` are read (as import reads them to decide a reuse) — not its `forge`, `profile`, `targets` or recipes — and `craftar.lock` is not read. The path may be inside the workspace; it is refused, before anything is imported, when it resolves inside the Forge (through symlinks and `..`), when a file already exists there, or when a component of it cannot be resolved. If the Kiro side cannot be computed — the plan fails, or `sync` would refuse it for a declared parameter with no value — its sections read `not computed: …`, the same is an import warning, and the import still exits 0. The report is written after the summary, which then ends with `report <file.md>`; if that write fails the import has still succeeded and the Forge is written, and the command says so and exits 1. Without `--report` none of this is computed. `--prefer-kiro <rule,…>` takes the named rules from `.kiro/steering/<name>.md` instead of `.claude/rules/<name>.md` — for a steering mirror that was edited by hand and is the text to keep; a rule left to an integration recipe is warned (`nothing to prefer; its steering file was not used`) and skipped, before that rule's own refusals (no steering file, a form with no certain source, a line that would not come back). The rule's text is built from the steering file — its frontmatter and its opening banner (the default one, or the line rendered by the `kiro.banner` sync would use: the workspace's `overrides.params`, else — for a profile already in the Forge — the profile's own or a default of a recipe it resolves) dropped, `.kiro/steering/` turned back into `.claude/rules/` except in a reference to a hand-written steering file and under `commands/` — and is accepted only when it is **proved to render back** to that steering file's body for Kiro over the workspace's own rules (every rule aimed at every target, as import writes them); it is then scanned for secrets and decided like any source (reused, a variant, or created; a rejection names the steering file's line, and a rejected rule gets no `prefer-kiro` line), its `inclusion` and `fileMatchPattern` still read from the steering frontmatter. Refused, with the Forge untouched: an empty or repeated name; a name with no `.claude/rules/<name>.md` (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no `.kiro/steering/<name>.md`; a steering file holding a form Kiro writes for a rule it does not have (`AGENTS.md (rule: <x>)`, a link to `AGENTS.md`, `rule not in this workspace`), which has no certain source; and a steering file that would not come back from the rule built from it (it names `.claude/rules/` where `sync` writes `.kiro/steering/`) — the last two name the line of the steering file and quote none of it. The ingredient's `origin.path` is the steering file; the summary gains `prefer-kiro <ref> — source .kiro/steering/<name>.md (N lines; .claude/rules/<name>.md has M)` per rule — N and M count the two files whole, the steering file's frontmatter and banner included — and `--report` a *Preferred from Kiro* section with the same lines. What the workspace sees next: the Forge rule now differs from `.claude/rules/<name>.md` on disk, so with no lock entry for that file the next `sync` shows it as `collision` and leaves it alone — delete it (and the steering file, if it also collides) and run `craftar sync` to regenerate both; where `craftar.lock` already records the file unedited, `sync` updates it. The summary says so in a `note` for each rule whose text changed (after the warnings, before the `report` line). Until the workspace converges, an import without the flag reads `.claude/rules/<name>.md` again and lands that older text as a `<name>--<profile>` variant: keep the flag, or converge first. Rules only: commands, agents and skills are not taken from Kiro. |
72
+ | `craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config] [--report <file.md>] [--prefer-kiro <rule,…>]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering — and, with `--prefer-kiro`, the text of the rules it names). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `<profile>-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml` (a manifest already at 2 or above is left as it is). A reused base takes the place its `<name>--<profile>` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `<name>--<profile>` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `<recipe>--<profile>`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections <names>` when a section value filled the render), `inferred`, `sectioned`, `param <key>: <old> → <new>`, `section <key> <name>: <old> → <new>`, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. In a Forge at schema 3 or above, what the profile's `integrations` block alone provides is **not imported** — not compared, not written — and is listed (`left to recipe <r> (integrations.<slot>): …`, and under `## Left to an integration recipe` in the `--report` file); `craftar status` then shows whether the workspace's copies differ (`drift`, `collision`); move an agent attached to a scoped rule into the integration recipe with its rule, or `base` splits on import. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand), and — in a Forge at schema 3 or above — a profile whose `integrations` block is malformed or, when the block holds a `kind`, that does not resolve. Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `authEnv` is never inferred; a variant import rewrites keeps the one it declares, a new variant takes its base's, and a declaration a profile loses is reported. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. `--report <file.md>` also writes a Markdown report, after the Forge is written: what was created, reused, made a variant or rejected, the import's warnings, and the Kiro side — *Kiro collisions*, every `.kiro/` file that differs from what `sync` would generate for the imported profile (`status`' `collision` state against no lock, so a difference of line endings, BOM or JSON formatting is not one), each with its ingredient and the line counts of the two sides, a rule's steering mirror that outgrew its rule marked `steering bigger than the rule`; and *Unsourced Kiro files*, the files under `.kiro/steering/`, `.kiro/agents/`, `.kiro/skills/` and `.kiro/settings/mcp.json` that the plan for the imported profile does not write — listed, never imported. The report holds **paths and line counts, never content** (a `.kiro/` mirror is not scanned for secrets), and names an inferred parameter's key, not its value; a warning, a rejection reason or the `not computed` line is written with every parameter and section value this run accepted, replaced or inferred as `"…"`, and a variant is named against its base, without its reason — the summary on the terminal keeps them. It describes the profile as imported: of the workspace's own `craftar.yaml` and `craftar.local.yaml` only `overrides.params` and `overrides.sections` are read (as import reads them to decide a reuse) — not its `forge`, `profile`, `targets` or recipes — and `craftar.lock` is not read. The path may be inside the workspace; it is refused, before anything is imported, when it resolves inside the Forge (through symlinks and `..`), when a file already exists there, or when a component of it cannot be resolved. If the Kiro side cannot be computed — the plan fails, or `sync` would refuse it for a declared parameter with no value — its sections read `not computed: …`, the same is an import warning, and the import still exits 0. The report is written after the summary, which then ends with `report <file.md>`; if that write fails the import has still succeeded and the Forge is written, and the command says so and exits 1. Without `--report` none of this is computed. `--prefer-kiro <rule,…>` takes the named rules from `.kiro/steering/<name>.md` instead of `.claude/rules/<name>.md` — for a steering mirror that was edited by hand and is the text to keep; a rule left to an integration recipe is warned (`nothing to prefer; its steering file was not used`) and skipped, before that rule's own refusals (no steering file, a form with no certain source, a line that would not come back). The rule's text is built from the steering file — its frontmatter and its opening banner (the default one, or the line rendered by the `kiro.banner` sync would use: the workspace's `overrides.params`, else — for a profile already in the Forge — the profile's own or a default of a recipe it resolves) dropped, `.kiro/steering/` turned back into `.claude/rules/` except in a reference to a hand-written steering file and under `commands/` — and is accepted only when it is **proved to render back** to that steering file's body for Kiro over the workspace's own rules (every rule aimed at every target, as import writes them); it is then scanned for secrets and decided like any source (reused, a variant, or created; a rejection names the steering file's line, and a rejected rule gets no `prefer-kiro` line), its `inclusion` and `fileMatchPattern` still read from the steering frontmatter. Refused, with the Forge untouched: an empty or repeated name; a name with no `.claude/rules/<name>.md` (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no `.kiro/steering/<name>.md`; a steering file holding a form Kiro writes for a rule it does not have (`AGENTS.md (rule: <x>)`, a link to `AGENTS.md`, `rule not in this workspace`), which has no certain source; and a steering file that would not come back from the rule built from it (it names `.claude/rules/` where `sync` writes `.kiro/steering/`) — the last two name the line of the steering file and quote none of it. The ingredient's `origin.path` is the steering file; the summary gains `prefer-kiro <ref> — source .kiro/steering/<name>.md (N lines; .claude/rules/<name>.md has M)` per rule — N and M count the two files whole, the steering file's frontmatter and banner included — and `--report` a *Preferred from Kiro* section with the same lines. What the workspace sees next: the Forge rule now differs from `.claude/rules/<name>.md` on disk, so with no lock entry for that file the next `sync` shows it as `collision` and leaves it alone — delete it (and the steering file, if it also collides) and run `craftar sync` to regenerate both; where `craftar.lock` already records the file unedited, `sync` updates it. The summary says so in a `note` for each rule whose text changed (after the warnings, before the `report` line). Until the workspace converges, an import without the flag reads `.claude/rules/<name>.md` again and lands that older text as a `<name>--<profile>` variant: keep the flag, or converge first. Rules only: commands, agents and skills are not taken from Kiro. In a schema 4 Forge an MCP base is compared as `sync` renders it (its declared params substituted into its `server`): a workspace whose server equals that rendering reuses the base — unless the base cites a declared key that neither the profile nor the workspace sets while some recipe of the Forge defaults it (import cannot know which value `sync` will render): that one becomes a variant whatever it holds; one whose server differs becomes a variant holding the server as the workspace has it, braces included, with no `params` (it declares nothing, so `sync` writes any braces in it back, with the undeclared-key warning whenever a layer gives that key a value). No value is ever inferred into a profile from a server. |
73
73
  | `craftar status` | With a remote Forge, the header's second line names it — `forge <url> @ <ref \| <branch> (default branch)> <commit>`, plus `· lock <commit>` when the Forge moved since the last sync — and `--json` carries `forge: {kind, source, ref, defaultBranch, commit, lockCommit, fetched}` for every Forge. Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling: `{forge, statuses, warnings, unsetParams}`. Exits 0 whenever it prints its listing, with one exception: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1 — `--json` still prints the whole object, with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none). Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in an emitted file not every target renders as text (copied as they are). |
74
- | `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock` (schema 2, or 3 when the workspace has a service — see *Local services*; it records the Forge's source, requested `ref` and commit, the resolved recipes and targets — rewritten only when that content changes). With a remote Forge it fetches first and exits 1 when the fetch fails, `--dry-run` and `--check` included. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default); `craftar drift` lists them, discards one, or promotes one to the Forge. A writing `sync` (not `--dry-run`, not `--check`) also records the workspace in `$CRAFTAR_HOME/registry.json` (see *Workspace registry*); a registry it cannot write is a warning, never a failed `sync`. **Refuses when a declared parameter has no value**: a key an ingredient declares under `params` with no `default`, that a file the plan writes cites as `{{key}}`, and that no layer sets (recipe default, profile `params`, `overrides.params` in `craftar.yaml` or `craftar.local.yaml`). The plan's warnings print, then one block on stderr — `error: N declared parameter(s) have no value — nothing written`, one line per key with the ingredients that declare it and the ones that cite it, and the fix — and `sync` exits 1 with nothing written: no file, no lock, no registry entry; `--dry-run` exits 1 the same way, and `--check` prints its listing, then the block, and exits 1 even when every file is `unchanged`. A `{{key}}` no ingredient declares (an Angular `{{ 'X' \| localize }}`, a stray placeholder) is still left verbatim with a warning, and an empty string is a value. |
74
+ | `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock` (schema 2, or 3 when the workspace has a service — see *Local services*; it records the Forge's source, requested `ref` and commit, the resolved recipes and targets — rewritten only when that content changes). With a remote Forge it fetches first and exits 1 when the fetch fails, `--dry-run` and `--check` included. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default); `craftar drift` lists them, discards one, or promotes one to the Forge. A writing `sync` (not `--dry-run`, not `--check`) also records the workspace in `$CRAFTAR_HOME/registry.json` (see *Workspace registry*); a registry it cannot write is a warning, never a failed `sync`. **Refuses when a declared parameter has no value**: a key an ingredient declares under `params` with no `default`, that a file the plan writes cites as `{{key}}`, and that no layer sets (recipe default, profile `params`, `overrides.params` in `craftar.yaml` or `craftar.local.yaml`). The plan's warnings print, then one block on stderr — `error: N declared parameter(s) have no value — nothing written`, one line per key with the ingredients that declare it and the ones that cite it, and the fix — and `sync` exits 1 with nothing written: no file, no lock, no registry entry; `--dry-run` exits 1 the same way, and `--check` prints its listing, then the block, and exits 1 even when every file is `unchanged`. A `{{key}}` no ingredient declares (an Angular `{{ 'X' \| localize }}`, a stray placeholder) is still left verbatim with a warning, and an empty string is a value. (That is a body; a `{{key}}` in an MCP server of a schema 4 Forge follows *Server params*.) |
75
75
  | `craftar diff [path] [--exit-code]` | Line diff between disk and what the Forge would generate, for every file `sync --check` refuses: `update`, `drift`, `collision`, `new`, an `orphan` (shown as the removal of the whole file — the next `sync` deletes it) and an `orphan-drift` (a header and one line: `sync` keeps it). For `.claude/settings.craftar.example.json`, when it has drifted or collides, `diff` prints the headers and `content not shown: an example file may hold a value typed by hand`. `[path]` limits it to the one file whose workspace-relative path, as `status` prints it, equals it. Exits 0 by default, differences or not. `--exit-code` exits 1 when there are differences — exactly when `sync --check` would — and makes a `[path]` that names no file craftar manages an error (exit 1, nothing on stdout); without the flag such a path prints `no differences`. With a remote Forge, `--exit-code` also exits 1 when the fetch fails, as `sync --check` does; plain `diff` reads the cached copy with a warning. When a declared parameter a file cites has no value (see `sync`), the refusal is printed on stderr after the diff: plain `diff` still exits 0, `--exit-code` with no `[path]` exits 1 even with `no differences` — as `sync --check` does — and with a `[path]` its exit code answers for that file only. |
76
- | `craftar drift [show] [path] [--workspace <dir>] [--offline] [--json]` | The hand-edited files (`drift`, `orphan-drift`), whether the Forge also changed each, whether it can be promoted; with a path, its diff. In a schema 3 Forge, a file the profile's `integrations` block alone provides reads `promote: no (provided by integrations.<slot>)` — a service file reads `promote: no (service file)` there too, whoever provides it. In a workspace whose profile's block holds a `kind` and that does not resolve on its own (its `recipes.remove` takes out a missing integration recipe, say), `show` warns that what the block provides could not be computed: a file the block provides then reads `promote: yes`, and `drift promote` refuses it. Read-only. `--json` is a contract: `{workspace, profile, files: [{path, state, target, ingredient, forge, promotable, reason}], warnings, unsetParams}`. Exits 0 whenever it prints its listing, with one exception: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1 — `--json` still prints the whole object, with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none). |
76
+ | `craftar drift [show] [path] [--workspace <dir>] [--offline] [--json]` | The hand-edited files (`drift`, `orphan-drift`), whether the Forge also changed each, whether it can be promoted; with a path, its diff. In a Forge at schema 3 or above, a file the profile's `integrations` block alone provides reads `promote: no (provided by integrations.<slot>)` — a service file reads `promote: no (service file)` there too, whoever provides it. In a workspace whose profile's block holds a `kind` and that does not resolve on its own (its `recipes.remove` takes out a missing integration recipe, say), `show` warns that what the block provides could not be computed: a file the block provides then reads `promote: yes`, and `drift promote` refuses it. Read-only. `--json` is a contract: `{workspace, profile, files: [{path, state, target, ingredient, forge, promotable, reason}], warnings, unsetParams}`. Exits 0 whenever it prints its listing, with one exception: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1 — `--json` still prints the whole object, with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none). |
77
77
  | `craftar drift discard <path...> [--workspace <dir>] [--dry-run] [--offline]` | A `sync` that regenerates only the named hand-edited files and removes a named hand-edited orphan; everything else `sync` would do happens too. A path that is not `drift` or `orphan-drift` refuses the whole run before any write. `--dry-run`. |
78
- | `craftar drift promote <path> [--forge <dir>] [--workspace <dir>] [--dry-run] [--json]` | Carries one hand edit of a `.claude/` file into the Forge's working tree: a value in the profile's `params` / `sections` when the edit is only that, otherwise a variant for the workspace's profile. In a schema 3 Forge, a file the profile's `integrations` block alone provides is not promotable — edit the ingredient in the Forge or discard the edit; and promote refuses to fork a recipe the block selects when it is not that profile's alone — another profile resolves it, or does not resolve at all; and it refuses every file when the block holds a `kind` and the profile does not resolve on its own. Gated on git, proved before the first write, never syncs, never commits. With a remote Forge, `--forge <dir>` names the clone to write. `--dry-run`, `--json`. `--json` is a contract: `{workspace, forge, profile, dryRun, path, ingredient, outcome, promoted, written: [{path, action}], params, sections, flattened, dependents, nextSync, impact, warnings}`. |
78
+ | `craftar drift promote <path> [--forge <dir>] [--workspace <dir>] [--dry-run] [--json]` | Carries one hand edit of a `.claude/` file into the Forge's working tree: a value in the profile's `params` / `sections` when the edit is only that, otherwise a variant for the workspace's profile. In a Forge at schema 3 or above, a file the profile's `integrations` block alone provides is not promotable — edit the ingredient in the Forge or discard the edit; and promote refuses to fork a recipe the block selects when it is not that profile's alone — another profile resolves it, or does not resolve at all; and it refuses every file when the block holds a `kind` and the profile does not resolve on its own. Gated on git, proved before the first write, never syncs, never commits. With a remote Forge, `--forge <dir>` names the clone to write. `--dry-run`, `--json`. `--json` is a contract: `{workspace, forge, profile, dryRun, path, ingredient, outcome, promoted, written: [{path, action}], params, sections, flattened, dependents, nextSync, impact, warnings}`. |
79
79
  | `craftar explain <path>` | Which ingredient, recipe chain, target and origin produced a file and, for an ingredient with sections, which layer filled each one: `sections flavors (profile globex), extra (default)` (`default`, `profile <p>` or `workspace`). For an ingredient whose files cite parameters, each cited key with the layer that filled it for this ingredient: `params org (profile globex), port (default), root (unset)` — `default` (the ingredient's own), `recipe <name>`, `profile <p>`, `workspace` (either workspace file) or `unset`. `AGENTS.md` gets neither line: it is not one ingredient. |
80
80
  | `craftar ls` | Recipes and ingredients resolved for this workspace. |
81
81
  | `craftar workspaces [--fetch] [--json]` | Every workspace a writing `sync` registered on this machine, sorted by path: name, path, profile, stack (`slot=recipe`), targets, the Forge, the last sync, and a status computed now — `up to date`, `outdated` (`new`, `update` or `orphan` pending), `drift` (a `drift`, `orphan-drift` or `collision`), `no lock`, `missing` (directory or `craftar.yaml` gone) or `error` (it no longer loads, plans or reads its lock, or its `sync` would refuse for a declared parameter with no value — the reason is one of the row's warnings: `sync refused: declared parameter(s) with no value: <keys>`), plus `forge moved` when the Forge's commit differs from the lock's. Read without the network: a remote Forge comes from the cache; `--fetch` fetches first. Each row's warnings print after the table, prefixed with its path. Times are UTC. Writes nothing. Exits 0 whenever the table prints. Takes no `--workspace`. `--json` is a contract: `{registry, fetch, workspaces, warnings}`. |
82
- | `craftar doctor [--workspace <dir>] [--fetch] [--strict] [--json]` | Checks this machine — Node ≥ 22, `git`, `$CRAFTAR_HOME`, the registry (entries whose workspace is gone), the Forge cache (its size, entries nothing names, a fetch that never completed) — and, when run inside one (the current directory, or `--workspace`), this workspace: the configuration and its Forge (cached, fetched or pinned), whether the workspace sits inside its Forge, the plan's warnings, declared parameters with no value, the `${NAME}` variables a written MCP server expects and this environment lacks — `mcp-env` reads `authEnv` next to `${NAME}` (names only, never values), `templates` when the profile declares a `templates` block (an empty `templates:` line declares none), `dotnet` when that block is accepted and declares a `dotnet-new` entry (the .NET SDK answering `dotnet --version` at the workspace root); neither goes above `warn` — the lock, `status`, and registration; in a workspace with a service: `docker` — Docker and its Compose plugin, 2.20.0 or later — and `services-env` — the variables a fragment needs. One line per finding — `ok`, `warn` or `error` — each problem with the command that fixes it; each cause is reported once. A declared parameter with no value is an `error` when a file the plan writes cites it (`sync` refuses that workspace) and a `warn` when nothing cites it. It only reports: nothing is written to a workspace, a lock or the registry, and the Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; under `--fetch`, a fetch and the cleanup of trees unused for 14 days). Read without the network; `--fetch` fetches a remote Forge first. Exits 1 on an `error`, or on a `warn` under `--strict`; an explicit `--workspace` without `craftar.yaml` exits 1, while the current directory without one runs the machine checks only. `--json` is a contract: `{version, workspace, fetch, strict, checks: [{id, scope, level, message, fix}], summary}`. |
82
+ | `craftar doctor [--workspace <dir>] [--fetch] [--strict] [--json]` | Checks this machine — Node ≥ 22, `git`, `$CRAFTAR_HOME`, the registry (entries whose workspace is gone), the Forge cache (its size, entries nothing names, a fetch that never completed) — and, when run inside one (the current directory, or `--workspace`), this workspace: the configuration and its Forge (cached, fetched or pinned), whether the workspace sits inside its Forge, the plan's warnings, declared parameters with no value, the `${NAME}` variables a written MCP server expects and this environment lacks — `mcp-env` reads `authEnv` next to `${NAME}` (names only, never values), `templates` when the profile declares a `templates` block (an empty `templates:` line declares none), `dotnet` when that block is accepted and declares a `dotnet-new` entry (the .NET SDK answering `dotnet --version` at the workspace root); neither goes above `warn` — the lock, `status`, and registration; in a workspace with a service: `docker` — Docker and its Compose plugin, 2.20.0 or later — and `services-env` — the variables a fragment needs. One line per finding — `ok`, `warn` or `error` — each problem with the command that fixes it; each cause is reported once. A declared parameter with no value is an `error` when a file the plan writes cites it (`sync` refuses that workspace) and a `warn` when nothing cites it. It only reports: nothing is written to a workspace, a lock or the registry, and the Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; under `--fetch`, a fetch and the cleanup of trees unused for 14 days). Read without the network; `--fetch` fetches a remote Forge first. Exits 1 on an `error`, or on a `warn` under `--strict`; an explicit `--workspace` without `craftar.yaml` exits 1, while the current directory without one runs the machine checks only. `--json` is a contract: `{version, workspace, fetch, strict, checks: [{id, scope, level, message, fix}], summary}`. In a schema 4 Forge `mcp-env` reads the server as rendered, so a `${NAME}` a param brings into `env` is checked. |
83
83
  | `craftar templates list [--json] [--workspace <dir>] [--offline]` | What the profile's `templates` block declares: one line per package — kind, package, version or `latest`, source or `default feeds` — and one per entry this craftar cannot install. Runs no program and does not know what is installed. `--json` is a contract: `{profile, templates: [{entry, kind, package, version, source, installable}], warnings}`. Exits 1 when the block is refused, naming the position and the rule, never a value. |
84
84
  | `craftar templates install [--dry-run] [--force] [--interactive] [--workspace <dir>] [--offline]` | Runs `dotnet new install <pkg>[::<version>]... [--add-source <source>]` once per `dotnet-new` entry, in the profile's order, at the workspace root. `--dry-run` prints the exact lines, needs no `dotnet` and exits 0 over an accepted block, even with a skipped entry. A real run exits 1 when the .NET SDK is not available. Both exit 1 when the block is refused or a package ID exists as a file or folder at the workspace root. `--force` and `--interactive` are passed to `dotnet` as typed, never otherwise; `--interactive` also gives `dotnet` the terminal's input. An entry that fails does not stop the others; a real run exits 1 when any failed or was skipped. When an entry declares a `source` and the workspace root has no `NuGet.Config`, a warning line is printed before the runs. Writes nothing in the workspace, the lock or the registry; nothing else (`sync`, `init`, `doctor`) runs it. See *Project templates*. |
85
85
  | `craftar cache prune [--dry-run] [--workspace <dir>] [--json]` | Removes the Forge cache entries nothing uses — no registered workspace (`missing` ones included until `craftar workspaces prune`) and not the current workspace — entries whose first fetch never completed (even when named), and trees unused for 14 days. Whole entries are kept with a warning when the registry is off or unreadable, or the current `craftar.yaml` does not read. Deletes by default; `--dry-run` shows. Never touches a workspace, a lock or the registry; no network. One removing prune at a time per `$CRAFTAR_HOME` (`forges/prune.lock`). An explicit `--workspace` without `craftar.yaml` exits 1. `--json` is a contract: `{home, dryRun, removed: [{path, kind, reason, bytes}], kept: [{path, reason, namedBy}], freedBytes, warnings}`. |
@@ -92,8 +92,8 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
92
92
  | `craftar forge variants [--forge <dir> \| --workspace <dir>] [--json]` | Lists ingredients that have variants, nearest first, with the profile each came from, its distance to the base and its hunks counted by suggested class (`[1 evolution · 2 block]`), then any variant whose base is missing from the Forge. Read-only; exits 0 either way. `--json` prints `{groups, orphans}`; each variant carries `classes: {evolution, value, block}`, which add up to `distance.hunks`. |
93
93
  | `craftar forge impact [--forge <dir> \| --workspace <dir>] [--json]` | Lists 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: a header, then one line per workspace sorted by path (`unchanged`, the next-sync counts, `missing` or `error: <message>` — a workspace whose `sync` would refuse for a declared parameter with no value is `error: sync refused: declared parameter(s) with no value: <keys>`; ` · after push (<remote>)` for a remote match, ` · after push and pull (<dir>)` for a clone match; `, pins <ref>` when the workspace pins a `ref`). When no registered workspace reads this Forge, the short header (`craftar forge impact — <forge>`) then one line: `no registered workspace reads this Forge on this machine`; when the registry is off, the short header then one line: `the registry is off (CRAFTAR_NO_REGISTRY)`; when the registry cannot be read, exits 1. A workspace whose Forge is remote is refused with the same message as `forge unify`. A remote URL holding credentials is not matched — one warning per remote, `remote <name> of <dir> holds credentials in its URL — not matched` (the URL is never printed). Read-only; no fetch. `--json` is a contract: `{forge, registry, workspaces: [{path, profile, match, via, ref, state, counts, error}]}` (every key always present). |
94
94
  | `craftar forge diff <type/name> [--against <profile>] [--forge <dir> \| --workspace <dir>] [--json]` | Shows the differences between a base ingredient and each of its variants: a header carrying the same distance `forge variants` reports, then hunk by hunk, each with a suggested class and reason — `evolution` (one side is newer text), `value` (an identifier-like token swapped in shared prose, with a suggested `param.<slug>`) or `block` (lines only one side has) — then the files that exist on only one side. A suggestion never decides anything. It compares the raw Forge text, section marker lines included, so a base with markers shows them as hunks against a variant that has none. Read-only. `--json` prints an array of `{ref, profile, distance, diff}`; each hunk carries `suggestion: {class, reason, tokens?}` next to its `kind`. |
95
- | `craftar forge unify <type/name> --profile <p> (--take base\|variant \| --plan <file> \| --save-plan <file>) [--no-impact] [--prune-recipes] [--forge <dir> \| --workspace <dir>] [--json]` | Resolves one variant back into its base, hunk by hunk. `--save-plan` writes a reviewable plan with every decision set to `keep`, each hunk annotated with its suggested class (which `--plan` ignores) and, for a `value` hunk, a pre-filled `params` list (`token` → suggested `key`); on a `block` hunk, a pre-filled `section: { name }` from the Markdown heading above (or `section-<n>`); and on a hunk touching a section the base already has, that section's name. It refuses a path that resolves inside the Forge (after `..` segments and symlinks) and a path that already exists, so it never overwrites a Forge file or a plan you already edited; it writes nothing else. A hunk set to `take: param` turns each listed token into `{{key}}` in the base, declares the base's text as the key's default in the base's `ingredient.yaml` and writes the variant's text into the variant profile's `profile.yaml` `params` — only after proving that both the base's and the variant's text render back exactly, and only when the same plan resolves the variant; it refuses (with the Forge untouched) a key another layer, profile or ingredient already uses, a variant another profile also uses, a whitespace-only difference, and a YAML file that would not round-trip unchanged; in a schema 3 Forge it never extracts into a key under `integrations.` (the prefix is reserved there). A hunk set to `take: section` (with `section: { name, lines? }`) wraps the base's lines in section markers as the default and writes the variant's lines into the variant profile's `sections` — consecutive hunks with one name form one section, `lines: "<from>-<to>"` widens it over equal base lines, and hunks inside a section the base already has only write the profile value; it is proved (both sides render back exactly) and needs the same plan to resolve the variant; `take: param` and `take: section` may share a plan, never a hunk. It refuses (Forge untouched) a `take: param` or `take: section` on a file no target emits, a variant that holds markers, a profile that already sets that section (another profile for a new section, or the variant's profile with other content), a span that cuts a hunk or crosses another section, a span that covers a hunk outside the run (including `take: base` on a hunk inside a section the plan fills), a hunk mixing lines inside and outside an existing section, and a span reaching a missing final newline; when it adds a section to a Forge still at `schema: 1` it sets `schema: 2` in `craftar.forge.yaml` in place (refused if the file does not round-trip). `--plan` applies an edited plan, refusing when it is not for this exact ingredient/profile or either side's fingerprint moved since it was saved. `--take base\|variant` resolves every decision to that side. Writes the merged base and, once every difference is resolved, removes the variant and rewrites every recipe reference to it (`ingredients`) to name the base. It never edits an `extends`, and deletes a recipe or repoints a profile's recipe list only under `--prune-recipes`; otherwise it edits a profile only to write the values of a `take: param` or `take: section` extraction into the variant's own profile. A `<recipe>--<profile>` left identical to `<recipe>` is reported as a warning unless pruned; `--prune-recipes` deletes such a recipe and repoints every profile that names it — the candidates are found in a dry pass, their profile files join the git check, and pruning is refused when another recipe's `extends` names the candidate, when the registry is off or unreadable, when the registry did not check every workspace (no registered workspace, a missing entry, one whose configuration did not load), when a registered workspace's plan throws, when a workspace's `recipes.add` or `recipes.remove` names the suffixed recipe, when a profile's recipe list cannot be edited in place (a YAML document that does not round-trip), when a profile selects the candidate through its `integrations` block, or when the Forge no longer loads when the prune reloads it; each remaining candidate is deleted and its profiles repointed only when every registered workspace plans the same files (the same paths and the same bytes) with and without it — any difference refuses that one candidate, and the Forge stays as `unify` left it. `--prune-recipes` with `--no-impact` is refused before anything is read. `unify` plans every registered workspace of the Forge before its writes and again after them, and reports one `impact:` line per workspace — `no effect`, `1 file changes — <path>` / `<n> files change — <paths>`, `missing` or `error: <message>` (each line carries the same ` · after push (<remote>)` / ` · after push and pull (<dir>)` / `, pins <ref>` suffix as `forge impact`) — replacing the `next: run craftar status …` hint when at least one workspace was checked; an unreadable registry is one warning. The three warnings — a removed variant a workspace may name in `overrides.ingredients.disable`, a new parameter a workspace's `overrides.params` now overrides, a new section a workspace's `overrides.sections` now fills — now name the concerned workspaces with the file that holds the entry, vanish when the registry proves none is concerned, or keep their previous text with a reason in parentheses (the registry could not check N workspaces, no workspace registered, registry off, registry unreadable). `--no-impact` skips both impact passes and restores the previous warning text (the identical-to-sibling warning keeps its new ending). Every recipe rewrite is checked before the first write, so one that cannot land (a reference behind a YAML alias) is refused with the Forge untouched; a failure after writing began (an I/O error, a locked file) names the paths already touched and the `git checkout` / `git clean` commands that undo them. A merge never adds, removes or changes a section marker other than the sections the plan declares; a plan or `--take variant` that would is refused before the first write — take `base` for the marker lines, or `take: section` to fill the section; the citation checks of `take: param` also read the profiles' section values, which can cite `{{key}}`. `ingredient.yaml` is never merged: when the two sides' metadata differs (beyond `name`, `as` and `origin`), the variant stays unresolved and the differing fields are named — edit it by hand, or `--take base` to discard the variant, metadata included. Requires a clean git checkout in the Forge with at least one commit (`--save-plan` excepted) — the Forge has no lock, so git is the undo. It also refuses when any path it would overwrite or delete (the base, the variant, the recipe files it rewrites, the profile a parameter or section extraction edits, the profiles `--prune-recipes` repoints, `craftar.forge.yaml` when it is bumped) is ignored, untracked, modified, or flagged skip-worktree or assume-unchanged in the index, since git could not restore it. `--json` prints `{base, profile, resolved, written, removed, unresolved, variantRemoved, recipes: {rewritten, identicalToSibling, pruned, kept}, metaDiffers, params, profileEdited, sections, manifestEdited, impact, warnings}` for `--take`/`--plan` (every key always present, `[]` when empty), or `{base, profile, plan, unresolved}` for `--save-plan`. |
96
- | `craftar forge promote <type/name> --profile <p> [--forge <dir> \| --workspace <dir>] [--no-impact] [--dry-run] [--json]` | Makes a profile's variant the base — `<type/name>` is the base, `--profile` names the variant, as for `forge unify`. The base takes the variant's whole directory (every file, raw bytes; its `ingredient.yaml` with the base's `name` and no `as`); every other profile that **resolves** the old base today gets it back as a variant of its own, `<name>--<q>` (the old base's whole directory, raw bytes); and the recipes are repointed so each profile resolves, in the same position, the ingredient holding the bytes it resolved before: a recipe naming the variant names the base; a recipe naming the base that the other profile owns by name (`<recipe>--<q>` or `<q>-steering`) is edited in place; any other recipe naming the base is **not edited** — each profile resolving it gets an owned copy `<recipe>--<q>` (the recipe's text with its name and that one entry changed: `slot`, `extends`, `params` and comments carry over) and its `profile.yaml` names the copy in the same position. The variant's directory is then removed. Other variants of the base are not touched; when no other profile resolves the old base, nothing is created and its text is discarded (`the old base is resolved by no profile — discarded (git holds it)`). **Before the first write**: the Forge must be a local directory (a remote one is refused before anything is fetched), a git repository with a commit and a clean tree; git must hold every path the run overwrites or deletes, and ignore none it creates; no path may be reached through a symbolic link; every YAML edit is made in place and refused when the file does not round-trip or the edit does not read back as exactly that change; and the **slot proof** — on a scratch copy holding the edits, every profile that resolves today must resolve the same ingredients in the same order (the variant now the base, the old base now its own variant, each holding the files of the one it replaces), through recipes on the same slots, with the same parameters, sections, targets and resolution warnings. Refused, exit 1, the Forge untouched: a `<type/name>` that is not an ingredient, is itself a variant, or has no variant for `<p>`; a Forge that is remote, not a repository, without a commit, dirty, or with a path git does not hold; a `<name>--<q>` that already exists for a profile that also resolves the base, the promoted profile resolving the base itself, or a recipe naming both (the Forge is inconsistent); a `<recipe>--<q>` that already exists; a profile that reaches a recipe naming the base only through another recipe's `extends` (edit the Forge by hand); in a schema 3 Forge, a profile that resolves a recipe naming the base only because its `integrations` block selects it (`selects recipe <slot>-<kind> … through integrations.<slot>` — promote edits a `recipes` list, and a block's selection is not in one; edit the Forge by hand); a recipe whose file and `name` disagree; and a failed slot proof, which names the profile and what would differ — an owned recipe another profile also resolves fails it. A profile that does not resolve today is warned about and neither given a variant nor checked. **Impact**, as `forge unify`: the workspaces registered on this machine that read the Forge are planned before and after the writes — `impact N workspace(s) planned before and after: no effect` when none changes, otherwise one `impact:` line per workspace with its files, and `impact no workspace of this Forge is registered on this machine` when there is none. A workspace changes only through a reference the Forge cannot see — `recipes.add` of a shared recipe naming the base, `recipes.remove` of a recipe this run copied for its profile (the profile now names the copy, so the removal no longer applies), or `overrides.ingredients.disable` naming the base or the variant — and those are named in a `warn` line (`… its next sync follows the Forge, not this promotion`). `--no-impact` skips both passes; with the registry off (`CRAFTAR_NO_REGISTRY`) or unreadable the command still runs and warns that no workspace was checked; a workspace synced elsewhere is not known. `--dry-run` runs every check and the first impact pass, prints what would be written (`would hold`, `would be created`, `impact N workspace(s) planned: not written, not compared`) and writes nothing. When a variant was created the output ends with `next craftar forge variants — the old base now shows as N variant(s) to unify`: the old base is now an ordinary variant per profile. `--json`, a tooling contract: `{base, profile, dryRun, removed, created, recipes: {edited, created: [{name, from}]}, profiles, impact, warnings}` in that order — `removed` and `created` are refs, `impact` is the array `forge unify --json` gives (`[{path, profile, match, via, ref, state, files, error}]`, `[]` under `--no-impact` or `--dry-run`, or when no workspace was checked), and every array is always present; adding a key is additive, removing or renaming one needs an *Upgrading* line. Exit 0 written, or `--dry-run` passed; 1 a refusal, or a failure after writing began — the error then names the paths already written and the `git checkout` / `git clean` lines that restore them (a directory they leave empty does not stop a re-run). Nothing is committed: review the working tree and commit, or `git checkout` / `git clean` to undo. |
95
+ | `craftar forge unify <type/name> --profile <p> (--take base\|variant \| --plan <file> \| --save-plan <file>) [--no-impact] [--prune-recipes] [--forge <dir> \| --workspace <dir>] [--json]` | Resolves one variant back into its base, hunk by hunk. `--save-plan` writes a reviewable plan with every decision set to `keep`, each hunk annotated with its suggested class (which `--plan` ignores) and, for a `value` hunk, a pre-filled `params` list (`token` → suggested `key`); on a `block` hunk, a pre-filled `section: { name }` from the Markdown heading above (or `section-<n>`); and on a hunk touching a section the base already has, that section's name. It refuses a path that resolves inside the Forge (after `..` segments and symlinks) and a path that already exists, so it never overwrites a Forge file or a plan you already edited; it writes nothing else. A hunk set to `take: param` turns each listed token into `{{key}}` in the base, declares the base's text as the key's default in the base's `ingredient.yaml` and writes the variant's text into the variant profile's `profile.yaml` `params` — only after proving that both the base's and the variant's text render back exactly, and only when the same plan resolves the variant; it refuses (with the Forge untouched) a key another layer, profile or ingredient already uses, a variant another profile also uses, a whitespace-only difference, and a YAML file that would not round-trip unchanged; in a Forge at schema 3 or above it never extracts into a key under `integrations.` (the prefix is reserved there). A hunk set to `take: section` (with `section: { name, lines? }`) wraps the base's lines in section markers as the default and writes the variant's lines into the variant profile's `sections` — consecutive hunks with one name form one section, `lines: "<from>-<to>"` widens it over equal base lines, and hunks inside a section the base already has only write the profile value; it is proved (both sides render back exactly) and needs the same plan to resolve the variant; `take: param` and `take: section` may share a plan, never a hunk. It refuses (Forge untouched) a `take: param` or `take: section` on a file no target emits, a variant that holds markers, a profile that already sets that section (another profile for a new section, or the variant's profile with other content), a span that cuts a hunk or crosses another section, a span that covers a hunk outside the run (including `take: base` on a hunk inside a section the plan fills), a hunk mixing lines inside and outside an existing section, and a span reaching a missing final newline; when it adds a section to a Forge still at `schema: 1` it sets `schema: 2` in `craftar.forge.yaml` in place (refused if the file does not round-trip). `--plan` applies an edited plan, refusing when it is not for this exact ingredient/profile or either side's fingerprint moved since it was saved. `--take base\|variant` resolves every decision to that side. Writes the merged base and, once every difference is resolved, removes the variant and rewrites every recipe reference to it (`ingredients`) to name the base. It never edits an `extends`, and deletes a recipe or repoints a profile's recipe list only under `--prune-recipes`; otherwise it edits a profile only to write the values of a `take: param` or `take: section` extraction into the variant's own profile. A `<recipe>--<profile>` left identical to `<recipe>` is reported as a warning unless pruned; `--prune-recipes` deletes such a recipe and repoints every profile that names it — the candidates are found in a dry pass, their profile files join the git check, and pruning is refused when another recipe's `extends` names the candidate, when the registry is off or unreadable, when the registry did not check every workspace (no registered workspace, a missing entry, one whose configuration did not load), when a registered workspace's plan throws, when a workspace's `recipes.add` or `recipes.remove` names the suffixed recipe, when a profile's recipe list cannot be edited in place (a YAML document that does not round-trip), when a profile selects the candidate through its `integrations` block, or when the Forge no longer loads when the prune reloads it; each remaining candidate is deleted and its profiles repointed only when every registered workspace plans the same files (the same paths and the same bytes) with and without it — any difference refuses that one candidate, and the Forge stays as `unify` left it. `--prune-recipes` with `--no-impact` is refused before anything is read. `unify` plans every registered workspace of the Forge before its writes and again after them, and reports one `impact:` line per workspace — `no effect`, `1 file changes — <path>` / `<n> files change — <paths>`, `missing` or `error: <message>` (each line carries the same ` · after push (<remote>)` / ` · after push and pull (<dir>)` / `, pins <ref>` suffix as `forge impact`) — replacing the `next: run craftar status …` hint when at least one workspace was checked; an unreadable registry is one warning. The three warnings — a removed variant a workspace may name in `overrides.ingredients.disable`, a new parameter a workspace's `overrides.params` now overrides, a new section a workspace's `overrides.sections` now fills — now name the concerned workspaces with the file that holds the entry, vanish when the registry proves none is concerned, or keep their previous text with a reason in parentheses (the registry could not check N workspaces, no workspace registered, registry off, registry unreadable). `--no-impact` skips both impact passes and restores the previous warning text (the identical-to-sibling warning keeps its new ending). Every recipe rewrite is checked before the first write, so one that cannot land (a reference behind a YAML alias) is refused with the Forge untouched; a failure after writing began (an I/O error, a locked file) names the paths already touched and the `git checkout` / `git clean` commands that undo them. A merge never adds, removes or changes a section marker other than the sections the plan declares; a plan or `--take variant` that would is refused before the first write — take `base` for the marker lines, or `take: section` to fill the section; the citation checks of `take: param` also read the profiles' section values, which can cite `{{key}}`. `ingredient.yaml` is never merged: when the two sides' metadata differs (beyond `name`, `as` and `origin`), the variant stays unresolved and the differing fields are named — edit it by hand, or `--take base` to discard the variant, metadata included. Requires a clean git checkout in the Forge with at least one commit (`--save-plan` excepted) — the Forge has no lock, so git is the undo. It also refuses when any path it would overwrite or delete (the base, the variant, the recipe files it rewrites, the profile a parameter or section extraction edits, the profiles `--prune-recipes` repoints, `craftar.forge.yaml` when it is bumped) is ignored, untracked, modified, or flagged skip-worktree or assume-unchanged in the index, since git could not restore it. `--json` prints `{base, profile, resolved, written, removed, unresolved, variantRemoved, recipes: {rewritten, identicalToSibling, pruned, kept}, metaDiffers, params, profileEdited, sections, manifestEdited, impact, warnings}` for `--take`/`--plan` (every key always present, `[]` when empty), or `{base, profile, plan, unresolved}` for `--save-plan`. In a schema 4 Forge `take: param` refuses, with the Forge untouched, a plan that would write a profile param an MCP server cites as a key it declares, and an MCP variant with no `params` under a base that declares some stays (`metaDiffers` then holds `params`); and when that variant's server cites a key the base declares, a second warning line says what discarding it does. `--take base` discards it, with neither line — the next `sync` may then render the base's params into the MCP files of the workspace that variant came from. |
96
+ | `craftar forge promote <type/name> --profile <p> [--forge <dir> \| --workspace <dir>] [--no-impact] [--dry-run] [--json]` | Makes a profile's variant the base — `<type/name>` is the base, `--profile` names the variant, as for `forge unify`. The base takes the variant's whole directory (every file, raw bytes; its `ingredient.yaml` with the base's `name` and no `as`); every other profile that **resolves** the old base today gets it back as a variant of its own, `<name>--<q>` (the old base's whole directory, raw bytes); and the recipes are repointed so each profile resolves, in the same position, the ingredient holding the bytes it resolved before: a recipe naming the variant names the base; a recipe naming the base that the other profile owns by name (`<recipe>--<q>` or `<q>-steering`) is edited in place; any other recipe naming the base is **not edited** — each profile resolving it gets an owned copy `<recipe>--<q>` (the recipe's text with its name and that one entry changed: `slot`, `extends`, `params` and comments carry over) and its `profile.yaml` names the copy in the same position. The variant's directory is then removed. Other variants of the base are not touched; when no other profile resolves the old base, nothing is created and its text is discarded (`the old base is resolved by no profile — discarded (git holds it)`). **Before the first write**: the Forge must be a local directory (a remote one is refused before anything is fetched), a git repository with a commit and a clean tree; git must hold every path the run overwrites or deletes, and ignore none it creates; no path may be reached through a symbolic link; every YAML edit is made in place and refused when the file does not round-trip or the edit does not read back as exactly that change; and the **slot proof** — on a scratch copy holding the edits, every profile that resolves today must resolve the same ingredients in the same order (the variant now the base, the old base now its own variant, each holding the files of the one it replaces), through recipes on the same slots, with the same parameters, sections, targets and resolution warnings. Refused, exit 1, the Forge untouched: a `<type/name>` that is not an ingredient, is itself a variant, or has no variant for `<p>`; a Forge that is remote, not a repository, without a commit, dirty, or with a path git does not hold; a `<name>--<q>` that already exists for a profile that also resolves the base, the promoted profile resolving the base itself, or a recipe naming both (the Forge is inconsistent); a `<recipe>--<q>` that already exists; a profile that reaches a recipe naming the base only through another recipe's `extends` (edit the Forge by hand); in a Forge at schema 3 or above, a profile that resolves a recipe naming the base only because its `integrations` block selects it (`selects recipe <slot>-<kind> … through integrations.<slot>` — promote edits a `recipes` list, and a block's selection is not in one; edit the Forge by hand); a recipe whose file and `name` disagree; and a failed slot proof, which names the profile and what would differ — an owned recipe another profile also resolves fails it. A profile that does not resolve today is warned about and neither given a variant nor checked. **Impact**, as `forge unify`: the workspaces registered on this machine that read the Forge are planned before and after the writes — `impact N workspace(s) planned before and after: no effect` when none changes, otherwise one `impact:` line per workspace with its files, and `impact no workspace of this Forge is registered on this machine` when there is none. A workspace changes only through a reference the Forge cannot see — `recipes.add` of a shared recipe naming the base, `recipes.remove` of a recipe this run copied for its profile (the profile now names the copy, so the removal no longer applies), or `overrides.ingredients.disable` naming the base or the variant — and those are named in a `warn` line (`… its next sync follows the Forge, not this promotion`). `--no-impact` skips both passes; with the registry off (`CRAFTAR_NO_REGISTRY`) or unreadable the command still runs and warns that no workspace was checked; a workspace synced elsewhere is not known. `--dry-run` runs every check and the first impact pass, prints what would be written (`would hold`, `would be created`, `impact N workspace(s) planned: not written, not compared`) and writes nothing. When a variant was created the output ends with `next craftar forge variants — the old base now shows as N variant(s) to unify`: the old base is now an ordinary variant per profile. `--json`, a tooling contract: `{base, profile, dryRun, removed, created, recipes: {edited, created: [{name, from}]}, profiles, impact, warnings}` in that order — `removed` and `created` are refs, `impact` is the array `forge unify --json` gives (`[{path, profile, match, via, ref, state, files, error}]`, `[]` under `--no-impact` or `--dry-run`, or when no workspace was checked), and every array is always present; adding a key is additive, removing or renaming one needs an *Upgrading* line. Exit 0 written, or `--dry-run` passed; 1 a refusal, or a failure after writing began — the error then names the paths already written and the `git checkout` / `git clean` lines that restore them (a directory they leave empty does not stop a re-run). Nothing is committed: review the working tree and commit, or `git checkout` / `git clean` to undo. |
97
97
  | `craftar services up [service...] [--wait] [--workspace <dir>]` | Starts the workspace's local services: `docker compose -p <project> -f .craftar/compose.yaml [--env-file .env] up -d`. It reads `craftar.lock` only — no Forge, no network — and hands Compose only a stack and fragments the lock records: it exits 1, one line per file with its fix, when a recorded file under `.craftar/` is missing (`run craftar sync`), was edited by hand (`run craftar drift discard <path>`: it regenerates the file, or removes it when the Forge no longer produces it), or the stack or a fragment is a file of yours the lock does not record (`run craftar status`), and when the lock records no service file (no recipe brings a service, or the workspace was never synced); a service name starting with `-` is refused. `[service...]` are Compose service names; `--wait` waits until they are running or healthy. Exits 1 as well when Docker or its Compose plugin is missing or Compose is older than 2.20.0. Otherwise exits with Compose's own code. Nothing else in craftar starts a container. See *Local services*. |
98
98
  | `craftar services down [--volumes] [--workspace <dir>]` | Stops them (`docker compose -p <project> [-f .craftar/compose.yaml [--env-file .env]] down`). Never refused on the files or the lock: when a recorded file is missing or edited, the stack is not craftar's, the lock cannot be read, or Compose is older than 2.20.0, the project is stopped by its name alone. An unreadable lock is a warning, not a refusal. `--volumes` also removes the volumes the stack declares. Exits with Compose's code; 1 when Docker or its Compose plugin is missing. |
99
99
  | `craftar services status [--json] [--workspace <dir>]` | One line per container of a declared service (a service with none reads `not created`) — its ingredient, state (`not created`, or Compose's own: `running`, `exited`…), health and published ports — then every pending file with its fix — a recorded file that is `missing` or `edited`, or a stack or fragment that is `unrecorded` (the lock does not record it). Exits 0 also when nothing runs; 1 when Docker cannot be asked (`--json` still prints, every service `not created`) or the lock cannot be read. A container of the project that no recorded fragment declares is listed too (`(not declared)`; `ingredient: null` in `--json`, where `health`, `composeFile` and `docker.compose` can be `null` as well; `docker.compose` is the bare version, `2.24.5` for `2.24.5-desktop.1`). `--json`: `{workspace, project, composeFile, docker: {available, compose}, services: [{name, ingredient, state, health, ports}], pending: [{path, reason}], warnings}`. `pending[].reason` is `missing`, `edited` or `unrecorded`. |
@@ -112,7 +112,7 @@ forge/
112
112
  │ ├── skills/<name>/{ingredient.yaml, SKILL.md, …}
113
113
  │ ├── scripts/<name>/{ingredient.yaml, <file>}
114
114
  │ ├── hooks/<name>/{ingredient.yaml, <file>}
115
- │ ├── mcp/<name>/ingredient.yaml # server definition
115
+ │ ├── mcp/<name>/ingredient.yaml # server definition (its declared params rendered at schema 4)
116
116
  │ ├── steerings/<name>/{ingredient.yaml, steering.md} # Kiro-only, no Claude counterpart
117
117
  │ └── services/<name>/{ingredient.yaml, compose.yaml, …} # a Compose fragment and its files — no AI coder's; see Local services
118
118
  ├── recipes/<name>.yaml
@@ -125,7 +125,7 @@ An ingredient's `as` and a script's or hook's `files` entries become part of the
125
125
 
126
126
  ```yaml
127
127
  name: forge
128
- schema: 2 # 1 (the default), 2 or 3; 2 is required for section markers, 3 for integrations (includes 2)
128
+ schema: 2 # 1 (the default), 2, 3 or 4; 2 is required for section markers, 3 for integrations, 4 for params in an MCP server — each includes the ones below it
129
129
  description: Craftar Forge — shared harness ingredients, recipes and client profiles.
130
130
  ```
131
131
 
@@ -160,7 +160,40 @@ server:
160
160
  timeout: 30
161
161
  ```
162
162
 
163
- `authEnv` names the environment variables the server needs — names only, never a value. It is a declaration: the server is emitted exactly as written (how the server receives the variable is yours to write: a `${NAME}` reference where the tool expands one, or nothing at all for a server that inherits the environment — Craftar never expands it). An item that is a token of a known format fails the load.
163
+ `authEnv` names the environment variables the server needs — names only, never a value. It is a declaration: below `schema: 4` the server is emitted exactly as written, and at 4 only the params it declares are rendered into it — see *Server params* (how the server receives the variable is yours to write: a `${NAME}` reference where the tool expands one, or nothing at all for a server that inherits the environment — Craftar never expands it). An item that is a token of a known format fails the load.
164
+
165
+ **Server params.** In a Forge that declares `schema: 4`, an MCP ingredient can hold one server for every client and take the part that differs from a param it declares:
166
+
167
+ ```yaml
168
+ # ingredients/mcp/idp/ingredient.yaml # profiles/acme/profile.yaml
169
+ type: mcp recipes: [base] # base lists mcp/idp
170
+ name: idp integrations:
171
+ params: idp:
172
+ integrations.idp.url: {} url: https://idp.example.com/mcp
173
+ server:
174
+ type: http
175
+ url: "{{integrations.idp.url}}"
176
+ ```
177
+
178
+ (The block has no `kind` here, so it only gives the param. With `kind: generic` the Forge would also need a recipe `idp-generic`.) At schema 4 every **string value** of `server` — `url`, `command`, each item of `args`, each value of `env` and `headers`, any key the tool defines, at any depth — has `{{key}}` replaced **for the keys the ingredient declares in its own `params`**, with the value the layers give (the ingredient's default, recipe defaults, the profile's `integrations` and `params`, the workspace's `overrides.params`). Never a key (a server name, a variable name, a header name), never a number or a boolean; the result is always a string (`port: "{{port}}"` renders `"8080"`), and substituted text is not substituted again. `${NAME}` and a tool's own `{{ … }}` are left alone. Both `.mcp.json` and `.kiro/settings/mcp.json` get the same rendered server. Below `schema: 4` a server is written exactly as the Forge holds it, braces included, with no warning. Schema 4 includes 3 and 2: a Forge that moves from 2 to 4 also gets its `integrations` blocks read.
179
+
180
+ What `sync` does with a `{{key}}` in a server, at schema 4:
181
+
182
+ | The key is | Result |
183
+ |---|---|
184
+ | declared by the ingredient, and some layer gives it a value | substituted |
185
+ | declared, with no value in any layer | left as written, and **the sync is refused** — a server is a file `sync` writes, so this is the unset declared-parameter refusal (`sync`, `sync --check`, `status`, `diff --exit-code` exit 1) |
186
+ | not declared by the ingredient, while some layer gives the key a value | left as written, with one warning per ingredient and key: `mcp/idp: server cites {{org}}, which has a value but is not declared in the ingredient's params — left verbatim` |
187
+ | not declared, and no layer gives it a value | left as written, silently: braces that belong to the tool |
188
+
189
+ **The credential check.** `.mcp.json` is generated and committed, and a param's value can come from any hand-written layer — the gitignored `craftar.local.yaml` among them. This is not the check on a `forge:` URL (see *Remote Forge*): the two read a URL by different rules. So at schema 4 every **substituted** value of every server a target writes is checked before a plan is returned:
190
+
191
+ - a URL with credentials (`url-credentials`), as the whole value or inside it, or a token of a known format (its kind, such as `github-token`) **fails the plan**: `sync` (also `--check` and `--dry-run`), `status`, `diff`, `ls` and `explain` exit 1 and write nothing, the `drift` commands fail the same way, `add recipe`, `remove recipe` and `init` refuse before they write, `doctor` reports it under `plan`, `craftar workspaces` and `forge impact` show `error`;
192
+ - a high-entropy value under `env` or `args` (`high-entropy-value`) **warns**, and the plan stands — organisation and project names in `args` can look like one. (A warning fails `doctor --strict`.)
193
+
194
+ The message names the ingredient, the field and the kind — `mcp/idp: the value rendered into server.headers.Authorization looks like a credential (github-token) — …` — and never the value. The way out is to keep the secret out of a param: have the server read it from the environment, and declare the variable in `authEnv` so `doctor` and the example settings file name it. A URL whose userinfo is made only of `${NAME}` references, such as `https://${IDP_USER}:${IDP_TOKEN}@idp.example.com/`, is let through. `doctor`'s `mcp-env` checks the names it finds in the rendered server's `env` and in `authEnv`; it reads no other field.
195
+
196
+ The check narrows the road; it does not close it. **Still passes, though it is a secret:** a token of an unknown format (outside `env` and `args` nothing looks for it; inside them it can only warn, and only when the whole value is letters, digits and `+ / = _ -`) — so `Bearer <token>`, `KEY: <token>` and a `…;Password=…` connection string pass when the token's format is not a known one; a secret in a URL's path or query (`https://idp.example.com/mcp?api_key=<token>`); a URL userinfo holding `/`, `?`, `#`, a quote, `,` or `;`, or white space other than a tab or a line break directly before the `@` — so a token followed by a space (`https://<token> @host/`) passes, and so does a tab inside the userinfo; a quote, `,` or `;` directly after the `://`; a tab or a line break inside the `://` itself (`https:<TAB>//user:<secret>@host/` — there is then no `://` to find); a user-only token whose scheme is broken by a tab, a line break or a space (`ht<TAB>tps://<token>@host/` — it no longer reads as `http(s)`; with a password it is still refused); credentials with no `://` (`user:<secret>@host`); a user-only token on a scheme that is not `http(s)`, `ws(s)` or `<x>+` one of them (`ssh://<token>@host/`); anything that is neither a URL with a userinfo nor a token of a known format. One more way a value can show: a param value written as `${<something>}` and rendered under `env` reads, to `doctor`'s `mcp-env`, as a variable reference: it prints `<something>` as the name of a variable it expects when that has the shape of a name and is not set — a known token format inside the braces is refused before that, an unknown one is not. **Refused, though it is not a secret:** a URL on `http(s)`, `ws(s)` or `<x>+` one of them with a user and no password (`https://acme@dev.azure.com/acme/Portal`); a userinfo that is not made only of plain references — a literal mixed with one (`postgres://acme:${DB_PASS}@db.example.com/app`; write `${DB_USER}:${DB_PASS}`) or a reference with a default (`https://${A:-x}@host/`); a URL with no path followed, with none of `/ ? #`, white space, a quote, `,` or `;` between, by text holding an `@` (`https://a.example.com&cc=ops@example.com`); a URL on any scheme but `file` written with an empty authority — extra `/`, `\` or white space after the `://` — when what follows then reads as a userinfo (`sqlite:///a:b@c.db`; on `http(s)`, `ws(s)` or `<x>+` one of them an `@` alone is enough: `https:///ops@example.com`; in prose, `see :// a:b@c`); a URL with no path, query or fragment followed by a tab or by line breaks — a blank line between too — and then an `@` (`https://registry.npmjs.org`, then `@acme/pkg` on the next line; a markdown link or an unquoted CSS `url(…)` followed by a line that starts with `@`), since a tab, a line feed or a carriage return directly before an `@` is read past — on a scheme that is not `http(s)`, `ws(s)` or `<x>+` one of them, only when the text before the `@` holds a `:`, such as a port; a standalone run of 52 lowercase letters and digits that holds a digit. The only way round a refusal is to keep the value out of a param: write it literally in the ingredient, or have the server read the environment. A literal the Forge author wrote in a server, with no placeholder in it, is **not** checked, at any schema: it is emitted as written, and `craftar import`'s scan stays the guard on what enters a Forge.
164
197
 
165
198
  **Sections.** A body (its **body files**: the `file` of a rule, agent, command or steering — `rule.md` etc. by default; `SKILL.md` of a file-layout skill; every `.md`, `.txt`, `.json`, `.yaml` or `.yml` file of a dir-layout skill; the text files listed in `files` of a script or hook — a body file stays inside the ingredient directory; one declared outside it, or behind a symlinked directory, or spelled differently from the file on disk (letter case, on a case-insensitive file system), fails `sync`, `status`, `diff`, `explain` and `ls`, naming it) can hold blocks a profile or a workspace replaces. A file no target emits (a `notes.md` beside `rule.md`) is ignored — for sections and for `{{param}}` citations alike; a file a target emits but does not render as text is copied (see below). A block sits between two marker lines, and its content is the default:
166
199
 
@@ -197,7 +230,7 @@ sections:
197
230
 
198
231
  A value is a string. It replaces the section's lines; a value without a final newline gets one, and `""` empties the section. The blank lines around the markers stay when a section is emptied, so put a separating blank line inside the section when an emptied section should leave none extra. A value may cite `{{param}}`: sections are expanded before params are substituted.
199
232
 
200
- **Integrations.** A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3`:
233
+ **Integrations.** A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above:
201
234
 
202
235
  ```yaml
203
236
  # profiles/acme/profile.yaml — in a Forge whose craftar.forge.yaml declares schema: 3
@@ -228,7 +261,7 @@ Stories live in Azure DevOps, organisation {{integrations.pm.org}}, project
228
261
  {{integrations.pm.project}}.
229
262
  ```
230
263
 
231
- `integrations.<slot>.kind: <kind>` selects the recipe **named** `<slot>-<kind>` — after the profile's own `recipes`, before the workspace's `recipes.add`; the mapping is by name, so a slot named like an unrelated recipe prefix selects that recipe (a slot `stack` with `kind: dotnet` selects `stack-dotnet`). Every scalar of the block, `kind` included, is the param `integrations.<slot>.<key>`, **under** the profile's own `params` (an explicit `params` entry of that key wins, silently) and under `overrides.params`; an integration with no `kind` gives params only; a mapping or a list is not a param and is warned wherever a resolution warning prints (`sync`, `status`, `recipes`, `doctor`, among others). **Only in a Forge that declares `schema: 3`** — set by hand, never by a command; below 3 the block is ignored **without a warning**: if nothing happens, look at `craftar.forge.yaml`. A `kind` with no recipe, or a malformed block: `sync`, `status`, `diff`, `explain`, `ls` exit 1; `craftar recipes` marks nothing as in use and warns; `doctor` reports it under `plan`; `craftar workspaces` and `forge impact` show `error`; `craftar add recipe`, `craftar remove recipe` and `craftar init` fail (they resolve the profile); `import` into that profile and `drift promote` in any of its workspaces fail; a workspace whose `recipes.remove` names the missing recipe (written by hand: `craftar remove recipe` never adds a name the Forge does not have) resolves and syncs, but `import` and `drift promote` still refuse for that profile (they resolve the profile alone). Malformed: a slot name is letters, digits and `_`, starting with a letter; a key inside a slot is letters, digits and `_`; `kind` is lowercase letters, digits, `.`, `_`, `-`, starting with a letter or digit; a slot whose value is not a mapping — nothing under it (`pm:`), a string (`pm: azure-devops`), a list — is an error; a key with no value is absent. Another profile's malformed block does not fail `craftar recipes` or `forge unify --prune-recipes` run for this one — both read that profile's recipes as written and warn (`profile <q>: … — its recipes are read as written`). An integration param reads `(profile <name>)` in `craftar explain`'s `params` line. `import` never writes or edits a profile's `integrations`, and in a schema 3 Forge never infers an `integrations.*` param. A workspace has no `integrations` of its own: `recipes.remove` takes the recipe out (the params stay), `overrides.params` changes a value; after `add recipe <other> --replace` on the same `slot:` the `{{integrations.<slot>.*}}` params still carry the block's values. Write the block in block style: a flow mapping with padding the YAML writer does not reproduce (`pm: { kind: azure-devops }`, two spaces after the colon) resolves but `import`, `forge unify` (`take: param`, `take: section`, `--prune-recipes`) and `drift promote` cannot edit that profile file in place, as for any file that does not round-trip. A URL written in the block is rendered into a rule, a generated and committed file — as one written in `params` is; keep credentials out of both.
264
+ `integrations.<slot>.kind: <kind>` selects the recipe **named** `<slot>-<kind>` — after the profile's own `recipes`, before the workspace's `recipes.add`; the mapping is by name, so a slot named like an unrelated recipe prefix selects that recipe (a slot `stack` with `kind: dotnet` selects `stack-dotnet`). Every scalar of the block, `kind` included, is the param `integrations.<slot>.<key>`, **under** the profile's own `params` (an explicit `params` entry of that key wins, silently) and under `overrides.params`; an integration with no `kind` gives params only; a mapping or a list is not a param and is warned wherever a resolution warning prints (`sync`, `status`, `recipes`, `doctor`, among others). **Only in a Forge that declares `schema: 3` or above** (what this README says of a schema 3 Forge holds at 4) — set by hand, never by a command; below 3 the block is ignored **without a warning**: if nothing happens, look at `craftar.forge.yaml`. A `kind` with no recipe, or a malformed block: `sync`, `status`, `diff`, `explain`, `ls` exit 1; `craftar recipes` marks nothing as in use and warns; `doctor` reports it under `plan`; `craftar workspaces` and `forge impact` show `error`; `craftar add recipe`, `craftar remove recipe` and `craftar init` fail (they resolve the profile); `import` into that profile and `drift promote` in any of its workspaces fail; a workspace whose `recipes.remove` names the missing recipe (written by hand: `craftar remove recipe` never adds a name the Forge does not have) resolves and syncs, but `import` and `drift promote` still refuse for that profile (they resolve the profile alone). Malformed: a slot name is letters, digits and `_`, starting with a letter; a key inside a slot is letters, digits and `_`; `kind` is lowercase letters, digits, `.`, `_`, `-`, starting with a letter or digit; a slot whose value is not a mapping — nothing under it (`pm:`), a string (`pm: azure-devops`), a list — is an error; a key with no value is absent. Another profile's malformed block does not fail `craftar recipes` or `forge unify --prune-recipes` run for this one — both read that profile's recipes as written and warn (`profile <q>: … — its recipes are read as written`). An integration param reads `(profile <name>)` in `craftar explain`'s `params` line. `import` never writes or edits a profile's `integrations`, and in a Forge at schema 3 or above never infers an `integrations.*` param. A workspace has no `integrations` of its own: `recipes.remove` takes the recipe out (the params stay), `overrides.params` changes a value; after `add recipe <other> --replace` on the same `slot:` the `{{integrations.<slot>.*}}` params still carry the block's values. Write the block in block style: a flow mapping with padding the YAML writer does not reproduce (`pm: { kind: azure-devops }`, two spaces after the colon) resolves but `import`, `forge unify` (`take: param`, `take: section`, `--prune-recipes`) and `drift promote` cannot edit that profile file in place, as for any file that does not round-trip. A URL written in the block is rendered into a rule, a generated and committed file — as one written in `params` is; keep credentials out of both.
232
265
 
233
266
  Recipe:
234
267
 
@@ -254,7 +287,7 @@ overrides:
254
287
  ingredients: {disable: []}
255
288
  ```
256
289
 
257
- Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; a placeholder in a file no target emits is never rendered, and only body files count as a citation for `forge unify` and `import`; a placeholder with no value in any layer is left untouched and reported as a warning by `status` and `sync` (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently). When the key is one an ingredient **declares** under `params` with no `default`, that is also an error: `sync` refuses and writes nothing (see `sync` in *Commands*). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty.
290
+ Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; a placeholder in a file no target emits is never rendered, and only body files count as a citation for `forge unify` and `import` — plus, in a schema 4 Forge, the keys an MCP server cites **and its ingredient declares** (see *Server params*); a placeholder in a body with no value in any layer is left untouched and reported as a warning by `status` and `sync` (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently). When the key is one an ingredient **declares** under `params` with no `default`, that is also an error: `sync` refuses and writes nothing (see `sync` in *Commands*). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty.
258
291
 
259
292
  Sections layer the same way, weakest → strongest: the body's default → the profile's `sections` → `craftar.yaml`'s `overrides.sections` → `craftar.local.yaml`'s, merged per section, so a workspace that sets one section of an ingredient keeps the profile's others. There is no recipe layer for sections.
260
293
 
@@ -276,10 +309,10 @@ Sections layer the same way, weakest → strongest: the body's default → the p
276
309
 
277
310
  **service** — unsupported in all three: a service is aimed at no AI coder (its `targets` defaults to `[]`, and an explicit one is skipped with the usual warning). `sync` writes it under `.craftar/` for no target — see *Local services*.
278
311
 
279
- **claude-code** — emits `.claude/rules|agents|commands|skills|scripts|hooks` and `.mcp.json` verbatim from the Forge (frontmatter kept byte-for-byte). Each MCP server is written exactly as the Forge holds it, undeclared keys included, in its own key order. When a written MCP ingredient declares `authEnv`, sync also writes `.claude/settings.craftar.example.json` — `env` with each declared name and an empty value — for the developer to copy into their own, untracked `.claude/settings.json`; a hand-maintained `.claude/settings.example.json` is never touched; `kiro` and `agents-md` write no counterpart. Existing files keep their line endings and BOM; new files are LF without a BOM. Steering is Kiro-only (its `targets` default to `[kiro]`); one aimed at `claude-code` explicitly (`targets: "*"` or a list naming it) is skipped with a warning.
312
+ **claude-code** — emits `.claude/rules|agents|commands|skills|scripts|hooks` and `.mcp.json` from the Forge (frontmatter kept byte-for-byte). Each MCP server is written exactly as the Forge holds it, undeclared keys included, in its own key order — in a schema 4 Forge with the params its ingredient declares rendered into its string values (*Server params*). When a written MCP ingredient declares `authEnv`, sync also writes `.claude/settings.craftar.example.json` — `env` with each declared name and an empty value — for the developer to copy into their own, untracked `.claude/settings.json`; a hand-maintained `.claude/settings.example.json` is never touched; `kiro` and `agents-md` write no counterpart. Existing files keep their line endings and BOM; new files are LF without a BOM. Steering is Kiro-only (its `targets` default to `[kiro]`); one aimed at `claude-code` explicitly (`targets: "*"` or a list naming it) is skipped with a warning.
280
313
 
281
314
  **kiro** — reproduces, then extends, the hand-written `sync-steering.ps1` script it replaces:
282
- steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/` — except a reference to a rule kiro does not write, which follows that rule: kept as `.claude/rules/<x>.md` when `claude-code` writes it, `AGENTS.md (rule: <x>)` when only `AGENTS.md` holds it, otherwise `<x> (rule not in this workspace)` with a warning; an unknown name is still rewritten and is reported. A reference inside a link's text follows the same rules (in a link kept for `claude-code`, only those references move), and a link whose target reads "rule not in this workspace" and whose text is exactly its own path becomes `<x> (rule not in this workspace)` (backticks kept); an unknown name keeps its link. Files are UTF-8 without BOM, CRLF. The same applies to agent prompts and descriptions, command bodies and descriptions, and skill text files; a `steering` ingredient is emitted as written. On top of what the script did, it also generates `.kiro/agents/*.json` (tools mapped to Kiro names, `resources` bound to the agent's stack rule + `repo-discovery`, or `**/*.md` for generic agents), `.kiro/steering/commands/*.md`, `.kiro/skills/*/SKILL.md` and `.kiro/settings/mcp.json`. `.kiro/settings/mcp.json` receives each MCP server exactly as the Forge holds it, in its own key order — including keys Kiro may not use (`type` always went through). The banner text is a parameter (`kiro.banner`) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning.
315
+ steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/` — except a reference to a rule kiro does not write, which follows that rule: kept as `.claude/rules/<x>.md` when `claude-code` writes it, `AGENTS.md (rule: <x>)` when only `AGENTS.md` holds it, otherwise `<x> (rule not in this workspace)` with a warning; an unknown name is still rewritten and is reported. A reference inside a link's text follows the same rules (in a link kept for `claude-code`, only those references move), and a link whose target reads "rule not in this workspace" and whose text is exactly its own path becomes `<x> (rule not in this workspace)` (backticks kept); an unknown name keeps its link. Files are UTF-8 without BOM, CRLF. The same applies to agent prompts and descriptions, command bodies and descriptions, and skill text files; a `steering` ingredient is emitted as written. On top of what the script did, it also generates `.kiro/agents/*.json` (tools mapped to Kiro names, `resources` bound to the agent's stack rule + `repo-discovery`, or `**/*.md` for generic agents), `.kiro/steering/commands/*.md`, `.kiro/skills/*/SKILL.md` and `.kiro/settings/mcp.json`. `.kiro/settings/mcp.json` receives each MCP server exactly as the Forge holds it (in a schema 4 Forge, as `.mcp.json` gets it: declared params rendered), in its own key order — including keys Kiro may not use (`type` always went through). The banner text is a parameter (`kiro.banner`) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning.
283
316
 
284
317
  **agents-md** — one `AGENTS.md` with the always-on rules concatenated and the scoped rules after them — each listed at the file a target of the workspace writes for it (`.claude/rules/<name>.md` when `claude-code` writes it, else `.kiro/steering/<name>.md` when `kiro` does), or, when no target writes it, embedded in full under a `> Scoped rule — <scope>` line, for tools that read the open standard (Codex, Cursor, Warp, Copilot, Kimi…). A `.claude/rules/<x>.md` reference inside a body follows the rule it names: it is kept when `claude-code` writes that rule, becomes `.kiro/steering/<x>.md` when only `kiro` writes that file (from the rule or from a `steering` ingredient of that name), becomes `AGENTS.md (rule: <x>)` when the rule's text is in this file, and otherwise becomes `<x> (rule not in this workspace)` with a warning (an unknown name is left as written when `claude-code` is a target); a reference inside a link's text is resolved the same way, and a link whose target reads "rule not in this workspace" and whose text is exactly its own path becomes `<x> (rule not in this workspace)` (backticks kept); without `claude-code`, the warning also names references to other `.claude/` files, which are left as written. Every other ingredient type aimed at `agents-md` — including through the default `targets: "*"` — has no `AGENTS.md` equivalent: it is skipped with a warning — one line per type, naming every skipped ingredient. The file keeps the line endings and BOM of the one it replaces; a new one is LF without a BOM.
285
318
 
@@ -427,7 +460,7 @@ A `kind` is a lowercase name (`dotnet-new`); a package is a NuGet ID, optionally
427
460
 
428
461
  Phase 0 — foundations (done): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; `forge variants`, `forge diff` and `forge unify` (`take: param`, `take: section`); sections.
429
462
 
430
- Phase 1 — client profile (in progress). Shipped: the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock) and `craftar cache prune`; the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`; `craftar forge impact`, `forge unify --prune-recipes` and `forge promote`; an interactive `craftar init` (choose the Forge and the profile); `authEnv` on MCP ingredients (the environment variables a server needs, by name, and the generated `.claude/settings.craftar.example.json`); `craftar drift show` / `drift discard` / `drift promote` (list the files edited by hand, discard an edit, or carry one into the Forge as a profile value or a variant); a sync refused when a declared parameter has no value; `craftar import --report` (a Markdown report of an import, as paths and line counts) and `import --prefer-kiro` (a rule's source taken from its Kiro steering file); a profile's `integrations` block (PM tool and IDP as recipes selected by the profile). Next: cloning a profile's repositories at init; `{{param}}` inside an MCP server.
463
+ Phase 1 — client profile (in progress). Shipped: the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock) and `craftar cache prune`; the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`; `craftar forge impact`, `forge unify --prune-recipes` and `forge promote`; an interactive `craftar init` (choose the Forge and the profile); `authEnv` on MCP ingredients (the environment variables a server needs, by name, and the generated `.claude/settings.craftar.example.json`); `craftar drift show` / `drift discard` / `drift promote` (list the files edited by hand, discard an edit, or carry one into the Forge as a profile value or a variant); a sync refused when a declared parameter has no value; `craftar import --report` (a Markdown report of an import, as paths and line counts) and `import --prefer-kiro` (a rule's source taken from its Kiro steering file); a profile's `integrations` block (PM tool and IDP as recipes selected by the profile); `{{param}}` inside an MCP server. Next: cloning a profile's repositories at init.
431
464
 
432
465
  Phase 2 — environment (in progress). Shipped: local services (`service` ingredients, `.craftar/`, `craftar services up | down | status`); `craftar templates list | install` (`dotnet new` project templates declared in a profile).
433
466
 
@@ -435,6 +468,23 @@ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`;
435
468
 
436
469
  ## Upgrading
437
470
 
471
+ ### to 0.28.1
472
+
473
+ - **A Forge URL with credentials that an earlier release recorded is no longer printed from the registry.** A release before `0.27.1` could record such a URL (see *to 0.27.1*). For a `missing` or an `error` row whose recorded URL holds credentials, `craftar workspaces --json` now gives `(a URL with credentials — not shown)` as `forge.source` and `null` as `forge.key`. `craftar cache prune` — its lines and, under `--json`, `kept[].path`, `removed[].path` and `warnings` — and the `cache` check of `craftar doctor` put the same label where the name of the cache entry such a row names would be: `path` then reads `forges/(a URL with credentials — not shown)`, `forges/(a URL with credentials — not shown)/trees/<commit>` or `forges/~removing-(a URL with credentials — not shown)-<pid>`, which is not a path, and two such entries read the same; a failure either command reports with a path inside such an entry names it the same way. No `--json` key is added, removed or renamed, and no type changes: `forge.key` could already be `null` (a path Forge that cannot be resolved); a `remote` row can now carry it too. A row that loads gives what `craftar.yaml` holds, as before.
474
+ - **A cache entry that no registered workspace names any more is still printed by its name.** Once every workspace that named the URL has synced again without the credentials, the registry no longer records it, and nothing in the cache says which entry was named after a refused URL. `craftar doctor` (`<name> — nothing names it`, `<name> — a fetch never completed`) and `craftar cache prune` (the line of the entry it removes or keeps, of a tree of one it keeps, and a warning about it) then print that name, which holds the user and the password. With `CRAFTAR_NO_REGISTRY` set or a registry that cannot be read, `cache prune` names every entry that way, and `doctor` every one whose fetch never completed. Run `craftar cache prune` right after the re-sync, in a terminal rather than where the output is kept (a CI log, a file) — and rotate the credential either way.
475
+ - **A `forge:` that holds credentials behind a `\` in place of the `//` is refused** — `https:\\user:token@host\…`, or any run of `/` and `\` after the scheme that holds a `\`. It was read as a path, and `Forge not found at …` printed it whole. It is now refused wherever a URL with credentials is (`… › forge holds credentials in the URL — remove them and let git authenticate`): `craftar.yaml` and `craftar.local.yaml`, `init --forge` and its questions, `import --write-config`, a remote of a Forge clone. As with `://`, what counts is any user on `http(s)` and a `user:password` on another scheme, and `file:` is left alone. A drive path (`C:\…`) is read as before — a name there cannot hold the `:` of a `user:password` — and so is such a spelling with no user and no password.
476
+ - **A `ref:` that holds a URL with credentials is not quoted.** The messages that name a ref — `ref "…" is ignored: the Forge is a path`, `ref "…" not found in <url>`, `ref "…" is both a branch and a tag` (which then gives no `write refs/heads/…` hint) and `… the cached copy cannot resolve …` — read `ref (a URL with credentials — not shown)` for such a value, as the whole of it or inside it. The interactive `craftar init` says `Ref: (a URL with credentials — not shown) (from craftar.local.yaml)` for one it reads there, and refuses one typed as an answer (`the answer holds credentials in a URL — a ref is a branch, a tag or a full SHA`), where it offered it back as the next default. Every other ref is printed as before.
477
+ - **No byte change**: no emitter changed, no lock changes and no workspace sees `update`. No exit code changes for a workspace whose `forge:` is still accepted. In one whose `forge:` is spelled with the `\` and holds credentials, the commands that load it failed before (`Forge not found at …`) and fail now with the refusal; `craftar import --write-config`, which replaced the value, now exits 1 with the Forge untouched, where it exited 0; `craftar cache prune` run there, or with `--workspace` naming it, keeps every whole entry (`unchecked`: the checked `craftar.yaml` does not read) where it pruned; and the `cache` check of `craftar doctor` there no longer reports an orphan — its line, or lines, end `(orphans not checked: the checked craftar.yaml does not read)`.
478
+
479
+ ### to 0.28.0
480
+
481
+ - **A Forge can declare `schema: 4`**, which includes 3 and 2. Every craftar before `0.28.0` refuses such a Forge at `craftar.forge.yaml` — `0.27.0` to `0.27.2`, which read a Forge at `schema: 3`, included — so none can write a templated server with its braces. Upgrade every machine and CI that reads the Forge before setting it. Nothing changes for a Forge at 1, 2 or 3.
482
+ - **Moving a Forge to 4** renders every server placeholder that is declared and valued: its workspaces see `update` on `.mcp.json` / `.kiro/settings/mcp.json` at their next `sync`. An undeclared placeholder whose key has a value becomes a plan warning (and fails `doctor --strict`); a declared one with no value refuses the sync. A Forge that moves to 4 from 1 or 2 also gets its profiles' `integrations` blocks read — 4 includes 3; see *to 0.27.0*.
483
+ - **At schema 4, a param value that looks like a credential stops the plan** when it is rendered into a server (see *The credential check*): `sync`, `status` and `diff` exit 1 and write nothing.
484
+ - **At schema 4, `craftar import` compares an MCP base as `sync` renders it**: a workspace whose server equals the rendered base reuses it, where it was a variant before; `forge unify` refuses a `take: param` plan that would write a profile param a server cites as a declared key.
485
+ - **`forge unify --json`**: `metaDiffers` can hold `params` for an MCP pair at schema 4 — a value in the list, not a key.
486
+ - **The message for an invalid manifest `schema` value** now lists `expected 4` among its alternatives.
487
+
438
488
  ### to 0.27.2
439
489
 
440
490
  - **`git` is looked for in the absolute entries of `PATH` only** — never in the directory craftar runs in, which is a workspace or a Forge, unless `PATH` names that directory by an absolute entry: a `git` committed in a repository is not run through an empty or a relative entry. `npx craftar` is the case to know: npm puts `<cwd>/node_modules/.bin`, and every ancestor's, first on `PATH` as absolute entries, so a `git` committed there is still run, as before. An empty entry (a leading or a trailing `:`, `::`), a relative one (`.`, `bin`, an unexpanded `~/bin`) and an unset `PATH` are not looked in. On Windows only `git.exe` / `git.com` in an entry with a drive or a UNC host count.
@@ -445,7 +495,7 @@ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`;
445
495
  ### to 0.27.1
446
496
 
447
497
  - **A URL whose credentials sit behind an empty authority is refused, like any other URL holding credentials.** `https:///user:token@host/…` — extra slashes after the scheme, or a `\` among them — passed the check, though a client reads past them and uses the user and the password: curl past up to three slashes, a WHATWG URL parser past a `\` too. It is now refused wherever a URL with credentials is: `forge:` in `craftar.yaml` and `craftar.local.yaml` (`… › forge holds credentials in the URL — remove them and let git authenticate`), `init --forge` and its questions, and a remote of a Forge clone, which `forge impact` then does not match. For a profile's template `source` only the spelling with a `\` changes (`a source must not carry a user or a password`); the one with slashes was refused already. Every scheme but `file` is read that way; a URL with no user and no password behind the extra slashes (`https:///host/…`) is still accepted, and so is `ssh:///git@host/…`.
448
- - **If a workspace named its Forge that way, the credential is already written down — rotate it.** craftar recorded the URL as given in `craftar.lock` (`forge.source`; the lock is a committed file) and in `$CRAFTAR_HOME/registry.json`, and the cache entry under `$CRAFTAR_HOME/forges/` carries it in its name; a template `source` spelled with the `\` was printed by `templates list` and `templates install`. Take the credential out of the URL, let git (or NuGet) authenticate, and `sync` again in every workspace that named it: the lock and the registry then record the new URL, and `craftar cache prune` removes the entry of the old one once no registered workspace names it (a `missing` one counts until `craftar workspaces prune`). Until then `craftar workspaces`, `craftar doctor` and `craftar cache prune` can still print what was recorded — the URL, or the name of the cache entry.
498
+ - **If a workspace named its Forge that way, the credential is already written down — rotate it.** craftar recorded the URL as given in `craftar.lock` (`forge.source`; the lock is a committed file) and in `$CRAFTAR_HOME/registry.json`, and the cache entry under `$CRAFTAR_HOME/forges/` carries it in its name; a template `source` spelled with the `\` was printed by `templates list` and `templates install`. Take the credential out of the URL, let git (or NuGet) authenticate, and `sync` again in every workspace that named it: the lock and the registry then record the new URL, and `craftar cache prune` removes the entry of the old one once no registered workspace names it (a `missing` one counts until `craftar workspaces prune`). Until then `craftar workspaces`, `craftar doctor` and `craftar cache prune` can still print what was recorded — the URL, or the name of the cache entry. Since `0.28.1` they print a fixed label for what the registry still records (see *to 0.28.1*).
449
499
  - **No byte change**: no emitter changed, and no lock changes for a workspace whose `forge:` is still accepted. No workspace sees `update`; no exit code changes for a workspace whose `forge:` and whose profile's template `source`s are still accepted, and no `--json` key changes.
450
500
 
451
501
  ### to 0.27.0
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { importClaudeCode } from "./importers/claude-code.js";
7
7
  import { parsePreferKiro } from "./importers/prefer-kiro.js";
8
8
  import { planPromote, applyPromote, UnheldError } from "./importers/drift-promote.js";
9
9
  import { providedRefs } from "./importers/provided.js";
10
+ import { holdsBaseCitations, rendersServers } from "./core/mcp-server.js";
10
11
  import { classifyForge } from "./core/remote.js";
11
12
  import { forget, listWorkspaces, prune, register, registryFile } from "./core/registry.js";
12
13
  import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState, countStates, countStatesObject, NEXT_SYNC_STATES } from "./core/impact.js";
@@ -50,12 +51,12 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
50
51
  */
51
52
  const DIFF_EXIT_CODE_FETCH_MODE = "sync";
52
53
  const program = new Command();
53
- program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.27.2");
54
+ program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.28.1");
54
55
  const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
55
56
  const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
56
57
  program
57
58
  .command("import")
58
- .description("Import an existing workspace harness into a Forge: creates or updates ingredients, recipes and a profile, reusing a templated base when it renders the workspace text or infers it into params or sections; in a schema 3 Forge, what the profile's integrations block provides is left to its recipe and reported")
59
+ .description("Import an existing workspace harness into a Forge: creates or updates ingredients, recipes and a profile, reusing a templated base when it renders the workspace text or infers it into params or sections; in a Forge at schema 3 or above, what the profile's integrations block provides is left to its recipe and reported")
59
60
  .requiredOption("--from <tool>", "source tool: claude-code")
60
61
  .requiredOption("--forge <dir>", "Forge directory (created if missing)")
61
62
  .requiredOption("--profile <name>", "client profile to create or update")
@@ -1864,6 +1865,10 @@ forge
1864
1865
  if (result.metaDiffers.length) {
1865
1866
  warnings.push(`ingredient.yaml differs in ${result.metaDiffers.join(", ")} — unify cannot merge ingredient.yaml, so ${variant.ref} stays; ` +
1866
1867
  `resolve it by hand, or use --take base to discard the variant`);
1868
+ // Spec 34 §5.2: the variant kept for its missing `params` holds the braces a workspace wrote.
1869
+ if (rendersServers(f.manifest) && holdsBaseCitations(base.meta, variant.meta)) {
1870
+ warnings.push(`${variant.ref} holds its server as a workspace wrote it; discarding it, or giving it the base's params, can make the next sync render those params into that workspace's MCP files`);
1871
+ }
1867
1872
  }
1868
1873
  // Ruling 42: a suffixed recipe left identical to its sibling is reported, never deleted — a
1869
1874
  // workspace's recipes.add or an extends chain may name the sibling, and removing the suffixed