craftar 0.29.0 → 0.30.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` 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`. `craftar mcp` serves a workspace to an AI coder over the Model Context Protocol — seven reading tools with the data of `status`, `diff`, `explain`, `recipes`, `ingredients`, `targets` and `doctor`; it reads only. Everything else in the spec (`craftar ui`) 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`. `craftar mcp` serves a workspace to an AI coder over the Model Context Protocol — seven reading tools with the data of `status`, `diff`, `explain`, `recipes`, `ingredients`, `targets` and `doctor`; it reads only. `craftar ui` serves a local, read-only dashboard of every registered workspace — the table of `craftar workspaces`, and for one workspace its file states, a file's diff and explanation, and its Forge's recipes and ingredients — on `127.0.0.1` only. Everything else in the spec (cloning a profile's repositories at init) is not built yet.
6
6
 
7
7
  ## The idea in one paragraph
8
8
 
@@ -69,11 +69,11 @@ 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 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. |
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` (not one `--prefer-kiro` took the rule from); 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
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 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). |
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. `show` does not plan a promote, so a file `drift promote` refuses by a check only its plan makes reads `promote: yes` too — a recipe chain (D17), or a recipe a profile's block selects that another profile resolves, or that a profile which does not resolve might (D19). 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
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. |
@@ -85,6 +85,7 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
85
85
  | `craftar mcp [--workspace <dir>] [--fetch]` | An MCP server (Model Context Protocol, revision `2025-06-18`) over stdin and stdout, started by an AI coder as a subprocess — not typed in a terminal, where it refuses to start. It serves **one workspace**, the one it was started in, through seven tools that only read: `status`, `diff` (the pending files; with a `path`, that file's diff), `explain`, `recipes`, `ingredients`, `targets` and `doctor` (the checks about this workspace, plus Node and `git`; like the command, it asks `docker compose` for its version when the workspace has a service and `dotnet` for its version when the profile declares a template it can install) — each answers with the data its command prints, as one JSON object. Nothing is written to the workspace, the lock, `craftar.yaml`, the registry or the Forge; there is no `sync` tool. 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). Without `--fetch` no tool touches the network: a remote Forge is read from the cached copy, and the answer says so — for `doctor` in its `forge` check, for the others but `targets` in `warnings` unless `ref` is a full commit SHA, which cannot go stale (`targets` reads only which targets the configuration resolves, never fetches and carries no such line). The `forge`, `ref` and `profile` of `craftar.yaml` / `craftar.local.yaml` are fixed when the server starts: after one of them changes, every tool but `targets` answers with an error until it is put back or the server is restarted (`targets` answers the matrix unmarked, with a warning). A hand-edited `.mcp.json`, `.kiro/settings/mcp.json` or `.claude/settings.craftar.example.json` is never returned — a note stands in for its diff. A line that is not JSON, or longer than 1 MiB, is not answered — the server reads on. Exits 0 when stdin closes; exits 1, with one line on stderr, in a terminal, without a `craftar.yaml`, or when the configuration does not read. See *Craftar inside the AI coder*. |
86
86
  | `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}`. |
87
87
  | `craftar workspaces forget <dir>` / `craftar workspaces prune` | `forget` removes one entry (the directory is not touched; an unregistered path exits 1); `prune` removes every `missing` entry and never an `error` one. Nothing else removes an entry. |
88
+ | `craftar ui [--port <n>]` | A local dashboard, in a browser: the table of `craftar workspaces` and, for a registered workspace, what `status`, `diff` and `explain` say about it and what `recipes` and `ingredients` say about its Forge. It prints one address and serves until interrupted (`Ctrl-C` exits 0). **Read-only**: nothing is written to a workspace, a lock, the registry or a Forge — the Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; `git` runs as for `craftar workspaces`) — and the page never runs `sync` — it prints the command to copy. **Loopback only**: it listens on `127.0.0.1` (no `--host`; `localhost` is refused), answers `GET` only, and every answer that carries data needs a token generated for that run — it travels in the address's fragment, which the browser does not send, so open the address exactly as printed (pasted, not clicked from a web page; a reload keeps working, a new tab needs the address again); it stays in the terminal's scrollback (and in a file, if the output is redirected) and may stay in the browser's history, and is worth nothing once the command stops. **No network**: a remote Forge is read from the cached copy, never fetched, and the page says so. `--port` (default `4747`; `0` takes a free one); a taken port is refused, never replaced. A Forge URL the registry recorded with credentials is shown as `(a URL with credentials — not shown)`, with no cache key — as `craftar workspaces --json` gives it since `0.28.1` (see *Upgrading › to 0.28.1*). Takes no `--workspace`; exits 1 when the registry is off (`CRAFTAR_NO_REGISTRY`) or cannot be read. The page's routes are not a contract. |
88
89
  | `craftar recipes [--forge <dir> [--profile <name>] \| --workspace <dir>] [--json]` | Every recipe of the Forge, sorted by name: slot, the parents it extends, the ingredients it lists itself and the profiles that reach it. In a workspace (or with `--forge <dir> --profile <name>`) each recipe says whether it is in use and why — `profile` (listed in `recipes` or selected by the profile's `integrations` block), `workspace` (`recipes.add`), `extends <child>` — and a recipe `recipes.remove` names reads `removed by this workspace`. A context that does not resolve (two recipes on one slot, an unknown recipe, a cycle) still lists everything, marks nothing and warns. Read-only: it resolves, never plans. `--json` is a contract: `{forge, context, recipes, warnings}`. |
89
90
  | `craftar ingredients [--recipe <name>] [--type <type>] [--forge <dir> [--profile <name>] \| --workspace <dir>] [--json]` | Every ingredient of the Forge, grouped by type: output name, targets, the recipes that list it, and whether the context uses it or the workspace disables it; a reference a recipe makes to an ingredient the Forge does not hold is listed under `missing`, never dropped. `--recipe <name>` lists what choosing that recipe brings, with the parents it extends, parents first; two of them on one slot is a warning. `--type` keeps one type. Read-only. `--json` is a contract: `{forge, context, recipe, ingredients, missing, warnings}`. |
90
91
  | `craftar targets [--workspace <dir>] [--json]` | The capability matrix (see *Targets*): what each target does with each ingredient type, the paths it writes and what changes. Needs no Forge; inside a workspace it marks the targets in use, and when that workspace does not load or resolve it prints the matrix unmarked with a warning. An explicit `--workspace <dir>` without a `craftar.yaml` exits 1; the current directory without one just prints the matrix unmarked. `--json` is a contract: `{ingredientTypes, targets, warnings}`. |
@@ -99,7 +100,7 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
99
100
  | `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. |
100
101
  | `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`. |
101
102
 
102
- All commands but `workspaces` take `--workspace <dir>` (default: current directory); the `forge` commands, `recipes` and `ingredients` also take `--forge <dir>` as an alternative to it — a directory: a URL there is refused — and `targets` needs no Forge. With a remote Forge (see *Remote Forge*), `status`, `sync`, `diff`, `explain`, `ls`, `recipes`, `ingredients`, `add recipe`, `remove recipe`, `forge variants` and `forge diff` fetch first and take `--offline` to use the cached copy; `targets` never fetches; `forge unify` and `forge promote` refuse a workspace whose Forge is remote — clone it and pass `--forge <dir>`; `import --write-config` keeps a remote `forge:` and sets only `profile` and `targets`.
103
+ All commands but `workspaces` and `ui` take `--workspace <dir>` (default: current directory); the `forge` commands, `recipes` and `ingredients` also take `--forge <dir>` as an alternative to it — a directory: a URL there is refused — and `targets` needs no Forge. With a remote Forge (see *Remote Forge*), `status`, `sync`, `diff`, `explain`, `ls`, `recipes`, `ingredients`, `add recipe`, `remove recipe`, `forge variants` and `forge diff` fetch first and take `--offline` to use the cached copy; `targets` never fetches; `forge unify` and `forge promote` refuse a workspace whose Forge is remote — clone it and pass `--forge <dir>`; `import --write-config` keeps a remote `forge:` and sets only `profile` and `targets`.
103
104
 
104
105
  ## Forge layout
105
106
 
@@ -427,7 +428,7 @@ Copy a workspace with `.claude/` and `.kiro/` into `fixtures/` (keep `local-stac
427
428
 
428
429
  A writing `sync` records the workspace in `$CRAFTAR_HOME/registry.json` (`~/.craftar` by default): its real path, profile, the Forge it used and where that Forge lives, the resolved recipes and targets, and when `sync` last ran there. It is an index, not the memory — `craftar.lock` stays that, and nothing in a workspace changes because of it. `craftar workspaces` prints it as a table with every status computed on the spot — `up to date`, `outdated`, `drift`, `no lock`, `missing` or `error` — read without the network (a remote Forge from the cache; `--fetch` fetches first); `forget` and `prune` remove entries, and a workspace that is gone stays listed as `missing` until you prune it.
429
430
 
430
- - **`CRAFTAR_NO_REGISTRY`** — read the name as "no registry", the way `NO_COLOR` means "no colour". Set to any non-empty value, it turns the registry off: `sync` neither reads nor writes it, and `craftar workspaces` exits 1. An empty value counts as unset. Set it on CI runners and throwaway checkouts, whose directories would only pile up as `missing`.
431
+ - **`CRAFTAR_NO_REGISTRY`** — read the name as "no registry", the way `NO_COLOR` means "no colour". Set to any non-empty value, it turns the registry off: `sync` neither reads nor writes it, and `craftar workspaces` and `craftar ui` exit 1. An empty value counts as unset. Set it on CI runners and throwaway checkouts, whose directories would only pile up as `missing`.
431
432
  - **A registry that cannot be written** (an unwritable `$CRAFTAR_HOME`, a busy lock after 60 s, a file this craftar cannot read) leaves `sync`'s files, lock and exit code as they would be, and adds one warning. Craftar never overwrites a registry it cannot read: repair or remove it yourself.
432
433
  - The registry is per machine: paths are native and absolute, and SSH and HTTPS spellings of one remote are two Forges in it, as they are two cache entries.
433
434
  - **`craftar init` reads it for the Forge it offers** when it asks: the Forge of the most recent `sync` on this machine, skipping a directory that is gone. The questions never write it — the first `sync` registers the new workspace as any `sync` does. With `CRAFTAR_NO_REGISTRY` set, or a registry it cannot read, no Forge is offered and the question is asked without a default.
@@ -486,14 +487,31 @@ The server answers for the directory it is started in; a client that starts its
486
487
 
487
488
  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.
488
489
 
489
- 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; `craftar mcp` (a read-only MCP server: seven reading tools for an AI coder, over stdin and stdout). Next: cloning a profile's repositories at init.
490
+ 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; `craftar mcp` (a read-only MCP server: seven reading tools for an AI coder, over stdin and stdout); `craftar ui` (a local, read-only dashboard of the registered workspaces, on `127.0.0.1` only). Next: cloning a profile's repositories at init.
490
491
 
491
492
  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).
492
493
 
493
- Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; `craftar ui`; writing tools for `craftar mcp` (`sync`, `add recipe`) and its Claude Code / Agent Plugin packaging.
494
+ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; writing tools for `craftar mcp` (`sync`, `add recipe`) and its Claude Code / Agent Plugin packaging.
494
495
 
495
496
  ## Upgrading
496
497
 
498
+ ### to 0.30.1
499
+
500
+ - **`craftar import` no longer imports a rule's Kiro mirror a second time.** When a rule is decided as a variant (`rule/<name>--<profile>`) and its `.kiro/steering/<name>.md` has no generated banner, import used to also create `steering/<name>` and list it in `<profile>-steering`, so the profile resolved two ingredients for one file (`two ingredients write .kiro/steering/<name>.md … (last wins)`) and the file read `adopt` against the steering copy. It is now the rule's mirror only: in a workspace with no lock entry for it the file reads `collision` (`sync` skips it, and `sync --check` exits 1 until it is edited to match, or deleted and written by a `sync`), and a workspace that already synced from such a Forge sees `update` on it at the next `sync` after a re-import — the rule variant's render, banner included. A re-import into a Forge an earlier import gave the duplicate takes `steering/<name>` out of `<profile>-steering` when the workspace has other hand-written steering files, and otherwise takes `<profile>-steering` out of the profile and leaves its recipe file as it was; `ingredients/steerings/<name>/` stays either way — remove what is left by hand.
501
+ - **`craftar import --report`** no longer marks `steering bigger than the rule` on a rule `--prefer-kiro` took from that steering file; the collision row stays.
502
+ - **`craftar forge promote`** warns about a `recipes.remove` of a recipe it copied only for a workspace whose profile the copy was made for — wherever `profile` and `recipes.remove` sit, `craftar.yaml` or `craftar.local.yaml`. A workspace of another profile is no longer named.
503
+ - **`craftar drift promote`'s D19** has a second wording when the only other profile in question does not resolve (`… and profile <q> does not resolve, so promote cannot tell whether it also uses it — …; fix profile <q>, …`). It refuses exactly as before; only the text differs.
504
+ - **A malformed `integrations` block no longer echoes a key that is not shaped like a name.** A slot or a key name that fails its grammar is still named in the refusal when it looks like a name (letters, digits, `_`, `-` and `.`, at most 64 characters: `my-slot`, `api-url`) and is not a token of a known format; any other is named by its position instead — `profile <p>: integrations key #<n> is not a slot name — …`, `profile <p>: integrations.<slot> key #<n> is not a key name — …`, each ending `(not shown: not shaped like a name)` — so a URL with a user and a password pasted where a key goes is never printed. The refusal is the same, and so is its exit code.
505
+ - No exit code, `--json` key or emitted byte of a workspace whose Forge is unchanged moves.
506
+
507
+ ### to 0.30.0
508
+
509
+ - **New command: `craftar ui [--port <n>]`**, a local, read-only dashboard in a browser (see *Commands*). It writes nothing to a workspace, its lock, the registry or a Forge — the Forge cache is used as every reader uses it — reaches no network (a remote Forge is read from the cached copy), and never runs `sync`: it prints the command to copy. It takes no `--workspace`, and exits 1 when `CRAFTAR_NO_REGISTRY` is set or the registry cannot be read.
510
+ - **Nothing that exists changes**: no emitted byte, no lock, no Forge schema, no `--json` key and no exit code; `workspaces`, `doctor`, `status --json`, `diff`, `drift show` and `explain` print what they printed before — `workspaces` and `doctor` pinned by tests, the others untouched. The page's routes are not a tooling contract: only `--port`, the refusals before the server starts and the exit codes are.
511
+ - **The first craftar command that listens on a socket.** It binds `127.0.0.1` only (no `--host`; `localhost` and every other name are refused) and answers `GET` only. The default port is `4747`: a second `craftar ui` on the same machine, or another tool on that port, needs `--port` (`0` takes a free one) — a taken port is refused, never replaced.
512
+ - **Whoever holds the printed address can read what the reading commands print**: every registered workspace's absolute path, its Forge's source, its profile and file states, and the on-disk side of a file that is not in sync — `.mcp.json` and `.kiro/settings/mcp.json` included, as `craftar diff` shows them and unlike `craftar mcp`. The address stays in the terminal's scrollback, in redirected output and possibly in the browser's history, and is worth nothing once the command stops; open it only in a browser of the same user on the same machine.
513
+ - **A Forge URL the registry recorded with credentials** is shown as `(a URL with credentials — not shown)` with no cache key, as `craftar workspaces --json` gives it since `0.28.1`; the page checks every row of its table itself, and a recorded `ref` that holds a URL with credentials gets the same label there.
514
+
497
515
  ### to 0.29.0
498
516
 
499
517
  - **New command: `craftar mcp`**, a read-only MCP server over stdin and stdout, started by an AI coder (see *Commands* and *Craftar inside the AI coder*). It writes nothing to a workspace, its lock or `craftar.yaml`, the registry or the Forge — the Forge cache is used as every reader uses it — and there is no `sync` tool.
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { planPromote, applyPromote, UnheldError } from "./importers/drift-promot
9
9
  import { providedRefs } from "./importers/provided.js";
10
10
  import { holdsBaseCitations, rendersServers } from "./core/mcp-server.js";
11
11
  import { classifyForge } from "./core/remote.js";
12
- import { forget, listWorkspaces, prune, register, registryFile } from "./core/registry.js";
12
+ import { forget, listWorkspaces, prune, readRegistry, register, registryFile } from "./core/registry.js";
13
13
  import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState, countStates, countStatesObject, NEXT_SYNC_STATES } from "./core/impact.js";
14
14
  import { runDoctor } from "./core/doctor.js";
15
15
  import { launchRefusal, pinOf } from "./core/mcp-protocol.js";
@@ -42,6 +42,7 @@ import { recordedServices, pendingLine, upRefusal, composeArgv, downArgv, readPs
42
42
  import { errorText } from "./core/schema-fault.js";
43
43
  import { declaredTemplates, DOTNET_MISSING, DOTNET_MISSING_FIX, dotnetVersion, feedMessage, installReport, installTemplates, profileLabel, templateRows, unpinnedSources } from "./core/templates.js";
44
44
  import { capture, spawnInherited } from "./core/proc.js";
45
+ import { startUi } from "./ui/server.js";
45
46
  process.stdout.on("error", (e) => { if (e.code === "EPIPE")
46
47
  process.exit(0); });
47
48
  /**
@@ -51,7 +52,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
51
52
  */
52
53
  const DIFF_EXIT_CODE_FETCH_MODE = "sync";
53
54
  const program = new Command();
54
- program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.29.0");
55
+ program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.30.1");
55
56
  const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
56
57
  const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
57
58
  program
@@ -891,6 +892,54 @@ workspaces
891
892
  for (const p of gone)
892
893
  console.log(`pruned ${p}`);
893
894
  });
895
+ /* ---------------------------------------------------------------- ui */
896
+ program
897
+ .command("ui")
898
+ .description("A local dashboard: the table of `craftar workspaces` and, for one workspace, its file states, a file's diff and explanation, and its Forge's recipes and ingredients — in a browser, on 127.0.0.1 only, behind an address that works for this run. Reads only — writes no workspace, lock, registry or Forge — and reaches no network; Ctrl-C stops it")
899
+ .option("--port <n>", "the port to listen on, 0 to 65535; 0 takes a free one", "4747")
900
+ .action(async (o) => {
901
+ // Every refusal comes before the bind (spec 36 §4.2).
902
+ const port = parsePort(o.port);
903
+ if (port === null)
904
+ fail("--port takes a whole number from 0 to 65535");
905
+ registryGate();
906
+ let reg;
907
+ try {
908
+ reg = await readRegistry(craftarHome());
909
+ }
910
+ catch (e) {
911
+ fail(errorText(e));
912
+ }
913
+ const version = program.version() ?? "unknown";
914
+ let ui;
915
+ try {
916
+ // The one line a failing request leaves in the terminal; nothing is printed per request otherwise.
917
+ ui = await startUi({ home: craftarHome(), version, port, onError: (line) => console.error(line) });
918
+ }
919
+ catch (e) {
920
+ // The code only, never the message.
921
+ const code = e.code;
922
+ if (code === "EADDRINUSE")
923
+ fail(`port ${port} is in use on 127.0.0.1 — pass --port <other>, or --port 0 for a free one`);
924
+ fail(`cannot listen on 127.0.0.1:${port}: ${code ?? "unknown"}`);
925
+ }
926
+ console.log(pc.bold(`craftar ui — craftar ${version} · read-only · ${reg.workspaces.length} registered`));
927
+ console.log(` open ${ui.url}`);
928
+ console.log(" the address works for this run only, on this machine — Ctrl-C stops it");
929
+ // The first signal closes the listener and lets the process end; a second one, while closing, ends it at once.
930
+ let closing = false;
931
+ const stop = () => {
932
+ if (closing)
933
+ process.exit(0);
934
+ closing = true;
935
+ void ui.close().then(() => {
936
+ console.log("stopped");
937
+ process.exitCode = 0;
938
+ });
939
+ };
940
+ process.on("SIGINT", stop);
941
+ process.on("SIGTERM", stop);
942
+ });
894
943
  /* ---------------------------------------------------------------- doctor */
895
944
  program
896
945
  .command("doctor")
@@ -2169,14 +2218,15 @@ forge
2169
2218
  // The references the Forge cannot see (spec 29 §4.4): a workspace adding a shared recipe that names the base, removing
2170
2219
  // one this run copies for its profile, or disabling the base or the variant by name, follows the Forge at its next sync, not this promotion.
2171
2220
  const shared = [...f.recipes.values()].filter((r) => r.ingredients.includes(promotion.base) && !promotion.recipes.edited.includes(r.name)).map((r) => r.name);
2172
- const split = promotion.recipes.created.map((r) => r.from);
2173
- const byName = (doc) => {
2221
+ const byName = (doc, merged) => {
2174
2222
  const d = (typeof doc === "object" && doc !== null ? doc : {});
2223
+ const m = (typeof merged === "object" && merged !== null ? merged : {});
2224
+ const profile = typeof m.profile === "string" ? m.profile : "";
2175
2225
  const add = Array.isArray(d.recipes?.add) ? d.recipes.add : [];
2176
2226
  const remove = Array.isArray(d.recipes?.remove) ? d.recipes.remove : [];
2177
2227
  const disable = Array.isArray(d.overrides?.ingredients?.disable) ? d.overrides.ingredients.disable : [];
2178
- // A removed recipe the profile no longer names (it names the copy) stops removing anything.
2179
- return add.some((r) => shared.includes(r)) || remove.some((r) => split.includes(r)) || disable.some((r) => r === promotion.base || r === promotion.removed);
2228
+ // A removed recipe concerns this workspace only when the recipe was copied for its profile (removing one this run copies for its profile).
2229
+ return add.some((r) => shared.includes(r)) || remove.some((r) => promotion.recipes.created.some((c) => c.from === r && c.name === `${r}--${profile}`)) || disable.some((r) => r === promotion.base || r === promotion.removed);
2180
2230
  };
2181
2231
  for (const c of concerned(impactBefore, impactEntries, byName).concerned) {
2182
2232
  warnings.push(`${c.path} (${c.file}) adds or removes a recipe that names ${promotion.base}, or disables ${promotion.base} or ${promotion.removed}, by name — its next sync follows the Forge, not this promotion`);
@@ -2492,6 +2542,13 @@ function printPrune(result) {
2492
2542
  function registryOff() {
2493
2543
  return Boolean(process.env.CRAFTAR_NO_REGISTRY);
2494
2544
  }
2545
+ /** `--port` as typed: digits only, 0 to 65535 — `0x10`, `1e3`, `+80` and `80.0` are not ports. */
2546
+ function parsePort(text) {
2547
+ if (!/^\d{1,5}$/.test(text))
2548
+ return null;
2549
+ const n = Number(text);
2550
+ return n <= 65535 ? n : null;
2551
+ }
2495
2552
  function registryGate() {
2496
2553
  if (registryOff())
2497
2554
  fail("the workspace registry is off (CRAFTAR_NO_REGISTRY is set)");