craftar 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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) 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` (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. Everything else in the spec (profiles with IDP/PM-tool integrations, local services, `dotnet new` project templates, `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` (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. Everything else in the spec (profiles with IDP/PM-tool integrations, local services, `dotnet new` project templates, `craftar ui`, `craftar mcp`) is not built yet.
6
6
 
7
7
  ## The idea in one paragraph
8
8
 
@@ -91,8 +91,9 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
91
91
  | `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). |
92
92
  | `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`. |
93
93
  | `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. 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), 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`. |
94
+ | `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); 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. |
94
95
 
95
- 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` refuses 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`.
96
+ 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`.
96
97
 
97
98
  ## Forge layout
98
99
 
@@ -290,7 +291,7 @@ Copy a workspace with `.claude/` and `.kiro/` into `fixtures/` (keep `local-stac
290
291
  - **The cache.** Each machine keeps one copy per URL in `$CRAFTAR_HOME/forges/` (`~/.craftar` by default): a bare repository and one tree per resolved commit, never written by Craftar, shared by every workspace on the machine. Each tree holds the commit's bytes on every platform: the host's `core.autocrlf`, `core.eol` and global attributes file do not apply to it; the Forge's own `.gitattributes` does. Trees unused for 14 days are removed after a fetch. `craftar cache prune` removes whole entries nothing uses and old trees on demand (see *Commands*). The cache and the workspace registry (below) are the two places, both under `$CRAFTAR_HOME`, where Craftar writes outside a workspace and a Forge on its own.
291
292
  - **The network.** `sync` (with `--check` and `--dry-run`) and `diff --exit-code` fetch first and exit 1 when they cannot; `status`, `diff`, `explain`, `ls`, `recipes`, `ingredients`, `add recipe`, `remove recipe`, `forge variants` and `forge diff` fall back to the cached copy with a warning; `--offline` uses the cached copy on purpose; with no cached copy every one of them exits 1. `targets` never fetches: it reads the cached copy, and without one prints the matrix unmarked, with a warning. A full SHA already cached is not fetched again.
292
293
  - **Authentication belongs to git** (credential helper, SSH agent). A URL holding credentials (`https://user:token@…`, a password in any scheme) is refused in `craftar.yaml` and `craftar.local.yaml`, naming the file and never the value. In CI, configure git (e.g. a credential helper or an SSH deploy key), never a token in `forge:`; without a terminal, git does not prompt.
293
- - **Writing into the Forge** stays local: `import` and `forge unify` need a clone passed as `--forge <dir>`; Craftar never pushes. After importing into the clone, push it for `sync` to see the import.
294
+ - **Writing into the Forge** stays local: `import`, `forge unify` and `forge promote` need a clone passed as `--forge <dir>`; Craftar never pushes. After importing into the clone, push it for `sync` to see the import.
294
295
  - **`craftar.local.yaml`** may override `forge:` (a local clone while you work on the Forge); every planning command then warns not to commit `craftar.lock` or the generated files.
295
296
 
296
297
  ## Workspace registry
@@ -301,18 +302,23 @@ A writing `sync` records the workspace in `$CRAFTAR_HOME/registry.json` (`~/.cra
301
302
  - **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.
302
303
  - 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.
303
304
  - **`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.
304
- - **The registry also tells a Forge its workspaces**: `forge impact` and `forge unify` read it (never write it); a workspace synced on CI, another machine or under `CRAFTAR_NO_REGISTRY` is not known to them, which is why `--prune-recipes` says so.
305
+ - **The registry also tells a Forge its workspaces**: `forge impact`, `forge unify` and `forge promote` read it (never write it); a workspace synced on CI, another machine or under `CRAFTAR_NO_REGISTRY` is not known to them, which is why `--prune-recipes` says so.
305
306
 
306
307
  ## Roadmap (from the functional spec)
307
308
 
308
309
  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.
309
310
 
310
- 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` and `forge unify --prune-recipes`; 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). Next: cloning a profile's repositories at init; profile-driven integrations (PM tool → MCP, IDP → MCP).
311
+ 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). Next: cloning a profile's repositories at init; profile-driven integrations (PM tool → MCP, IDP → MCP).
311
312
 
312
313
  Later: `service` ingredients with compose fragments (`craftar services up`); `dotnet new` template registration; rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; `craftar ui`; `craftar mcp` + Claude Code / Agent Plugin packaging.
313
314
 
314
315
  ## Upgrading
315
316
 
317
+ ### to 0.24.0
318
+
319
+ - **`craftar forge promote <type/name> --profile <p>` is new**: it makes a profile's variant the base, and gives the old base back to every other profile that resolved it as a variant of its own. `forge promote --json` is a new tooling contract. Nothing to do for a Forge or a workspace that does not run it.
320
+ - **No byte change**: no emitter, no import decision and no lock changed; `forge unify` prints what it printed. No workspace sees `update`.
321
+
316
322
  ### to 0.23.0
317
323
 
318
324
  - **`craftar import --prefer-kiro <rule,…>` is new** and additive: without the flag, `import` reads, decides, writes and prints what it did before. Nothing to do.
package/dist/cli.js CHANGED
@@ -22,7 +22,8 @@ import { preferredLine, renderImportReport } from "./core/import-report.js";
22
22
  import { driftList, unmanaged } from "./core/drift.js";
23
23
  import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
24
24
  import { hashNormalized, toLf, stripBom } from "./core/text.js";
25
- import { exists, gitDirty, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
25
+ import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
26
+ import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./core/promote.js";
26
27
  import { fingerprintDir } from "./core/fingerprint.js";
27
28
  import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, pruneCandidates, pruneRecipes, note, } from "./core/unify.js";
28
29
  import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
@@ -44,7 +45,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
44
45
  */
45
46
  const DIFF_EXIT_CODE_FETCH_MODE = "sync";
46
47
  const program = new Command();
47
- program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.23.0");
48
+ program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.24.0");
48
49
  const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
49
50
  const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
50
51
  program
@@ -1955,24 +1956,7 @@ forge
1955
1956
  console.log(` ${pc.yellow("warn")} ${w}`);
1956
1957
  // Spec 25 §4.2: impact lines, one per workspace, with suffix format
1957
1958
  for (let i = 0; i < impactResults.length; i++) {
1958
- const ws = impactEntries[i];
1959
- const r = impactResults[i];
1960
- let stateStr;
1961
- if (r.state === "no-effect") {
1962
- stateStr = "no effect";
1963
- }
1964
- else if (r.state === "changed") {
1965
- const n = r.files.length;
1966
- stateStr = n === 1 ? `1 file changes — ${r.files[0]}` : `${n} files change — ${r.files.join(", ")}`;
1967
- }
1968
- else if (r.state === "missing") {
1969
- stateStr = "missing";
1970
- }
1971
- else {
1972
- stateStr = `error: ${r.error}`;
1973
- }
1974
- const impactSuffix = matchSuffix(ws);
1975
- console.log(` impact: ${ws.entry.path} (${ws.entry.profile}) ${stateStr}${impactSuffix}`);
1959
+ console.log(` ${impactLine(impactEntries[i], impactResults[i])}`);
1976
1960
  }
1977
1961
  // Spec 25 §4.2: the next: hint is printed only when no workspace was checked
1978
1962
  // (every entry missing/stage-config error, or no entries, or --no-impact, or unreadable)
@@ -1981,6 +1965,150 @@ forge
1981
1965
  console.log(` next: run \`craftar status --workspace <dir>\` in a workspace on profile ${o.profile} to see what moved`);
1982
1966
  }
1983
1967
  });
1968
+ forge
1969
+ .command("promote")
1970
+ .description("Make a profile's variant the base: the base takes the variant's files, every other profile that resolves the old base gets it back as its own variant through a recipe it owns, and every profile is proved to resolve the same ingredients before anything is written. Writes to the Forge")
1971
+ .argument("<type/name>", "base ingredient (rule/workflow)")
1972
+ .requiredOption("--profile <p>", "the profile whose variant becomes the base")
1973
+ .option("--forge <dir>", "Forge directory (instead of --workspace)")
1974
+ .option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
1975
+ .option("--no-impact", "skip planning the registered workspaces of this Forge before and after the writes")
1976
+ .option("--dry-run", "run every check and the first impact pass, print what would be written, and write nothing", false)
1977
+ .option("--json", "machine-readable output", false)
1978
+ .action(async (ref, o) => {
1979
+ // Never through the cache, as `forge unify` (spec 13 §4.4).
1980
+ const f = await resolveForge({
1981
+ forge: o.forge,
1982
+ workspace: o.workspace,
1983
+ refuseRemote: (url) => `the Forge of this workspace is remote (${url}) — clone it and pass --forge <dir>`,
1984
+ });
1985
+ // P2 (spec 29 §4.4): git is the undo, so it must be there, hold a commit and be clean — before anything is planned.
1986
+ if (f.commit === null) {
1987
+ if (await gitIsRepo(f.root))
1988
+ fail(`${f.root} has no commits yet — forge promote writes to the Forge and needs git as the undo`);
1989
+ fail(`${f.root} is not a git repository — forge promote writes to the Forge and needs git as the undo`);
1990
+ }
1991
+ if (await gitDirty(f.root))
1992
+ fail(`${f.root} is not a clean git checkout — commit or stash your changes first`);
1993
+ const promotion = await planBasePromotion(f, ref, o.profile);
1994
+ const forgeRel = (abs) => path.relative(f.root, abs).split(path.sep).join("/");
1995
+ // Every path overwritten or deleted must be held by git; a path created must not be one git ignores, or `git clean` would not undo it.
1996
+ const unheld = await gitUnheld(f.root, promotion.mustHold);
1997
+ for (const w of promotion.writes) {
1998
+ if (w.created && (await gitIgnored(f.root, forgeRel(w.abs))))
1999
+ unheld.push({ path: forgeRel(w.abs), reason: "ignored" });
2000
+ }
2001
+ if (unheld.length)
2002
+ fail(unheldMessage("forge promote", f.root, unheld));
2003
+ await proveBasePromotion(f, promotion);
2004
+ let impactState = "skipped";
2005
+ let impactEntries = [];
2006
+ let impactBefore = [];
2007
+ const warnings = [...promotion.warnings];
2008
+ if (o.impact) {
2009
+ try {
2010
+ const fw = await forgeWorkspaces(craftarHome(), f.root);
2011
+ impactEntries = fw.workspaces;
2012
+ warnings.push(...fw.warnings);
2013
+ impactBefore = await planAll(fw.workspaces, f);
2014
+ impactState = refineRegistryState(fw.state, impactBefore);
2015
+ }
2016
+ catch (e) {
2017
+ impactState = "unreadable";
2018
+ warnings.push(`the registry could not be read (${e instanceof Error ? e.message : String(e)}) — no workspace was checked`);
2019
+ }
2020
+ if (impactState === "off")
2021
+ warnings.push("the registry is off — no workspace was checked");
2022
+ }
2023
+ // The references the Forge cannot see (spec 29 §4.4): a workspace adding a shared recipe that names the base, removing
2024
+ // 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.
2025
+ const shared = [...f.recipes.values()].filter((r) => r.ingredients.includes(promotion.base) && !promotion.recipes.edited.includes(r.name)).map((r) => r.name);
2026
+ const split = promotion.recipes.created.map((r) => r.from);
2027
+ const byName = (doc) => {
2028
+ const d = (typeof doc === "object" && doc !== null ? doc : {});
2029
+ const add = Array.isArray(d.recipes?.add) ? d.recipes.add : [];
2030
+ const remove = Array.isArray(d.recipes?.remove) ? d.recipes.remove : [];
2031
+ const disable = Array.isArray(d.overrides?.ingredients?.disable) ? d.overrides.ingredients.disable : [];
2032
+ // A removed recipe the profile no longer names (it names the copy) stops removing anything.
2033
+ return add.some((r) => shared.includes(r)) || remove.some((r) => split.includes(r)) || disable.some((r) => r === promotion.base || r === promotion.removed);
2034
+ };
2035
+ for (const c of concerned(impactBefore, impactEntries, byName).concerned) {
2036
+ 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`);
2037
+ }
2038
+ const journal = [];
2039
+ if (!o.dryRun) {
2040
+ try {
2041
+ await applyBasePromotion(promotion, journal);
2042
+ }
2043
+ catch (e) {
2044
+ // After writing began: name what was touched and how git undoes it, and still exit 1.
2045
+ fail(lateFailure(e, f.root, journal, "forge promote"));
2046
+ }
2047
+ }
2048
+ const checked = impactState !== "skipped" && impactState !== "unreadable" && impactState !== "off";
2049
+ let impactResults = [];
2050
+ if (!o.dryRun && checked && impactEntries.length > 0) {
2051
+ let impactAfter;
2052
+ try {
2053
+ impactAfter = await planAll(impactEntries, await loadForge(f.root));
2054
+ }
2055
+ catch (e) {
2056
+ const message = e instanceof Error ? e.message : String(e);
2057
+ impactAfter = impactEntries.map(() => ({ kind: "error", stage: "plan", message }));
2058
+ }
2059
+ impactResults = impactEntries.map((_, i) => impactOf(impactBefore[i], impactAfter[i]));
2060
+ }
2061
+ if (o.json) {
2062
+ return console.log(JSON.stringify({
2063
+ base: promotion.base,
2064
+ profile: promotion.profile,
2065
+ dryRun: o.dryRun,
2066
+ removed: promotion.removed,
2067
+ // Every key is always present, `[]` when empty.
2068
+ created: promotion.created,
2069
+ recipes: promotion.recipes,
2070
+ profiles: promotion.profiles,
2071
+ impact: impactResults.map((r, i) => ({
2072
+ path: impactEntries[i].entry.path,
2073
+ profile: impactEntries[i].entry.profile,
2074
+ match: impactEntries[i].match,
2075
+ via: impactEntries[i].via,
2076
+ ref: impactEntries[i].ref,
2077
+ state: r.state,
2078
+ files: r.files,
2079
+ error: r.error,
2080
+ })),
2081
+ warnings,
2082
+ }, null, 2));
2083
+ }
2084
+ const n = (count) => `${count} workspace(s)`;
2085
+ console.log(pc.bold(`craftar forge promote ${promotion.base} ← ${promotion.profile}${o.dryRun ? " (dry run — nothing written)" : ""}`));
2086
+ console.log(` base ${promotion.base} ${o.dryRun ? "would hold" : "now holds"} ${promotion.profile}'s text (was ${promotion.removed})`);
2087
+ for (const c of promotion.created)
2088
+ console.log(` variant ${c} ${o.dryRun ? "would be created" : "created"} (the old base)`);
2089
+ if (promotion.discarded)
2090
+ console.log(` the old base is resolved by no profile — discarded (git holds it)`);
2091
+ const recipeLines = [...promotion.detail.recipes.filter((r) => r.from === null), ...promotion.detail.recipes.filter((r) => r.from !== null)];
2092
+ for (const r of recipeLines)
2093
+ console.log(` recipe ${r.name}${r.from === null ? "" : ` ${o.dryRun ? "would be created" : "created"} from ${r.from}`}: ${r.old} → ${r.new}`);
2094
+ for (const p of promotion.detail.profiles)
2095
+ console.log(` profile ${p.file}: ${p.swaps.map((s) => `${s.old} → ${s.new}`).join(", ")}`);
2096
+ for (const w of warnings)
2097
+ console.log(` ${pc.yellow("warn")} ${w}`);
2098
+ if (checked) {
2099
+ if (impactEntries.length === 0)
2100
+ console.log(` impact no workspace of this Forge is registered on this machine`);
2101
+ else if (o.dryRun)
2102
+ console.log(` impact ${n(impactEntries.length)} planned: not written, not compared`);
2103
+ else if (impactResults.every((r) => r.state === "no-effect"))
2104
+ console.log(` impact ${n(impactResults.length)} planned before and after: no effect`);
2105
+ else
2106
+ for (let i = 0; i < impactResults.length; i++)
2107
+ console.log(` ${impactLine(impactEntries[i], impactResults[i])}`);
2108
+ }
2109
+ if (promotion.created.length)
2110
+ console.log(` next craftar forge variants — the old base now shows as ${promotion.created.length} variant(s) to unify`);
2111
+ });
1984
2112
  /* ---------------------------------------------------------------- cache ---------------------------------------------------------------- */
1985
2113
  const cache = program.command("cache").description("The per-machine Forge cache in $CRAFTAR_HOME/forges/");
1986
2114
  cache
@@ -2146,6 +2274,19 @@ function warnStderr(warnings) {
2146
2274
  for (const w of warnings)
2147
2275
  console.error(`${pc.yellow("warn")} ${w}`);
2148
2276
  }
2277
+ /** One workspace's line after a Forge write was planned before and after (spec 25 §4.2) — `forge unify` and `forge promote` print the same. */
2278
+ function impactLine(ws, r) {
2279
+ let state;
2280
+ if (r.state === "no-effect")
2281
+ state = "no effect";
2282
+ else if (r.state === "changed")
2283
+ state = r.files.length === 1 ? `1 file changes — ${r.files[0]}` : `${r.files.length} files change — ${r.files.join(", ")}`;
2284
+ else if (r.state === "missing")
2285
+ state = "missing";
2286
+ else
2287
+ state = `error: ${r.error}`;
2288
+ return `impact: ${ws.entry.path} (${ws.entry.profile}) ${state}${matchSuffix(ws)}`;
2289
+ }
2149
2290
  /**
2150
2291
  * Spec 25 §4.1–§4.2: the suffix format for a workspace that is not a path match.
2151
2292
  * Used by both `forge impact` and `forge unify` so the format is shared.