flowharness (flowharness-scan console script)
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, andportability. Preferuvx(isolated environments) overpip installinto 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_hashare 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4596baa7254e526e8fb068075d376d7d5080cde19418622c83a02ec3dc25b3dc
|
|
| MD5 |
2bda1961642c064391099bafa7fedef6
|
|
| BLAKE2b-256 |
51c51731619921a8c23f95b60f9df8481d0d7574af6d9bfdc42f9378836f8652
|
Provenance
The following attestation bundles were made for flowharness-0.2.0.tar.gz:
Publisher:
release.yml on suleimanmahmoud/flowharness
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
flowharness-0.2.0.tar.gz -
Subject digest:
4596baa7254e526e8fb068075d376d7d5080cde19418622c83a02ec3dc25b3dc - Sigstore transparency entry: 2769188846
- Sigstore integration time:
-
Permalink:
suleimanmahmoud/flowharness@e3018cfe9f982f5d07559cc071a490a221f7c4e4 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/suleimanmahmoud
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e3018cfe9f982f5d07559cc071a490a221f7c4e4 -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9aca61d9bc6b02c0b53a6bc78d04b42c8ccb762882776960fd17d3714e99b7dc
|
|
| MD5 |
4745ffce4edf5c8984f41ca1c2dddbbb
|
|
| BLAKE2b-256 |
a1131f386ee4566c871f20954b03e6b9e1b6647b7322a8ab674692a1d98e7b5e
|
Provenance
The following attestation bundles were made for flowharness-0.2.0-py3-none-any.whl:
Publisher:
release.yml on suleimanmahmoud/flowharness
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
flowharness-0.2.0-py3-none-any.whl -
Subject digest:
9aca61d9bc6b02c0b53a6bc78d04b42c8ccb762882776960fd17d3714e99b7dc - Sigstore transparency entry: 2769189382
- Sigstore integration time:
-
Permalink:
suleimanmahmoud/flowharness@e3018cfe9f982f5d07559cc071a490a221f7c4e4 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/suleimanmahmoud
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e3018cfe9f982f5d07559cc071a490a221f7c4e4 -
Trigger Event:
push
-
Statement type: