250 lines
13 KiB
Markdown
250 lines
13 KiB
Markdown
|
|
# Design: Machine Report Schema
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This design fixes the literal shape of the **machine report** — the JSON
|
||
|
|
artifact the `check` skill writes to `.dochygiene/` and that `clean`, `sweep`,
|
||
|
|
and the token estimator consume. The human-readable report (`.md`) is a
|
||
|
|
projection of this same data; its shape is **not** specified here — it is a
|
||
|
|
sibling user-facing contract owned by the upcoming `check` change (load-bearing
|
||
|
|
for the confirm-tier escalation UX). See proposal.md "Impact".
|
||
|
|
|
||
|
|
## Top-Level Envelope
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"schema_version": "1.0",
|
||
|
|
"tool_version": "0.1.0",
|
||
|
|
"generated_at": "2026-06-18T09:52:00Z",
|
||
|
|
"scan": {
|
||
|
|
"project_root": "/abs/path/to/project",
|
||
|
|
"scope_globs": ["**/*.md", "CLAUDE.md"],
|
||
|
|
"excluded_dirs": ["build", "vendor", "archive", "graphify-out", ".dochygiene"],
|
||
|
|
"files_scanned": 42
|
||
|
|
},
|
||
|
|
"shortlist": ["docs/old-plan.md", "CLAUDE.md", "README.md"],
|
||
|
|
"entries": [ /* one Entry per file the AI classified */ ]
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- `shortlist` is the deterministic scanner's candidate set (paths only). Every
|
||
|
|
path in `entries[].path` MUST be a member of `shortlist`; the scanner produces
|
||
|
|
the shortlist, the AI pass produces `entries`.
|
||
|
|
- `generated_at` is the check timestamp used by the clean step's mtime guard.
|
||
|
|
|
||
|
|
## Per-File Entry
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"path": "CLAUDE.md",
|
||
|
|
"category": { "class": "stale", "subtype": "contradicted" },
|
||
|
|
"signals": [
|
||
|
|
{ "name": "broken_reference", "detail": "links scripts/old.py (missing)" },
|
||
|
|
{ "name": "version_skew", "detail": "pins tool v1, repo on v3" }
|
||
|
|
],
|
||
|
|
"op": "Remove the contradicted reference block (lines 40-52).",
|
||
|
|
"op_type": "deterministic",
|
||
|
|
"is_destructive": true,
|
||
|
|
"is_reversible": false, // delete-range removes in-place content — see kinds table
|
||
|
|
"safety_tier": "confirm", // DERIVED by script from (op_type, is_destructive, is_reversible) — not model-assigned
|
||
|
|
"exact_edit": {
|
||
|
|
"kind": "delete-range",
|
||
|
|
"anchor": { "start_line": 40, "end_line": 52 },
|
||
|
|
"expected_sha256": "<hash of file at check time>"
|
||
|
|
},
|
||
|
|
"token_estimate": {
|
||
|
|
"raw_tokens": 180,
|
||
|
|
"injection_frequency": "per-session",
|
||
|
|
"weighted_tokens": 180
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Field semantics
|
||
|
|
|
||
|
|
| Field | Meaning |
|
||
|
|
|-------|---------|
|
||
|
|
| `path` | Project-root-relative path. Member of `shortlist`. |
|
||
|
|
| `category.class` | `stale` or `bloat`. |
|
||
|
|
| `category.subtype` | Closed enum (see below). |
|
||
|
|
| `signals` | Objective facts from the scanner that drove the classification. May be empty for AI-only judgments but SHOULD carry the supporting evidence. |
|
||
|
|
| `op` | One-line human description of the recommended operation the classifier selected. |
|
||
|
|
| `op_type` | `deterministic` (exact, pre-computed, no model) or `generative` (prose transform, needs Sonnet at clean time). A **property of the chosen `op`**, not a free field: it is consistent with `op` and with `exact_edit` (see consistency rule below). |
|
||
|
|
| `is_destructive` | Boolean. An op is **destructive iff it removes or overwrites information that is not preserved elsewhere in the repository.** Text-level line removal is *not* automatically destructive — what matters is whether the information survives (e.g. a `dedupe` removes a span but the content is preserved verbatim at `canonical_ref`, so it is *not* destructive; a `delete-range` removes content kept nowhere else, so it *is*). Characterizes the chosen `op`; supplied by the classifier as an objective property of the op. |
|
||
|
|
| `is_reversible` | Boolean. `true` when the op can be undone mechanically (e.g. move-to-archive, freeze-stamp). A delete of in-place content is `false`. Characterizes the chosen `op`. |
|
||
|
|
| `safety_tier` | `auto` (runs without prompt) or `confirm` (escalated). **Derived** by a deterministic script function from `(op_type, is_destructive, is_reversible)` — never freely assigned by the model. The report records the computed value. |
|
||
|
|
| `exact_edit` | Present iff `op_type == deterministic`. The mechanically-applicable edit plus an integrity hash for the mtime/content guard. |
|
||
|
|
| `token_estimate` | Per-entry context-weight reduction (see below). |
|
||
|
|
|
||
|
|
### Category sub-type enum (closed)
|
||
|
|
|
||
|
|
- Stale: `contradicted`, `orphaned`, `superseded`, `provisional`,
|
||
|
|
`completed-in-place`, `duplicated`.
|
||
|
|
- Bloat: `distill`, `split`, `freeze`.
|
||
|
|
|
||
|
|
### `exact_edit.kind` is a closed enum (frozen)
|
||
|
|
|
||
|
|
Every `exact_edit` carries a `kind` drawn from a **closed** set, one per
|
||
|
|
deterministic op family the PRD names (PRD "Operation taxonomy" +
|
||
|
|
US-14: move-to-archive, freeze-stamp, known-target link fix, exact-dup dedupe,
|
||
|
|
plus delete). Each kind fixes its own required sub-fields and an inherent
|
||
|
|
`(is_destructive, is_reversible)` characterization. Those two booleans are what
|
||
|
|
feed the `safety_tier` derivation above — the kind is not a free annotation, it
|
||
|
|
constrains the characterization, which in turn fixes the tier. A validator can
|
||
|
|
therefore reject an `exact_edit` whose `kind` is unknown or whose required
|
||
|
|
fields are missing.
|
||
|
|
|
||
|
|
| kind | required fields | is_destructive | is_reversible | derived safety_tier |
|
||
|
|
|------|-----------------|----------------|---------------|---------------------|
|
||
|
|
| `delete-range` | `anchor { start_line, end_line }` | true | false | **confirm** |
|
||
|
|
| `move-to-archive` | `anchor { start_line, end_line }`, `dest_path` | false | true | **auto** |
|
||
|
|
| `insert-frontmatter` | `key`, `value` (e.g. `hygiene: frozen`) | false | true | **auto** |
|
||
|
|
| `replace-text` | `anchor { start_line, end_line }`, `match`, `replacement` | false | true | **auto** |
|
||
|
|
| `dedupe` | `anchor { start_line, end_line }` (removed span), `canonical_ref` (kept location) | false | true | **auto** |
|
||
|
|
|
||
|
|
Notes:
|
||
|
|
|
||
|
|
- `delete-range` removes in-place content with nothing kept elsewhere, so the
|
||
|
|
information is lost → it is destructive **and** irreversible at the op level →
|
||
|
|
`confirm`.
|
||
|
|
- `move-to-archive` (freeze-stamp `insert-frontmatter`, link-fix `replace-text`)
|
||
|
|
are non-destructive and mechanically reversible → `auto`.
|
||
|
|
- `dedupe` removes a span, but that span is an **exact duplicate preserved
|
||
|
|
verbatim at `canonical_ref`** — no information leaves the repository — so under
|
||
|
|
the refined definition of destructive it is **not destructive** (and remains
|
||
|
|
reversible) → `auto`.
|
||
|
|
- **`delete-range` vs `dedupe` — the contrast that justifies separate kinds.**
|
||
|
|
Both remove text, but only one loses information. `delete-range` removes content
|
||
|
|
kept nowhere else (info lost → destructive → `confirm`); `dedupe` removes a span
|
||
|
|
whose content survives at `canonical_ref` (info preserved → not destructive →
|
||
|
|
`auto`). This information-preservation distinction is the whole reason they are
|
||
|
|
separate kinds rather than one delete primitive.
|
||
|
|
- All `anchor`-bearing kinds rely on `expected_sha256` (carried on `exact_edit`,
|
||
|
|
not per-kind) for the mtime/content guard.
|
||
|
|
|
||
|
|
**PRD US-14 reconciliation (resolved).** `dedupe` is `auto` because
|
||
|
|
exact-duplicate removal preserves information at `canonical_ref`; consistent with
|
||
|
|
PRD US-14 (which lists exact-dup dedupe among the no-prompt `auto` ops) and
|
||
|
|
invariant #7 (`auto` = deterministic + reversible + objective). No divergence
|
||
|
|
remains.
|
||
|
|
|
||
|
|
### `op_type` is a property of the chosen `op` (consistency, not lookup)
|
||
|
|
|
||
|
|
`op_type` is **not** a free third field and is **not** looked up from
|
||
|
|
`category.subtype`. The same subtype can map to either a deterministic delete or
|
||
|
|
a generative rewrite depending on the op the classifier selects (e.g. a
|
||
|
|
`contradicted` block can be deterministically deleted, or generatively
|
||
|
|
rewritten). Therefore `op_type` describes the chosen `op`, and the schema
|
||
|
|
requires the two to be consistent, checked deterministically:
|
||
|
|
|
||
|
|
- `exact_edit` is present **iff** `op_type == deterministic`.
|
||
|
|
- `op_type == generative` ⟹ no `exact_edit` (the edit is deferred to clean
|
||
|
|
time, not pre-written).
|
||
|
|
|
||
|
|
A validator enforces this biconditional mechanically; an entry that violates it
|
||
|
|
(generative with an `exact_edit`, or deterministic without one) is invalid.
|
||
|
|
|
||
|
|
### `safety_tier` is DERIVED, never model-assigned
|
||
|
|
|
||
|
|
The classifier proposes `op`, `op_type`, and the op's objective
|
||
|
|
characterization (`is_destructive`, `is_reversible`). It does **not** assign
|
||
|
|
`safety_tier`. A deterministic script computes it, so the model cannot violate
|
||
|
|
invariant #7 by mislabeling a tier:
|
||
|
|
|
||
|
|
```
|
||
|
|
safety_tier(op_type, is_destructive, is_reversible):
|
||
|
|
if op_type == "generative": return "confirm" # generative ⟹ confirm
|
||
|
|
if is_destructive: return "confirm" # destructive ⟹ confirm
|
||
|
|
if not is_reversible: return "confirm" # irreversible ⟹ confirm
|
||
|
|
return "auto" # deterministic + reversible + objective ⟹ auto
|
||
|
|
```
|
||
|
|
|
||
|
|
**Where "objective" lives.** Invariant #7's `auto` requires
|
||
|
|
deterministic + reversible + *objective*. Objectivity is not a separate input
|
||
|
|
because it is implied by construction: a `deterministic` op is an exact,
|
||
|
|
pre-computed edit (objective by definition), while subjectivity enters the
|
||
|
|
system only through `generative` ops — which the first branch already forces to
|
||
|
|
`confirm`. So the three inputs above fully cover invariant #7.
|
||
|
|
|
||
|
|
This function can **never** emit `auto` for a generative op or for any
|
||
|
|
destructive/irreversible op. The only path to `auto` is
|
||
|
|
deterministic + reversible (hence objective). The truth table below is the
|
||
|
|
output of this function, not a constraint the model is trusted to satisfy:
|
||
|
|
|
||
|
|
| op_type | is_destructive | is_reversible | → safety_tier | Example |
|
||
|
|
|---------|----------------|---------------|---------------|---------|
|
||
|
|
| deterministic | false | true | **auto** | move-to-archive, freeze-stamp (insert-frontmatter), known-target link fix (replace-text), exact-dup dedupe (span preserved at `canonical_ref` → not destructive) |
|
||
|
|
| deterministic | true | (any) | **confirm** | delete-range (info lost, kept nowhere else → destructive → always confirm) |
|
||
|
|
| deterministic | false | false | **confirm** | irreversible in-place edit |
|
||
|
|
| generative | (any) | (any) | **confirm** | distill narrative, split file (generative is never auto) |
|
||
|
|
|
||
|
|
### Token estimate
|
||
|
|
|
||
|
|
```json
|
||
|
|
{ "raw_tokens": 180, "injection_frequency": "per-session" | "on-demand" | null, "weighted_tokens": 180 }
|
||
|
|
```
|
||
|
|
|
||
|
|
- `raw_tokens` (**required, v1**): local-tokenizer count of the removed/reduced
|
||
|
|
span (no API call). This is the only mandatory field in v1.
|
||
|
|
- `injection_frequency` (**optional / nullable, v2**): `per-session` for
|
||
|
|
auto-injected files (CLAUDE.md, memory index) → counted as real per-session
|
||
|
|
savings; `on-demand` for docs read only when opened → theoretical-max savings.
|
||
|
|
May be `null` (or omitted) in v1; populated by the v2 bonus.
|
||
|
|
- `weighted_tokens` (**optional / nullable, v2**): `raw_tokens` adjusted by
|
||
|
|
injection frequency. May be `null` (or omitted) in v1; populated by the v2
|
||
|
|
bonus.
|
||
|
|
- Roll-up (file → category → total) is computed bottom-up by the estimator and
|
||
|
|
is not stored redundantly per entry; the report's consumer sums `entries`.
|
||
|
|
|
||
|
|
**v1 vs v2 scope.** Per PRD build-order phase 5, the token estimator
|
||
|
|
(injection-frequency weighting + bottom-up rollup) is explicitly a **v2 bonus**.
|
||
|
|
v1 requires only the deterministic `raw_tokens` count (local tokenizer, no API
|
||
|
|
call — consistent with invariant #6). The weighting fields are part of the
|
||
|
|
schema shape from the start so the contract never changes, but their population
|
||
|
|
is deferred to v2.
|
||
|
|
|
||
|
|
## Decisions
|
||
|
|
|
||
|
|
### Why `category` is a `{ class, subtype }` object, not a flat string
|
||
|
|
|
||
|
|
Consumers frequently branch on `class` (stale vs bloat) alone (different remedy
|
||
|
|
families). Nesting keeps that branch cheap while preserving the precise subtype.
|
||
|
|
|
||
|
|
### Why `exact_edit` carries `expected_sha256`
|
||
|
|
|
||
|
|
The clean step's mtime guard needs a content fingerprint to refuse applying a
|
||
|
|
cached edit to a file that changed since `generated_at`. Storing it on the edit
|
||
|
|
makes the guard a pure function of the report plus the current file.
|
||
|
|
|
||
|
|
### Why `safety_tier` is derived, not assigned
|
||
|
|
|
||
|
|
Invariant #7 is a safety boundary: `auto` ops run unattended. If the model could
|
||
|
|
write `safety_tier` directly, a single misclassification would let a destructive
|
||
|
|
or generative op run without approval. Making the tier a pure function of
|
||
|
|
`(op_type, is_destructive, is_reversible)` removes the model from that decision
|
||
|
|
entirely — the model only describes the op's objective properties, and the
|
||
|
|
script enforces the boundary. This is the difference between trusting the model
|
||
|
|
to obey invariant #7 and making it structurally unable to violate it.
|
||
|
|
|
||
|
|
### Why scanner signals live only on `entries` (cleared-file audit-trail tradeoff)
|
||
|
|
|
||
|
|
The deterministic scanner produces `shortlist` (paths only) and the per-entry
|
||
|
|
`signals` arrive on the AI-produced `entries`. A shortlisted file the AI judges
|
||
|
|
clean produces no entry (see the "shortlisted file may be cleared" scenario), so
|
||
|
|
its scanner signals are **not** recorded in the report. This separation is
|
||
|
|
**intentional**: the scanner contributes facts and a candidate set, while the AI
|
||
|
|
contributes judgments and entries; the report records decisions, not the raw
|
||
|
|
scan. The acknowledged tradeoff is an audit-trail gap — there is no record of
|
||
|
|
*why* a cleared file was shortlisted. The PRD does not require cleared-file
|
||
|
|
provenance (US-29's audit trail is about clean-time `confirm` approvals, not
|
||
|
|
check-time clears), so v1 keeps the separation. A future change could attach
|
||
|
|
per-path scanner signals to `shortlist` (or add a `cleared` list) for
|
||
|
|
auditability without restructuring `entries`; this note records the decision but
|
||
|
|
does not change the schema.
|
||
|
|
|
||
|
|
### Why generative ops omit `exact_edit`
|
||
|
|
|
||
|
|
A check must stay cheap even when no clean follows. Pre-writing prose
|
||
|
|
transformations would spend Sonnet tokens at check time for work that may never
|
||
|
|
be applied, so generative edits are produced at clean time instead.
|