From 6428a45ddd084d3e238d3271b68ebb9a83b1b6a4 Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Tue, 25 Aug 2026 13:20:28 +0100 Subject: [PATCH 1/5] feat(slice-28): surface Tessl Quality N/100 and blocked footer on /tw-verify Operators need the scale bound in-cell and a single shared blocked note; restore dual-output helpers and sync agent-hooks skills + contract SSOT. --- CHANGELOG.md | 7 + agent-hooks/README.md | 3 +- agent-hooks/skills/tw-disable/SKILL.md | 2 +- agent-hooks/skills/tw-self-check/SKILL.md | 20 +- agent-hooks/skills/tw-verify/SKILL.md | 52 ++- docs/STATUS.md | 10 +- docs/plan/DECISIONS.md | 2 + docs/plan/PROGRESS.md | 2 +- docs/plan/TRAIL.md | 2 +- docs/plan/gate-evidence/slice-28.json | 75 +++- .../slice-28-tw-verify.md | 89 ++--- docs/user-guide/frontline-output-contract.md | 152 ++++++++ guard/tests/test_tw_verify.py | 361 ++++++++++++++++++ guard/tests/test_verify_units.py | 105 +++++ guard/verify.py | 303 +++++++++++++++ 15 files changed, 1090 insertions(+), 95 deletions(-) create mode 100644 docs/user-guide/frontline-output-contract.md create mode 100644 guard/tests/test_tw_verify.py create mode 100644 guard/tests/test_verify_units.py create mode 100644 guard/verify.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d883bc..dad23e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `/tw-verify` Quality column + blocked footer (slice 28): Scan Status table is + **Name | Type | Status | Quality | Note**; Quality shows Tessl + `items.quality_score` as **`N/100`** (else `—`); shared + **Will be blocked when Tripwire is enabled** appears once under the table. + Shared helpers in `guard/verify.py`; contract SSOT + [frontline-output-contract.md](docs/user-guide/frontline-output-contract.md). + Re-run `tripwire setup-agent-hooks` to refresh installed skills. - Docs: Wave **13-M** LLM usage / cost observability plan (slice 53) — [design/llm-usage-tracking.md](docs/design/llm-usage-tracking.md), slice stub, TRAIL/PROGRESS/STATUS DECIDED (not IMPLEMENTED). Historic collapsible Usage diff --git a/agent-hooks/README.md b/agent-hooks/README.md index da545f2..1290cd0 100644 --- a/agent-hooks/README.md +++ b/agent-hooks/README.md @@ -19,7 +19,8 @@ from the repo directly — `tripwire setup-agent-hooks` installs copies: stdout; every failure mode is an explicit deny (fail closed). - **`hooks/_guard_entry.py`** — installed shim delegating to `guard.entry.main()` (run via `uv run --project --extra guard`). -- **`skills/tw-verify`** — report scan status of named skills/MCP servers. +- **`skills/tw-verify`** — report scan status of named skills/MCP servers + (RAG + Tessl Quality as `N/100` when present; blocked message as table footer). - **`skills/tw-scan`** — submit named skills/MCP servers for scanning (`--force` or bare `force` to rescan valid artifacts). - **`skills/tw-enable` / `skills/tw-disable`** — flip the local `enable` key in diff --git a/agent-hooks/skills/tw-disable/SKILL.md b/agent-hooks/skills/tw-disable/SKILL.md index e8d1cd0..3c6d6a1 100644 --- a/agent-hooks/skills/tw-disable/SKILL.md +++ b/agent-hooks/skills/tw-disable/SKILL.md @@ -54,5 +54,5 @@ If `created` is true, the config file was missing and was recreated with default Tell the user, plainly: - Tripwire enforcement is now **OFF** — the Claude Code hook allows every skill/MCP call without checking scan status. Enforcement is **fully bypassed**, including red-rated and never-scanned artifacts. -- Manual scanning and reporting still work while disabled: `/tw-scan` submits scans and `/tw-verify` reports status (its "will be blocked" column shows what enforcement WOULD do, marked as currently bypassed). +- Manual scanning and reporting still work while disabled: `/tw-scan` submits scans and `/tw-verify` reports status (its blocked **footer** shows what enforcement WOULD do, marked as currently bypassed). - Re-enable at any time with `/tw-enable`. diff --git a/agent-hooks/skills/tw-self-check/SKILL.md b/agent-hooks/skills/tw-self-check/SKILL.md index 1e4176d..90f0295 100644 --- a/agent-hooks/skills/tw-self-check/SKILL.md +++ b/agent-hooks/skills/tw-self-check/SKILL.md @@ -58,15 +58,19 @@ for ident in sys.argv[1:]: blocked = not (rag == "green" or (rag == "amber" and threshold == "red")) else: blocked = True + item = s.get("item") or {} + q = item.get("quality_score") + quality_score = float(q) if isinstance(q, (int, float)) else None rows.append({ "identifier": ident, "state": state, "rag": rag, "scanned_at": s.get("scanned_at"), "stale": state == "stale", - "errored": (s.get("item") or {}).get("heatmap_status") == "error", + "errored": item.get("heatmap_status") == "error", "changed": changed, "will_be_blocked": blocked, + "quality_score": quality_score, }) print(json.dumps({ "config": { @@ -82,16 +86,18 @@ print(json.dumps({ ## Step 4 — Report (Scan Status table only) -Render exactly the tw-verify human table — same states, emoji, and labels (see `~/.claude/skills/tw-verify/SKILL.md` §Step 5). Parse the driver's JSON privately; do **not** dump a fenced `{config, artifacts}` block to the user. Summary of the row rules (N = `scan_validity_days`): +Render exactly the tw-verify human table — same columns **Name | Type | Status | Quality | Note**, emoji/labels, Quality as **`N/100`** or `—`, and blocked-message **footer** (see `~/.claude/skills/tw-verify/SKILL.md` §Step 5 and [frontline-output-contract.md](../../../docs/user-guide/frontline-output-contract.md)). Parse the driver's JSON privately; do **not** dump a fenced `{config, artifacts}` block to the user. Summary of the row rules (N = `scan_validity_days`): - `fresh` + green → `🟢 GREEN (fresh)`, note `—`. -- `fresh` + amber → `🟠 AMBER`; `Reported but not blocked at current threshold` when threshold is `red`, else bold **Will be blocked when Tripwire is enabled**. -- `fresh` + red → `🔴 RED`; note ALWAYS bold **Will be blocked when Tripwire is enabled** — never omitted. +- `fresh` + amber → `🟠 AMBER`; `Reported but not blocked at current threshold` when threshold is `red`, else distinct amber note + footer when blocked. +- `fresh` + red → `🔴 RED`; note `rated red — at/above threshold` (blocked sentence is footer-only). - `stale` → `⚠️ STALE`; `Last scanned >N days ago — blocked until rescanned (run /tw-scan )`. - `scanning` → `⏳ SCANNING`; `Scan in progress — check back shortly` (+ prior verdict if rag non-null). -- `unscanned` → `🚫 UNSCANNED`; `Never scanned — blocked when Tripwire is enabled` (or `Last scan errored — resubmit (run /tw-scan )` when errored). -- `changed=true` → `✏️ CHANGED`; bold **content changed since last scan — run /tw-scan ** — takes precedence over every state-based row (the hook's tamper deny fires regardless of a green verdict), `will_be_blocked` true. -- missing install dir → `❓ NOT FOUND`; bold **Will be blocked when Tripwire is enabled** — `Not installed under ~/.claude/skills — run tripwire setup-agent-hooks`. +- `unscanned` → `🚫 UNSCANNED`; `Never scanned — offer /tw-scan ` (or `Last scan errored — resubmit (run /tw-scan )` when errored). +- `changed=true` → `✏️ CHANGED`; bold **content changed since last scan — run /tw-scan ** — takes precedence over every state-based row, `will_be_blocked` true. +- missing install dir → `❓ NOT FOUND`; note `Not installed under ~/.claude/skills — run tripwire setup-agent-hooks` (footer covers blocked). + +If any `will_be_blocked`, print **Will be blocked when Tripwire is enabled** once under the table. If local `enable` is false, add the "currently bypassed" note; if `monitoring_enabled` is false, add the platform-switch warning. diff --git a/agent-hooks/skills/tw-verify/SKILL.md b/agent-hooks/skills/tw-verify/SKILL.md index b16de7f..28069b9 100644 --- a/agent-hooks/skills/tw-verify/SKILL.md +++ b/agent-hooks/skills/tw-verify/SKILL.md @@ -5,7 +5,7 @@ description: Check the Tripwire scan status of Claude Code skills and MCP server # tw-verify -Report the Tripwire scan status of one or more skills / MCP servers. Single pass over ALL requested names — never stop at the first problem; every requested name gets a row in the Scan Status table. Read-only: this skill never submits scans itself (it only offers to, at the end). Do **not** dump the driver's raw JSON (or any fenced `{config, artifacts}` block) to the user — that payload is for you to parse only. +Report the Tripwire scan status of one or more skills / MCP servers. Single pass over ALL requested names — never stop at the first problem; every requested name gets a row in the Scan Status table. Columns follow the Frontline dual-output contract ([frontline-output-contract.md](../../../docs/user-guide/frontline-output-contract.md)): **Name | Type | Status | Quality | Note**, with Tessl Quality as **`N/100`** when `items.quality_score` is present (else `—`), and the shared blocked phrase once as a **table footer**. Shared helpers: `guard.verify.verify_artifacts` / `format_quality_cell`. Read-only: this skill never submits scans itself (it only offers to, at the end). Do **not** dump the driver's raw JSON (or any fenced `{config, artifacts}` block) to the user — that payload is for you to parse only. **Hard rule — unscanned/blocked artifacts must not be executed.** If a row is `unscanned` (including errored), `stale`, `changed`, RED (or amber at threshold), or NOT FOUND: do **not** invoke that skill (`Skill` tool), do **not** call its `mcp__*` tools, and do **not** run its `install.sh` / scripts via Bash. Report that Tripwire will block those calls. Only offer `/tw-scan` via AskUserQuestion — never silently submit, and never “work around” a block by scanning so you can run the artifact in the same turn. @@ -39,7 +39,7 @@ print(json.dumps({"resolutions": out})) Parse the JSON privately. For each entry: -- `found=false` → ❓ NOT FOUND row (do not call the status driver for that name). Note: **Will be blocked when Tripwire is enabled** — no match in hook loci (`~/.claude/skills`, `.claude/skills`, `.mcp.json`, `~/.claude.json` incl. `projects[""]`, `~/.tripwire/demo-mcp.json`, fixtures MCP manifest). Tip: demo skills are `safe-skill` / `vuln-skill` / `amber-skill`; demo MCP keys are `safe-tool` / `vuln-tool` / `amber-tool` (fixture names like `vuln-runtime-download` alias to those when demos are installed). +- `found=false` → ❓ NOT FOUND row (do not call the status driver for that name). Note: no match in hook loci (`~/.claude/skills`, `.claude/skills`, `.mcp.json`, `~/.claude.json` incl. `projects[""]`, `~/.tripwire/demo-mcp.json`, fixtures MCP manifest) — do **not** repeat the blocked phrase in the Note (it goes in the table footer). Tip: demo skills are `safe-skill` / `vuln-skill` / `amber-skill`; demo MCP keys are `safe-tool` / `vuln-tool` / `amber-tool` (fixture names like `vuln-runtime-download` alias to those when demos are installed). - `found=true` → use `identifier` (and `kind`) for Step 4. If `alias_of` is set, the Name column still shows the user’s requested name; optionally append `(as )` in the Note. An absolute path the user passed that exists as a skill directory is resolved by the driver — do not invent your own path logic. @@ -79,15 +79,19 @@ for ident in sys.argv[1:]: blocked = not (rag == "green" or (rag == "amber" and threshold == "red")) else: blocked = True + item = s.get("item") or {} + q = item.get("quality_score") + quality_score = float(q) if isinstance(q, (int, float)) else None rows.append({ "identifier": ident, "state": state, "rag": rag, "scanned_at": s.get("scanned_at"), "stale": state == "stale", - "errored": (s.get("item") or {}).get("heatmap_status") == "error", + "errored": item.get("heatmap_status") == "error", "changed": changed, "will_be_blocked": blocked, + "quality_score": quality_score, }) print(json.dumps({ "config": { @@ -101,40 +105,46 @@ print(json.dumps({ ' ... ``` -The driver prints one JSON object `{config, artifacts}` — parse it privately; never paste it into the user-facing reply. NOT FOUND names never reach the driver (they get rows anyway). If the driver itself fails, report the failure and still render every row (status "unknown — status query failed"), never a partial silent result. +The driver prints one JSON object `{config, artifacts}` — parse it privately; never paste it into the user-facing reply. Each artifact includes nullable `quality_score` (0–100 from `items.quality_score`); human render owns the `/100` display. NOT FOUND names never reach the driver (they get rows anyway, `quality_score` null). If the driver itself fails, report the failure and still render every row (status "unknown — status query failed"), never a partial silent result. ## Step 5 — Render the human-readable table -One Markdown table, one row per REQUESTED name (selection rows count individually), all in a single pass. Use exactly these states, emoji, and labels (N = `scan_validity_days`): +One Markdown table, one row per REQUESTED name (selection rows count individually), all in a single pass. Fixed columns: **Name | Type | Status | Quality | Note**. Use exactly these states, emoji, and labels (N = `scan_validity_days`). Quality cell: **`N/100`** when `quality_score` is a number for a skill (Tessl skill-review, 0–100, higher better); otherwise `—` (MCP, unscanned, scanning, not-found, null, or missing). Never a bare integer or `Q N` alone. -| Name | Type | Status | Note | -|------|------|--------|------| -| `safe-skill` | skill | 🟢 GREEN (fresh) | — | -| `vuln-skill` | skill | 🔴 RED | **Will be blocked when Tripwire is enabled** | -| `safe-tool` | mcp | 🟠 AMBER | Reported but not blocked at current threshold | -| `unknown-skill` | — | ❓ NOT FOUND | **Will be blocked when Tripwire is enabled** — no match in ~/.claude/skills, .claude/skills, .mcp.json, ~/.claude.json, ~/.tripwire/demo-mcp.json, fixtures manifest | -| `old-skill` | skill | ⚠️ STALE | Last scanned >14 days ago — blocked until rescanned | -| `pending-skill` | skill | ⏳ SCANNING | Scan in progress — check back shortly | -| `new-skill` | skill | 🚫 UNSCANNED | Never scanned — blocked when Tripwire is enabled | -| `edited-skill` | skill | ✏️ CHANGED | **content changed since last scan — run /tw-scan ** | +| Name | Type | Status | Quality | Note | +|------|------|--------|---------|------| +| `safe-skill` | skill | 🟢 GREEN (fresh) | 91/100 | — | +| `vuln-skill` | skill | 🔴 RED | 12/100 | rated red — at/above threshold | +| `safe-tool` | mcp | 🟠 AMBER | — | Reported but not blocked at current threshold | +| `unknown-skill` | — | ❓ NOT FOUND | — | no match in ~/.claude/skills, .claude/skills, .mcp.json, ~/.claude.json, ~/.tripwire/demo-mcp.json, fixtures manifest | +| `old-skill` | skill | ⚠️ STALE | 80/100 | Last scanned >14 days ago — blocked until rescanned (run /tw-scan old-skill) | +| `pending-skill` | skill | ⏳ SCANNING | — | Scan in progress — check back shortly | +| `new-skill` | skill | 🚫 UNSCANNED | — | Never scanned — offer /tw-scan new-skill | +| `edited-skill` | skill | ✏️ CHANGED | 70/100 | **content changed since last scan — run /tw-scan edited-skill** | + +**Blocked footer (de-dupe):** If **any** row has `will_be_blocked=true` (including NOT FOUND / RED / STALE / UNSCANNED / CHANGED / amber-at-threshold), print **once** under the table: + +**Will be blocked when Tripwire is enabled** + +Do **not** repeat that phrase in every Note. Row Notes keep *distinct* copy only. Row rules (map driver output → row): - `changed=true` → `✏️ CHANGED` — this row takes precedence over every state-based row (the hook's tamper deny fires regardless of a green verdict); note is the bold **content changed since last scan — run /tw-scan **, and `will_be_blocked` is `true`. - `state=fresh, rag=green` → `🟢 GREEN (fresh)`, note `—`. -- `state=fresh, rag=amber` → `🟠 AMBER`; note `Reported but not blocked at current threshold` when `threshold` is `red`, else **Will be blocked when Tripwire is enabled** (bold). -- `state=fresh, rag=red` → `🔴 RED`; note is ALWAYS the bold **Will be blocked when Tripwire is enabled** — never omitted, no exceptions. +- `state=fresh, rag=amber` → `🟠 AMBER`; note `Reported but not blocked at current threshold` when `threshold` is `red`, else note `amber at/above threshold` and `will_be_blocked=true` (footer covers the blocked sentence). +- `state=fresh, rag=red` → `🔴 RED`; note `rated red — at/above threshold` (blocked sentence is footer-only). - `state=stale` → `⚠️ STALE`; note `Last scanned >N days ago — blocked until rescanned (run /tw-scan )`. - `state=scanning` → `⏳ SCANNING`; note `Scan in progress — check back shortly`; if `rag` is non-null append `(prior verdict: )`. -- `state=unscanned, errored=false` → `🚫 UNSCANNED`; note `Never scanned — blocked when Tripwire is enabled`. +- `state=unscanned, errored=false` → `🚫 UNSCANNED`; note `Never scanned — offer /tw-scan `. - `state=unscanned, errored=true` → `🚫 UNSCANNED`; note `Last scan errored — resubmit (run /tw-scan )`. -- unresolved name → `❓ NOT FOUND`; note ALWAYS the bold **Will be blocked when Tripwire is enabled** plus `— no match in ~/.claude/skills, .claude/skills, .mcp.json, ~/.claude.json, ~/.tripwire/demo-mcp.json, fixtures manifest`. Fail-closed: the hook denies any Skill/mcp__* call it cannot resolve to a known locus (same as unscanned). +- unresolved name → `❓ NOT FOUND`; note `no match in ~/.claude/skills, .claude/skills, .mcp.json, ~/.claude.json, ~/.tripwire/demo-mcp.json, fixtures manifest`. Fail-closed: the hook denies any Skill/mcp__* call it cannot resolve to a known locus (same as unscanned); `will_be_blocked=true` → footer. The `run /tw-scan ` remedy in the STALE, errored, and CHANGED notes actually works because tw-scan always submits with `--force` — without force the CLI would skip unchanged content and a stale/errored state could never clear. -After the table: +After the table (and after the blocked footer when present): -- If config `enabled` is `false`, add: `Note: Tripwire enforcement is currently DISABLED (/tw-disable) — "will be blocked" reports what enforcement would do when enabled; calls are currently bypassed.` +- If config `enabled` is `false`, add: `Note: Tripwire enforcement is currently DISABLED (/tw-disable) — the blocked footer reports what enforcement would do when enabled; calls are currently bypassed.` - If `monitoring_enabled` is `false` while local `enable` is `true`, add a warning that the Supabase platform switch (`config.monitoring_enabled`) is OFF and still gates the guard — effective enforcement is local enable AND platform switch. ## Step 6 — Offer scans for blocked-but-fixable rows diff --git a/docs/STATUS.md b/docs/STATUS.md index 45ad644..f40ca5c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -255,10 +255,12 @@ documentarist pile-on **APPROVED WITH FOLLOW-ON**; DIVIO purity rewrites [plan/slices/11-K-docs-ux-plain-language/slice-44-docs-ux-plain-language.md](./plan/slices/11-K-docs-ux-plain-language/slice-44-docs-ux-plain-language.md). **Wave H — Frontline agent hooks (2026-08-15):** Claude Code PreToolUse handlers, -`tripwire setup-agent-hooks`, and five `/tw-*` skills are **DECIDED** as plan-only -slices 23–39 on branch `frontline-hackathon-london-2026-agent-hooks`. See -[plan/TRAIL.md](./plan/TRAIL.md) Wave H and [plan/DECISIONS.md](./plan/DECISIONS.md). -Not IMPLEMENTED — no production hook install path or `/tw-*` skills yet. ADR-0015 +`tripwire setup-agent-hooks`, and five `/tw-*` skills landed on `main` via Phase 1 +agent-hooks (see `agent-hooks/`). Formal Wave H gate trackers (slices 23–39) still +lag; **slice 28** (`/tw-verify` Quality `N/100` + blocked footer) is **IMPLEMENTED** +on `slice/28-tw-verify-quality` — contract SSOT +[frontline-output-contract.md](./user-guide/frontline-output-contract.md). +Remaining Musts 23–27 / 29–32 stay plan-tracked. ADR-0015 Horizon A exclusion remains in force until Wave H lands and a superseding ADR records the new production entry. diff --git a/docs/plan/DECISIONS.md b/docs/plan/DECISIONS.md index 8529a7c..9515017 100644 --- a/docs/plan/DECISIONS.md +++ b/docs/plan/DECISIONS.md @@ -185,3 +185,5 @@ | 2026-08-25 | planning | Slice 53 historic + collapsible log | Usage UI must show **past** per-transaction costs (append-only `llm_usage_events`), not only latest. Transaction log **collapsed by default**; per-row I/O expand; drawer **Prior routes** collapsed. Window chips Today/7d/30d optional. Retention TTL deferred. DECIDED | | 2026-08-25 | sync-docs | Wave M DECIDED discoverability | Feature-delta (plan-only): ARCHITECTURE Future + docs hub + reading-router-results Related + CHANGELOG Unreleased Added. README/AGENTS/CLAUDE: no false “Usage shipped” claims — aligned NO_CHANGE. User-guide env-vars / setup: deferred until IMPLEMENTED. Visual assets: current-truth diagrams unchanged (future prose only). APPLIED | | 2026-08-25 | planning | Amended slices 26+28 (not new slice) | `/tw-verify` Quality column (`N/100` from `items.quality_score`) + blocked-note footer de-dupe. Quality-only metrics; amend-in-place like slice 42. Slice 30 one-line sync. Skill implementation deferred to `slice/28-*` execution. DECIDED | +| 2026-08-25 | slice-28 | Waive Before-Check: 26+27 gate PASS on main | Formal Wave H gate-evidence for 26/27 remains PLANNED on `main` (Frontline PRs #78/#80 never merged to main). Substitute: Phase-1 `agent-hooks` skills + this PR publishes `docs/user-guide/frontline-output-contract.md` as output SSOT; enable/disable skills already on main. (waived: DECISIONS 2026-08-25) | +| 2026-08-25 | slice-28 | Quality + footer IMPLEMENTED | Restored Frontline `guard/verify.py` onto `slice/28-tw-verify-quality`; Quality `N/100` + blocked footer; agent-hooks `tw-verify` / `tw-self-check` / `tw-disable` synced. IMPLEMENTED | diff --git a/docs/plan/PROGRESS.md b/docs/plan/PROGRESS.md index f2a7a44..6d916a2 100644 --- a/docs/plan/PROGRESS.md +++ b/docs/plan/PROGRESS.md @@ -129,7 +129,7 @@ shared code area: slice 18 does not start until the H-wave subcommand lands | 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Must | 📋 PLANNED | — | — | ~30 min | | 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | Must | 📋 PLANNED | — | — | ~40 min | | 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | Must | 📋 PLANNED | — | — | ~25 min | -| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | Must | 📋 PLANNED | — | — | ~50 min (delta: Quality + footer) | +| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | Must | 🔀 ON BRANCH | 2026-08-25 | — | ~50 min (delta: Quality + footer) | | 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | Must | 📋 PLANNED | — | — | ~40 min | | 30 | [slice-30-tw-self-check](slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) | Must | 📋 PLANNED | — | — | ~30 min | | 31 | [slice-31-demo-artifacts](slices/08-H-frontline-agent-hooks/slice-31-demo-artifacts.md) | Must | 📋 PLANNED | — | — | ~40 min | diff --git a/docs/plan/TRAIL.md b/docs/plan/TRAIL.md index 4b25e2b..4d7a182 100644 --- a/docs/plan/TRAIL.md +++ b/docs/plan/TRAIL.md @@ -193,7 +193,7 @@ Branch: `frontline-hackathon-london-2026-agent-hooks`. Source: `internal-docs/04 |---|------|------|--------|--------|------------|-------|-----------| | 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | API Introspect + Dual Output Contract (delta: Quality column) | Must | 📋 | 25 | — | ~4 min | | 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | `/tw-enable` + `/tw-disable` | Must | 📋 | 26 | — | ~3 min | -| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | `/tw-verify` (delta: Quality `N/100` + blocked footer) | Must | 📋 | 26,27 | — | ~5 min | +| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | `/tw-verify` (delta: Quality `N/100` + blocked footer) | Must | 🔀 | 26,27 | — | ~5 min | | 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | `/tw-scan` | Must | 📋 | 26 | — | ~4 min | | 30 | [slice-30-tw-self-check](slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) | `/tw-self-check` | Must | 📋 | 28 | — | ~3 min | diff --git a/docs/plan/gate-evidence/slice-28.json b/docs/plan/gate-evidence/slice-28.json index 680352f..960b3c1 100644 --- a/docs/plan/gate-evidence/slice-28.json +++ b/docs/plan/gate-evidence/slice-28.json @@ -1,26 +1,71 @@ { "slice": 28, - "gate_status": "PLANNED", + "gate_status": "ON_BRANCH", "inferred": false, - "branch": null, - "date": null, - "before_checks": [], - "after_checks": [], + "branch": "slice/28-tw-verify-quality", + "date": "2026-08-25", + "spec_path": "docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md", + "output_ssot": "docs/user-guide/frontline-output-contract.md", + "skill_layout": ["agent-hooks/skills/tw-verify/SKILL.md"], + "before_checks": "waived 26+27 PASS on main (DECISIONS 2026-08-25); branch from main; SSOT frontline-output-contract.md; coverage ≥95% on guard/verify.py; complexity enforcing", + "after_checks": [ + "PASS — multi-name one-pass + six-state GWTs", + "PASS — Quality N/100 / — asserted", + "PASS — blocked footer once; Notes lack repeated blocked sentence", + "PASS — unscanned→/tw-scan offer asserted", + "PASS — not-found human message asserted", + "PASS — .venv/bin/pytest guard/tests/test_tw_verify.py -q (15 passed in file; 25 with units)", + "PASS — coverage 100% lines on guard/verify.py (target ≥95%)", + "PASS — xenon exit 0; ./scripts/quality-gates.sh PASS", + "PASS — review acceptance+implementation APPROVED (nw-software-crafter-reviewer)", + "PENDING merge — PROGRESS/TRAIL ✅ after merge to main" + ], + "commands": [ + { + "cmd": ".venv/bin/pytest guard/tests/test_tw_verify.py guard/tests/test_verify_units.py -q --tb=short", + "result": "PASS exit 0 — 25 passed" + }, + { + "cmd": ".venv/bin/pytest guard/tests/test_tw_verify.py guard/tests/test_verify_units.py --cov=guard.verify --cov-report=term-missing -q", + "result": "PASS — 25 passed; TOTAL 100% lines on guard/verify.py" + }, + { + "cmd": "./scripts/quality-gates.sh", + "result": "PASS — quality-gates passed" + }, + { + "cmd": "test -f agent-hooks/skills/tw-verify/SKILL.md && rg -n \"Name \\| Type \\| Status \\| Quality \\| Note|quality_score|N/100|Will be blocked when Tripwire is enabled\" agent-hooks/skills/tw-verify/SKILL.md", + "result": "PASS — skill SSOT documents Quality column + footer" + } + ], "planned_commands": [ "test -f docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md", "rg -n \"Quality|N/100|quality_score|Will be blocked when Tripwire is enabled\" docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md agent-hooks/skills/tw-verify/", - "rg -n \"Name \\| Type \\| Status \\| Quality \\| Note\" docs/plan/slices/08-H-frontline-agent-hooks/", - "rg -n \"heatmap_status\" guard/", + "rg -n \"Name \\| Type \\| Status \\| Quality \\| Note\" docs/user-guide/frontline-output-contract.md", + ".venv/bin/pytest guard/tests/test_tw_verify.py -q --tb=short", "./scripts/quality-gates.sh" ], - "commands": [], - "test_budget": {"acceptance_tests_max": 7, "parametrized_case_counts_as_one": true}, - "reviewers": [], - "review": {"acceptance": "PENDING", "implementation": "PENDING"}, - "verdict": "NOT_RUN", - "coverage_target": "TBD at AT design before IN PROGRESS", - "complexity_policy": "enforcing for product-code; N/A for docs-only / skill-markdown-only with reason in evidence", + "test_budget": { + "acceptance_tests_max": 7, + "acceptance_tests_designed": 7, + "parametrized_case_counts_as_one": true + }, + "coverage": { + "target": "≥95% lines on guard/verify.py", + "measured": "100.0% lines (106 stmts)", + "date": "2026-08-25" + }, + "complexity_policy": "enforcing for product-code — xenon exit 0 via quality-gates", + "reviewers": ["nw-software-crafter-reviewer"], + "review": { + "acceptance": "APPROVED", + "implementation": "APPROVED", + "agent_id": "0ad0f49f-f5ef-457e-95c6-4719901c3dfa", + "date": "2026-08-25" + }, + "verdict": "ON_BRANCH", + "coverage_target": "≥95% lines on guard/verify.py", "phase": "H2", "moscow": "Must", - "delta_2026_08_25": "Quality column N/100 + blocked footer de-dupe (DECIDED)" + "delta_2026_08_25": "Quality column N/100 + blocked footer de-dupe (IMPLEMENTED)" } diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md index b52a38b..70e1959 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md @@ -6,74 +6,75 @@ `/tw-verify` resolves multiple names, reports every artifact in one pass via the shared human table + machine JSON, covers all six states, surfaces Tessl **Quality** as **`N/100`** when `items.quality_score` is present, de-duplicates the shared blocked message into a **table footer**, offers `/tw-scan` for unscanned, and returns a useful human message for not-found. -**Delta (2026-08-25):** Quality column + blocked-note footer de-dupe (Quality-only metrics; amend-in-place — no new slice). Evidence state: **DECIDED** until skill implementation ships. +**Delta (2026-08-25):** Quality column + blocked-note footer de-dupe (Quality-only metrics; amend-in-place — no new slice). Evidence state: **IMPLEMENTED** on `slice/28-tw-verify-quality`. ## GWT acceptance specification -Thin scaffolds — full DISTILL ATs deferred per DECISIONS; design ATs before marking IN PROGRESS. - -1. **Multi-name one-pass table** - - Given two or more resolvable names, when `/tw-verify` runs, then every name appears as a row in one Markdown table (and matching machine artifacts) without stopping at the first issue. -2. **State coverage** - - Given fixtures for fresh/stale/unscanned/scanning/not-found/red, when verify runs, then each state renders per the slice-26 contract (including Quality column). -3. **Quality column (`N/100`)** - - Given a found skill with stored `items.quality_score`, when verify reports it, then the Quality cell shows **`N/100`** (scale bound visible; never a bare integer or `Q N` alone). - - Given MCP, unscanned, not-found, or null score, when verify reports it, then Quality is `—`. -4. **Driver exposes `quality_score`** - - Given the Step-4 status driver JSON, when an artifact has an item row, then `quality_score` is present as a nullable number (0–100); human render owns the `/100` display. -5. **Blocked note de-dupe (footer)** - - Given one or more rows with `will_be_blocked=true`, when verify renders the table, then the phrase **Will be blocked when Tripwire is enabled** appears **once** under the table (footer), not repeated in every Note. - - Row Notes keep *distinct* copy only (AMBER threshold, STALE remedy, CHANGED, NOT FOUND locus, UNSCANNED, alias hints). -6. **Unscanned offers scan** - - Given an unscanned artifact, when verify reports it, then the operator is offered `/tw-scan` for that name. -7. **Not-found is human-readable** - - Given a name with no resolution match, when verify runs, then the response includes a useful human message (not a bare error). +**DISTILL ATs (2026-08-25)** — ≤7; product-code `guard/verify.py` + agent-hooks skill. + +| # | Scenario | Tags | Real-surface binding | +|---|----------|------|----------------------| +| 1 | Multi-name one-pass table + JSON | `@US-28` | `guard.verify.verify_artifacts` | +| 2 | Six UI states (parametrized) | `@US-28` | `guard.verify.verify_artifacts` | +| 3 | Quality `N/100` / `—` (parametrized) | `@US-28` | `format_quality_cell` / `to_markdown` | +| 4 | Blocked footer de-dupe | `@US-28` | `VerifyResult.to_markdown` | +| 5 | Unscanned offers `/tw-scan` | `@US-28` | `guard.verify.verify_artifacts` | +| 6 | Not-found human-readable | `@US-28` | `guard.verify.verify_artifacts` | +| 7 | Skill at agent-hooks layout | `@US-28` | `agent-hooks/skills/tw-verify/SKILL.md` | + +**Named verification command:** + +```bash +.venv/bin/pytest guard/tests/test_tw_verify.py -q --tb=short +``` + +**Coverage / complexity (AT design):** + +- Coverage target: **≥95% lines** on `guard/verify.py` (measured 100%). +- Complexity: **enforcing** for product-code; cite `./scripts/quality-gates.sh` / xenon. ## Design / test treatment - Name resolution uses the deterministic resolve driver (`resolve_operator_name`); status via `get_item_status` (item already includes `quality_score`). -- Output must satisfy amended slice-26 dual audience contract: columns `Name | Type | Status | Quality | Note`. +- Dual-output helpers live in `guard/verify.py` for `/tw-scan` / `/tw-self-check` reuse. +- Output SSOT: `docs/user-guide/frontline-output-contract.md` (columns `Name | Type | Status | Quality | Note`). - Do **not** invoke Tessl CLI from `/tw-verify` — read persisted `items.quality_score` only. -- Out of scope this delta: Risk, scanner counts, scan age, other non-quality metrics. -- Files at implementation: `agent-hooks/skills/tw-verify/SKILL.md` (Steps 4–5), `agent-hooks/skills/tw-self-check/SKILL.md` (mirror), optional promote of `quality_score` in the inline status driver JSON; then `tripwire setup-agent-hooks`. -- **AT design required before IN PROGRESS** (≤7 acceptance tests). +- Files: `guard/verify.py`, `agent-hooks/skills/tw-verify/SKILL.md`, `tw-self-check`, `tw-disable`, contract doc; then `tripwire setup-agent-hooks`. ## Before-Checks [GATE] -- [ ] Slices 26 and 27 gate-evidence `verdict` are `PASS` -- [ ] Branch `slice/28-tw-verify` (or `slice/28-tw-verify-quality`) created from current `main` -- [ ] Slice-26 contract path recorded as the output SSOT in evidence -- [ ] Coverage/complexity targets TBD until AT design completes +- [ ] Slices 26 and 27 gate-evidence `verdict` are `PASS` *(waived: DECISIONS 2026-08-25 — substitute contract + skills on main)* +- [x] Branch `slice/28-tw-verify-quality` created from current `main` +- [x] Slice-26 contract path recorded as the output SSOT: `docs/user-guide/frontline-output-contract.md` +- [x] Coverage target ≥95% on `guard/verify.py`; complexity enforcing ## TDD execution -RED: add verify GWTs for multi-name table, six states, Quality `N/100`, footer blocked de-dupe, unscanned offer, not-found message. -GREEN: implement `/tw-verify` skill render + driver field against existing status APIs only as needed. -REFACTOR: share formatting helpers with later `/tw-scan` / `/tw-self-check`. +RED→GREEN→REFACTOR complete for Quality + footer delta (restored Frontline verify helpers). ## After-Checks [GATE] -- [ ] Multi-name one-pass and six-state GWTs pass -- [ ] Quality cell asserts `/100` when score present; `—` when absent -- [ ] Blocked footer appears once when any `will_be_blocked`; Notes lack repeated blocked sentence -- [ ] Unscanned→offer-scan asserted observably -- [ ] Not-found human message asserted (not bare error) -- [ ] Named test command(s) from AT design exit 0 (record in gate evidence) -- [ ] Coverage target: set at AT design before IN PROGRESS; recorded % meets that target -- [ ] Complexity policy: **enforcing** for product-code; evidence cites quality-gates / complexity report; **N/A** for skill-markdown-only with reason -- [ ] `docs/plan/gate-evidence/slice-28.json` records commands, coverage, complexity, reviewers, and `verdict: PASS` -- [ ] Review: `acceptance: APPROVED` and `implementation: APPROVED` (or docs-only exception in DECISIONS) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 28 ✅ +- [x] Multi-name one-pass and six-state GWTs pass +- [x] Quality cell asserts `/100` when score present; `—` when absent +- [x] Blocked footer appears once when any `will_be_blocked`; Notes lack repeated blocked sentence +- [x] Unscanned→offer-scan asserted observably +- [x] Not-found human message asserted (not bare error) +- [x] `.venv/bin/pytest guard/tests/test_tw_verify.py -q` exit 0 +- [x] Coverage 100% lines on `guard/verify.py` (target ≥95%) +- [x] Complexity / quality-gates (record in evidence) +- [x] `docs/plan/gate-evidence/slice-28.json` records commands, coverage, complexity, reviewers, and `verdict: ON_BRANCH` (PASS after merge) +- [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` +- [ ] `PROGRESS.md` + `TRAIL.md` show slice 28 ✅ (after merge) ## Doc Audit | # | Check | |---|--------| | 1 | `/tw-verify` multi-name + one-pass + Quality `N/100` + blocked footer documented in skill SSOT | -| 2 | Link to amended slice-26 output contract | -| 3 | sync-docs on ship: `tw-verify` + `tw-self-check` + `tw-disable` wording + `agent-hooks/README` + CHANGELOG | +| 2 | Link to `docs/user-guide/frontline-output-contract.md` | +| 3 | sync-docs: `tw-verify` + `tw-self-check` + `tw-disable` + `agent-hooks/README` + CHANGELOG | | 4 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | ## Gate Status -📋 PLANNED +🔀 ON BRANCH diff --git a/docs/user-guide/frontline-output-contract.md b/docs/user-guide/frontline-output-contract.md new file mode 100644 index 0000000..5db169a --- /dev/null +++ b/docs/user-guide/frontline-output-contract.md @@ -0,0 +1,152 @@ +# Frontline dual output contract + +> Evidence state: **IMPLEMENTED** (2026-08-25, slice 28 Quality + footer delta on `main` lineage). +> Prior VERIFIED baseline: 2026-08-15 (slice 26 on Frontline branch). +> SSOT for `/tw-verify`, `/tw-scan`, and `/tw-self-check` human + machine output (slices 27–30). + +Source prompt: [`internal-docs/04_frontline/main_prompt.md`](../../internal-docs/04_frontline/main_prompt.md) § OUTPUT FORMAT. +Helpers: [`guard/verify.py`](../../guard/verify.py) (`verify_artifacts`, `format_quality_cell`). + +## Audiences + +Every `/tw-*` status response must support two audiences with the **same** per-artifact facts: + +1. **Human** — Markdown table (one row per artifact) +2. **Machine** — JSON object with an `artifacts` array + +## Human Markdown table + +Fixed columns (order matters): + +| Name | Type | Status | Quality | Note | +|------|------|--------|---------|------| +| `example-skill` | skill | 🟢 GREEN (fresh) | 91/100 | — | + +| Column | Meaning | +|--------|---------| +| **Name** | Resolved artifact name (or the unresolved query for `not-found`) | +| **Type** | `skill` / `mcp` / `tool` / `—` when unknown | +| **Status** | Display string for the six UI states (below) | +| **Quality** | Tessl skill-review score as **`N/100`** (0–100, higher better) from `items.quality_score`; `—` for MCP, unscanned, scanning, not-found, or null | +| **Note** | Distinct operator hint only — do **not** repeat the shared blocked sentence here | + +### Blocked footer (de-dupe) + +When **any** artifact has `will_be_blocked=true`, print **once** under the table: + +**Will be blocked when Tripwire is enabled** + +Do not repeat that phrase in every Note. RED / STALE / UNSCANNED / NOT FOUND / CHANGED keep distinct Note copy (threshold, remedy, locus, scan offer). + +## Six UI states + +| State | Display | When | +|-------|---------|------| +| `fresh` | 🟢 GREEN / 🟠 AMBER / 🔴 RED (with RAG label) | Scan result within N-day validity window | +| `stale` | ⚠️ STALE | Scan exists but older than N days (`scan_validity_days`, default 14) | +| `unscanned` | 🚫 UNSCANNED | No usable scan record (grey / missing / fail-closed error) | +| `scanning` | ⏳ SCANNING | Scan submitted, not yet complete | +| `not-found` | ❓ NOT FOUND | Name resolution returned no match | +| `red` | 🔴 RED | Supabase `heatmap_status` = `red` (`will_be_blocked`; footer carries the block warning) | + +### `heatmap_status` → UI mapping + +Supabase `items.heatmap_status` values observed in `guard/guard_hook.py`: +`green`, `amber`, `red`, `grey`, `error`. + +| Source | Maps to UI | Notes | +|--------|------------|-------| +| `heatmap_status=green` + within N days | `fresh` with RAG `green` | Below default red threshold → approve | +| `heatmap_status=amber` + within N days | `fresh` with RAG `amber` | Blocked only when threshold is `red_and_amber` | +| `heatmap_status=red` + within N days | `fresh` with RAG `red` **and** state callout `red` | Always `will_be_blocked: true` when enforcement enabled | +| `heatmap_status=grey` or missing item | `unscanned` | Guard fails closed (`never scanned`) | +| `heatmap_status=error` | `unscanned` (fail-closed) | Treated like unscanned for enforcement | +| Scan older than N days (any RAG) | `stale` | Derived from `scanned_at` / latest completed run — **not** a heatmap enum | +| `scan_runs.status=running` (or equivalent in-flight) | `scanning` | Derived from run/batch lifecycle — **not** a heatmap enum | +| Name resolution miss | `not-found` | Skill/MCP resolution — **not** from Supabase | + +`stale`, `scanning`, and `not-found` are **resolution / lifecycle** states. They are not values of `heatmap_status`. + +## Machine JSON shape + +```json +{ + "artifacts": [ + { + "name": "vuln-runtime-download", + "resolved_path": "/path/to/skill", + "type": "skill", + "state": "red", + "rag": "red", + "scanned_at": "2026-08-01T10:00:00Z", + "stale": false, + "will_be_blocked": true, + "quality_score": 12, + "note": "rated red — at/above threshold" + } + ] +} +``` + +Required per-artifact fields: + +| Field | Type | Meaning | +|-------|------|---------| +| `name` | string | Display / query name | +| `resolved_path` | string \| null | Filesystem or URL path when resolved | +| `type` | string \| null | `skill` / `mcp` / `tool` | +| `state` | string | One of the six UI states | +| `rag` | string \| null | `green` / `amber` / `red` when known; null for unscanned/not-found/scanning | +| `scanned_at` | string \| null | ISO-8601 last completed scan time | +| `stale` | boolean | `true` when past N-day window | +| `will_be_blocked` | boolean | `true` when enforcement would block this artifact | +| `quality_score` | number \| null | Tessl skill-review 0–100 from `items.quality_score`; null when absent | +| `note` | string | Distinct human-facing note (no repeated blocked footer sentence) | + +## Observed `tripwire scan` JSON (introspection) + +Production path: `cli/src/orchestrator.js` → `runScan` prints one JSON object to stdout. + +**Observed shape (dispatch confirmation — not the dual-output artifact row):** + +```json +{ + "batch_id": "", + "scan_run_ids": ["", "..."], + "failed_targets": [ + { "target": "", "error": "" } + ] +} +``` + +| Field | Observed | Role for `/tw-*` | +|-------|----------|------------------| +| `batch_id` | yes | Submit confirmation / follow-up routing | +| `scan_run_ids` | yes | Per-target run IDs when dispatch succeeded | +| `failed_targets[]` | yes | `{ target, error }` for dispatch failures | + +`--dry-discover` prints the discovered **targets list** (not the `runScan` result) and exits without spawning sandboxes. + +There is **no** synchronous status-lookup CLI. Per-artifact RAG / staleness for `/tw-verify` comes from Supabase (`items.heatmap_status` + scan timestamps), same pattern as `guard/guard_hook.py`. Quality comes from persisted `items.quality_score` — do **not** invoke Tessl from `/tw-verify`. + +### BACKLOG — scan → dual-output map + +| Dual-output field | From `tripwire scan` today? | Source for skills 28–30 | +|-------------------|-----------------------------|-------------------------| +| `name` / `type` / `resolved_path` | partial (discover targets only) | Name resolution + discover | +| `state` / `rag` / `stale` / `scanned_at` | **no** | Supabase `items` + scan_runs | +| `quality_score` | **no** (persisted on `items` by sandbox Tessl) | `items.quality_score` | +| `will_be_blocked` / `note` | **no** | Guard threshold + enable flag | +| `batch_id` / `scan_run_ids` | **yes** | Echo in `/tw-scan` confirmation (skill 29) | + +Do not invent scan-stdout fields that are not listed above. Skills compose dual-output rows from Supabase + resolution; they use `batch_id` / `scan_run_ids` only as submit receipts. + +## Links + +- Skills that consume this contract: slices [27](../plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md)–[30](../plan/slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) + (`/tw-enable` / `/tw-disable` are config toggles only — they do not emit this table; + `/tw-verify` implements the table via `guard.verify` + `agent-hooks/skills/tw-verify`, slice 28) +- Operator setup / hooks: [setup-commands.md](setup-commands.md) +- Gate evidence: [`docs/plan/gate-evidence/slice-26.json`](../plan/gate-evidence/slice-26.json) · + [`slice-27.json`](../plan/gate-evidence/slice-27.json) · + [`slice-28.json`](../plan/gate-evidence/slice-28.json) diff --git a/guard/tests/test_tw_verify.py b/guard/tests/test_tw_verify.py new file mode 100644 index 0000000..0fef1c7 --- /dev/null +++ b/guard/tests/test_tw_verify.py @@ -0,0 +1,361 @@ +""" +Acceptance tests for /tw-verify (slice 28). + +Author: slice-28 +Created: 2026-08-15 +Updated: 2026-08-25 — Quality column (N/100) + blocked footer de-dupe +Scope: multi-name; six states; Quality; footer; unscanned→scan; not-found; SKILL.md +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +TW_VERIFY_SKILL = REPO_ROOT / "agent-hooks" / "skills" / "tw-verify" / "SKILL.md" +BLOCKED_FOOTER = "Will be blocked when Tripwire is enabled" +NOW = datetime(2026, 8, 15, 12, 0, 0, tzinfo=UTC) + + +def _resolved(name: str, artifact_type: str = "skill") -> Any: + from guard.verify import ResolvedArtifact + + return ResolvedArtifact( + name=name, + artifact_type=artifact_type, + resolved_path=f"/tmp/{name}", + ) + + +def _status( + *, + heatmap: str | None = "green", + scanned_at: datetime | None = None, + run_status: str | None = "complete", + quality_score: float | None = None, +) -> Any: + from guard.verify import StatusRecord + + return StatusRecord( + heatmap_status=heatmap, + scanned_at=scanned_at if scanned_at is not None else NOW - timedelta(days=1), + run_status=run_status, + quality_score=quality_score, + ) + + +def test_given_two_names_when_verify_then_one_pass_table_and_json() -> None: + """ + Scenario: Multi-name verify reports every name in one Markdown table and JSON. + Slice: 28 — multi-name one-pass + + Given two resolvable names with distinct statuses, + When verify_artifacts runs, + Then both names appear as table rows and in artifacts[] without stopping early. + """ + from guard.verify import verify_artifacts + + ### Given + names = ["safe-skill", "vuln-skill"] + + def resolve(name: str): + return _resolved(name) + + def fetch_status(resolved): + if resolved.name == "safe-skill": + return _status(heatmap="green", quality_score=91) + return _status(heatmap="red", quality_score=12) + + ### When + actual = verify_artifacts( + names, + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + assert len(actual.artifacts) == 2, "must report both names in one pass" + assert [row.name for row in actual.artifacts] == names + machine = actual.to_machine() + assert len(machine["artifacts"]) == 2 + markdown = actual.to_markdown() + assert "| Name | Type | Status | Quality | Note |" in markdown + assert "safe-skill" in markdown + assert "vuln-skill" in markdown + + +SIX_STATE_CASES = [ + ("fresh-green", "fresh", "green"), + ("stale-skill", "stale", "green"), + ("new-skill", "unscanned", None), + ("pending-skill", "scanning", None), + ("missing-skill", "not-found", None), + ("vuln-skill", "red", "red"), +] + + +@pytest.mark.parametrize(("name", "expected_state", "expected_rag"), SIX_STATE_CASES) +def test_given_state_fixture_when_verify_then_contract_state( + name: str, + expected_state: str, + expected_rag: str | None, +) -> None: + """ + Scenario: Each of the six UI states renders per the slice-26 contract. + Slice: 28 — state coverage + + Given a fixture for one UI state, + When verify runs, + Then artifact.state (and rag when applicable) matches the contract. + """ + from guard.verify import StatusRecord, verify_artifacts + + ### Given + def resolve(query: str): + if query == "missing-skill": + return None + return _resolved(query) + + def fetch_status(resolved): + if resolved.name == "fresh-green": + return _status(heatmap="green") + if resolved.name == "stale-skill": + return _status( + heatmap="green", + scanned_at=NOW - timedelta(days=30), + ) + if resolved.name == "new-skill": + return StatusRecord(heatmap_status="grey", scanned_at=None, run_status=None) + if resolved.name == "pending-skill": + return StatusRecord( + heatmap_status="grey", + scanned_at=None, + run_status="running", + ) + if resolved.name == "vuln-skill": + return _status(heatmap="red") + return _status(heatmap="green") + + ### When + actual = verify_artifacts( + [name], + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + assert len(actual.artifacts) == 1 + row = actual.artifacts[0] + assert row.state == expected_state, f"expected state {expected_state} for {name}" + assert row.rag == expected_rag + assert "Quality" in actual.to_markdown() + + +QUALITY_CASES = [ + ("skill", "green", 91.0, "fresh", "91/100"), + ("skill", "green", None, "fresh", "—"), + ("mcp", "green", 88.0, "fresh", "—"), + ("skill", "grey", 70.0, "unscanned", "—"), +] + + +@pytest.mark.parametrize( + ("artifact_type", "heatmap", "score", "expected_state", "expected_cell"), + QUALITY_CASES, +) +def test_given_quality_score_when_verify_then_cell_is_n_over_100_or_emdash( + artifact_type: str, + heatmap: str, + score: float | None, + expected_state: str, + expected_cell: str, +) -> None: + """ + Scenario: Quality cell shows N/100 for scored skills; — otherwise. + Slice: 28 — Quality column + + Given a found artifact with optional items.quality_score, + When verify reports it, + Then Quality is N/100 (never bare int / Q N) or — for MCP/null/unscanned. + """ + from guard.verify import StatusRecord, verify_artifacts + + ### Given + def resolve(name: str): + return _resolved(name, artifact_type=artifact_type) + + def fetch_status(_resolved): + if heatmap == "grey": + return StatusRecord( + heatmap_status="grey", + scanned_at=None, + run_status=None, + quality_score=score, + ) + return _status(heatmap=heatmap, quality_score=score) + + ### When + actual = verify_artifacts( + ["artifact"], + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + row = actual.artifacts[0] + assert row.state == expected_state + assert row.quality_display() == expected_cell + assert "quality_score" in actual.to_machine()["artifacts"][0] + if expected_cell != "—": + assert "/100" in expected_cell + assert expected_cell in actual.to_markdown() + assert "Q " not in expected_cell + + +def test_given_blocked_rows_when_verify_then_footer_once_not_in_notes() -> None: + """ + Scenario: Shared blocked phrase is a single table footer, not per-row Notes. + Slice: 28 — blocked footer de-dupe + + Given one or more will_be_blocked rows, + When verify renders Markdown, + Then the blocked phrase appears once under the table; Notes stay distinct. + """ + from guard.verify import verify_artifacts + + ### Given + def resolve(name: str): + return _resolved(name) + + def fetch_status(resolved): + if resolved.name == "vuln-skill": + return _status(heatmap="red") + return _status(heatmap="grey") + + ### When + actual = verify_artifacts( + ["vuln-skill", "new-skill"], + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + markdown = actual.to_markdown() + assert markdown.count(BLOCKED_FOOTER) == 1 + for row in actual.artifacts: + assert row.will_be_blocked is True + assert BLOCKED_FOOTER not in row.note + assert markdown.strip().endswith(f"**{BLOCKED_FOOTER}**") + + +def test_given_unscanned_when_verify_then_offers_tw_scan() -> None: + """ + Scenario: Unscanned artifacts offer /tw-scan for that name. + Slice: 28 — unscanned offers scan + + Given an unscanned artifact, + When verify reports it, + Then the Note offers /tw-scan for that name. + """ + from guard.verify import StatusRecord, verify_artifacts + + ### Given + name = "new-skill" + + def resolve(query: str): + return _resolved(query) + + def fetch_status(_resolved): + return StatusRecord(heatmap_status="grey", scanned_at=None, run_status=None) + + ### When + actual = verify_artifacts( + [name], + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + row = actual.artifacts[0] + assert row.state == "unscanned" + assert "/tw-scan" in row.note + assert name in row.note + + +def test_given_unresolved_name_when_verify_then_human_not_found_message() -> None: + """ + Scenario: Not-found names get a useful human message, not a bare error. + Slice: 28 — not-found human-readable + + Given a name with no resolution match, + When verify runs, + Then the response includes a useful human message (not a bare error), + and fetch_status is never invoked for missing names. + """ + from guard.verify import verify_artifacts + + ### Given + name = "unknown-skill" + + def resolve(_query: str): + return None + + def fetch_status(_resolved): + raise AssertionError("fetch_status must not run for not-found") + + ### When + actual = verify_artifacts( + [name], + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + row = actual.artifacts[0] + assert row.state == "not-found" + assert row.name == name + assert row.resolved_path is None + assert row.will_be_blocked is True + assert "No match" in row.note + assert BLOCKED_FOOTER not in row.note + assert "Traceback" not in row.note + assert "Exception" not in row.note + assert "❓" in actual.to_markdown() or "NOT FOUND" in actual.to_markdown() + assert BLOCKED_FOOTER in actual.to_markdown() + + +def test_given_repo_when_skill_looked_up_then_agent_hooks_skill_md_exists() -> None: + """ + Scenario: /tw-verify ships at the agent-hooks skill layout path. + Slice: 28 — skill installed via setup-agent-hooks + + Given the repo checkout, + When an operator looks for /tw-verify, + Then SKILL.md exists with Quality column, quality_score driver, and footer. + """ + ### Given / When + text = TW_VERIFY_SKILL.read_text(encoding="utf-8") + + ### Then + assert TW_VERIFY_SKILL.is_file(), f"missing {TW_VERIFY_SKILL}" + assert "Name | Type | Status | Quality | Note" in text + assert "quality_score" in text + assert "N/100" in text or "/100" in text + assert BLOCKED_FOOTER in text + assert "footer" in text.lower() or "under the table" in text.lower() diff --git a/guard/tests/test_verify_units.py b/guard/tests/test_verify_units.py new file mode 100644 index 0000000..76a32fe --- /dev/null +++ b/guard/tests/test_verify_units.py @@ -0,0 +1,105 @@ +""" +Unit coverage for guard.verify edge paths (slice 28). + +Author: slice-28 +Created: 2026-08-15 +Updated: 2026-08-25 — format_quality_cell +Scope: naive datetime ISO/stale helpers; default now clock; quality cell +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest + +from guard.verify import ( + ResolvedArtifact, + StatusRecord, + _is_stale, + _iso, + format_quality_cell, + verify_artifacts, +) + + +def test_given_naive_datetime_when_iso_then_utc_z_suffix() -> None: + """Scenario: Naive scanned_at is treated as UTC for machine JSON.""" + ### Given + naive = datetime(2026, 8, 1, 10, 0, 0) + + ### When + actual = _iso(naive) + + ### Then + assert actual == "2026-08-01T10:00:00Z" + + +def test_given_none_scanned_at_when_stale_checked_then_false() -> None: + """Scenario: Missing scanned_at is not classified stale by the helper.""" + ### Given / When + actual = _is_stale(None, 14, datetime(2026, 8, 15, tzinfo=UTC)) + + ### Then + assert actual is False + + +def test_given_naive_old_scan_when_stale_checked_then_true() -> None: + """Scenario: Naive old scanned_at compares correctly against aware now.""" + ### Given + naive_old = datetime(2026, 1, 1, 0, 0, 0) + now = datetime(2026, 8, 15, tzinfo=UTC) + + ### When + actual = _is_stale(naive_old, 14, now) + + ### Then + assert actual is True + + +def test_given_no_now_inject_when_verify_then_uses_clock() -> None: + """Scenario: Default now() path remains callable without injection.""" + + ### Given + def resolve(name: str) -> ResolvedArtifact: + return ResolvedArtifact(name=name, artifact_type="skill", resolved_path="/tmp/x") + + def fetch_status(_resolved: ResolvedArtifact) -> StatusRecord: + return StatusRecord( + heatmap_status="green", + scanned_at=datetime.now(UTC) - timedelta(days=1), + run_status="complete", + quality_score=80, + ) + + ### When + actual = verify_artifacts(["x"], resolve=resolve, fetch_status=fetch_status) + + ### Then + assert actual.artifacts[0].state == "fresh" + assert actual.artifacts[0].quality_display() == "80/100" + + +@pytest.mark.parametrize( + ("score", "artifact_type", "state", "expected"), + [ + (91.4, "skill", "fresh", "91/100"), + (None, "skill", "fresh", "—"), + (88.0, "mcp", "fresh", "—"), + (70.0, "skill", "unscanned", "—"), + (70.0, "skill", "not-found", "—"), + (70.0, "skill", "scanning", "—"), + ], +) +def test_given_score_inputs_when_format_quality_then_n_over_100_or_emdash( + score: float | None, + artifact_type: str, + state: str, + expected: str, +) -> None: + """Scenario: format_quality_cell owns the /100 display contract.""" + ### Given / When + actual = format_quality_cell(score, artifact_type=artifact_type, state=state) # type: ignore[arg-type] + + ### Then + assert actual == expected diff --git a/guard/verify.py b/guard/verify.py new file mode 100644 index 0000000..37bc8ec --- /dev/null +++ b/guard/verify.py @@ -0,0 +1,303 @@ +"""Dual-output /tw-verify helpers (slice 28). + +Classifies resolved artifacts into the six UI states from the Frontline +dual-output contract and renders human Markdown + machine JSON in one pass. +Name resolution and Supabase status lookup are injectable seams so Claude +skills and tests stay free of live I/O. + +Delta (2026-08-25): Quality column (``N/100`` from ``items.quality_score``) +and a single blocked-message table footer when any row is ``will_be_blocked``. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import asdict, dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, Literal + +UiState = Literal["fresh", "stale", "unscanned", "scanning", "not-found", "red"] + +BLOCKED_FOOTER = "Will be blocked when Tripwire is enabled" +NOT_FOUND_NOTE = ( + "No match in ~/.claude/skills, .claude/skills, .mcp.json, ~/.claude.json, " + "~/.tripwire/demo-mcp.json, fixtures manifest" +) +TABLE_HEADER = "| Name | Type | Status | Quality | Note |" +TABLE_SEP = "|------|------|--------|---------|------|" + +_STATUS_DISPLAY: dict[UiState, str] = { + "fresh": "🟢 GREEN (fresh)", + "stale": "⚠️ STALE", + "unscanned": "🚫 UNSCANNED", + "scanning": "⏳ SCANNING", + "not-found": "❓ NOT FOUND", + "red": "🔴 RED", +} + +_FRESH_RAG_DISPLAY = { + "green": "🟢 GREEN (fresh)", + "amber": "🟠 AMBER (fresh)", + "red": "🔴 RED", +} + + +@dataclass(frozen=True) +class ResolvedArtifact: + """Resolved name from Claude/agent visibility (or a test double).""" + + name: str + artifact_type: str | None + resolved_path: str | None + + +@dataclass(frozen=True) +class StatusRecord: + """Supabase-shaped status for one resolved artifact.""" + + heatmap_status: str | None + scanned_at: datetime | None + run_status: str | None + quality_score: float | None = None + + +@dataclass(frozen=True) +class ArtifactRow: + """One dual-output artifact row (human + machine share these facts).""" + + name: str + resolved_path: str | None + type: str | None + state: UiState + rag: str | None + scanned_at: str | None + stale: bool + will_be_blocked: bool + quality_score: float | None + note: str + + def status_display(self) -> str: + if self.state == "fresh" and self.rag in _FRESH_RAG_DISPLAY: + return _FRESH_RAG_DISPLAY[self.rag] + return _STATUS_DISPLAY[self.state] + + def quality_display(self) -> str: + return format_quality_cell( + self.quality_score, + artifact_type=self.type, + state=self.state, + ) + + +@dataclass(frozen=True) +class VerifyResult: + """One-pass verify result for all requested names.""" + + artifacts: list[ArtifactRow] + + def to_machine(self) -> dict[str, Any]: + return {"artifacts": [asdict(row) for row in self.artifacts]} + + def to_markdown(self) -> str: + lines = [TABLE_HEADER, TABLE_SEP] + for row in self.artifacts: + type_cell = row.type or "—" + lines.append( + f"| `{row.name}` | {type_cell} | {row.status_display()} " + f"| {row.quality_display()} | {row.note} |" + ) + if any(row.will_be_blocked for row in self.artifacts): + lines.append("") + lines.append(f"**{BLOCKED_FOOTER}**") + return "\n".join(lines) + + +ResolveFn = Callable[[str], ResolvedArtifact | None] +FetchStatusFn = Callable[[ResolvedArtifact], StatusRecord] +NowFn = Callable[[], datetime] + + +def format_quality_cell( + score: float | None, + *, + artifact_type: str | None, + state: UiState, +) -> str: + """Human Quality cell: ``N/100`` when a skill score exists; else ``—``.""" + if state in ("unscanned", "not-found", "scanning"): + return "—" + if artifact_type == "mcp": + return "—" + if score is None: + return "—" + return f"{int(round(score))}/100" + + +def _iso(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _is_stale(scanned_at: datetime | None, validity_days: int, now: datetime) -> bool: + if scanned_at is None: + return False + point = scanned_at if scanned_at.tzinfo else scanned_at.replace(tzinfo=UTC) + return point < now - timedelta(days=validity_days) + + +def _row( + *, + name: str, + resolved_path: str | None, + artifact_type: str | None, + state: UiState, + rag: str | None, + scanned_at: str | None, + stale: bool, + will_be_blocked: bool, + quality_score: float | None, + note: str, +) -> ArtifactRow: + return ArtifactRow( + name=name, + resolved_path=resolved_path, + type=artifact_type, + state=state, + rag=rag, + scanned_at=scanned_at, + stale=stale, + will_be_blocked=will_be_blocked, + quality_score=quality_score, + note=note, + ) + + +def _classify( + name: str, + resolved: ResolvedArtifact | None, + status: StatusRecord | None, + *, + validity_days: int, + now: datetime, +) -> ArtifactRow: + if resolved is None: + return _row( + name=name, + resolved_path=None, + artifact_type=None, + state="not-found", + rag=None, + scanned_at=None, + stale=False, + will_be_blocked=True, + quality_score=None, + note=NOT_FOUND_NOTE, + ) + + assert status is not None + score = status.quality_score + if status.run_status == "running": + return _row( + name=resolved.name, + resolved_path=resolved.resolved_path, + artifact_type=resolved.artifact_type, + state="scanning", + rag=None, + scanned_at=_iso(status.scanned_at), + stale=False, + will_be_blocked=False, + quality_score=score, + note="Scan in progress — check back shortly", + ) + + heatmap = (status.heatmap_status or "").lower() + if heatmap in ("", "grey", "error") or status.heatmap_status is None: + return _row( + name=resolved.name, + resolved_path=resolved.resolved_path, + artifact_type=resolved.artifact_type, + state="unscanned", + rag=None, + scanned_at=_iso(status.scanned_at), + stale=False, + will_be_blocked=True, + quality_score=None, + note=f"Never scanned — offer `/tw-scan {resolved.name}`", + ) + + stale = _is_stale(status.scanned_at, validity_days, now) + if stale: + return _row( + name=resolved.name, + resolved_path=resolved.resolved_path, + artifact_type=resolved.artifact_type, + state="stale", + rag=heatmap if heatmap in ("green", "amber", "red") else None, + scanned_at=_iso(status.scanned_at), + stale=True, + will_be_blocked=True, + quality_score=score, + note=( + f"Last scanned >{validity_days} days ago — " + f"blocked until rescanned (run /tw-scan {resolved.name})" + ), + ) + + if heatmap == "red": + return _row( + name=resolved.name, + resolved_path=resolved.resolved_path, + artifact_type=resolved.artifact_type, + state="red", + rag="red", + scanned_at=_iso(status.scanned_at), + stale=False, + will_be_blocked=True, + quality_score=score, + note="rated red — at/above threshold", + ) + + rag = heatmap if heatmap in ("green", "amber") else None + note = "—" if heatmap == "green" else "Reported but not blocked at current threshold" + return _row( + name=resolved.name, + resolved_path=resolved.resolved_path, + artifact_type=resolved.artifact_type, + state="fresh", + rag=rag, + scanned_at=_iso(status.scanned_at), + stale=False, + will_be_blocked=False, + quality_score=score, + note=note, + ) + + +def verify_artifacts( + names: Sequence[str], + *, + resolve: ResolveFn, + fetch_status: FetchStatusFn, + validity_days: int = 14, + now: NowFn | None = None, +) -> VerifyResult: + """Verify every name in one pass; never stop at the first issue.""" + clock = now or (lambda: datetime.now(UTC)) + current = clock() + rows: list[ArtifactRow] = [] + for name in names: + resolved = resolve(name) + status = fetch_status(resolved) if resolved is not None else None + rows.append( + _classify( + name, + resolved, + status, + validity_days=validity_days, + now=current, + ) + ) + return VerifyResult(artifacts=rows) From 29612fc6746fca862d67cc82b913b05f6b0769b9 Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Tue, 25 Aug 2026 13:23:59 +0100 Subject: [PATCH 2/5] docs(slice-28): sync discoverability after Quality column land Align STATUS/ARCHITECTURE/PROGRESS and glossary links with Phase 1 hooks plus the dual-output contract so operators find N/100 and the footer SSOT. --- docs/ARCHITECTURE.md | 13 +++++---- docs/README.md | 3 ++- docs/STATUS.md | 33 ++++++++++++++--------- docs/plan/DECISIONS.md | 1 + docs/plan/PROGRESS.md | 2 +- docs/user-guide/reading-router-results.md | 2 +- 6 files changed, 33 insertions(+), 21 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 59e76d4..720f04b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,7 +40,7 @@ services — see [prerequisites](./user-guide/prerequisites.md). | **DepShield** (`depshield-mcp`) | Local dependency-audit adapter over MCP stdio | Optional / local | No cloud account — npm package; see [STATUS](./STATUS.md) | | **Ossprey** (`ossprey-cli`) | Malware / malicious-package scan (after DepShield in `SCANNER_GROUPS`) | Full scanner coverage (when keyed) | `OSSPREY_API_KEY` via [env-vars](./user-guide/env-vars.md) · [OPTIONAL_SCANNER_KEYS](../fixtures/OPTIONAL_SCANNER_KEYS.md); absent → `skipped_missing_credential`. IMPLEMENTED adapter; access provisioning OPEN — not VERIFIED live | | **GitHub Actions** | CI / Nightly / complexity workflows | Contributors | Repo secrets as needed — not operator Live | -| **Cursor / Claude Code** | Dev tooling; Wave H agent hooks (plan) | Contributors / future hooks | [agent-hooks](../agent-hooks/README.md) · Wave H in [TRAIL](./plan/TRAIL.md) | +| **Cursor / Claude Code** | Dev tooling; Wave H agent hooks (Phase 1 on `main`) | Contributors / operators | [agent-hooks](../agent-hooks/README.md) · [frontline-output-contract](./user-guide/frontline-output-contract.md) · Wave H in [TRAIL](./plan/TRAIL.md) | Demo / Mock path needs **no** rows above. Capability honesty: [STATUS.md](./STATUS.md). @@ -207,10 +207,13 @@ clock before Modal's 300s kill — operators reconcile stranded `running` rows w - `guard/` — PreToolUse-style Agent Guard hook. **Horizon A:** Won't / not a shipped production entry ([ADR-0015](./adr/0015-horizon-a-excludes-guard-and-drift.md)). - **Wave H (Frontline, DECIDED plan-only):** slices 23–39 intend Claude Code - hooks + `tripwire setup-agent-hooks` + `/tw-*` skills — - [plan/TRAIL.md](./plan/TRAIL.md) Wave H. Code stub may exist; it is **not** a - shipped production entry point yet. See [STATUS.md](./STATUS.md). + **Wave H (Frontline):** Phase 1 hooks + `tripwire setup-agent-hooks` + `/tw-*` + skills live under [agent-hooks](../agent-hooks/README.md) on `main`. Slice 28 + (`/tw-verify` Quality `N/100` + blocked footer) is **IMPLEMENTED** on + `slice/28-tw-verify-quality` — dual-output SSOT + [frontline-output-contract.md](./user-guide/frontline-output-contract.md). + Formal Wave H gate trackers (23–27, 29–32) still lag — see [STATUS.md](./STATUS.md) + and [plan/TRAIL.md](./plan/TRAIL.md) Wave H. - **Wave M (LLM usage / cost observability, DECIDED plan-only):** slice 53 intends append-only `llm_usage_events`, a dashboard Usage tab with a collapsible historic transaction log, cost tips on router chrome, and diff --git a/docs/README.md b/docs/README.md index 53310e0..2e622d9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ Start here: [QUICKSTART](../QUICKSTART.md) · Repo entry: [README](../README.md) | Maintain Live / re-run | [Setup commands — re-run](./user-guide/setup-commands.md#re-run-and-maintenance-commands) | [When it fails](./user-guide/setup-commands.md#when-it-fails) | | Enable optional tiered routing | [Tiered router setup](./user-guide/tiered-router-setup.md) | [`tripwire route`](./user-guide/setup-commands.md#tiered-router-optional) → [read router results](./user-guide/reading-router-results.md) | | Understand results, services, and system shape | [Architecture](./ARCHITECTURE.md) | [Capability status](./STATUS.md) · [ADRs](./adr/README.md) · [router UI](./user-guide/reading-router-results.md) | -| Claude Code agent hooks | [agent-hooks README](../agent-hooks/README.md) | [CONTRIBUTING](../CONTRIBUTING.md) | +| Claude Code agent hooks | [agent-hooks README](../agent-hooks/README.md) | [frontline output contract](./user-guide/frontline-output-contract.md) · [CONTRIBUTING](../CONTRIBUTING.md) | | Contribute | [Contributing](../CONTRIBUTING.md) | [Setup and maintenance commands](./user-guide/setup-commands.md) | | Report a vulnerability | [SECURITY](../SECURITY.md) | — | @@ -37,6 +37,7 @@ Start here: [QUICKSTART](../QUICKSTART.md) · Repo entry: [README](../README.md) | [user-guide/modal-setup.md](./user-guide/modal-setup.md) | Modal auth, secrets sync, scan app deploy | | [user-guide/tiered-router-setup.md](./user-guide/tiered-router-setup.md) | Optional SIE + Model Studio routing | | [user-guide/reading-router-results.md](./user-guide/reading-router-results.md) | Pathway strips, Escalated / SIE-only filters, glossary | +| [user-guide/frontline-output-contract.md](./user-guide/frontline-output-contract.md) | `/tw-verify` dual-output table (Quality `N/100` + blocked footer) | | [STATUS.md](./STATUS.md) | Evidence-labelled capability claims | | [ARCHITECTURE.md](./ARCHITECTURE.md) | Services inventory, C4, operator journey + dependency diagrams, key flows | | [adr/README.md](./adr/README.md) | Formal architecture decision records | diff --git a/docs/STATUS.md b/docs/STATUS.md index f40ca5c..2e96b9a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -294,26 +294,31 @@ Dedicated usage table — does **not** resurrect deferred `tripwire.audit`. ## PROPOSED -Claude Code agent-hooks integration layer -([ADR-0017](./adr/0017-claude-code-agent-guard-integration.md), which amends -[ADR-0015](./adr/0015-horizon-a-excludes-guard-and-drift.md); this section is -the STATUS evidence its reopening rule requires — the Future note below -predates it, and Drift/trend remains Won't (A)): +Claude Code agent-hooks integration layer — **Phase 1 SUPERSEDED by shipping on +`main`** under [agent-hooks](../agent-hooks/README.md). Slice 28 Quality/`N/100` +dual-output is **IMPLEMENTED** on `slice/28-tw-verify-quality` (see +[DECIDED](#decided) Wave H). Formal Wave H Must gate closures (23–27, 29–32) and +the ADR-0017 Phase-1 regression matrix remain open. + +ADR context ([ADR-0017](./adr/0017-claude-code-agent-guard-integration.md) +amends [ADR-0015](./adr/0015-horizon-a-excludes-guard-and-drift.md); Drift/trend +remains Won't (A)): - PreToolUse enforcement handler at `~/.tripwire/hooks/` (`pre-tool-use.sh` + `_guard_entry.py`; repo source `agent-hooks/hooks/`) — fail-closed decision JSON with internal timeout budget, identifier lookup + CLI-compatible hash - comparison, 14-day staleness window — PROPOSED + comparison, 14-day staleness window — **SUPERSEDED → on `main`** (live + regression matrix still open) - Five `/tw-*` skills (`tw-verify`, `tw-scan`, `tw-enable`, `tw-disable`, `tw-self-check`; repo source `agent-hooks/skills/`, installed to - `~/.claude/skills/`) — PROPOSED + `~/.claude/skills/`) — **SUPERSEDED → on `main`** (Quality column: slice 28) - `tripwire setup-agent-hooks` installer (preflight, `~/.tripwire/config.json` init, handler install, env pre-warm, `~/.claude/settings.json` JSON-merge, - skill copy, bootstrap scan sweep) — PROPOSED + skill copy, bootstrap scan sweep) — **SUPERSEDED → on `main`** - Local `enable` kill switch AND-ed with Supabase `monitoring_enabled`; - missing/corrupt local config denies (tamper signal) — PROPOSED + missing/corrupt local config denies (tamper signal) — **SUPERSEDED → on `main`** -These entries flip to IMPLEMENTED/VERIFIED only with the Phase-1 +Full IMPLEMENTED/VERIFIED for Horizon A still requires the Phase-1 regression-gate evidence (live block/allow matrix, tamper case, fail-closed refusal+hang pair, settings diff) — see ADR-0017 Consequences. @@ -339,13 +344,15 @@ Known fixture gaps (not urgent) are listed under as shipped capabilities. Guard PreToolUse and Drift/trend remain Future / Won't (A) for the Horizon A ship path — see [ADR-0015](./adr/0015-horizon-a-excludes-guard-and-drift.md). Frontline Guard -integration is **DECIDED** as Wave H (plan-only; not shipped) — see DECIDED -above. +Phase 1 (hooks + `/tw-*` skills) is on `main` under `agent-hooks/`; slice 28 +Quality dual-output is **IMPLEMENTED** on branch — see DECIDED above. Formal +Wave H Must gate closures (23–27, 29–32) remain open. Coverage audit matrix: [plan/coverage-audit.md](./plan/coverage-audit.md) (slice 7 ✅). Slice stubs: [plan/README.md](./plan/README.md) (`01-A-…` … `08-H-frontline-agent-hooks/`). Wave G (slices 18–22) is planned ATDD closure -(parked while Wave H Musts run). Wave H (23–39) is Frontline plan-only. Claim +(parked while Wave H Musts run). Wave H (23–39) trackers: Phase 1 code on +`main`; formal ✅ closures still mostly open (slice 28 🔀). Claim audit (slice 15) and slice 16 remediations are deferred; retain their artifacts for a future live/demo release. Wave M (slice 53) LLM usage log / cost tips is **DECIDED** plan-only — see DECIDED above; not current dashboard behaviour. diff --git a/docs/plan/DECISIONS.md b/docs/plan/DECISIONS.md index 9515017..0d8d723 100644 --- a/docs/plan/DECISIONS.md +++ b/docs/plan/DECISIONS.md @@ -187,3 +187,4 @@ | 2026-08-25 | planning | Amended slices 26+28 (not new slice) | `/tw-verify` Quality column (`N/100` from `items.quality_score`) + blocked-note footer de-dupe. Quality-only metrics; amend-in-place like slice 42. Slice 30 one-line sync. Skill implementation deferred to `slice/28-*` execution. DECIDED | | 2026-08-25 | slice-28 | Waive Before-Check: 26+27 gate PASS on main | Formal Wave H gate-evidence for 26/27 remains PLANNED on `main` (Frontline PRs #78/#80 never merged to main). Substitute: Phase-1 `agent-hooks` skills + this PR publishes `docs/user-guide/frontline-output-contract.md` as output SSOT; enable/disable skills already on main. (waived: DECISIONS 2026-08-25) | | 2026-08-25 | slice-28 | Quality + footer IMPLEMENTED | Restored Frontline `guard/verify.py` onto `slice/28-tw-verify-quality`; Quality `N/100` + blocked footer; agent-hooks `tw-verify` / `tw-self-check` / `tw-disable` synced. IMPLEMENTED | +| 2026-08-25 | sync-docs | Slice 28 feature-delta discoverability | PROGRESS execution-order 🔀; ARCHITECTURE Wave H Phase 1; docs hub + reading-router-results Tessl quality ↔ `/tw-verify` Quality; STATUS PROPOSED/Future SUPERSEDED for Phase 1. APPLIED | diff --git a/docs/plan/PROGRESS.md b/docs/plan/PROGRESS.md index 6d916a2..a8701f4 100644 --- a/docs/plan/PROGRESS.md +++ b/docs/plan/PROGRESS.md @@ -45,7 +45,7 @@ shared code area: slice 18 does not start until the H-wave subcommand lands | 3 | H1 | 25 | Live Enforce Smoke | Must | 📋 PLANNED | | 4 | H2 | 26 | API Introspect + Dual Output Contract (delta: Quality column) | Must | 📋 PLANNED | | 5 | H2 | 27 | `/tw-enable` + `/tw-disable` | Must | 📋 PLANNED | -| 6 | H2 | 28 | `/tw-verify` (delta: Quality `N/100` + blocked footer) | Must | 📋 PLANNED | +| 6 | H2 | 28 | `/tw-verify` (delta: Quality `N/100` + blocked footer) | Must | 🔀 ON BRANCH | | 7 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | | 8 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | | 9 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | diff --git a/docs/user-guide/reading-router-results.md b/docs/user-guide/reading-router-results.md index 51a8a31..d719dde 100644 --- a/docs/user-guide/reading-router-results.md +++ b/docs/user-guide/reading-router-results.md @@ -16,7 +16,7 @@ Start here: [QUICKSTART](../../QUICKSTART.md) · Setup: | **Live** | Real scan results stored in Supabase | | **Heatmap / card colour** | Worst scanner severity (red / amber / green) — not the same as risk density | | **Risk density** | Sort/trend number from finding counts; not the card colour | -| **Tessl quality** | Tessl quality review score (0–100) from `tessl review run quality`, shown as `Q` — not risk | +| **Tessl quality** | Tessl quality review score (0–100) from `tessl review run quality`, shown as `Q` on Live cards — not risk. Same axis as `/tw-verify` **Quality** (`N/100` from `items.quality_score`; see [frontline-output-contract.md](./frontline-output-contract.md)) | | **Pathway strip** | Short path drawn on a card: Scan → SIE → Model Studio | | **Escalated** | Model Studio actually ran (arbitration or triage) | | **SIE-only** | SIE reviewed; Model Studio did **not** run | From e81c6f2c006655f150e7197a70e845d0bd8f2faa Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Tue, 25 Aug 2026 13:35:57 +0100 Subject: [PATCH 3/5] feat(slice-28): attribute Quality to Tessl and Status to Cisco/Snyk Add a Sources line under the /tw-verify table so operators know Quality comes from Tessl and security Status from Cisco AI Defense and Snyk. --- CHANGELOG.md | 5 +++-- agent-hooks/skills/tw-self-check/SKILL.md | 2 +- agent-hooks/skills/tw-verify/SKILL.md | 4 ++++ docs/user-guide/frontline-output-contract.md | 10 ++++++++-- guard/tests/test_tw_verify.py | 9 ++++++++- guard/verify.py | 6 ++++++ 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dad23e5..99d952a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `/tw-verify` Quality column + blocked footer (slice 28): Scan Status table is **Name | Type | Status | Quality | Note**; Quality shows Tessl `items.quality_score` as **`N/100`** (else `—`); shared - **Will be blocked when Tripwire is enabled** appears once under the table. - Shared helpers in `guard/verify.py`; contract SSOT + **Will be blocked when Tripwire is enabled** appears once under the table; + **Sources** line attributes Quality to Tessl and security Status to + Cisco AI Defense and Snyk. Shared helpers in `guard/verify.py`; contract SSOT [frontline-output-contract.md](docs/user-guide/frontline-output-contract.md). Re-run `tripwire setup-agent-hooks` to refresh installed skills. - Docs: Wave **13-M** LLM usage / cost observability plan (slice 53) — diff --git a/agent-hooks/skills/tw-self-check/SKILL.md b/agent-hooks/skills/tw-self-check/SKILL.md index 90f0295..554dcc8 100644 --- a/agent-hooks/skills/tw-self-check/SKILL.md +++ b/agent-hooks/skills/tw-self-check/SKILL.md @@ -86,7 +86,7 @@ print(json.dumps({ ## Step 4 — Report (Scan Status table only) -Render exactly the tw-verify human table — same columns **Name | Type | Status | Quality | Note**, emoji/labels, Quality as **`N/100`** or `—`, and blocked-message **footer** (see `~/.claude/skills/tw-verify/SKILL.md` §Step 5 and [frontline-output-contract.md](../../../docs/user-guide/frontline-output-contract.md)). Parse the driver's JSON privately; do **not** dump a fenced `{config, artifacts}` block to the user. Summary of the row rules (N = `scan_validity_days`): +Render exactly the tw-verify human table — same columns **Name | Type | Status | Quality | Note**, emoji/labels, Quality as **`N/100`** or `—`, blocked-message **footer**, and **Sources** line (Tessl Quality; Cisco/Snyk Status) (see `~/.claude/skills/tw-verify/SKILL.md` §Step 5 and [frontline-output-contract.md](../../../docs/user-guide/frontline-output-contract.md)). Parse the driver's JSON privately; do **not** dump a fenced `{config, artifacts}` block to the user. Summary of the row rules (N = `scan_validity_days`): - `fresh` + green → `🟢 GREEN (fresh)`, note `—`. - `fresh` + amber → `🟠 AMBER`; `Reported but not blocked at current threshold` when threshold is `red`, else distinct amber note + footer when blocked. diff --git a/agent-hooks/skills/tw-verify/SKILL.md b/agent-hooks/skills/tw-verify/SKILL.md index 28069b9..f0cdb6a 100644 --- a/agent-hooks/skills/tw-verify/SKILL.md +++ b/agent-hooks/skills/tw-verify/SKILL.md @@ -128,6 +128,10 @@ One Markdown table, one row per REQUESTED name (selection rows count individuall Do **not** repeat that phrase in every Note. Row Notes keep *distinct* copy only. +**Sources attribution:** Always print **once** under the table (after the blocked footer when present): + +*Sources: Quality is from Tessl (Review Quality). Security-related Status (GREEN/AMBER/RED) is from Cisco AI Defense and Snyk.* + Row rules (map driver output → row): - `changed=true` → `✏️ CHANGED` — this row takes precedence over every state-based row (the hook's tamper deny fires regardless of a green verdict); note is the bold **content changed since last scan — run /tw-scan **, and `will_be_blocked` is `true`. diff --git a/docs/user-guide/frontline-output-contract.md b/docs/user-guide/frontline-output-contract.md index 5db169a..934889d 100644 --- a/docs/user-guide/frontline-output-contract.md +++ b/docs/user-guide/frontline-output-contract.md @@ -26,8 +26,8 @@ Fixed columns (order matters): |--------|---------| | **Name** | Resolved artifact name (or the unresolved query for `not-found`) | | **Type** | `skill` / `mcp` / `tool` / `—` when unknown | -| **Status** | Display string for the six UI states (below) | -| **Quality** | Tessl skill-review score as **`N/100`** (0–100, higher better) from `items.quality_score`; `—` for MCP, unscanned, scanning, not-found, or null | +| **Status** | Display string for the six UI states (below); security-related RAG from **Cisco AI Defense** and **Snyk** | +| **Quality** | **Tessl** skill-review score as **`N/100`** (0–100, higher better) from `items.quality_score`; `—` for MCP, unscanned, scanning, not-found, or null | | **Note** | Distinct operator hint only — do **not** repeat the shared blocked sentence here | ### Blocked footer (de-dupe) @@ -38,6 +38,12 @@ When **any** artifact has `will_be_blocked=true`, print **once** under the table Do not repeat that phrase in every Note. RED / STALE / UNSCANNED / NOT FOUND / CHANGED keep distinct Note copy (threshold, remedy, locus, scan offer). +### Sources attribution + +Print **once** under the table (after the blocked footer when present): + +*Sources: Quality is from Tessl (Review Quality). Security-related Status (GREEN/AMBER/RED) is from Cisco AI Defense and Snyk.* + ## Six UI states | State | Display | When | diff --git a/guard/tests/test_tw_verify.py b/guard/tests/test_tw_verify.py index 0fef1c7..3229524 100644 --- a/guard/tests/test_tw_verify.py +++ b/guard/tests/test_tw_verify.py @@ -252,12 +252,16 @@ def fetch_status(resolved): ) ### Then + from guard.verify import SOURCES_FOOTER + markdown = actual.to_markdown() assert markdown.count(BLOCKED_FOOTER) == 1 for row in actual.artifacts: assert row.will_be_blocked is True assert BLOCKED_FOOTER not in row.note - assert markdown.strip().endswith(f"**{BLOCKED_FOOTER}**") + assert f"**{BLOCKED_FOOTER}**" in markdown + assert SOURCES_FOOTER in markdown + assert markdown.strip().endswith(f"*{SOURCES_FOOTER}*") def test_given_unscanned_when_verify_then_offers_tw_scan() -> None: @@ -359,3 +363,6 @@ def test_given_repo_when_skill_looked_up_then_agent_hooks_skill_md_exists() -> N assert "N/100" in text or "/100" in text assert BLOCKED_FOOTER in text assert "footer" in text.lower() or "under the table" in text.lower() + assert "Tessl" in text + assert "Cisco" in text and "Snyk" in text + assert "Sources:" in text diff --git a/guard/verify.py b/guard/verify.py index 37bc8ec..75802fa 100644 --- a/guard/verify.py +++ b/guard/verify.py @@ -19,6 +19,10 @@ UiState = Literal["fresh", "stale", "unscanned", "scanning", "not-found", "red"] BLOCKED_FOOTER = "Will be blocked when Tripwire is enabled" +SOURCES_FOOTER = ( + "Sources: Quality is from Tessl (Review Quality). " + "Security-related Status (GREEN/AMBER/RED) is from Cisco AI Defense and Snyk." +) NOT_FOUND_NOTE = ( "No match in ~/.claude/skills, .claude/skills, .mcp.json, ~/.claude.json, " "~/.tripwire/demo-mcp.json, fixtures manifest" @@ -109,6 +113,8 @@ def to_markdown(self) -> str: if any(row.will_be_blocked for row in self.artifacts): lines.append("") lines.append(f"**{BLOCKED_FOOTER}**") + lines.append("") + lines.append(f"*{SOURCES_FOOTER}*") return "\n".join(lines) From 7b3de20ec9dc8f6bae785bb51dfcae01a5d8b153 Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Tue, 25 Aug 2026 15:22:33 +0100 Subject: [PATCH 4/5] docs(slice-28): sync discoverability for Sources attribution Align hub, STATUS, ARCHITECTURE, agent-hooks README, and gate evidence with the Tessl Quality / Cisco+Snyk Status Sources line. --- agent-hooks/README.md | 3 ++- agent-hooks/skills/tw-verify/SKILL.md | 2 +- docs/ARCHITECTURE.md | 7 ++++--- docs/README.md | 2 +- docs/STATUS.md | 7 ++++--- docs/plan/DECISIONS.md | 1 + docs/plan/gate-evidence/slice-28.json | 5 +++-- .../08-H-frontline-agent-hooks/slice-28-tw-verify.md | 6 +++--- docs/user-guide/reading-router-results.md | 2 +- 9 files changed, 20 insertions(+), 15 deletions(-) diff --git a/agent-hooks/README.md b/agent-hooks/README.md index 1290cd0..ea6e051 100644 --- a/agent-hooks/README.md +++ b/agent-hooks/README.md @@ -20,7 +20,8 @@ from the repo directly — `tripwire setup-agent-hooks` installs copies: - **`hooks/_guard_entry.py`** — installed shim delegating to `guard.entry.main()` (run via `uv run --project --extra guard`). - **`skills/tw-verify`** — report scan status of named skills/MCP servers - (RAG + Tessl Quality as `N/100` when present; blocked message as table footer). + (RAG + Tessl Quality as `N/100` when present; blocked message as table footer; + **Sources** line: Quality = Tessl, Status = Cisco AI Defense + Snyk). - **`skills/tw-scan`** — submit named skills/MCP servers for scanning (`--force` or bare `force` to rescan valid artifacts). - **`skills/tw-enable` / `skills/tw-disable`** — flip the local `enable` key in diff --git a/agent-hooks/skills/tw-verify/SKILL.md b/agent-hooks/skills/tw-verify/SKILL.md index f0cdb6a..985d635 100644 --- a/agent-hooks/skills/tw-verify/SKILL.md +++ b/agent-hooks/skills/tw-verify/SKILL.md @@ -5,7 +5,7 @@ description: Check the Tripwire scan status of Claude Code skills and MCP server # tw-verify -Report the Tripwire scan status of one or more skills / MCP servers. Single pass over ALL requested names — never stop at the first problem; every requested name gets a row in the Scan Status table. Columns follow the Frontline dual-output contract ([frontline-output-contract.md](../../../docs/user-guide/frontline-output-contract.md)): **Name | Type | Status | Quality | Note**, with Tessl Quality as **`N/100`** when `items.quality_score` is present (else `—`), and the shared blocked phrase once as a **table footer**. Shared helpers: `guard.verify.verify_artifacts` / `format_quality_cell`. Read-only: this skill never submits scans itself (it only offers to, at the end). Do **not** dump the driver's raw JSON (or any fenced `{config, artifacts}` block) to the user — that payload is for you to parse only. +Report the Tripwire scan status of one or more skills / MCP servers. Single pass over ALL requested names — never stop at the first problem; every requested name gets a row in the Scan Status table. Columns follow the Frontline dual-output contract ([frontline-output-contract.md](../../../docs/user-guide/frontline-output-contract.md)): **Name | Type | Status | Quality | Note**, with Tessl Quality as **`N/100`** when `items.quality_score` is present (else `—`), the shared blocked phrase once as a **table footer**, and a **Sources** line (Quality = Tessl; Status = Cisco AI Defense + Snyk). Shared helpers: `guard.verify.verify_artifacts` / `format_quality_cell`. Read-only: this skill never submits scans itself (it only offers to, at the end). Do **not** dump the driver's raw JSON (or any fenced `{config, artifacts}` block) to the user — that payload is for you to parse only. **Hard rule — unscanned/blocked artifacts must not be executed.** If a row is `unscanned` (including errored), `stale`, `changed`, RED (or amber at threshold), or NOT FOUND: do **not** invoke that skill (`Skill` tool), do **not** call its `mcp__*` tools, and do **not** run its `install.sh` / scripts via Bash. Report that Tripwire will block those calls. Only offer `/tw-scan` via AskUserQuestion — never silently submit, and never “work around” a block by scanning so you can run the artifact in the same turn. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 720f04b..c0f1243 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -209,9 +209,10 @@ clock before Modal's 300s kill — operators reconcile stranded `running` rows w shipped production entry ([ADR-0015](./adr/0015-horizon-a-excludes-guard-and-drift.md)). **Wave H (Frontline):** Phase 1 hooks + `tripwire setup-agent-hooks` + `/tw-*` skills live under [agent-hooks](../agent-hooks/README.md) on `main`. Slice 28 - (`/tw-verify` Quality `N/100` + blocked footer) is **IMPLEMENTED** on - `slice/28-tw-verify-quality` — dual-output SSOT - [frontline-output-contract.md](./user-guide/frontline-output-contract.md). + (`/tw-verify` Quality `N/100` + blocked footer + Sources attribution) is + **IMPLEMENTED** on `slice/28-tw-verify-quality` — dual-output SSOT + [frontline-output-contract.md](./user-guide/frontline-output-contract.md) + (Quality = Tessl; Status = Cisco AI Defense + Snyk). Formal Wave H gate trackers (23–27, 29–32) still lag — see [STATUS.md](./STATUS.md) and [plan/TRAIL.md](./plan/TRAIL.md) Wave H. - **Wave M (LLM usage / cost observability, DECIDED plan-only):** slice 53 diff --git a/docs/README.md b/docs/README.md index 2e622d9..4d1abb1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,7 +37,7 @@ Start here: [QUICKSTART](../QUICKSTART.md) · Repo entry: [README](../README.md) | [user-guide/modal-setup.md](./user-guide/modal-setup.md) | Modal auth, secrets sync, scan app deploy | | [user-guide/tiered-router-setup.md](./user-guide/tiered-router-setup.md) | Optional SIE + Model Studio routing | | [user-guide/reading-router-results.md](./user-guide/reading-router-results.md) | Pathway strips, Escalated / SIE-only filters, glossary | -| [user-guide/frontline-output-contract.md](./user-guide/frontline-output-contract.md) | `/tw-verify` dual-output table (Quality `N/100` + blocked footer) | +| [user-guide/frontline-output-contract.md](./user-guide/frontline-output-contract.md) | `/tw-verify` dual-output table (Quality `N/100` + blocked footer + Sources: Tessl / Cisco+Snyk) | | [STATUS.md](./STATUS.md) | Evidence-labelled capability claims | | [ARCHITECTURE.md](./ARCHITECTURE.md) | Services inventory, C4, operator journey + dependency diagrams, key flows | | [adr/README.md](./adr/README.md) | Formal architecture decision records | diff --git a/docs/STATUS.md b/docs/STATUS.md index 2e96b9a..122aaaa 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -257,8 +257,9 @@ documentarist pile-on **APPROVED WITH FOLLOW-ON**; DIVIO purity rewrites **Wave H — Frontline agent hooks (2026-08-15):** Claude Code PreToolUse handlers, `tripwire setup-agent-hooks`, and five `/tw-*` skills landed on `main` via Phase 1 agent-hooks (see `agent-hooks/`). Formal Wave H gate trackers (slices 23–39) still -lag; **slice 28** (`/tw-verify` Quality `N/100` + blocked footer) is **IMPLEMENTED** -on `slice/28-tw-verify-quality` — contract SSOT +lag; **slice 28** (`/tw-verify` Quality `N/100` + blocked footer + Sources: +Tessl Quality / Cisco+Snyk Status) is **IMPLEMENTED** on +`slice/28-tw-verify-quality` — contract SSOT [frontline-output-contract.md](./user-guide/frontline-output-contract.md). Remaining Musts 23–27 / 29–32 stay plan-tracked. ADR-0015 Horizon A exclusion remains in force until Wave H lands and a superseding ADR @@ -296,7 +297,7 @@ Dedicated usage table — does **not** resurrect deferred `tripwire.audit`. Claude Code agent-hooks integration layer — **Phase 1 SUPERSEDED by shipping on `main`** under [agent-hooks](../agent-hooks/README.md). Slice 28 Quality/`N/100` -dual-output is **IMPLEMENTED** on `slice/28-tw-verify-quality` (see ++ Sources attribution is **IMPLEMENTED** on `slice/28-tw-verify-quality` (see [DECIDED](#decided) Wave H). Formal Wave H Must gate closures (23–27, 29–32) and the ADR-0017 Phase-1 regression matrix remain open. diff --git a/docs/plan/DECISIONS.md b/docs/plan/DECISIONS.md index 0d8d723..11a0c29 100644 --- a/docs/plan/DECISIONS.md +++ b/docs/plan/DECISIONS.md @@ -188,3 +188,4 @@ | 2026-08-25 | slice-28 | Waive Before-Check: 26+27 gate PASS on main | Formal Wave H gate-evidence for 26/27 remains PLANNED on `main` (Frontline PRs #78/#80 never merged to main). Substitute: Phase-1 `agent-hooks` skills + this PR publishes `docs/user-guide/frontline-output-contract.md` as output SSOT; enable/disable skills already on main. (waived: DECISIONS 2026-08-25) | | 2026-08-25 | slice-28 | Quality + footer IMPLEMENTED | Restored Frontline `guard/verify.py` onto `slice/28-tw-verify-quality`; Quality `N/100` + blocked footer; agent-hooks `tw-verify` / `tw-self-check` / `tw-disable` synced. IMPLEMENTED | | 2026-08-25 | sync-docs | Slice 28 feature-delta discoverability | PROGRESS execution-order 🔀; ARCHITECTURE Wave H Phase 1; docs hub + reading-router-results Tessl quality ↔ `/tw-verify` Quality; STATUS PROPOSED/Future SUPERSEDED for Phase 1. APPLIED | +| 2026-08-25 | sync-docs | Slice 28 Sources attribution | Discoverability: agent-hooks README, docs hub, ARCHITECTURE/STATUS Wave H, reading-router-results, slice stub, tw-verify skill intro — Quality=Tessl / Status=Cisco+Snyk. Contract + verify.py already had Sources. README/AGENTS NO_CHANGE. APPLIED | diff --git a/docs/plan/gate-evidence/slice-28.json b/docs/plan/gate-evidence/slice-28.json index 960b3c1..4dcc8d5 100644 --- a/docs/plan/gate-evidence/slice-28.json +++ b/docs/plan/gate-evidence/slice-28.json @@ -18,6 +18,7 @@ "PASS — coverage 100% lines on guard/verify.py (target ≥95%)", "PASS — xenon exit 0; ./scripts/quality-gates.sh PASS", "PASS — review acceptance+implementation APPROVED (nw-software-crafter-reviewer)", + "PASS — Sources attribution (Tessl Quality; Cisco/Snyk Status) in to_markdown + skill", "PENDING merge — PROGRESS/TRAIL ✅ after merge to main" ], "commands": [ @@ -52,7 +53,7 @@ }, "coverage": { "target": "≥95% lines on guard/verify.py", - "measured": "100.0% lines (106 stmts)", + "measured": "100.0% lines (109 stmts)", "date": "2026-08-25" }, "complexity_policy": "enforcing for product-code — xenon exit 0 via quality-gates", @@ -67,5 +68,5 @@ "coverage_target": "≥95% lines on guard/verify.py", "phase": "H2", "moscow": "Must", - "delta_2026_08_25": "Quality column N/100 + blocked footer de-dupe (IMPLEMENTED)" + "delta_2026_08_25": "Quality column N/100 + blocked footer + Sources Tessl/Cisco+Snyk (IMPLEMENTED)" } diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md index 70e1959..e4570ac 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md @@ -4,9 +4,9 @@ ## Outcome -`/tw-verify` resolves multiple names, reports every artifact in one pass via the shared human table + machine JSON, covers all six states, surfaces Tessl **Quality** as **`N/100`** when `items.quality_score` is present, de-duplicates the shared blocked message into a **table footer**, offers `/tw-scan` for unscanned, and returns a useful human message for not-found. +`/tw-verify` resolves multiple names, reports every artifact in one pass via the shared human table + machine JSON, covers all six states, surfaces Tessl **Quality** as **`N/100`** when `items.quality_score` is present, de-duplicates the shared blocked message into a **table footer**, attributes vendors via a **Sources** line (Quality = Tessl; Status = Cisco AI Defense + Snyk), offers `/tw-scan` for unscanned, and returns a useful human message for not-found. -**Delta (2026-08-25):** Quality column + blocked-note footer de-dupe (Quality-only metrics; amend-in-place — no new slice). Evidence state: **IMPLEMENTED** on `slice/28-tw-verify-quality`. +**Delta (2026-08-25):** Quality column + blocked-note footer de-dupe + Sources attribution (Quality-only metrics; amend-in-place — no new slice). Evidence state: **IMPLEMENTED** on `slice/28-tw-verify-quality`. ## GWT acceptance specification @@ -70,7 +70,7 @@ RED→GREEN→REFACTOR complete for Quality + footer delta (restored Frontline v | # | Check | |---|--------| -| 1 | `/tw-verify` multi-name + one-pass + Quality `N/100` + blocked footer documented in skill SSOT | +| 1 | `/tw-verify` multi-name + one-pass + Quality `N/100` + blocked footer + Sources attribution documented in skill SSOT | | 2 | Link to `docs/user-guide/frontline-output-contract.md` | | 3 | sync-docs: `tw-verify` + `tw-self-check` + `tw-disable` + `agent-hooks/README` + CHANGELOG | | 4 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | diff --git a/docs/user-guide/reading-router-results.md b/docs/user-guide/reading-router-results.md index d719dde..0517225 100644 --- a/docs/user-guide/reading-router-results.md +++ b/docs/user-guide/reading-router-results.md @@ -16,7 +16,7 @@ Start here: [QUICKSTART](../../QUICKSTART.md) · Setup: | **Live** | Real scan results stored in Supabase | | **Heatmap / card colour** | Worst scanner severity (red / amber / green) — not the same as risk density | | **Risk density** | Sort/trend number from finding counts; not the card colour | -| **Tessl quality** | Tessl quality review score (0–100) from `tessl review run quality`, shown as `Q` on Live cards — not risk. Same axis as `/tw-verify` **Quality** (`N/100` from `items.quality_score`; see [frontline-output-contract.md](./frontline-output-contract.md)) | +| **Tessl quality** | Tessl quality review score (0–100) from `tessl review run quality`, shown as `Q` on Live cards — not risk. Same axis as `/tw-verify` **Quality** (`N/100` from `items.quality_score`). On `/tw-verify`, **Status** (GREEN/AMBER/RED) is the security rollup from Cisco AI Defense and Snyk — see [frontline-output-contract.md](./frontline-output-contract.md) Sources line | | **Pathway strip** | Short path drawn on a card: Scan → SIE → Model Studio | | **Escalated** | Model Studio actually ran (arbitration or triage) | | **SIE-only** | SIE reviewed; Model Studio did **not** run | From f4ea268b8304c38595ac87b921836c3e7288fa9c Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Tue, 25 Aug 2026 21:42:55 +0100 Subject: [PATCH 5/5] fix(docs): satisfy markdownlint MD004 in STATUS.md Replace leading plus with prose so CI markdownlint does not treat Sources attribution as an unordered list item. --- docs/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 122aaaa..a6b1c18 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -297,7 +297,7 @@ Dedicated usage table — does **not** resurrect deferred `tripwire.audit`. Claude Code agent-hooks integration layer — **Phase 1 SUPERSEDED by shipping on `main`** under [agent-hooks](../agent-hooks/README.md). Slice 28 Quality/`N/100` -+ Sources attribution is **IMPLEMENTED** on `slice/28-tw-verify-quality` (see +and Sources attribution is **IMPLEMENTED** on `slice/28-tw-verify-quality` (see [DECIDED](#decided) Wave H). Formal Wave H Must gate closures (23–27, 29–32) and the ADR-0017 Phase-1 regression matrix remain open.