[v0.1/P0] Define immutable build identity and offline version output #17

Closed
opened 2026-09-03 16:18:01 +09:00 by natsukium · 2 comments
Owner

Parent: #12 (P0.3). Supersedes the release-identity part of #9.

Why

The current --version reports hashes without semver and launches the client and contacts a daemon. There is no typed identity shared by the three processes, and dirty builds are not distinguishable.

Scope

  • Define BuildIdentity { version, revision, dirty }.
  • Use it in Welcome, daemon status, client diagnostics, and machine output.
  • Render semver (revision[-dirty]) for humans.
  • Make felis --version report only the front-door build with no subprocess or network I/O.
  • Put explicit front-door/client/daemon comparison in felis version --format human|json.
  • Detect dirty worktrees in builds and reject dirty release builds.

Acceptance criteria

  • felis --version succeeds offline and cannot autospawn or dial.
  • Human and machine forms contain semver, revision, and dirty state.
  • Cross-process diagnostics use one typed shape rather than scraped text.
  • Clean and dirty build identities have deterministic tests.
  • Workspace/versioning, CLI, IPC, and changelog docs are updated together.
Parent: #12 (P0.3). Supersedes the release-identity part of #9. ## Why The current `--version` reports hashes without semver and launches the client and contacts a daemon. There is no typed identity shared by the three processes, and dirty builds are not distinguishable. ## Scope - Define `BuildIdentity { version, revision, dirty }`. - Use it in `Welcome`, daemon status, client diagnostics, and machine output. - Render `semver (revision[-dirty])` for humans. - Make `felis --version` report only the front-door build with no subprocess or network I/O. - Put explicit front-door/client/daemon comparison in `felis version --format human|json`. - Detect dirty worktrees in builds and reject dirty release builds. ## Acceptance criteria - [ ] `felis --version` succeeds offline and cannot autospawn or dial. - [ ] Human and machine forms contain semver, revision, and dirty state. - [ ] Cross-process diagnostics use one typed shape rather than scraped text. - [ ] Clean and dirty build identities have deterministic tests. - [ ] Workspace/versioning, CLI, IPC, and changelog docs are updated together.
Author
Owner

CLI args focused review (#55) で追加確認。

現状は felis --version sessions listfelis --version -- htop が parse に成功し、後続 command/argv を無視して exit 0 の version report を返す。offline/self-only 化に加え、標準 --version と同様に exclusive であることを acceptance に含めたい。既知 subcommand を silent ignore する形は typo を隠す。

CLI args focused review (#55) で追加確認。 現状は `felis --version sessions list` と `felis --version -- htop` が parse に成功し、後続 command/argv を無視して exit 0 の version report を返す。offline/self-only 化に加え、標準 `--version` と同様に exclusive であることを acceptance に含めたい。既知 subcommand を silent ignore する形は typo を隠す。
Author
Owner

Triage plan (2026-09-03)

Source-grounded triage against main at 69076d42, reviewed through seven rounds of an independent reviewer (pi sol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. Where a "Review amendments" section below conflicts with an earlier section, the amendment is the decision.

Claim check

Every claim in the issue body and in the #55 comment holds at HEAD (69076d42).

--version reports hashes without semver. crates/felis-cli/src/cli_version.rs:18-22 prints three lines of the form cli (hash); the only place a semver appears is the client's and daemon's own clap --version (crates/felis-client/src/main.rs:241, crates/felis-daemon/src/lib.rs:46-48), which the front door never shows. Live run of target/debug/felis --version gives cli (69076d42) / client (5077d74b) / daemon (b2a2ddf) — note the hash width already differs between processes (8 vs 7 chars) because build-common.rs:27-28 uses git rev-parse --short, whose width depends on the local object store. Nothing sourced from the identity is stable enough to compare.

It launches the client and contacts a daemon. cli_version.rs:26-36 runs felis-client --version as a subprocess and scrapes the parenthesised hash (paren_hash, :38-42); cli_version.rs:46-78 builds a tokio runtime and dials the daemon through crate::conn::dial (crates/felis-cli/src/conn.rs:103-105). The local carrier never autospawns from that path (RemoteSpawn only governs the SSH relay; crates/felis-client-core/src/connector.rs:436-446, 455-466), so the issue's "cannot autospawn" criterion is already met locally — verified: felis --version --socket /nonexistent/x.sock prints daemon (not running), exit 0. But with --host the dial spawns ssh <host> felis-daemon relay --no-spawn (connector.rs:237-245, 462-467), which is network I/O. docs/reference/cli.md:767-768 documents --host/--socket as selecting which daemon --version asks, so the docs agree with the code and both must change.

No typed identity shared by the three processes. The wire carries two untyped strings: ConnWelcome.build_id (crates/felis-protocol/proto/felis.proto:655-659, populated from felis_daemon::build_hash() at crates/felis-daemon/src/serve.rs:1930) and OpsStatusReply.version as "<semver> (<hash>)" (felis.proto:1369-1371). Consumers: cli_doctor.rs:128-141 (prints conn.daemon_build_id inside a sentence), cli_daemon.rs:73-75,125 (echoes the string), and the bench harness scripts/bench/envinfo.py:195-206 which regex-scrapes felis --version (\(([0-9a-f]{7,40})\)) — a third scraper the issue does not mention but which the change breaks.

Dirty builds are not distinguishable — partially wrong. The Cargo path (crates/build-common.rs:5-33) has no dirty detection at all. The Nix flake path already stamps -dirty: flake.nix:251 passes inputs.self.shortRev or inputs.self.dirtyShortRev, and nix/package.nix:32,78 forwards it as FELIS_GIT_HASH. So nix build of a dirty tree yields (abc1234-dirty); cargo build of the same tree yields (abc1234). Two further stamping gaps: the overlay (flake.nix:530) and the Home Manager module default package (nix/hm-module.nix:95) call package.nix with no gitHash, so those install paths report (unknown).

#55 comment: --version is not exclusive. Verified live: felis --version sessions list and felis --version -- htop both print the report and exit 0. Cause: version is a plain bool arg (crates/felis-cli/src/main.rs:294-295) checked before the subcommand match (main.rs:347-351); no conflicts_with on cmd/command.

Nothing here was changed by the three post-snapshot commits (they touched font.size_px and the spawn separator only).

Verdict

accept-with-changes. The direction is right; three scope adjustments:

  1. Split the two audiences into two surfaces, as the issue implies but does not spell out. felis --version becomes the clap-standard self-report of the front-door binary only (offline, exclusive). The cross-process comparison moves to a new felis version verb (--format human|json), which is the only place that may exec felis-client or dial. Today's aggregate --version disappears; do not keep a "report if reachable" middle ground.
  2. Derive the wire shape from #30 before adding fields. Because #30 resets the public baseline to protocol 2.0 and drops the minor ledger, add BuildIdentity to Welcome/StatusReply as replacements for build_id/version on the 2.0 schema rather than as ledger row 6 beside them. If #17 lands before #30, add them additively (row 6) and let #30 collapse the pair. Either way the recommended order in #12 (#19 and #45 before #17) still holds because #19's compatibility gate is what makes the proto edit reviewable.
  3. Do not make felis-protocol own display formatting for the CLI, but do make it own the type. BuildIdentity is pure data (String, String, bool) and belongs in felis-protocol (the everyone-depends-on-it crate; no tokio/OS use, so the purity rule at CLAUDE.md "Hard rule" is respected). Its Display/FromStr canonical form is the one thing every process shares, and that is the "typed shape rather than scraped text" the acceptance criterion asks for — the front door parses felis-client --version's canonical line with the same FromStr, round-trip tested, instead of paren_hash.

Principle check: no capability is added (principle 1 test "no real consumer needs it yet" — the consumers are doctor, the bench harness, and #18's release gate); explicitness improves (principle 4). Pass.

Approach

felis-protocol (crates/felis-protocol)

  • New module src/build_identity.rs: pub struct BuildIdentity { pub version: String, pub revision: String, pub dirty: bool } with Display rendering <version> (<revision>[-dirty]) and FromStr accepting exactly that (revision = unknown or 7..40 lowercase hex; reject anything else). Keep the hash full (40 hex) in the type; Display abbreviates to a fixed 12 so the width no longer floats with the object store. Proptest round-trip Display → FromStr (independent oracle: build the string by hand, per the extend-ipc skill's "never production against itself").
  • proto/felis.proto: message BuildIdentity { string version = 1; string revision = 2; bool dirty = 3; }; add BuildIdentity identity to ConnWelcome and OpsStatusReply (field numbers per #30's outcome, see Verdict 2). just proto, commit src/generated/, convert/conn.rs:75,118 and the ops convert arm, plus the fixtures at messages/conn.rs:112-115, codec.rs:252, felis-grid/src/transcode.rs:183, benches/ipc_throughput.rs:43.

Build stamping (crates/build-common.rs, the four build.rs includes)

  • Emit three rustc-envs: FELIS_BUILD_VERSION (= CARGO_PKG_VERSION, kept for symmetry), FELIS_BUILD_REVISION (full git rev-parse HEAD), FELIS_BUILD_DIRTY (git status --porcelain --untracked-files=no non-empty). Keep accepting the single FELIS_GIT_HASH=<hex>[-dirty] env as the Nix injection form so nix/package.nix:78 and flake.nix:251 need only switch from shortRev/dirtyShortRev to rev/dirtyRev (full hash). Add rerun-if-changed=../../.git/index so a staged change re-stamps; note in the comment that an unstaged edit will not (the exact-identity guarantee is the Nix build's, not cargo's).
  • Fix the two (unknown) paths: flake.nix:530 overlay and nix/hm-module.nix:95 should take the same gitHash (thread self through, e.g. packages.felis reused via inputs.self.packages.${system}.felis for the HM default, and the overlay documented as "unknown unless you pass gitHash").
  • Each binary exposes pub const fn build_identity() -> BuildIdentity-equivalent (a BuildIdentity::from_env_consts(env!(...)) helper); felis_daemon::version() (lib.rs:46-48) and felis-client's FELIS_VERSION (main.rs:241) render through Display so the three binaries' --version are byte-identical in shape.

felis-cli (crates/felis-cli)

  • main.rs:294-295: replace the bool with clap's built-in #[command(version = <const rendered identity>)] so -V/--version is exclusive by clap's own rules and prints felis <semver> (<rev12>[-dirty]), no subprocess, no runtime, no dial. Add a test that felis --version sessions list and felis --version -- htop exit 2 with a usage error (exit-code contract at docs/reference/cli.md:44-50). If clap's Version action turns out to short-circuit before seeing the trailing tokens, keep the bool and emit Cli::command().error(ErrorKind::ArgumentConflict, ..) when cmd.is_some() || !command.is_empty(); either way the test pins the contract.
  • New Cmd::Version { #[command(flatten)] format: PointFormat } in cli_version.rs: human output is three aligned rows (cli, client, daemon) each rendering a BuildIdentity or one of the status words already defined (not running, unavailable, incompatible: … at cli_version.rs:44-78); JSON is the standard {"v":1, ...} envelope with {"cli": {version,revision,dirty}, "client": {…}|null, "daemon": {…}|null, "daemon_status": "…"}. Client identity comes from felis-client --version parsed with BuildIdentity::from_str; daemon identity from Welcome.identity. This verb is the only one allowed the subprocess and the dial; keep the Refuse semantics and cold-socket tests (cli_version.rs:108-215) as they are, retargeted at the verb.
  • cli_doctor.rs:128-141 and cli_daemon.rs:73-75,125: render the typed identity; felis daemon status --format json gains identity beside the existing version string (or replaces it under #30).
  • Completions/man: automatic from clap; nothing hand-maintained (nix/package.nix:123-134).

Release-build rejection of dirty trees

Belongs to #18's workflow, not to the build script: the tag job evaluates nix eval .#felis.gitHash/self.dirtyRev and fails on -dirty, then asserts the built binary's felis --version reports dirty=false and the tag's revision. This issue only has to make the fact observable; document the split in both issues.

Other consumers

  • scripts/bench/envinfo.py:195-230 (paren_hash, revision_drift) and crossterm_test.py:615-635: parse the new canonical line (felis <semver> (<rev12>[-dirty])); revision_drift can then use the dirty flag instead of a suffix heuristic.

Docs cascade (doc-cascade skill)

  • Reference: docs/reference/cli.md:744-787 "Version reporting" (rewrite: --version self-report + felis version verb + JSON shape), docs/reference/ipc.md:516-522 (Welcome), :1738-1752 (ledger row or 2.0 baseline), docs/reference/workspace.md:157-182 "Versioning" (add the build-identity axis: semver + revision + dirty; how each build path stamps it), docs/reference/testing.md:867 (bench meta wording).
  • Explanation: docs/explanation/architecture/ipc.md:908-912 (Welcome.build_id → identity; still no gate on it), docs/explanation/terminal-identity.md:171-189 (why --version carries the hash and TERM_PROGRAM_VERSION does not — unchanged argument, new wording), docs/explanation/architecture/control-surfaces.md:21.
  • CHANGELOG.md Unreleased: removed aggregate --version, added felis version, changed Welcome/status shapes.
  • skills/felis/SKILL.md: add felis version --format json under the health section (:213-261), since it is the verb an agent should use to detect a stale daemon.

Tests that prove it

  • felis-protocol: Display/FromStr unit + proptest; proto convert round-trips for identity.
  • felis-cli integration (crates/felis-cli/tests/): --version offline on a cold --socket path exits 0 within the process (no socket created, no felis-client on PATH needed — run with an empty PATH), exclusivity → exit 2, felis version --format json against the fake-daemon harness (clean and dirty identities, not running, major-refusal).
  • A deterministic clean vs dirty test: set FELIS_GIT_HASH=abc…-dirty when building a fixture? Build-script envs are not settable per-test; instead test BuildIdentity::from_env_consts("0.1.0", "abc…", "-dirty" form) with both shapes.

Dependencies

  • #19 first (tracker order 3 before 4): the proto edit needs the real compatibility gate in place.
  • Decide #30 (baseline reset to 2.0) before choosing field numbers and whether build_id/version strings are dropped or kept.
  • #23 (machine contract) should agree on the JSON envelope for the new verb; if #23 lands later it must not re-shape felis version output.
  • #18 consumes the identity (tag/revision/dirty assertions). #17 blocks #18.
  • Bench harness (scripts/bench) must be updated in the same change or just bench* breaks.

Risk/effort

M. Touches four crates, the proto schema, build scripts, three Nix entry points, and the bench harness. Main risks: (1) clap's --version exclusivity semantics (covered by the test); (2) stale-stamp on unstaged edits under cargo (documented, not solved — the release path is Nix); (3) forgetting one of the (unknown) Nix paths, which would make a Home Manager install report an unknown identity right after the release that introduces the concept.

Labels

Keep priority/P0, release/v0.1.0. The exclusivity acceptance from #55's comment should be added to the issue body's checklist. Add a note that "reject dirty release builds" is delivered by #18's workflow, with #17 only making dirty observable, so the two issues do not double-implement it.

Review amendments (round 1)

  • Canonical form is the full revision; abbreviation is human-only. The one canonical line every process emits and parses is <binary> <semver> (<rev40>[-dirty]) with the full 40-hex revision (or unknown). FromStr accepts only that canonical form. felis-client --version prints the canonical line; felis version --format json carries the full revision; only human renderings (felis --version, felis version human table) abbreviate to 12 hex. #31's comparison against git rev-parse HEAD reads the JSON (full) form. There is no claim that the abbreviated rendering round-trips.
  • No const fn producing owned strings. BuildIdentity holds Cow<'static, str> fields (or String with a plain runtime constructor BuildIdentity::from_build_env(version: &'static str, revision: &'static str, dirty: bool)); each binary builds it once at startup from the env! constants.
## Triage plan (2026-09-03) Source-grounded triage against `main` at `69076d42`, reviewed through seven rounds of an independent reviewer (`pi` sol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. Where a "Review amendments" section below conflicts with an earlier section, the amendment is the decision. ## Claim check Every claim in the issue body and in the #55 comment holds at HEAD (`69076d42`). **`--version` reports hashes without semver.** `crates/felis-cli/src/cli_version.rs:18-22` prints three lines of the form `cli (hash)`; the only place a semver appears is the client's and daemon's own clap `--version` (`crates/felis-client/src/main.rs:241`, `crates/felis-daemon/src/lib.rs:46-48`), which the front door never shows. Live run of `target/debug/felis --version` gives `cli (69076d42)` / `client (5077d74b)` / `daemon (b2a2ddf)` — note the hash width already differs between processes (8 vs 7 chars) because `build-common.rs:27-28` uses `git rev-parse --short`, whose width depends on the local object store. Nothing sourced from the identity is stable enough to compare. **It launches the client and contacts a daemon.** `cli_version.rs:26-36` runs `felis-client --version` as a subprocess and scrapes the parenthesised hash (`paren_hash`, `:38-42`); `cli_version.rs:46-78` builds a tokio runtime and dials the daemon through `crate::conn::dial` (`crates/felis-cli/src/conn.rs:103-105`). The local carrier never autospawns from that path (`RemoteSpawn` only governs the SSH relay; `crates/felis-client-core/src/connector.rs:436-446`, `455-466`), so the issue's "cannot autospawn" criterion is *already* met locally — verified: `felis --version --socket /nonexistent/x.sock` prints `daemon (not running)`, exit 0. But with `--host` the dial spawns `ssh <host> felis-daemon relay --no-spawn` (`connector.rs:237-245`, `462-467`), which is network I/O. `docs/reference/cli.md:767-768` documents `--host`/`--socket` as selecting which daemon `--version` asks, so the docs agree with the code and both must change. **No typed identity shared by the three processes.** The wire carries two untyped strings: `ConnWelcome.build_id` (`crates/felis-protocol/proto/felis.proto:655-659`, populated from `felis_daemon::build_hash()` at `crates/felis-daemon/src/serve.rs:1930`) and `OpsStatusReply.version` as `"<semver> (<hash>)"` (`felis.proto:1369-1371`). Consumers: `cli_doctor.rs:128-141` (prints `conn.daemon_build_id` inside a sentence), `cli_daemon.rs:73-75,125` (echoes the string), and the bench harness `scripts/bench/envinfo.py:195-206` which regex-scrapes `felis --version` (`\(([0-9a-f]{7,40})\)`) — a third scraper the issue does not mention but which the change breaks. **Dirty builds are not distinguishable — partially wrong.** The Cargo path (`crates/build-common.rs:5-33`) has no dirty detection at all. The Nix flake path already stamps `-dirty`: `flake.nix:251` passes `inputs.self.shortRev or inputs.self.dirtyShortRev`, and `nix/package.nix:32,78` forwards it as `FELIS_GIT_HASH`. So `nix build` of a dirty tree yields `(abc1234-dirty)`; `cargo build` of the same tree yields `(abc1234)`. Two further stamping gaps: the overlay (`flake.nix:530`) and the Home Manager module default package (`nix/hm-module.nix:95`) call `package.nix` with no `gitHash`, so those install paths report `(unknown)`. **#55 comment: `--version` is not exclusive.** Verified live: `felis --version sessions list` and `felis --version -- htop` both print the report and exit 0. Cause: `version` is a plain `bool` arg (`crates/felis-cli/src/main.rs:294-295`) checked before the subcommand match (`main.rs:347-351`); no `conflicts_with` on `cmd`/`command`. Nothing here was changed by the three post-snapshot commits (they touched `font.size_px` and the spawn separator only). ## Verdict **accept-with-changes.** The direction is right; three scope adjustments: 1. **Split the two audiences into two surfaces, as the issue implies but does not spell out.** `felis --version` becomes the clap-standard self-report of the front-door binary only (offline, exclusive). The cross-process comparison moves to a new `felis version` verb (`--format human|json`), which is the only place that may exec `felis-client` or dial. Today's aggregate `--version` disappears; do not keep a "report if reachable" middle ground. 2. **Derive the wire shape from #30 before adding fields.** Because #30 resets the public baseline to protocol 2.0 and drops the minor ledger, add `BuildIdentity` to `Welcome`/`StatusReply` as *replacements* for `build_id`/`version` on the 2.0 schema rather than as ledger row 6 beside them. If #17 lands before #30, add them additively (row 6) and let #30 collapse the pair. Either way the recommended order in #12 (#19 and #45 before #17) still holds because #19's compatibility gate is what makes the proto edit reviewable. 3. **Do not make felis-protocol own display formatting for the CLI, but do make it own the type.** `BuildIdentity` is pure data (`String`, `String`, `bool`) and belongs in `felis-protocol` (the everyone-depends-on-it crate; no tokio/OS use, so the purity rule at `CLAUDE.md` "Hard rule" is respected). Its `Display`/`FromStr` canonical form is the one thing every process shares, and that is the "typed shape rather than scraped text" the acceptance criterion asks for — the front door parses `felis-client --version`'s canonical line with the same `FromStr`, round-trip tested, instead of `paren_hash`. Principle check: no capability is added (principle 1 test "no real consumer needs it yet" — the consumers are `doctor`, the bench harness, and #18's release gate); explicitness improves (principle 4). Pass. ## Approach ### felis-protocol (`crates/felis-protocol`) - New module `src/build_identity.rs`: `pub struct BuildIdentity { pub version: String, pub revision: String, pub dirty: bool }` with `Display` rendering `<version> (<revision>[-dirty])` and `FromStr` accepting exactly that (revision = `unknown` or 7..40 lowercase hex; reject anything else). Keep the hash *full* (40 hex) in the type; `Display` abbreviates to a fixed 12 so the width no longer floats with the object store. Proptest round-trip `Display → FromStr` (independent oracle: build the string by hand, per the extend-ipc skill's "never production against itself"). - `proto/felis.proto`: `message BuildIdentity { string version = 1; string revision = 2; bool dirty = 3; }`; add `BuildIdentity identity` to `ConnWelcome` and `OpsStatusReply` (field numbers per #30's outcome, see Verdict 2). `just proto`, commit `src/generated/`, `convert/conn.rs:75,118` and the ops convert arm, plus the fixtures at `messages/conn.rs:112-115`, `codec.rs:252`, `felis-grid/src/transcode.rs:183`, `benches/ipc_throughput.rs:43`. ### Build stamping (`crates/build-common.rs`, the four `build.rs` includes) - Emit three rustc-envs: `FELIS_BUILD_VERSION` (= `CARGO_PKG_VERSION`, kept for symmetry), `FELIS_BUILD_REVISION` (full `git rev-parse HEAD`), `FELIS_BUILD_DIRTY` (`git status --porcelain --untracked-files=no` non-empty). Keep accepting the single `FELIS_GIT_HASH=<hex>[-dirty]` env as the Nix injection form so `nix/package.nix:78` and `flake.nix:251` need only switch from `shortRev`/`dirtyShortRev` to `rev`/`dirtyRev` (full hash). Add `rerun-if-changed=../../.git/index` so a staged change re-stamps; note in the comment that an unstaged edit will not (the exact-identity guarantee is the Nix build's, not cargo's). - Fix the two `(unknown)` paths: `flake.nix:530` overlay and `nix/hm-module.nix:95` should take the same `gitHash` (thread `self` through, e.g. `packages.felis` reused via `inputs.self.packages.${system}.felis` for the HM default, and the overlay documented as "unknown unless you pass `gitHash`"). - Each binary exposes `pub const fn build_identity() -> BuildIdentity`-equivalent (a `BuildIdentity::from_env_consts(env!(...))` helper); `felis_daemon::version()` (`lib.rs:46-48`) and `felis-client`'s `FELIS_VERSION` (`main.rs:241`) render through `Display` so the three binaries' `--version` are byte-identical in shape. ### felis-cli (`crates/felis-cli`) - `main.rs:294-295`: replace the bool with clap's built-in `#[command(version = <const rendered identity>)]` so `-V/--version` is exclusive by clap's own rules and prints `felis <semver> (<rev12>[-dirty])`, no subprocess, no runtime, no dial. Add a test that `felis --version sessions list` and `felis --version -- htop` exit 2 with a usage error (exit-code contract at `docs/reference/cli.md:44-50`). If clap's `Version` action turns out to short-circuit before seeing the trailing tokens, keep the bool and emit `Cli::command().error(ErrorKind::ArgumentConflict, ..)` when `cmd.is_some() || !command.is_empty()`; either way the test pins the contract. - New `Cmd::Version { #[command(flatten)] format: PointFormat }` in `cli_version.rs`: human output is three aligned rows (`cli`, `client`, `daemon`) each rendering a `BuildIdentity` or one of the status words already defined (`not running`, `unavailable`, `incompatible: …` at `cli_version.rs:44-78`); JSON is the standard `{"v":1, ...}` envelope with `{"cli": {version,revision,dirty}, "client": {…}|null, "daemon": {…}|null, "daemon_status": "…"}`. Client identity comes from `felis-client --version` parsed with `BuildIdentity::from_str`; daemon identity from `Welcome.identity`. This verb is the only one allowed the subprocess and the dial; keep the `Refuse` semantics and cold-socket tests (`cli_version.rs:108-215`) as they are, retargeted at the verb. - `cli_doctor.rs:128-141` and `cli_daemon.rs:73-75,125`: render the typed identity; `felis daemon status --format json` gains `identity` beside the existing `version` string (or replaces it under #30). - Completions/man: automatic from clap; nothing hand-maintained (`nix/package.nix:123-134`). ### Release-build rejection of dirty trees Belongs to #18's workflow, not to the build script: the tag job evaluates `nix eval .#felis.gitHash`/`self.dirtyRev` and fails on `-dirty`, then asserts the built binary's `felis --version` reports `dirty=false` and the tag's revision. This issue only has to make the fact observable; document the split in both issues. ### Other consumers - `scripts/bench/envinfo.py:195-230` (`paren_hash`, `revision_drift`) and `crossterm_test.py:615-635`: parse the new canonical line (`felis <semver> (<rev12>[-dirty])`); `revision_drift` can then use the `dirty` flag instead of a suffix heuristic. ### Docs cascade (doc-cascade skill) - Reference: `docs/reference/cli.md:744-787` "Version reporting" (rewrite: `--version` self-report + `felis version` verb + JSON shape), `docs/reference/ipc.md:516-522` (Welcome), `:1738-1752` (ledger row or 2.0 baseline), `docs/reference/workspace.md:157-182` "Versioning" (add the build-identity axis: semver + revision + dirty; how each build path stamps it), `docs/reference/testing.md:867` (bench meta wording). - Explanation: `docs/explanation/architecture/ipc.md:908-912` (`Welcome.build_id` → identity; still no gate on it), `docs/explanation/terminal-identity.md:171-189` (why `--version` carries the hash and `TERM_PROGRAM_VERSION` does not — unchanged argument, new wording), `docs/explanation/architecture/control-surfaces.md:21`. - `CHANGELOG.md` Unreleased: removed aggregate `--version`, added `felis version`, changed Welcome/status shapes. - `skills/felis/SKILL.md`: add `felis version --format json` under the health section (`:213-261`), since it is the verb an agent should use to detect a stale daemon. ### Tests that prove it - felis-protocol: Display/FromStr unit + proptest; proto convert round-trips for `identity`. - felis-cli integration (`crates/felis-cli/tests/`): `--version` offline on a cold `--socket` path exits 0 within the process (no socket created, no `felis-client` on PATH needed — run with an empty `PATH`), exclusivity → exit 2, `felis version --format json` against the fake-daemon harness (clean and dirty identities, `not running`, major-refusal). - A deterministic clean vs dirty test: set `FELIS_GIT_HASH=abc…-dirty` when building a fixture? Build-script envs are not settable per-test; instead test `BuildIdentity::from_env_consts("0.1.0", "abc…", "-dirty" form)` with both shapes. ## Dependencies - **#19 first** (tracker order 3 before 4): the proto edit needs the real compatibility gate in place. - **Decide #30** (baseline reset to 2.0) before choosing field numbers and whether `build_id`/`version` strings are dropped or kept. - **#23** (machine contract) should agree on the JSON envelope for the new verb; if #23 lands later it must not re-shape `felis version` output. - **#18** consumes the identity (tag/revision/dirty assertions). #17 blocks #18. - Bench harness (`scripts/bench`) must be updated in the same change or `just bench*` breaks. ## Risk/effort **M.** Touches four crates, the proto schema, build scripts, three Nix entry points, and the bench harness. Main risks: (1) clap's `--version` exclusivity semantics (covered by the test); (2) stale-stamp on unstaged edits under cargo (documented, not solved — the release path is Nix); (3) forgetting one of the `(unknown)` Nix paths, which would make a Home Manager install report an unknown identity right after the release that introduces the concept. ## Labels Keep `priority/P0`, `release/v0.1.0`. The exclusivity acceptance from #55's comment should be added to the issue body's checklist. Add a note that "reject dirty release builds" is delivered by #18's workflow, with #17 only making `dirty` observable, so the two issues do not double-implement it. ## Review amendments (round 1) - **Canonical form is the full revision; abbreviation is human-only.** The one canonical line every process emits and parses is `<binary> <semver> (<rev40>[-dirty])` with the full 40-hex revision (or `unknown`). `FromStr` accepts only that canonical form. `felis-client --version` prints the canonical line; `felis version --format json` carries the full revision; only human renderings (`felis --version`, `felis version` human table) abbreviate to 12 hex. #31's comparison against `git rev-parse HEAD` reads the JSON (full) form. There is no claim that the abbreviated rendering round-trips. - **No `const fn` producing owned strings.** `BuildIdentity` holds `Cow<'static, str>` fields (or `String` with a plain runtime constructor `BuildIdentity::from_build_env(version: &'static str, revision: &'static str, dirty: bool)`); each binary builds it once at startup from the `env!` constants.
Sign in to join this conversation.
No description provided.