Skip to main content

flowharness (flowharness-scan console script)

flowharness drift badge

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

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


flowharness-scan reads the agent-rules surface of a repository — CLAUDE.md, .cursorrules, .cursor/rules/*.mdc, AGENTS.md, .continue/**, .opencode/**, playbook/**, and an 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:

$ 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

$ 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

$ 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)

$ 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.

$ 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.

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

$ 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.

$ 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

# .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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

flowharness-0.2.0.tar.gz (436.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

flowharness-0.2.0-py3-none-any.whl (526.7 kB view details)

Uploaded Python 3

File details

Details for the file flowharness-0.2.0.tar.gz.

File metadata

  • Download URL: flowharness-0.2.0.tar.gz
  • Upload date:
  • Size: 436.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for flowharness-0.2.0.tar.gz
Algorithm Hash digest
SHA256 4596baa7254e526e8fb068075d376d7d5080cde19418622c83a02ec3dc25b3dc
MD5 2bda1961642c064391099bafa7fedef6
BLAKE2b-256 51c51731619921a8c23f95b60f9df8481d0d7574af6d9bfdc42f9378836f8652

See more details on using hashes here.

Provenance

The following attestation bundles were made for flowharness-0.2.0.tar.gz:

Publisher: release.yml on suleimanmahmoud/flowharness

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file flowharness-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: flowharness-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 526.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for flowharness-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9aca61d9bc6b02c0b53a6bc78d04b42c8ccb762882776960fd17d3714e99b7dc
MD5 4745ffce4edf5c8984f41ca1c2dddbbb
BLAKE2b-256 a1131f386ee4566c871f20954b03e6b9e1b6647b7322a8ab674692a1d98e7b5e

See more details on using hashes here.

Provenance

The following attestation bundles were made for flowharness-0.2.0-py3-none-any.whl:

Publisher: release.yml on suleimanmahmoud/flowharness

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page