@antoneeo/kb-agentic-skill 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,12 +45,50 @@ below — so it needs no separate dispatch hook.)
45
45
 
46
46
  ## Model tiers (client-relative, no provider names)
47
47
 
48
- - Default dispatch: **economy** implementer tier.
48
+ - Default dispatch: **economy** implementer tier. This is legal precisely
49
+ because a dispatched task carries `task.verify` — the threshold signal that
50
+ makes a wrong cheap answer visible. **The floor that governs every role, and
51
+ the rule that a tier may be lowered only where such a signal exists, is
52
+ `review.md` §The capability floor** — cited, never restated here.
49
53
  - After **two** consecutive `verify_result: fail` on the same task: escalate
50
54
  to the **deep** tier for the retry (ADR 2026-07-02). Do not escalate on the
51
55
  first failure — a single fail is often a brief or environment issue, not a
52
56
  capability gap.
53
57
 
58
+ ## What may be delegated at all
59
+
60
+ The sections above govern the opt-in PLAN loop; **this one is family-wide** and
61
+ applies to any pass, at any level, dispatch or no dispatch. Delegation is not a
62
+ token-saving technique — a second context re-reads, re-briefs and reports back,
63
+ so the total spend goes UP. What it buys is a main context that stays clean, and
64
+ independence where independence is the point. Judge a pass on two axes:
65
+
66
+ | | output falsifiable cheaply (`file:line`, an assertion result, a diff) | output is judgement |
67
+ |---|---|---|
68
+ | **reads ≫ report** | **delegate** — the whole win lives here | **never**: the biggest apparent win is where plausible-but-wrong enters |
69
+ | **reads ≈ report** | keep it: the brief costs more than the pass | keep it |
70
+
71
+ - **Delegate**: locating and verifying (a pass that returns citable facts and
72
+ discards its exploration trace), executing a probe or battery and reporting
73
+ the assertion table, and every independent review — the last for correctness,
74
+ not economy, and it is mandatory regardless of what it costs.
75
+ - **Never delegate**: authoring a governed artifact (Vision, ANALYSIS/E-ISP,
76
+ threat model, use cases, the Interface Contract), the triage level and router
77
+ verdict, a reconciliation or scope decision, and any pass whose result the
78
+ orchestrator must hold across many later turns — delegating that one just
79
+ moves the reading into the main context via the report.
80
+ - **The delegability condition**: a task whose brief cannot be expressed as
81
+ **pointers** — paths, ids, a command — is **not delegable** yet; a brief that
82
+ must carry the content pays those tokens twice, once to write it and once to
83
+ read it. `plan brief` is this rule already realized for the PLAN loop (it
84
+ prints `guides` as paths, never pasted content); the condition generalizes it.
85
+
86
+ **Declared residual**, so the silence is not read as prohibition: this section
87
+ settles the CLASSIFICATION. *When* to reach for a delegated search or
88
+ orientation pass — the trigger, its brief format and its output contract — is a
89
+ separate unit and is not answered here; until it lands, reaching for one is the
90
+ orchestrator's judgement against the table above, not a rule.
91
+
54
92
  ## Review slots — one-shot, not iterative
55
93
 
56
94
  Exactly three review touches per task, never a loop:
@@ -96,11 +96,19 @@ Claims live in the owning topic's `## Claims` table (`templates.md` has the temp
96
96
  makes the same assertion mint the same id in another project, which is what makes
97
97
  `portability.md`'s de-duplication mechanical instead of a judgement call. The price is
98
98
  that **one span cannot carry two different assertions at the same qty** — the id
99
- cannot tell them apart, and the validator refuses the pair. The fix is to widen one
100
- locator to the span that actually carries its assertion, or to merge the two rows.
101
- It is **not** to edit the qty or nudge the locator until the hash differs: that
102
- distorts the evidence to satisfy a hash function, which is the one repair this
103
- ledger exists to prevent.
99
+ cannot tell them apart, and the validator refuses the pair. That refusal is a
100
+ symptom of a non-atomic row (§3): the fix is to **narrow each locator to the
101
+ sub-span that carries its own assertion** — narrow spans make atomic rows
102
+ id-distinct by construction. Merge ONLY when the two rows state the same single
103
+ assertion twice; merging distinct assertions manufactures a bundled row (§3),
104
+ trading the mechanical error for a semantic one no check can see. Narrowing is
105
+ not nudging: a narrowed locator still addresses exactly the words that support
106
+ its assertion, and the validator still opens the span. What stays forbidden is
107
+ editing the qty, or moving a locator onto bytes that do NOT carry the assertion,
108
+ until the hash differs — that distorts the evidence to satisfy a hash function,
109
+ which is the one repair this ledger exists to prevent. A locator edit mints a
110
+ NEW id; citers of the old one are reached by the cascade (`reconciliation.md`
111
+ §Splitting a bundled row).
104
112
  - **valid** — `-`, `from X`, `until X`, `from X until Y`, `if <condition>`. Half-open:
105
113
  `until 2026-03-01` and `from 2026-03-01` do NOT overlap. A time-bounded fact is not a
106
114
  conflict with its successor — write the scope, or reconciliation will manufacture one.
@@ -117,8 +125,8 @@ Claims live in the owning topic's `## Claims` table (`templates.md` has the temp
117
125
  the file and checks.
118
126
  - **prov** — `GIVEN | ELICITED | DERIVED | RULING | IMPORTED`. `IMPORTED` is a ruling that came from another project (`portability.md`): it keeps its text and original `basis:`, its note must say `imported_from:`, and it may not supersede a local row until you re-ratify it. Information for whoever resolves a
119
127
  conflict; never a rank.
120
- - **state** — `OK`, `CONTESTED <ids>`, `SUPERSEDED <id>` (`reconciliation.md` owns the
121
- transitions).
128
+ - **state** — `OK`, `CONTESTED <id>[,..]`, `SUPERSEDED <id>[,..]` (`reconciliation.md`
129
+ owns the transitions; a split bundle names ALL its successors).
122
130
 
123
131
  ## 3. Extraction discipline
124
132
 
@@ -127,6 +135,29 @@ the offset span it came from. The extractor **invents nothing**: no labels, no
127
135
  summaries-as-claims, no filling of gaps from model knowledge. What the source does not
128
136
  assert does not become a row — it may become a `gaps:` entry on the topic.
129
137
 
138
+ **One row asserts one thing — the span is not the unit.** The extraction unit is a
139
+ source span; the unit of use is one assertion, and they are not the same size: a row
140
+ can be atomic as provenance and plural as meaning. The common source of plurality is
141
+ the **reviewer-comment shape** — one comment under one feature heading asserting
142
+ several facts about several subjects (a capability at the door, another actor's
143
+ console, a verification flow). Extracted as one span it becomes one legal row, and
144
+ every downstream citation of it is faithful to the text while asserting what the
145
+ source did not: the off-topic part is read as applying to the citing context
146
+ (borrowed capability), another actor's surface counts as this actor's (wrong actor),
147
+ an ambiguous subject silently switches referent — and the writer who avoids all
148
+ three by quoting only the on-topic part has lost an assertion instead. Both halves
149
+ of the north star, violated by one bundled row. So **emit one row per assertion even
150
+ when the source packs several into one sentence**, and **cite the narrowest span
151
+ that supports the assertion, not the paragraph that contains it** — narrow spans are
152
+ also what make atomic rows id-distinct (§2). Sub-line precision needs no
153
+ re-extraction on a stored `.txt`: a form-feed-free file is one page, so
154
+ `p=1@<a>-<b>` addresses character offsets below the line; re-extraction is needed
155
+ only for a source with no character-addressable stored form. A bundled row already
156
+ in the ledger is repaired by splitting (`reconciliation.md` §Splitting a bundled
157
+ row), never edited in place. The validator can only hint here — a `[note]` on
158
+ suspicious claim text, heuristic and fallible, never an error: atomicity is
159
+ meaning, and meaning is verified at the ingestion review.
160
+
130
161
  **Exhaust the source; never sample it.** *Invents nothing* is a floor, and a floor is not
131
162
  a target: an extractor that stops the moment nothing it wrote is false stops on page
132
163
  twenty of a two-hundred-page manual, with every row it emitted correct. A source is
@@ -69,6 +69,28 @@ premise: signed amendments arrive later.
69
69
  | newer source vs a ruling | new set {new row, ruling row}; escalation shows the `basis:` |
70
70
  | member refined | the refining row inherits the member's contested relations (re-judged); counterparts rewritten to the new id |
71
71
  | corroboration of a contested member | source appended; the set untouched — more evidence is not new information, and the escalation form shows source counts |
72
+ | bundled row split (`distillation.md` §3) | N atomic rows `OK`, each on its narrowed span; the bundle → `SUPERSEDED <id1>[, <id2> ..]`, text intact; contested relations re-judged per successor |
73
+
74
+ ## 3-bis. Splitting a bundled row
75
+
76
+ A row carrying more than one assertion (`distillation.md` §3 — atomic as provenance,
77
+ plural as meaning) is repaired by supersession, never in place:
78
+
79
+ 1. Write one atomic row per assertion, each citing the **narrowed span** that carries
80
+ it (`p=1@<a>-<b>` reaches below a line on a stored `.txt`). Narrow spans mint
81
+ distinct ids by construction.
82
+ 2. The bundled row becomes `SUPERSEDED <id1>, <id2>, ...` — every successor named,
83
+ its text intact, exactly as refinement keeps superseded text.
84
+ 3. Contested relations of the bundle are **re-judged per successor**: a counterpart
85
+ usually contests ONE of the assertions, not all of them.
86
+ 4. The cascade runs (§2, *the cascade runs*): every living document citing the fallen
87
+ id joins the revision worklist, and each citer is revised to the successor in ITS
88
+ scope — the moment a borrowed-capability citation dies.
89
+
90
+ This is not new information arriving (§2): the source said N things all along; the
91
+ ledger only now says them one at a time. No ruling and no `basis:` is needed — the
92
+ split is a repair of our own extraction, and it travels with its own diary/commit
93
+ record like any claim-table edit.
72
94
 
73
95
  ## 4. Escalation — batched, and in the legal form only
74
96
 
@@ -82,15 +82,92 @@ its outcome. The trigger term "permission-gated" scopes the STOP; the row word
82
82
 
83
83
  Use a different model from the author's where the client allows it.
84
84
 
85
+ **The capability floor — which tier may run this role.** Independence says the
86
+ reviewer must not be the author; it says nothing about whether the reviewer *can
87
+ do the job*. A gate run below the capability its judgement needs produces the
88
+ myopia the gate exists to catch, **while reporting as an independent review** —
89
+ worse than no gate, because it certifies. So each role carries a minimum tier.
90
+ Tiers are **client-relative capability levels, never provider names** (a name
91
+ rots at the next model release; the tier→model binding lives in the client's own
92
+ agent definition, never in this file):
93
+
94
+ | Role | Floor | Why there |
95
+ |---|---|---|
96
+ | **authoring a governed artifact** (Vision, the analysis/design, use cases, the Interface Contract, threat model — `dispatch.md`'s never-list owns the enumeration) | **deep** | the purest case of the rule below: the output is judgement, nothing scores it, and `dispatch.md`'s never-list forbids moving it elsewhere — so the session's own capability IS this work's capability |
97
+ | design review, closure review, any verdict on judgement | **deep** | the output is a judgement whose wrongness no check can see |
98
+ | a light conformance or consistency pass (schema, coverage, citation resolution) | **light** | the criterion is stated and mechanical |
99
+ | implementer dispatch, probe execution, anchor resolution, index regeneration | **economy** | a threshold signal catches a wrong cheap answer |
100
+
101
+ The rule underneath the table: **a tier may be lowered only where a threshold
102
+ signal exists** — `task.verify`, a test, a diff against a spec, an assertion
103
+ harness — because that is what makes a wrong cheap answer visible instead of
104
+ plausible. Where the output is judgement and nothing scores it, there is no
105
+ signal to lower against, and the floor is the whole policy.
106
+
107
+ **A session's floor is the highest floor among the roles it performs itself.** The
108
+ table above binds reviewers and dispatched subagents, chosen per call — but the
109
+ session doing the authoring was chosen by the USER before the agent existed, and
110
+ an agent cannot re-tier itself mid-session. So the rule that reaches it is not
111
+ routing, it is **disclosure**: where a session can determine that it is below the
112
+ floor for a role it performs itself, it says so **once per session, at the first
113
+ such role** — not once per unit — and the disclosure is repeated in the review
114
+ row's existing `reviewer` cell (Hybrid: `notes`), which is what makes the pair
115
+ below visible without adding a column.
116
+
117
+ **Say plainly how thin this reach is.** A session knows its model NAME; tiers here
118
+ are deliberately client-relative and this file maps no name to a tier, so the
119
+ antecedent is satisfiable only where a session can recognize its own tier — which
120
+ is also the session least likely to run the check. Where a session **cannot
121
+ determine its own tier**, it owes nothing: the rule binds what an agent can know
122
+ and say, never a capability it cannot acquire. **The reliably working half of this
123
+ rule is the one addressed to the user, not to the agent** — which is why the
124
+ package READMEs carry the tier-selection guidance, and why that is not a footnote.
125
+
126
+ **Never both below the floor on the same unit — except when nothing better
127
+ exists.** Where the authoring session ran below floor, the independent review's
128
+ floor becomes non-negotiable and the same unit should not ALSO run a below-floor
129
+ review. **The exception is the case *When independence and capability conflict*
130
+ governs below**: where no independent rung at or above the floor exists at all,
131
+ that rule wins, the review still runs, BOTH disclosures are recorded, and the
132
+ result is not a violation but the worst admissible state — honestly labelled.
133
+ Forbidding it outright would push the agent toward the abstention that rule
134
+ explicitly rejects.
135
+
136
+ **When independence and capability conflict, independence wins.** A client whose
137
+ only independent rung is bound below the floor (a single-model client, or a
138
+ subagent facility whose agent definition fixes a cheap model) still runs the
139
+ gate: a below-floor *independent* reviewer catches the class the author is
140
+ structurally blind to, while a self-pass catches none of it. What the floor then
141
+ requires is **disclosure, not abstention** — the row records the tier that
142
+ actually ran (`below floor: <reason>`), and that record IS the remedy. Where a
143
+ higher-capability path exists but is permission-gated, nothing new applies: *The
144
+ gated rung* above already governs, one question per gate per intact context.
145
+ Where the client exposes no capability choice at all, the row says
146
+ `single (client exposes no choice)` and the floor is satisfied by construction —
147
+ it is a routing rule, and a routing rule with one route is not a failure.
148
+
85
149
  **Rounds are capped at 3.** FAIL → revise → re-review. If findings still stand
86
150
  after the third, stop and surface them to the user with the artifact — a gate that
87
151
  can block forever gets removed. **Log one row per completed review, PASS or FAIL**
88
152
  — a FAIL surfaced to the user is the highest-value outcome the gate produces, and
89
153
  logging only passes would erase exactly that evidence. The row goes in
90
154
  `ai_docs/audit/reviews/REVIEW_LOG.md` (create it if absent — `templates.md`):
91
- `| date | doc_key | tier | reviewer | findings_raised | findings_real | verdict | revise_rounds |`,
92
- with `tier` = `design` or `closure` in Standalone. One schema for both modes: a
93
- Hybrid project's devPNT gates write to the same file.
155
+ `| date | doc_key | tier | model | reviewer | findings_raised | findings_real | verdict | revise_rounds |`,
156
+ with `tier` = `design` or `closure` in Standalone. **`model` records the capability
157
+ tier that actually ran** — `deep`, `light`, `economy`, `single (client exposes no
158
+ choice)`, or `below floor: <reason>` — never a provider name in doctrine (a project
159
+ may of course write its own model names in its own log). Without it the floor above
160
+ is a belief: the row would say a gate ran and never whether it could do its job, and
161
+ the correlation with `revise_rounds` and `findings_real` — the only evidence the
162
+ policy pays — could not be drawn.
163
+
164
+ **One schema for both modes**, meaning one required CORE plus each mode's own
165
+ **mode-specific** realization columns. The core is what both modes actually share: `date`, `doc_key`,
166
+ `tier`, `model`, `findings_raised`, `findings_real`, `verdict`, `revise_rounds`.
167
+ Around it, Standalone adds `reviewer` and Hybrid adds `instrument` and `notes` —
168
+ a devPNT row carries no `reviewer` column at all, so "one identical column list"
169
+ was never true of the two modes. This costs nothing for the reason `templates.md`
170
+ §`ai_docs/audit/reviews/REVIEW_LOG.md` owns and states.
94
171
  The log is how the gate's value is measured over time; skipping it makes the gate
95
172
  unfalsifiable, the same defect as an unnamed EXISTS or a faked router verdict.
96
173
 
@@ -187,6 +187,13 @@ def kb_claim_id(source_path, locator, qty_key=""):
187
187
  return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
188
188
 
189
189
 
190
+ # F-047: bundling tells for the atomicity note in kb_check_claims. Literal
191
+ # substrings, deliberately few and conservative: the note is a hint, and a
192
+ # noisy hint trains readers to skip notes. Field-calibrated on the motivating
193
+ # reviewer-comment shape ("states X, that Y, and that Z").
194
+ KB_BUNDLE_HINTS = (", and that ", "; ", ", while ", " and also ")
195
+
196
+
190
197
  def kb_unquote(value):
191
198
  """Strip ONE pair of wrapping YAML quotes (then re-strip whitespace).
192
199
  load_frontmatter keeps values verbatim by design; paths with spaces force
@@ -498,6 +505,20 @@ def kb_check_claims(root):
498
505
  if not m:
499
506
  errors.append("%s: about must be '<predicate> -> <slug>' or '-'"
500
507
  % where)
508
+ # --- atomicity hint (F-047): a row atomic as provenance can be
509
+ # plural as meaning. Text cannot PROVE plurality, so this is a
510
+ # note — never a warning or an error, never the exit code.
511
+ hints = [h for h in KB_BUNDLE_HINTS if h in row["claim"]]
512
+ if hints:
513
+ notes.append("%s: claim text of %s reads like more than one "
514
+ "assertion (matched %s) — one row asserts one "
515
+ "thing; if it is a bundle, split it "
516
+ "(distillation.md section 3, reconciliation.md "
517
+ "section 'Splitting a bundled row'). Heuristic "
518
+ "only: a genuinely "
519
+ "single assertion needs no change"
520
+ % (where, row["id"] or "(id pending)",
521
+ ", ".join(repr(h) for h in hints)))
501
522
  # --- id: recompute, or note fill-pending ---
502
523
  if not row["id"]:
503
524
  notes.append("%s: id missing — fill-pending, run "
@@ -534,14 +555,22 @@ def kb_check_claims(root):
534
555
  "is global across topics/" % (where, row["id"],
535
556
  all_ids[row["id"]]))
536
557
  elif same_span:
558
+ # F-047: recommending a merge here was a trap — merging
559
+ # distinct assertions manufactures a bundled row, trading
560
+ # this mechanical error for a semantic one no check sees.
537
561
  errors.append(
538
562
  "%s: id %s collides with the row at %s — two "
539
563
  "DIFFERENT rows cite the same span with the same "
540
564
  "qty, and the id function cannot separate them (it "
541
565
  "hashes path#locator#qty and excludes the text on "
542
- "purpose). Widen one locator to the span that "
543
- "actually carries its assertion, or merge the two "
544
- "rows — do not edit the qty to break the tie"
566
+ "purpose). Narrow each locator to the sub-span that "
567
+ "carries its own assertion — narrow spans make "
568
+ "atomic rows id-distinct (on a stored .txt, "
569
+ "p=<page>@<a>-<b> reaches below a line). Merge ONLY "
570
+ "if the two rows state the same single assertion; "
571
+ "merging distinct assertions trades this mechanical "
572
+ "error for a semantic one no check can see — do not "
573
+ "edit the qty to break the tie"
545
574
  % (where, row["id"], all_ids[row["id"]]))
546
575
  else:
547
576
  # Same id, different text AND different span: the id
@@ -570,7 +599,7 @@ def kb_check_claims(root):
570
599
  m = re.match(r"^(CONTESTED|SUPERSEDED) ([0-9a-f, ]+)$", state)
571
600
  if not m:
572
601
  errors.append("%s: state must be OK, 'CONTESTED <id>[,..]' or "
573
- "'SUPERSEDED <id>': %r" % (where, state))
602
+ "'SUPERSEDED <id>[,..]': %r" % (where, state))
574
603
  continue
575
604
  kind = m.group(1)
576
605
  targets = [t.strip() for t in m.group(2).split(",") if t.strip()]
@@ -1320,7 +1320,7 @@ def cmd_validate(root, strict=False, hybrid=False):
1320
1320
  # comment-stripped, anchored: a '<!-- TODO: the ## Capability Ledger -->'
1321
1321
  # must not read as the section being present
1322
1322
  # Hybrid: the design lives in devPNT and its §4.5 gate owns this slot
1323
- # (SKILL.md ownership matrix: "run ONE of them, never both"), and its log
1323
+ # (the Hybrid ownership matrix: "run ONE of them, never both"), and its log
1324
1324
  # rows are keyed on e_isp_/e_tdd_ doc_keys, not on this filename -- so
1325
1325
  # firing here would be a permanent, unfixable false positive.
1326
1326
  if not hybrid and design_review_due(meta) and not review_logged(root, p.name):
@@ -2052,6 +2052,237 @@ def cmd_plan(root, args):
2052
2052
  return 0
2053
2053
 
2054
2054
 
2055
+ # --- F-050: the benefit report -------------------------------------------
2056
+ # The process's central claim is that it makes divergence visible BEFORE
2057
+ # implementation. That claim lived in prose for four months while the evidence
2058
+ # for it sat unread in REVIEW_LOG. This reads it.
2059
+ #
2060
+ # Columns are resolved BY HEADER NAME, never by width. `templates.md` states
2061
+ # that contract ("extra or reordered columns are fine -- but the header must
2062
+ # say `tier`") and three widths are legal today: the original 8, Standalone's 9
2063
+ # (F-048 added `model`), and Hybrid's 10 (`instrument` + `notes`, and no
2064
+ # `reviewer` at all). A width whitelist reads none of the last, and silently
2065
+ # reports a devPNT-governed project as having no data at all.
2066
+ #
2067
+ # Two honesty rules, both learned from this unit's own review: whatever cannot
2068
+ # be read is COUNTED and NAMED, at the row level AND at the cell level. A ratio
2069
+ # over a silently shrunken denominator is worse than no ratio.
2070
+ REVIEW_CORE = ("date", "doc_key", "tier", "findings_real", "verdict")
2071
+ REVIEW_POSITIONAL = {
2072
+ 8: ("date", "doc_key", "tier", "reviewer", "findings_raised",
2073
+ "findings_real", "verdict", "revise_rounds"),
2074
+ 9: ("date", "doc_key", "tier", "model", "reviewer", "findings_raised",
2075
+ "findings_real", "verdict", "revise_rounds"),
2076
+ 10: ("date", "doc_key", "tier", "model", "instrument", "findings_raised",
2077
+ "findings_real", "verdict", "revise_rounds", "notes"),
2078
+ }
2079
+
2080
+
2081
+ def _table_cells(line):
2082
+ """Cells of a Markdown table row, or None when the line is not one.
2083
+ Separator rows (---, :---:, any alignment colons) are not data."""
2084
+ s = line.strip()
2085
+ if not s.startswith("|"):
2086
+ return None
2087
+ if set(s) <= set("|-: "):
2088
+ return None
2089
+ return [c.strip() for c in s.strip("|").split("|")]
2090
+
2091
+
2092
+ def parse_review_log(text):
2093
+ """-> (rows, unparsed). Columns come from the header when there is one.
2094
+
2095
+ Falls back to the positional maps only for a table with no header, so a
2096
+ hand-kept log still reads. A row whose cell count does not match the
2097
+ header's is unparsed and named -- never dropped."""
2098
+ rows, unparsed, names = [], [], None
2099
+ for n, line in enumerate(text.splitlines(), 1):
2100
+ cells = _table_cells(line)
2101
+ if cells is None:
2102
+ continue
2103
+ lowered = [c.lower() for c in cells]
2104
+ if names is None and "tier" in lowered and "verdict" in lowered:
2105
+ names = lowered # the header IS the column map
2106
+ continue
2107
+ # The header supplies the map -- that is what makes reordered and extra
2108
+ # columns readable. But a log legitimately carries MIXED widths (F-048
2109
+ # widened the schema and did not rewrite history), so a row that does
2110
+ # not match the header is read by the positional map for its own width
2111
+ # before it is given up on. Only a width no map knows is unparsed.
2112
+ if names is not None and len(cells) == len(names):
2113
+ row = dict(zip(names, cells))
2114
+ else:
2115
+ positional = REVIEW_POSITIONAL.get(len(cells))
2116
+ if positional is None:
2117
+ unparsed.append((n, "%d cells: %s" % (
2118
+ len(cells),
2119
+ "header declares %d and no positional map fits"
2120
+ % len(names) if names else "no header, and no map fits")))
2121
+ continue
2122
+ row = dict(zip(positional, cells))
2123
+ missing = [c for c in REVIEW_CORE if c not in row]
2124
+ if missing:
2125
+ unparsed.append((n, "no %s column" % "/".join(missing)))
2126
+ continue
2127
+ if not re.match(r"^\d{4}-\d{2}-\d{2}$", row["date"]):
2128
+ unparsed.append((n, "date cell %r is not a date" % row["date"][:24]))
2129
+ continue
2130
+ rows.append(row)
2131
+ return rows, unparsed
2132
+
2133
+
2134
+ def review_findings(value):
2135
+ """The count in a `findings_real` cell, or None when it states no number.
2136
+
2137
+ None is NOT zero. `all` and `VOID -- instrument error` appear in real logs,
2138
+ and counting them as zero findings silently deflates the one ratio this
2139
+ report exists to state. Anchored at the START of the cell so a prose cell
2140
+ like `R1 4 BLOCK + 8 WARN` does not yield a confident 4."""
2141
+ m = re.match(r"^(\d+)\b", (value or "").strip())
2142
+ return int(m.group(1)) if m else None
2143
+
2144
+
2145
+ def _is_fail(verdict):
2146
+ """FAIL as a WORD, so `PASS (no failures)` is not counted as a failure."""
2147
+ return re.search(r"\bFAIL\b", (verdict or "").upper()) is not None
2148
+
2149
+
2150
+ def _is_pass(verdict):
2151
+ return re.search(r"\bPASS\b", (verdict or "").upper()) is not None
2152
+
2153
+
2154
+ def _print_read_cost():
2155
+ """The cost side, printed with the benefit and NEVER without it: a catch
2156
+ rate with no cost term justifies unbounded doctrine, and a cost term with
2157
+ no catch rate justifies deleting the process. When it cannot be measured
2158
+ the line still prints, saying so -- silence here IS the catch rate quoted
2159
+ alone, which is the thing this report exists to prevent."""
2160
+ skill_dir = Path(__file__).resolve().parent.parent
2161
+ contract = skill_dir / "SKILL.md"
2162
+ print()
2163
+ if not contract.is_file():
2164
+ print("Read cost: unavailable -- SKILL.md not found beside this "
2165
+ "validator (%s)" % skill_dir)
2166
+ return
2167
+ files = [p for p in sorted(skill_dir.glob("*.md")) if p.name != "SKILL.md"]
2168
+ support = sum(p.stat().st_size for p in files)
2169
+ head = contract.stat().st_size
2170
+ # The TOTAL, and the files itemised. Printing only the two terms lets the
2171
+ # one that fell be quoted while the one that grew stays lumped -- which is
2172
+ # exactly how a -7.8% headline coexisted with a read surface that grew.
2173
+ print("Read cost of %s: SKILL.md %d bytes (every session) + %d bytes "
2174
+ "across %d support files (read on trigger) = %d bytes total"
2175
+ % (skill_dir.name, head, support, len(files), head + support))
2176
+ for p in files:
2177
+ print(" %-24s %8d" % (p.name, p.stat().st_size))
2178
+
2179
+
2180
+ def cmd_benefit(root):
2181
+ """Report what the process caught, beside what it costs to read.
2182
+
2183
+ ALWAYS exits 0 and emits no warning or advisory. This number is the
2184
+ criterion future units are judged by; a measurement that can fail a build
2185
+ becomes a target, and a targeted measurement stops measuring."""
2186
+ log = root / review_log_rel()
2187
+ print("=== benefit: what the process caught, and what it costs to read ===")
2188
+ if not log.is_file():
2189
+ print("[info] no %s yet: nothing to report. The number appears once "
2190
+ "reviews are logged." % review_log_rel())
2191
+ _print_read_cost()
2192
+ return 0
2193
+ rows, unparsed = parse_review_log(read_text(log))
2194
+ # `tier` carries the MOMENT in Standalone (design / closure) and the
2195
+ # reviewer WEIGHT in Hybrid (deep / light / code / guide / vision). A
2196
+ # Hybrid row is a real review whose moment this column does not state, so
2197
+ # it is counted and named, never folded into a moment by guesswork.
2198
+ moments = {"design": [], "closure": []}
2199
+ unstated = {}
2200
+ unstated_found = unstated_code = unstated_uncounted = 0
2201
+ for r in rows:
2202
+ tier = r["tier"].lower()
2203
+ if tier.startswith("design"):
2204
+ moments["design"].append(r)
2205
+ elif tier.startswith("closure"):
2206
+ moments["closure"].append(r)
2207
+ else:
2208
+ unstated[tier] = unstated.get(tier, 0) + 1
2209
+ n = review_findings(r["findings_real"])
2210
+ if n is None:
2211
+ unstated_uncounted += 1
2212
+ else:
2213
+ unstated_found += n
2214
+ if tier.startswith("code"):
2215
+ unstated_code += n
2216
+ classified = moments["design"] + moments["closure"]
2217
+ print("rows parsed: %d rows unparsed: %d%s"
2218
+ % (len(rows), len(unparsed),
2219
+ (" -> " + "; ".join("line %d: %s" % u for u in unparsed[:5]))
2220
+ if unparsed else ""))
2221
+ print("reviews with a stated moment: %d moment not stated by `tier`: %d%s"
2222
+ % (len(classified), sum(unstated.values()),
2223
+ (" (" + ", ".join("%s x%d" % (t, n) for t, n
2224
+ in sorted(unstated.items())) + ")")
2225
+ if unstated else ""))
2226
+ if not classified:
2227
+ _print_read_cost()
2228
+ return 0
2229
+ dates = sorted(r["date"] for r in classified)
2230
+ print("span of the reviews counted below: %s -> %s" % (dates[0], dates[-1]))
2231
+
2232
+ print()
2233
+ print("%-9s %6s %10s %11s %10s" % ("moment", "rows", "findings",
2234
+ "FAIL verdict", "per review"))
2235
+ totals, uncounted = {}, 0
2236
+ for m in ("design", "closure"):
2237
+ rs = moments[m]
2238
+ counts = [review_findings(r["findings_real"]) for r in rs]
2239
+ uncounted += sum(1 for c in counts if c is None)
2240
+ found = sum(c for c in counts if c is not None)
2241
+ failed = sum(1 for r in rs if _is_fail(r["verdict"]))
2242
+ totals[m] = found
2243
+ print("%-9s %6d %10d %11d %10.1f"
2244
+ % (m, len(rs), found, failed, (found / len(rs)) if rs else 0.0))
2245
+ total = sum(totals.values())
2246
+ if uncounted:
2247
+ print("findings cells stating no number: %d (counted as unknown, "
2248
+ "never as zero)" % uncounted)
2249
+ if total:
2250
+ print()
2251
+ print("Caught BEFORE the code existed: %d / %d findings = %.0f%%"
2252
+ % (totals["design"], total, 100.0 * totals["design"] / total))
2253
+ # The residue, stated as a BOUND rather than as a direction. The
2254
+ # sentence this replaces claimed the share was "a floor, not a
2255
+ # ceiling" because `code` rows are post-implementation -- which is
2256
+ # backwards, since folding them LOWERS the design share. But the fact
2257
+ # was right and only the direction wrong, so `code` findings sit on
2258
+ # the closure side of the upper bound instead of being discarded with
2259
+ # it. No direction is printed at all: the unstated set pushes both
2260
+ # ways, and only an interval is true of all of it.
2261
+ if unstated_found:
2262
+ denom = total + unstated_found
2263
+ print("Bounded by the %d findings in rows whose moment `tier` "
2264
+ "does not state: %.0f%%-%.0f%% (`code` rows are "
2265
+ "post-implementation by definition, so their findings sit "
2266
+ "on the closure side of the upper bound)"
2267
+ % (unstated_found, 100.0 * totals["design"] / denom,
2268
+ 100.0 * (totals["design"] + unstated_found - unstated_code)
2269
+ / denom))
2270
+ if unstated_uncounted:
2271
+ print("%d unstated rows state no findings count (`all`, `VOID`): "
2272
+ "neither zero nor countable, so they lie outside both "
2273
+ "bounds" % unstated_uncounted)
2274
+ failed_all = sum(1 for r in classified if _is_fail(r["verdict"]))
2275
+ inconclusive = sum(1 for r in classified if not _is_fail(r["verdict"])
2276
+ and not _is_pass(r["verdict"]))
2277
+ print("Reviews whose verdict contains FAIL: %d / %d = %.0f%%%s"
2278
+ % (failed_all, len(classified), 100.0 * failed_all / len(classified),
2279
+ (" (+%d inconclusive)" % inconclusive) if inconclusive else ""))
2280
+ print("(both figures are over the %d reviews whose moment `tier` states)"
2281
+ % len(classified))
2282
+ _print_read_cost()
2283
+ return 0
2284
+
2285
+
2055
2286
  def cmd_orient(args):
2056
2287
  """SessionStart hook: emit a bounded, repo-sourced ai_docs/ orientation to
2057
2288
  stdout and ALWAYS return 0 (fail-open, P-TM T8) -- a session hook must never
@@ -2315,6 +2546,11 @@ def main(argv=None):
2315
2546
  gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
2316
2547
  gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")
2317
2548
 
2549
+ sub.add_parser("benefit", parents=[common],
2550
+ help="report what the review gates caught (and what the "
2551
+ "doctrine costs to read) -- a report, never a gate: "
2552
+ "always exits 0")
2553
+
2318
2554
  sub.add_parser("orient", parents=[common, hybrid_opt],
2319
2555
  help="SessionStart hook: emit docs-root orientation to stdout (fail-open, zero-execution)")
2320
2556
 
@@ -2374,6 +2610,8 @@ def main(argv=None):
2374
2610
  return cmd_stale(root, hybrid=args.hybrid)
2375
2611
  if args.cmd == "mark":
2376
2612
  return cmd_mark(root, args.paths)
2613
+ if args.cmd == "benefit":
2614
+ return cmd_benefit(root)
2377
2615
  if args.cmd == "migrate":
2378
2616
  return cmd_migrate(root, args)
2379
2617
  if args.cmd == "plan":
@@ -439,16 +439,18 @@ Date: 2026-06-11 (UTC)
439
439
 
440
440
  One row per completed review (`review.md` §When a review is due). Append-only; it
441
441
  is the record that the gate ran and what it was worth. **One schema for both
442
- modes** — a Hybrid project's devPNT gates write to this same file, so Standalone
443
- adds values to the existing columns rather than a second table.
442
+ modes** — one required CORE (`date`, `doc_key`, `tier`, `model`, `findings_raised`,
443
+ `findings_real`, `verdict`, `revise_rounds`) plus each mode's own realization
444
+ columns: Standalone adds `reviewer`, a Hybrid devPNT row adds `instrument` and
445
+ `notes` and carries no `reviewer` at all. Both modes write this same file.
444
446
 
445
447
  ```markdown
446
448
  # Independent Review Log
447
449
 
448
- | date | doc_key | tier | reviewer | findings_raised | findings_real | verdict | revise_rounds |
449
- |---|---|---|---|---|---|---|---|
450
- | 2026-06-11 | ANALYSIS_login_sso.md | design | subagent (opus, fresh ctx) | 4 | 3 | FAIL → PASS | 2 |
451
- | 2026-06-12 | diff feature/sso-login | closure | self-pass (declared; absent) | 2 | 2 | PASS with findings → corrections re-reviewed, PASS | 2 |
450
+ | date | doc_key | tier | model | reviewer | findings_raised | findings_real | verdict | revise_rounds |
451
+ |---|---|---|---|---|---|---|---|---|
452
+ | 2026-06-11 | ANALYSIS_login_sso.md | design | deep | subagent (fresh ctx) | 4 | 3 | FAIL → PASS | 2 |
453
+ | 2026-06-12 | diff feature/sso-login | closure | deep | self-pass (declared; absent) | 2 | 2 | PASS with findings → corrections re-reviewed, PASS | 2 |
452
454
 
453
455
  ## Notes
454
456
  <!-- One short paragraph per review that found something worth remembering: what
@@ -460,9 +462,14 @@ adds values to the existing columns rather than a second table.
460
462
 
461
463
  `tier` is the moment plus, in Hybrid, the reviewer weight: `design`, `design (late)`
462
464
  and `closure` (Standalone); `deep`, `light`, `code`, `guide`, `vision` (devPNT gates
463
- and the Vision blind check). The validator reads this column by its header name, so
465
+ and the Vision blind check). `model` is a different question about the same review: `tier` says WHICH GATE ran,
466
+ `model` says WHAT CAPABILITY ran it (`deep`, `light`, `economy`,
467
+ `single (client exposes no choice)`, `below floor: <reason>` — the floor and its
468
+ arbitration are `review.md` §The capability floor). The two columns share the words
469
+ `deep`/`light` and mean different things by them, which is why they are read by
470
+ header and never by position. The validator reads this column by its header name, so
464
471
  extra or reordered columns are fine — but the header must say `tier`. `reviewer`
465
- records the realization actually used — fresh subagent, one-shot client run, or a
472
+ also carries the AUTHORING session's own below-floor disclosure where one is owed (the log has no column for the authoring tier, so `review.md` §The capability floor routes it here; Hybrid rows use `notes`). Otherwise it records the realization actually used — fresh subagent, one-shot client run, or a
466
473
  **declared** self-pass — and, for any rung below rung 1, WHY the rung(s) above did
467
474
  not run, in the ladder's reason words (`review.md`): `absent` — the client has no
468
475
  such facility (a claim about the client, never about a policy); `gated, declined` —
@@ -631,7 +638,8 @@ status: CURRENT
631
638
  - `qty`: `<value> <unit> <kind>` — effort in person-days (h/d/w/mo/fte-mo; 8h=1d, 1w=5d,
632
639
  1mo=21d), duration in calendar days (h/d/w/mo), cost within one currency, count unit-matched.
633
640
  - `about`: `<predicate> -> <slug>` for relationship claims; stored once, under the subject.
634
- - `state`: `OK` | `CONTESTED <id>[,..]` | `SUPERSEDED <id>` — per claim, never per node.
641
+ - `state`: `OK` | `CONTESTED <id>[,..]` | `SUPERSEDED <id>[,..]` — per claim, never per
642
+ node; a split bundle (`reconciliation.md` §Splitting) names all its successors.
635
643
  - A tombstone (merged/renamed topic): `status: SUPERSEDED` + `redirect_to: <slug>`, body empty.
636
644
 
637
645
  ## ai_docs/corpus/given/[name].meta.md (source sidecar)