Metadata-Version: 2.4
Name: flowharness
Version: 0.2.0
Summary: Architectural Drift Index and risk scan over a repo's agent-rules surface — local, deterministic, zero network
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Dist: flowharness-core==0.2.0
Requires-Dist: flowharness-inspection==0.2.0
Requires-Dist: flowharness-portability==0.2.0
Requires-Dist: httpx>=0.27 ; extra == 'remote'
Requires-Dist: cryptography>=43 ; extra == 'serverless'
Requires-Python: >=3.12
Project-URL: Homepage, https://flowharness.ai
Project-URL: Documentation, https://github.com/flowharness-ai/flowharness
Provides-Extra: remote
Provides-Extra: serverless
Description-Content-Type: text/markdown

<div align="center">

# flowharness (flowharness-scan console script)

<img src="assets/badge-example.svg" alt="flowharness drift badge" height="22" />

**The Architectural Drift Index + Vibe-Risk scan for your repo's agent-rules surface.**

Local · static · deterministic · zero network · zero model calls · zero config.

</div>

---

`flowharness-scan` reads the agent-rules surface of a repository — `CLAUDE.md`,
`.cursorrules`, `.cursor/rules/*.mdc`, `AGENTS.md`, `.continue/**`, `.opencode/**`,
`playbook/**`, and an [agentharnesses](https://github.com/agentharnesses/agentharnesses)
bundle (`HARNESS.md` + `skills/**/SKILL.md` + routing/reference files) — and tells you
two things in one pass:

- **How messy** it is — the **Architectural Drift Index** (0–100): duplication,
  contradictions, orphaned rules, and cross-harness divergence.
- **How dangerous** it is — a **quarantine verdict**: leaked secrets, indirect
  prompt-injection, unattested/always-on guidance.

## Quickstart

No install required:

```console
$ uvx flowharness scan .
flowharness-scan: drift index 30/100 - QUARANTINE (policy scoring/1)
```

`uvx flowharness init` scaffolds a new repo's agent-rules surface the same way, with no
persistent install. `flowharness` and `flowharness-scan` are the same entry point (see below);
`uvx --from flowharness flowharness-scan .` also works if you prefer the explicit name.

> **Note:** these packages currently install top-level modules named `core`, `inspection`,
> `evaluation`, and `portability`. Prefer `uvx` (isolated environments) over `pip install`
> into a shared venv until the 0.2 module-namespace rename lands.

Exit codes wire straight into CI / pre-commit: **PASS = 0 · NEEDS_HUMAN = 1 · FAIL = 2.**

## Output modes

```console
$ flowharness-scan .                      # terminal banner (default)
$ flowharness-scan . --json               # full inspection/v1 report (content-addressed)
$ flowharness-scan . --badge > drift.svg  # local SVG badge for your README
```

## Gating

```console
$ flowharness-scan . --fail-on risk        # fail on the quarantine verdict (default)
$ flowharness-scan . --fail-on "index>40"  # fail when the drift index exceeds 40
$ flowharness-scan . --policy scoring.toml # custom, versioned scoring weights
```

### Baseline (only fail on *new* drift)

```console
$ flowharness-scan . --set-baseline > .flowharness-baseline.json   # commit this once
$ flowharness-scan . --baseline .flowharness-baseline.json --fail-on baseline
```

`--set-baseline` writes nothing into your repo — it prints the baseline to stdout, so
the scanner stays strictly read-only on the project directory.

## Platform (opt-in network: `pull` / `upload`)

The default scan path is **strictly zero-network**. The `pull`/`upload` subcommands and
`scan --upload` are the *only* paths that touch the network (install the `[remote]` extra for the
`httpx` dependency). They authenticate with a scoped bearer token.

```console
$ export FLOWHARNESS_API_URL=https://app.example.com   # required (or pass --api-url)
$ export FLOWHARNESS_TOKEN=fht_...                      # the scoped bearer token

$ flowharness-scan pull <release_id>                    # fetch, plan and APPLY — see below
$ flowharness-scan pull <release_id> --pins-only        # fetch a release's pins -> offline cache
$ flowharness-scan . --upload --enrollment <id>         # scan, then upload the InspectionRecord
$ flowharness-scan upload sync  --enrollment <id> --release <id> --observed observed.json
$ flowharness-scan upload sync  --enrollment <id> --from-tree .  [--release <id>]
$ flowharness-scan upload eval  --run run.json          # signs the report (X-Signature) + uploads
```

Notes: a leading token that names a real directory is scanned, not treated as a subcommand
(`flowharness-scan ./pull` scans `./pull`). A remote `http://` URL is refused before any socket
opens; an unset URL or token is a usage error (exit 2). Non-2xx responses map to stable exit codes
(10–16); 17 and 18 are ACK-derived — raised on a 2xx whose body says the server did not do what
the client needed (17: an eval run stored but not verified; 18: a sync-report ack that did not
echo the hash domain — the report landed, see `--hash-domain` below). A duplicate *upload*
(`409 *write_conflict`) is treated as an idempotent success, but `pull` GETs only and refuses
every 409 (exit 13). `FLOWHARNESS_TOKEN` is the eval-signing HMAC key and is never written to
disk.

**`upload sync` takes exactly one observation source.** `--observed <file>` (paired with a
required `--release <id>`) posts hand-written hashes; `--from-tree <dir>` derives them offline
from the applied tree's own lockfile instead, so `--release` is optional there and only
cross-checked against the lockfile's origin — a mismatch refuses locally, nothing posted.

**`--from-tree` refusal classes (payload domain, the default).** Under the
`upload_sync_from_tree_refused` slug, any pin the server did not stamp recomputable refuses
the WHOLE report, naming each offending row: a pre-token lockfile row (no `recompute` — an
old apply; re-pull online from an upgraded server heals it) or a pin stamped `"none"`
(permanently not tree-recomputable by construction — fall back to `--observed`, or to
`--hash-domain artifact` below). The same slug also carries `duplicate_asset_id` (two
derivable rows sharing an `asset_id`) and `artifact_id_malformed` (a whole artifact id that is
not `sha256:` + 64 lowercase hex — the WHOLE id is checked, never just the leaf after the
colon) row reasons — both SURVIVE `--hash-domain artifact`, though not unchanged: the
duplicate scan widens there to count ALL rows, not just derivable ones (see below); the
recompute-token refusal (`not_recomputable`) is the one that is payload-domain-only. A
handful of typed, tree-shaped
refusals exit 2 before any row is even read — `observe_lockfile_missing`,
`observe_origin_not_release`, `observe_assets_empty`, `observe_all_rows_missing` — printed as
`error: <code>: <value>`, in both domains.

**`--hash-domain artifact`: report recomputed sidecar-document hashes instead.**
`--hash-domain {payload,artifact}` (default `payload`) selects which hash family
`observed[].content_hash` carries. `artifact` mode derives directly from every parseable
sidecar document — no recompute-token dispatch — so the `not_recomputable` refusal above
vanishes: rule, legacy, and `"none"`-stamped rows are all reportable. `duplicate_asset_id`
and `artifact_id_malformed` remain the refusal set, now counted over ALL rows, not just
derivable ones.

Artifact mode needs a server new enough to ECHO `hash_domain` back in the ack. An old or
ignoring server silently adjudicates the artifact hashes against the *payload* expected map —
every asset diverges with no error signal (a false-drift storm) — so the client checks the
echo of the SAME post that caused it and exits **18** (`SyncReportDomainIgnored`) the moment
it is absent or wrong: the report already landed, but its recorded verdict is unreliable;
upgrade the server, or re-run with `--hash-domain payload`.

**Exit 13 on a reused `Idempotency-Key` across a client upgrade.** `upload sync` mints a
fresh nonce and idempotency key on every invocation (the default, and the safe choice). A
caller who hand-supplies or replays a key captured before the client's `hash_domain` wire
field existed hits the platform's *generic* idempotency middleware, not the sync-report
dedup: the key's stored request fingerprint no longer matches the new body byte-for-byte, so
the platform answers `409 idempotency_key_conflict` (pre-existing middleware semantics,
newly reachable because the request body just grew a field) — which maps to **exit 13**, the
genuine-conflict exit, not the `*write_conflict` dedup no-op's exit 0. Use a fresh key rather
than reusing one captured before an upgrade.

**Bounds, by pin shape.** `title` and `provenance` are excluded from the ARTIFACT hash by
design (identity excludes presentation and source), so artifact mode never sees an edit
touching only those fields — or an added top-level key the sidecar codec does not read — on
ANY pin. The payload domain splits by how the pin was stamped: on an AUTHORED-stamped pin
those fields sit outside the payload too, so the edit is invisible in both domains; on an
IMPORTED pin the payload synthesis folds every non-body document key into the frontmatter, so
the payload domain DOES catch a `title`/`provenance`/unknown-key edit there — and artifact
mode loses exactly that detection. That is the disclosed trade: artifact mode buys total
reportability (rule/legacy/`"none"` pins) at the price of imported-pin metadata sensitivity;
report imported-pin trees in the payload domain when that class of edit matters. A rendered
native surface (the actual `CLAUDE.md` / `.cursorrules` / etc. this tree compiles to) is
never part of either hash domain; only the `.flowharness/artifacts/` sidecar document is
checkable, in payload or artifact form.

**Cross-domain drift-case resolution is deliberate.** A drift case is keyed by
`(enrollment, applied_release)`, not by hash domain, so an artifact-domain clean report
resolves a case a payload-domain report opened, and vice versa: a domain switch resolves
cases opened in the other domain — re-report in the original domain to confirm.

**Warnings are not refusals.** Missing or altered rows print a per-asset `warning:` line to
stderr but do not block the post: the report still submits, and the process exits 0 —
provided nothing about the POST itself failed (in artifact mode an ack that does not echo the
domain still exits 18 after those same warnings; a dedup 409 still exits 0 with its own
"already recorded" line). A caller that must fail on the warning signals has to read stderr
itself — there is no machine-readable summary yet (a planned `--json` follow-up would carry
them). The same construction makes a `title`/`provenance`-only edit on an authored-stamped
document invisible to the check: those fields sit outside the payload domain `recompute`
governs.

**No network for a purely local refusal.** Any refusal above, or a malformed `--observed` file,
is detected before a `PlatformClient` is ever built — no `FLOWHARNESS_TOKEN`, no reachable API
URL, no socket.

**`--enrollment` and the tree are not cross-checked against each other.** `--from-tree` only
verifies the applied tree's own lockfile origin against `--release` when one is given; the
operator alone is responsible for pointing `--enrollment` at the enrollment the tree actually
belongs to.

**`--observed` diagnostics may echo file content.** A malformed entry's stderr message includes
up to 80 characters of the offending value's `repr()` — fine because `--observed` is the
operator's own hand-written file, never platform-controlled input.

**`pull` is a writing verb.** It used to fetch pins into `.flowharness/cache/releases/` and touch
nothing else; it now applies the release into the worktree. `--pins-only` is the old behaviour,
kept — see the next section.

## `pull` — fetch, plan and apply a release, in one shot

`flowharness pull` resolves one release, fetches one render plan per target harness, merges them
and applies the result in a single pass. One release, one atomic decision: every plan is known
before the first byte is written, so the first conflict aborts the whole pull.

```console
$ flowharness pull [release_id]           # default: the channel head
                   [--channel <name>]     # default: [flowharness].channel, else 'stable'
                   [--harness <name>]...  # repeatable; default: config, lockfile, else detected
                   [--playbook <id>]      # disambiguate a channel name two playbooks publish
                   [--dir <path>]         # worktree (default: .); the root is discovered upward
                   [--dry-run] [--json] [--prune] [--force] [--allow-stubs]
                   [--offline]            # the saved plans only; needs an explicit release id
                   [--pins-only]          # the pre-promotion verb, kept (pins only, no writes)
                   [--api-url ...]        # else $FLOWHARNESS_API_URL
```

Everything above except `release_id`, `--offline` and `--api-url` is new in this release;
`--cache-root` survives only as a hidden `--pins-only` alias (`pull <id> --cache-root <path>`
without `--pins-only` is a usage error — use `--dir`).

```console
$ flowharness pull
flowharness pull: rel_11 (channel stable, TLS) → claude_code, cursor · root /home/you/work/api
  update    .claude/commands/review.md
  create    .cursor/rules/testing.mdc
  applied rel_11 (was rel_9) · 2 files · fidelity: 2 native, 1 encode, 0 stub across 2 harnesses · 'flowharness pull rel_9 --prune' reverts
```

One line per *mutating* action, then the summary — a converged re-pull of a 200-file tree prints
two lines, not 200. `--json` emits the canonical `flowharness/pull/v1` payload instead, which
carries **every** action (including `unchanged`/`orphan`) and raw, unescaped paths. The trust word
is derived from the resolved URL scheme and is never `signed` for a merely-TLS fetch.

**Exit map.**

| code    | meaning                                                                                          |
| ------- | ------------------------------------------------------------------------------------------------ |
| `0`     | applied, converged, or `--dry-run` (also a mutating pull whose `applied.json` record failed — the tree really is the release's, and that is said out loud on stderr) |
| `2`     | usage (including a flag `--pins-only` cannot honour, and `--cache-root` without it), a refused `flowharness.toml`, no usable harness target, an ambiguous channel, an unsafe release id, or `--offline` with no usable saved plan |
| `3`     | the apply family: a conflict, an unacknowledged behaviour-bearing stub, an unsafe path, the multi-harness surface collision `apply_harness_surface_collision`, or a filesystem failure — nothing written in every case except a mid-write `OSError` |
| `10-17` | the platform taxonomy (`12` no such channel or release, and a channel that never advanced — never `0`, so `pull && deploy` cannot read an empty channel as "nothing to do"; `13` any `409` on `pull`'s GET routes; `15` unavailable; `16` malformed body or transport) |
| `18`    | artifact-mode ack did not echo the domain — ACK-derived, raised on a 2xx; the report landed but its recorded verdict is unreliable (`upload sync --hash-domain artifact` only, never `pull`; listed here because this table documents the shared 10-18 handler) |

**The config gate is fail-closed.** An unreadable, malformed or `mode = "serverless"`
`flowharness.toml` refuses at exit 2 **before any socket opens** — a verb that writes must not
guess which channel it is converging onto, and an unreadable config may have been the serverless
opt-out. `--pins-only` is exempt from the *read-failure* arm only, so a broken TOML cannot flip a
working invocation; it still refuses a legibly opted-out repo, and it still refuses the eight
pipeline flags it cannot honour (`--channel`, `--harness`, `--playbook`, `--dry-run`, `--json`,
`--prune`, `--force`, `--allow-stubs`) at exit 2 rather than accepting and ignoring them. Its
stdout, exit code and single request are otherwise byte-for-byte the pre-promotion verb's.

**Saved plans.** Every fetched plan lands in `.flowharness/cache/plans/<release>.<harness>.json`
in a marked, clock-free `flowharness/plan-cache.v1` envelope, so `--offline` and `status` work
without the network. The directory keeps the **current and previous** release per harness, and
eviction runs on *every* terminal outcome of a run that resolved a release **it did not already
have** — applied, `--dry-run`, and a conflict abort alike. Two outcomes evict nothing: a refusal
before the release is resolved (it cached nothing to evict), and a **converged re-pull of the
release the lockfile already records** — that run superseded nothing, and evicting would take out
the previous release's plans, which is the `pull <older id>` the last summary offered as the
undo. An eviction that fails on I/O is swallowed rather than failing the pull.
`--offline` reads **that** cache — not the pin cache, which only `--pins-only --offline` reads —
and opens no socket at all.

**Reversibility is git plus the lockfile** — no backup files, no rollback verb. `pull <older id>`
is the undo, and the summary prints the exact command after a pull that changed the release
(with `--prune` when this release rendered paths the previous record does not track, since
without it the printed command would leave the extras behind). The sidecar's `cache/` and
`personal/` are git-ignored for you on the first mutating pull — and on a `--dry-run`, which
creates the caches.

## `status` — am I running what the channel publishes?

`flowharness status` answers two questions in one line — *is the applied release the channel's
head?* and *has anyone edited the governed files?* — and it is **strictly read-only**: it writes
nothing in any mode, and under `--offline` it opens no socket and imports no `httpx` at all.

```console
$ flowharness status
flowharness: 2 releases behind stable (rel_9 → rel_11) — 'flowharness pull' to catch up

$ flowharness status --json          # the flowharness/status/v1 payload, canonical bytes
$ flowharness status --check         # exit 7 on proven divergence; stdout is unchanged
$ flowharness status --offline       # local facts only; freshness is reported as unknown
$ flowharness status --dir path/to/repo --channel beta --api-url https://app.example.com
```

`flowharness` and `flowharness-scan` are two names for the same entry point — the examples above
and below use them interchangeably, and every subcommand works under either.

Run it from anywhere inside the repo: `status` walks **up** to the nearest ancestor holding
`.flowharness/` or `flowharness.toml`, stopping at a `.git` boundary, the way `git status` does.
`--channel` overrides `[flowharness].channel`, which overrides the `stable` default.
`--api-url` is accepted and ignored under `--offline`.

**Exit map.** Bare `status` **always exits 0** — a corrupt lockfile, a dead network, a typo'd
`flowharness.toml`, or an unreadable governed file is a *reported state*, never an exit code, so
the verb is safe in a shell prompt. `--check` returns **7** on proven divergence (a governed file
drifted, or the applied release is provably behind the head); if nothing diverged but the probe
itself failed, it returns that failure's code (10–17). A bad flag or a `--dir` that does not
exist is the usual argparse **2**.

Divergence requires *proof*: an unreadable origin, an unrecognised origin, or an unorderable pair
of release versions is reported as `unknown` (`drift.known: false` in the JSON) and `--check`
returns 0 rather than claiming a divergence it did not observe.

**Not yet here.** `--diff` (show the actual edits) lands with PR-D; serverless freshness (a git
remote instead of a channel) lands with PR-F.

**Known limitations.** Drift is byte-comparison against the lockfile's hashes, so: a filename
stored NFC on Linux and NFD on macOS can flip between "converged" and "missing", and
`core.autocrlf=true` on Windows makes every governed text file read as `modified`. Every
governed file is re-hashed on every run (a megabyte at a time — a huge file is slow, never an
out-of-memory), and a console that cannot encode the banner's `·`/`—`/`→` gets those characters
back-slash-escaped rather than a traceback.

Behind a corporate proxy or a private CA, note that the client sets `trust_env=False`: `status`,
`pull` and `upload` deliberately ignore `HTTP_PROXY`/`HTTPS_PROXY` and
`SSL_CERT_FILE`/`SSL_CERT_DIR`/`REQUESTS_CA_BUNDLE`, and there is not yet an explicit option to
put them back.

## Pre-commit

```yaml
# .pre-commit-config.yaml
-   repo: https://github.com/suleimanmahmoud/flowharness
    rev: <tag>
    hooks:
    -   id: flowharness-scan
```

## What it flags

| Dimension | Check | Catches |
| --- | --- | --- |
| duplication | `DuplicationCheck` | the same guideline pasted across files/sections |
| contradiction | `ContradictionCheck` | retry-vs-fail-fast, tests-first-vs-prototype, … |
| orphans | `OrphanCheck` | rules no active harness loads; leftovers of removed tools |
| divergence | `DivergenceCheck` | the "same" rule that differs across harnesses |
| risk | `LeakageCheck` | secrets/credentials/template-vars — **redacted to fingerprints** |
| risk | `InjectionHeuristicsCheck` | indirect prompt-injection (override / exfil / fetch-and-execute) |
| risk | `AttestationCheck` / `BoundsCheck` | unattested or implicitly always-on guidance |

A finding **never contains a secret value**; the report carries structural metadata
only (no rule-body text).

## Guarantees

- **Deterministic** — identical directory state ⇒ byte-identical report; ids and the
  `report_hash` are content hashes; no timestamps.
- **Read-only & offline** — the scanner only ever reads the project directory; no
  network, no model calls on the default path.
- **Fast** — a 50-unit repo scans in tens of milliseconds (budget: < 2 s).

## Report

`--json` emits the `inspection/v1` schema — snapshot metadata, redacted findings, the
risk assessment, and the scored drift index — documented in
[`docs/specs/inspection-report-v1.md`](../../docs/specs/inspection-report-v1.md).
