Metadata-Version: 2.4
Name: flowharness
Version: 0.1.1
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.1.1
Requires-Dist: flowharness-inspection==0.1.1
Requires-Dist: flowharness-portability==0.1.1
Requires-Dist: httpx>=0.27 ; extra == 'remote'
Requires-Python: >=3.12
Project-URL: Homepage, https://github.com/flowharness-ai
Provides-Extra: remote
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 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–17); 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.

**`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) |

**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).
