← Files APODICTIC Development EditorARCHIVED FILE
scripts/coaching_history.py
97.8 KB · Nov 12, 2026 · 18:56 UTC
#!/usr/bin/env python3
"""coaching-history — Coaching History & Pattern Recognition (Coaching Deepening).
`validate.sh coaching-history <project_root> [--strict]` shells out here. Over multiple revision
cycles the coach can surface a cross-session PROCESS pattern — "you tend to defer character-agency
work; the same finding was deferred across three consecutive sessions" — as a rolling, opt-in,
local-only artifact of DESCRIPTIVE observations, each MECHANICALLY derived from recorded session
history (a count over the finding_disposition / finding_states records, never a vibe). A coaching
observation is a claim about the writer's process, so it carries NO editorial severity (no
Must/Should/Could token, no apodictic:finding block); the intensity scale of a manuscript defect is
orthogonal to a process observation (the Author-Voice / Content-Advisory orthogonal-severity
discipline). Each observation is an apodictic.coaching_observation.v1 block; this validator owns the
artifact's contract and its two ethics gates.
This is APODICTIC's ONE ethically-sensitive surface — the two Fable conditions (2026-07-05) are
mechanized here as the load-bearing gates, "mechanical, or don't build":
H1 schema + unique a coaching_observation block fails its schema (bad pattern enum, malformed CH-NN
id, missing field, broken JSON, dup id within the artifact), OR a per-pattern
count floor (deferral-recurrence >=3, phase-incompletion >=2 — Fable-verdict).
H2 provenance / every `evidence` reference resolves against the recorded records — each F-id
anti-fabrication matches ^F-[A-Za-z0-9]+-[0-9]{2,}$ AND a finding_disposition record with
disposition=='deferred' exists at the cited session; len(evidence) >= count; the
cited session ordinals are ACTUALLY CONSECUTIVE (a gap fails). No fabricated
streak. (The author_fingerprint F2 anti-fabrication posture.)
SCOPE (Codex review F2): the anti-fabrication GUARANTEE is fabrication-RESISTANT
ONLY on GOVERNED projects — the multi-session run is reconstructed from the
independent per-session `gate_events[].disposition_deltas` event records the
engine wrote at each clear. On NON-GOVERNED projects the session history is
SELF-REPORTED by /coach (the folded record's `sessions` list) and is NOT
independently verified; so a non-governed deferral-recurrence must carry a
VISIBLE honesty caveat that its streak is from the coach's own notes, not a
checked record (WARN, ERROR --strict, per-id override) — the writer is never
shown an unverified pattern as if it were verified (the H7 principle).
H3 descriptive, the observation prose carries a prescriptive directive (author_fingerprint
not judgmental _PRESCRIPTIVE_RE) OR a trait-blame construction ("you always / you avoid / you
fail to / you're bad at …"). WARN; ERROR --strict; per-id override
<!-- override: coaching-observation CH-NN — <why> -->. (KNOWN LIMIT: a lexicon
gate catches token framing, not passive trait-attribution — "the pattern
suggests reluctance"; semantic validation deferred to v2.)
H4 no-severity-leak the artifact carries an editorial Must/Should/Could-Fix token (severity_vocab
SSoT, M8) or an apodictic:finding block. A process observation is off the
editorial scale. ERROR.
W1 local-only an external http(s) URL / telemetry reference (author_fingerprint W2). Advisory;
ERROR --strict.
H7 tentative- (transference-health, operator-directed.) The count is the coach's PRIVATE
framing evidence; what reaches the writer is the coach's NOTICING — confident about the
pattern, tentative about its MEANING. ENFORCEABLE FLOOR (shape checks over the
human-facing `observation`): (a) no third-person trait-attribution VERDICT
("Writer defers endings", "she is an ending-avoider"); (b) no bare-scoreboard
rendering (a lone `<label>: <int>` tally with no interpretive prose); (c) the
observation offers SOME invitation to the writer's reading (a question / "does
that land?"). WARN; ERROR --strict; shares the H3 per-id override slug. HONEST
LIMIT: the full "confident about the seeing, open about the meaning" property is a
SKILL-contract obligation (the human terminus) — a lexicon/shape gate catches the
scoreboard and the bald verdict, not a fluent-but-diagnostic paraphrase; §6a.
H5 single-home / (Fable condition a — writer-visible, no coach-only shadow.) Coaching observations
no-shadow may persist in EXACTLY ONE writer-facing artifact. H5 scans the project root +
runs/* archives over the APODICTIC-authored artifacts PLUS the machine-facing
Diagnostic_State.meta.json sidecar (NOT the manuscript). ERROR (non-overridable)
on: (i) a parsed coaching_observation block outside the one Coaching_History
file; (ii) the literal schema-id outside it; (iii) an evidence-grammar string
(`deferred @ session <n>` / `phase <label> incomplete @ session <n>`) outside
it; (iv) >1 Coaching_History file; (v) any coaching material in the sidecar
beyond exactly `coaching_history_seq: <int>` (recursive JSON walk). WARN
(ERROR --strict; override <!-- override: coaching-residue <token> — <why> -->):
a bare boundary-guarded CH-NN token elsewhere (chapter-shorthand false-positive).
H6 deletion-honored (Fable condition b — the record is the writer's, not the tool's.) Under the
(RECOMPUTE) `<!-- coaching-history: deleted -->` tombstone the validator RECOMPUTES deletion
from artifacts (never trusts the marker — the PR #161 recorded-field rule,
disposition_check DP2.6): the full H5 scan must be empty AND no surviving
Coaching_History file AND no coaching_history_seq in the sidecar. Any residue =
ERROR, NO override accepted. `opted-in` + `deleted` both present = ERROR.
The `delete <project_root>` subcommand honors the deletion in one move: removes every
Coaching_History file (root + ALL subdirs, recursive), drops coaching_history_seq from the sidecar at
EVERY depth (recursive strip — not just the top-level/execution.* homes), and flips the
Diagnostic_State.md consent marker to `<!-- coaching-history: deleted -->` (deletion revokes consent
— no silent re-derivation). Because H5's always-on projection ban means observations only ever live
in the one deletable artifact, deleting it is complete BY CONSTRUCTION — and H6 re-verifies anyway.
COMPLETENESS (the incomplete-verification class the reviews keep catching — F1, F2, and Codex round-1
twice more): every scan/recompute here is complete across ALL patterns and ALL depths. The .md scan +
Coaching_History enumeration walk the tree recursively (os.walk); the sidecar walk + seq residue
search recurse to every depth; each v1 pattern's evidence is either verified-against-record or
self-reported-and-caveated (never shape-checked-only). Check every location, not the known ones.
Opt-in: the artifact is only produced under `<!-- coaching-history: opted-in -->`, whose home is
Diagnostic_State.md (writer-visible; the coach reads it at session start); the artifact also
self-carries the marker (content_advisory W2 style; content_advisory._OPT_IN_RE precedent). No marker
anywhere → the validator no-ops (exit 0/2), like content-advisory / legal-risk; tombstone → H6 mode.
Reuses apodictic_artifacts (block grammar + schema engine + parse_blocks + FID_RE) and the shared
override_marker / severity_vocab / author_fingerprint firewall regexes (single-sourced, never
re-derived). Stdlib-only; degrades to an advisory WARN without python3. See docs/coaching-history.md.
coaching_history.py coaching-history <project_root|files...> [--strict]
coaching_history.py delete <project_root>
coaching_history.py --self-test
Exit: 0 clean / WARN-only, 1 ERROR (or WARN under --strict), 2 usage / no artifact.
"""
import glob
import json
import os
import re
import sys
from override_marker import override_targets # SSoT: code-span-stripped, boundary-matched override scan
from severity_vocab import SEVERITY_TOKEN_RE # SSoT: the editorial Must/Should/Could-Fix leak token
try:
import apodictic_artifacts as art
except ImportError:
art = None
# H3 firewall — reuse the author_fingerprint prescriptive-directive regex (single-sourced; the module
# describes, never prescribes). Imported, never re-derived. Degrades to None if the sibling is absent.
try:
from author_fingerprint import _PRESCRIPTIVE_RE as _AF_PRESCRIPTIVE_RE
except ImportError:
_AF_PRESCRIPTIVE_RE = None
_SCHEMA_ID = "apodictic.coaching_observation.v1"
_HISTORY_GLOB = "*_Coaching_History_*.md"
_SIDECAR_NAME = "Diagnostic_State.meta.json"
_STATE_MD = "Diagnostic_State.md"
# The one sidecar key coaching-history is permitted to write (a scalar-in-sidecar, like
# execution.state_version — run_gate.py). Everything else coaching-shaped in the sidecar is a shadow.
_SEQ_KEY = "coaching_history_seq"
# Per-pattern count floors (Fable verdict 2026-07-05; ROADMAP §8.2 CLOSED). The schema keeps only the
# global minimum >=2; H1 enforces these.
_COUNT_FLOOR = {"deferral-recurrence": 3, "phase-incompletion": 2}
# Which patterns HAVE a governed verification path — a recorded, independent, per-session ground truth
# the evidence can be checked against (Codex round-1 P1 + the incomplete-verification-class sweep). The
# rule for EVERY pattern: evidence is either verified-against-record (governed) or self-reported (then
# it MUST carry the honesty caveat — the writer never sees an unverified pattern as if it were checked).
# * deferral-recurrence — YES. The independent per-session record is `gate_events[].disposition_deltas`
# (each delta carries its own `session`; run_gate._disposition_deltas). H2 verifies each cited
# session against _deferred_history(); governed observations need no caveat.
# * phase-incompletion — NO. Grepped exhaustively (2026-07-07): per-session revision-arc PHASE
# completion is recorded NOWHERE. `finding_states` is a rolling last-write map ("finding_states is a
# rolling all-session map" — finding_trace E5); `finding_deltas` carry NO session ordinal (only
# disposition_deltas do); the revision arc is a STATELESS re-plan that OVERWRITES the prior arc each
# run (revision_arc.v1 schema $comment) — it keeps no per-session history. So the
# `phase <label> incomplete @ session <n>` evidence has NO recorded ground truth to check against on
# ANY project — it is self-reported EVERYWHERE. H2 does not claim a verification it cannot perform;
# instead it requires the honesty caveat on EVERY phase-incompletion observation (governed or not).
_GOVERNED_VERIFIED_PATTERNS = frozenset({"deferral-recurrence"})
# Consent markers (home: Diagnostic_State.md; the artifact self-carries the opt-in too). The opt-in
# pattern mirrors content_advisory._OPT_IN_RE.
_OPT_IN_RE = re.compile(r"<!--\s*coaching-history:\s*opted-in\s*-->", re.IGNORECASE)
_DELETED_RE = re.compile(r"<!--\s*coaching-history:\s*deleted\s*-->", re.IGNORECASE)
# The editorial Must/Should/Could-Fix leak token — the shared severity_vocab SSoT (M8), never a local
# re.compile. Alias kept so the H4 call site reads like the sibling firewalls.
_SEVERITY_RE = SEVERITY_TOKEN_RE
# H3 trait-blame — a coaching-specific lexicon on TOP of the reused prescriptive regex. Catches the
# "you always / you avoid / you fail to / you're bad at / you keep / you never / you can't" framing the
# posture forbids (a claim ABOUT the writer, not a description of a mechanical pattern). Deliberately a
# lexicon gate (the documented limit: it catches token framing, not passive trait-attribution like
# "the pattern suggests reluctance" — semantic validation is a v2 item, same class as F4/W1's).
_TRAIT_BLAME_RE = re.compile(
r"\byou\s+(?:always|never|constantly|habitually|chronically|keep(?:\s+on)?|tend\s+to\s+always)\b"
r"|\byou\s+(?:avoid|dodge|evade|shy\s+away\s+from|refuse\s+to|fail\s+to|can'?t\s+seem\s+to|"
r"struggle\s+to|are\s+unable\s+to|are\s+incapable\s+of)\b"
r"|\byou'?re\s+(?:bad|weak|hopeless|terrible|lazy|undisciplined|avoidant)\b"
r"|\byou\s+have\s+(?:a\s+)?(?:problem|weakness|blind\s?spot|habit)\s+(?:with|of)\b",
re.IGNORECASE)
# H7 trait-verdict — a THIRD-PERSON trait-attribution verdict about the writer ("Writer defers
# endings", "the writer IS an ending-avoider", "she always parks the hard work"). The transference-
# health complement to H3's second-person blame: the meaning must be handed to the writer, never
# asserted as a trait/verdict. Deliberately a lexicon gate (the documented limit — passive attribution
# like "the pattern suggests reluctance" is a SKILL-contract concern, not statically catchable).
_TRAIT_VERDICT_RE = re.compile(
# "she is an ending-avoider", "the author remains a procrastinator"
r"\b(?:the\s+writer|writer|author|the\s+author|she|he|they)\s+"
r"(?:is|are|has\s+become|remains?)\s+(?:an?\s+)?"
r"(?:\w+[- ]?)?(?:avoid(?:er|ant)|procrastinat(?:or|ing)|deferrer|ducker|shirker|quitter)\b"
# "writer always/keeps/tends-to-always <verb>"
r"|\b(?:the\s+writer|writer|author|the\s+author|she|he|they)\s+"
r"(?:always|never|constantly|habitually|chronically|keeps?|tends?\s+to\s+always)\s+\w+"
# a bald third-person present-tense process verdict: "Writer defers endings", "the author parks
# the hard work", "she sets aside structure" — subject + a deferral/avoidance verb, asserted flat.
r"|\b(?:the\s+writer|writer|the\s+author|author)\s+"
r"(?:defers?|parks?|avoids?|dodges?|ducks?|shelves?|postpones?|sidesteps?|"
r"sets?\s+aside|puts?\s+off|backs?\s+away\s+from)\b",
re.IGNORECASE)
# H7 anti-scoreboard — the human-facing `observation` must be the coach's remembered-attention-plus-
# open-question form, not a bare `<label>: <int>` / `<label> (×N)` tally the writer confronts alone.
# We fire ONLY on the unambiguous scoreboard SHAPE: prose that is (essentially) just a label and a
# number with no interpretive/invitational tissue. `_is_scoreboard` decides; WARN-tier + override,
# because "brief but real prose" must not be punished.
_SCOREBOARD_RE = re.compile(
r"^\s*[\w '\-]{0,40}?[:\-]?\s*(?:[x×]\s*\d+|\d+\s*(?:times?|sessions?|x)?|\(\s*[x×]?\s*\d+\s*\))\s*\.?\s*$",
re.IGNORECASE)
# The invitational half (property 2): a question, or an invitation-to-read lexicon. Its ABSENCE on an
# otherwise-terse observation is the WARN signal (the meaning was asserted, not handed over).
_INVITATION_RE = re.compile(
r"\?" # an actual question handed to the writer
r"|\b(?:does\s+that|is\s+that|what\s+do\s+you|how\s+does\s+that|worth\s+a\s+look|"
r"curious\s+(?:if|whether)|wonder(?:ing)?\s+(?:if|whether)|does\s+it\s+(?:feel|land|track)|"
r"your\s+(?:read|sense|call)|see\s+it\s+the\s+same|if\s+that\s+(?:fits|lands|tracks|resonates))\b",
re.IGNORECASE)
# H5 (iii) — the coaching-history evidence grammar, as a bare string signature scanned OUTSIDE the
# artifact. The `@ session <n>` token is coaching-history-specific: it does NOT collide with the pinned
# finding-disposition marker grammar (`<!-- deferred: F-… until: … -->`,
# apodictic_artifacts._DISPOSITION_RE), so a legitimate Coaching-Log disposition marker never trips it.
_EVIDENCE_GRAMMAR_RE = re.compile(
r"(?:F-[A-Za-z0-9]+-[0-9]{2,}\s+deferred\s+@\s+session\s+\d+"
r"|phase\s+.+?\s+incomplete\s+@\s+session\s+\d+)",
re.IGNORECASE)
# H5 WARN — a bare boundary-guarded CH-NN token (the FID_RE boundary style). The guard `(?<![\w-])`
# already excludes an F-CH-01 finding id (the `-` before `CH` is a boundary char that fails the
# lookbehind), so this is the SIGNATURE that can false-positive on chapter shorthand ("CH-12") — hence
# WARN-tier + overridable, while (i)-(iii)/(iv)/(v) stay non-overridable.
_CH_TOKEN_RE = re.compile(r"(?<![\w-])CH-[0-9]{2,}(?![\w-])")
# Per-evidence-item parse grammars (H2). Anchored so a well-formed item resolves cleanly.
_EV_DEFERRAL_RE = re.compile(
r"^\s*(F-[A-Za-z0-9]+-[0-9]{2,})\s+deferred\s+@\s+session\s+(\d+)\s*$", re.IGNORECASE)
_EV_PHASE_RE = re.compile(
r"^\s*phase\s+(.+?)\s+incomplete\s+@\s+session\s+(\d+)\s*$", re.IGNORECASE)
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
# F2 (Codex review) — H2's anti-fabrication GUARANTEE holds only on GOVERNED projects (the per-session
# gate_events[].disposition_deltas are independent event records the engine wrote at each clear). On
# NON-GOVERNED projects the multi-session history is a self-reported field on the folded record (the
# `sessions` list /coach persists) — NOT independently verified (the mode-11 recorded-field-without-
# verifier antipattern the spec itself forbids for H6's recompute). So a non-governed `deferral-
# recurrence` observation must carry a VISIBLE honest caveat that its streak came from the coach's own
# session notes, not an independently checked record — the writer must never be shown an unverified
# pattern as if it were verified (the H7 transference-health principle). Cleanly mechanizable as a
# shape check (WARN + per-id override): the observation prose OR the artifact carries a caveat token.
_NONGOVERNED_CAVEAT_RE = re.compile(
r"from\s+(?:my|the\s+coach'?s?)\s+(?:own\s+)?(?:session\s+)?notes"
r"|not\s+(?:an?\s+)?independently\s+(?:verified|checked)"
r"|coach'?s?\s+(?:own\s+)?record|my\s+own\s+notes|unverified\s+(?:against|by)"
r"|(?:i\s+)?(?:have\s+)?been\s+tracking|from\s+what\s+i(?:'?ve)?\s+(?:been\s+)?track"
r"|from\s+my\s+notes\s+across",
re.IGNORECASE)
# H5 scan scope (Codex review F1 — the projection-ban is only as good as its coverage). A project root
# has ~46 tool-authored artifact types (output-structure.md: the pass artifacts, SYNTHESIS.md,
# *_Core_DE_Synthesis_*, *_Editorial_Letter_*, README.md, session plans, …). An allowlist of a handful
# of globs let a projection into any UNLISTED artifact (the editorial letter, the synthesis) escape H5
# AND survive `delete` — a false "deletion honored" — breaking BOTH Fable gates. So:
#
# (i) parsed apodictic:coaching_observation block + (ii) the literal schema-id string are
# SELF-IDENTIFYING apodictic markers with ZERO false-positive risk on any legitimate non-coaching
# file (no editorial letter / session plan / manuscript legitimately carries a coaching_observation
# block or the schema-id). These are scanned over EVERY *.md in scope, minus the one Coaching_History
# artifact — the exhaustive net that catches the real projection (a projected block carries both).
#
# (iii) the evidence-grammar (`@ session <n>`) standalone scan + the bare CH-NN WARN token DO carry
# manuscript false-positive risk (chapter "CH-12" refs; prose "@ session"), so they stay MANUSCRIPT-
# EXCLUDED — but the manuscript is excluded POSITIVELY (the frozen `*_Manuscript_Snapshot_*.md` the
# intake protocol persists; annotation_manifest._SNAPSHOT_GLOB), NOT via an authored-artifact
# allowlist. A real projection is already caught by (i)/(ii) over all .md; (iii) is the secondary net
# for grammar-without-block leakage (the §6a paraphrase class).
_MANUSCRIPT_SNAPSHOT_GLOB = "*_Manuscript_Snapshot_*.md" # the persisted frozen manuscript (positive id)
def _read(path):
# UnicodeDecodeError included: a non-UTF8 artifact (an archived report) must degrade to the honest
# skip path (None), never a traceback — the disposition_check adjacent-exception class swept repo-wide.
try:
with open(path, encoding="utf-8") as fh:
return fh.read()
except (OSError, UnicodeDecodeError):
return None
def _newest(paths):
return max(paths, key=os.path.getmtime) if paths else None
def _glob_multi(dirs, pattern):
out = []
for d in dirs:
out.extend(glob.glob(os.path.join(d, pattern)))
return sorted(set(out))
def _scan_dirs(project_root):
"""The H5/H6 scan scope: the project root + each runs/* archive dir (the disposition_check DP2.6
reachable-evidence scope). NOTE: the .md SIGNATURE scan and the Coaching_History enumeration walk
the tree RECURSIVELY via _all_md_files / _history_files — this dir list is retained only for the
doc-comment scope description; the completeness of the scan is the guarantee (Codex round-1 class:
check every depth, not the known ones)."""
dirs = [project_root]
dirs.extend(sorted(d for d in glob.glob(os.path.join(project_root, "runs", "*")) if os.path.isdir(d)))
return dirs
def _all_md_files(project_root):
"""EVERY *.md anywhere under the project root, at ANY depth (recursive — os.walk). The depth-complete
H5/H6 scan surface (Codex round-1 class sweep, item 3): a projection or a shadow Coaching_History
file hidden in a nested subdir (`drafts/ch3/notes.md`, `runs/r2/artifacts/x.md`) must NOT escape.
Symlinked dirs are not followed (os.walk default) so a cycle can't hang the walk."""
out = []
for dirpath, _dirnames, filenames in os.walk(project_root):
for fn in filenames:
if fn.endswith(".md"):
out.append(os.path.join(dirpath, fn))
return sorted(set(out))
def _history_files(project_root):
"""Every *_Coaching_History_*.md under the project root at ANY depth (recursive), sorted+deduped —
so a shadow Coaching_History file nested in a subdir is counted (H5.iv) and removed (delete)."""
return [f for f in _all_md_files(project_root)
if re.search(r"_Coaching_History_", os.path.basename(f))]
# --------------------------------------------------------------------------- parsing
def parse_observations(text):
"""[(obj_or_None, schema_errs, index), ...] for each apodictic:coaching_observation block."""
out = []
if not text or art is None:
return out
schema = art.load_schema(_SCHEMA_ID)
idx = 0
for btype, obj, jerr in art.parse_blocks(text):
if btype != "coaching_observation":
continue
idx += 1
where = "coaching_observation #%d" % idx
if jerr:
out.append((None, ["%s: invalid JSON — %s" % (where, jerr)], idx))
continue
errs = art.validate_obj(obj, schema, where)
out.append((obj, errs, idx))
return out
def _overrides(text, slug, target):
"""Overridden ids/tokens for `slug` — via the shared override_marker SSoT (code spans stripped,
slug boundary-matched), so a marker quoted in a code span is not honored as a live directive."""
return {t[0] for t in override_targets(text or "", slug, target)}
def _load_sidecar(project_root):
"""Return (obj_or_None, parse_ok). A missing sidecar is (None, True) — not an error here (H5 walks
it only when present). A present-but-unparseable sidecar is (None, False)."""
path = os.path.join(project_root, _SIDECAR_NAME)
if not os.path.exists(path):
return None, True
raw = _read(path)
if raw is None:
return None, False
try:
return json.loads(raw), True
except json.JSONDecodeError:
return None, False
def _walk_json_strings(node):
"""Yield every string VALUE anywhere in a parsed-JSON tree (recursive; keys handled by the caller)."""
if isinstance(node, str):
yield node
elif isinstance(node, dict):
for v in node.values():
yield from _walk_json_strings(v)
elif isinstance(node, list):
for v in node:
yield from _walk_json_strings(v)
def _walk_json_keys(node):
"""Yield every KEY string anywhere in a parsed-JSON tree (recursive)."""
if isinstance(node, dict):
for k, v in node.items():
yield k
yield from _walk_json_keys(v)
elif isinstance(node, list):
for v in node:
yield from _walk_json_keys(v)
def _find_key_values(node, key):
"""Yield the VALUE of every `key` occurrence anywhere in a parsed-JSON tree (recursive, all depths).
The depth-complete residue finder (Codex round-1 P2): a `coaching_history_seq` nested under ANY key
— not just the two known homes — must be found, so the H6 recompute's completeness is the guarantee,
never a trust that delete cleaned the known homes."""
if isinstance(node, dict):
for k, v in node.items():
if k == key:
yield v
yield from _find_key_values(v, key)
elif isinstance(node, list):
for v in node:
yield from _find_key_values(v, key)
def _strip_key_recursive(node, key):
"""Remove EVERY occurrence of `key` anywhere in a parsed-JSON tree (recursive, all depths, in place).
Returns True if at least one was removed. The depth-complete `delete` (Codex round-1 P2): drop
`coaching_history_seq` wherever it hides, not just top-level + execution.*."""
removed = False
if isinstance(node, dict):
if key in node:
del node[key]
removed = True
for v in list(node.values()):
if _strip_key_recursive(v, key):
removed = True
elif isinstance(node, list):
for v in node:
if _strip_key_recursive(v, key):
removed = True
return removed
def _sidecar_seq(sidecar_obj):
"""The recorded coaching_history_seq value if present ANYWHERE in the sidecar (depth-complete), else
None. v1 legitimate homes are top-level and execution.* (a governed sidecar nests scalars under
execution, like state_version), but the H6 recompute must find the key at ANY depth (Codex round-1
P2 — a seq nested under a non-execution key survived delete and the recompute falsely reported
"deletion honored"). RECOMPUTE-not-trust: completeness is the guarantee. Returns the first value
found (any type — a non-int under the blessed name is itself a smuggled shadow H5.v flags), or None
if the key is absent everywhere."""
if not isinstance(sidecar_obj, dict):
return None
for v in _find_key_values(sidecar_obj, _SEQ_KEY):
return v
return None
# --------------------------------------------------------------------------- H2 provenance
def _is_governed(sidecar_obj):
"""True iff the sidecar carries a non-empty execution.gate_events log (the state-lifecycle dual-
writer split; the same test disposition_check.check uses). On a governed project the per-session
disposition_deltas are independent event records — H2's anti-fabrication guarantee holds; on a
non-governed project the multi-session history is self-reported (F2)."""
if not isinstance(sidecar_obj, dict):
return False
ex = sidecar_obj.get("execution")
return isinstance(ex, dict) and bool(ex.get("gate_events"))
def _deferred_history(sidecar_obj):
"""{F-id: set(session ordinals at which the finding was `deferred`)} — reconstructed from BOTH
disposition surfaces (spec §4):
* NON-GOVERNED — the folded `execution.finding_dispositions` map is last-event-wins (docs/finding-
dispositions.md), so it attests at most ONE deferred session per finding directly. A richer
writer may also keep the full history on the record under `sessions`/`deferred_sessions`
(accepted defensively).
* GOVERNED — each `execution.gate_events[]` event carries `disposition_deltas` (the full per-run
disposition records, each with its OWN `session` ordinal — run_gate._disposition_deltas), so the
UNBROKEN multi-session deferral run reconstructs from the event log even though the fold keeps
only the latest. This is the surface H2's consecutive-run proof needs on governed projects.
A malformed/absent surface contributes nothing (H2 then reports the reference unresolved — fail-
closed). This is the ONLY provenance read; a session H2 cannot find HERE is a fabricated streak
member, no matter what the observation asserts."""
hist = {}
def _add(fid, session):
if isinstance(fid, str) and isinstance(session, int) and not isinstance(session, bool):
hist.setdefault(fid, set()).add(session)
if not isinstance(sidecar_obj, dict):
return hist
ex = sidecar_obj.get("execution")
if not isinstance(ex, dict):
return hist
# non-governed / folded surface (+ any defensive history list)
recs = ex.get("finding_dispositions")
if isinstance(recs, dict):
for fid, rec in recs.items():
if isinstance(rec, dict) and rec.get("disposition") == "deferred":
_add(rec.get("id") or fid, rec.get("session"))
for key in ("sessions", "deferred_sessions"):
for x in (rec.get(key) or []) if isinstance(rec.get(key), list) else []:
_add(rec.get("id") or fid, x)
# governed surface: the full per-event delta log (each delta carries its own session ordinal)
events = ex.get("gate_events")
if isinstance(events, list):
for ev in events:
if not isinstance(ev, dict):
continue
dd = ev.get("disposition_deltas")
if not isinstance(dd, dict):
continue
for fid, rec in dd.items():
if isinstance(rec, dict) and rec.get("disposition") == "deferred":
_add(rec.get("id") or fid, rec.get("session"))
return hist
# --------------------------------------------------------------------------- the core check
def check(text, project_root=None, sidecar_obj=None, strict=False, *, scan_root=None,
consent="opted-in"):
"""Run the coaching-history integrity checks over an already-read artifact `text`.
`consent` ∈ {"opted-in", "deleted", "none"} is resolved by the caller from Diagnostic_State.md +
the artifact self-marker. `scan_root` (defaults to project_root) is the H5/H6 scan scope. Returns
(code, lines)."""
lines, errs, warns = [], [], []
if art is None:
return 2, ["coaching-history: apodictic_artifacts unavailable — cannot run"]
root = scan_root or project_root
# ---- H6 short-circuits: a tombstone means RECOMPUTE deletion; opted-in artifact checks do not run.
if consent == "deleted":
return _h6_recompute(root, sidecar_obj, strict)
observations = parse_observations(text)
if not observations:
lines.append("coaching-history: no coaching_observation blocks found — prose-level checks only")
# ---- H1 — schema / JSON / per-pattern count floor / unique id
for obj, schema_errs, _idx in observations:
for e in schema_errs:
errs.append("H1 schema: %s" % e)
valid = [(obj, idx) for obj, schema_errs, idx in observations
if isinstance(obj, dict) and not schema_errs]
seen = {}
for obj, idx in valid:
seen.setdefault(art.fid_key(obj.get("id")), []).append(idx)
for cid, where in sorted(seen.items(), key=lambda kv: str(kv[0])):
if len(where) > 1:
errs.append("H1 schema: %s appears %d times (ids must be unique)" % (cid, len(where)))
for obj, _idx in valid:
floor = _COUNT_FLOOR.get(obj.get("pattern"))
cnt = obj.get("count")
if floor is not None and isinstance(cnt, int) and not isinstance(cnt, bool) and cnt < floor:
errs.append("H1 count floor: %s is a %s with count=%d — the Fable-verdict floor is >=%d"
% (obj.get("id"), obj.get("pattern"), cnt, floor))
# ---- H2 — provenance / anti-fabrication
deferred_hist = _deferred_history(sidecar_obj)
for obj, _idx in valid:
errs.extend(_h2_check(obj, deferred_hist))
# ---- H2 self-reported honesty caveat (Codex F2 + round-1 P1 + the class sweep). EVERY pattern's
# evidence is either verified-against-record or self-reported; a SELF-REPORTED streak must carry
# a VISIBLE caveat that it is from the coach's own notes, not an independently verified record —
# the writer must never see an unverified pattern as if it were checked (the H7 principle). WARN;
# ERROR --strict; per-id override (shares the coaching-observation slug). An observation is
# self-reported when its pattern has no governed verification path (phase-incompletion — ALWAYS,
# no per-session ground truth exists anywhere), OR its pattern is governed-verifiable but this is
# a NON-governed project (deferral-recurrence without gate_events). Governed deferral-recurrence
# is exempt (its per-session event records ARE the independent verification).
ov_caveat_ids = _overrides(text, "coaching-observation", r"(CH-[0-9]+)")
governed = _is_governed(sidecar_obj)
for obj, _idx in valid:
cid = obj.get("id")
pattern = obj.get("pattern")
if cid in ov_caveat_ids:
continue
has_governed_path = pattern in _GOVERNED_VERIFIED_PATTERNS
self_reported = (not has_governed_path) or (not governed)
if not self_reported:
continue
carrier = (obj.get("observation") or "") + "\n" + (text or "")
if not _NONGOVERNED_CAVEAT_RE.search(carrier):
why = ("has no recorded per-session ground truth on ANY project (per-session phase "
"completion is not persisted anywhere) — it is inherently self-reported"
if not has_governed_path else
"is on a NON-governed project (no gate_events), so its streak is self-reported")
warns.append("H2 self-reported caveat: %s (%s) %s. Its streak is from the coach's own "
"session notes, not an independently verified record — state that honestly in "
"the observation (e.g. 'from what I've been tracking across sessions') so the "
"writer never sees an unverified pattern as if it were checked"
% (cid, pattern, why))
# ---- H4 — no editorial-severity leak (the observation is off the editorial scale)
# Scan the WHOLE artifact (blocks + prose): a severity token or a finding block anywhere leaks.
if _SEVERITY_RE.search(text or ""):
errs.append("H4 severity leak: the Coaching History carries an editorial Must/Should/Could-Fix "
"token — a process observation is off the editorial severity scale")
if _has_block(text, "finding"):
errs.append("H4 severity leak: the Coaching History contains an apodictic:finding block — a "
"coaching observation is not a finding")
# ---- H3 — descriptive, not judgmental (per-id override)
ov_ids = _overrides(text, "coaching-observation", r"(CH-[0-9]+)")
for obj, _idx in valid:
cid = obj.get("id")
prose = obj.get("observation") or ""
if cid in ov_ids:
continue
if (_AF_PRESCRIPTIVE_RE is not None and _AF_PRESCRIPTIVE_RE.search(prose)) \
or _TRAIT_BLAME_RE.search(prose):
warns.append("H3 descriptive: %s's observation prescribes or blames a trait ('you always / "
"you fail to / fix your …') — the coach surfaces a pattern, it does not judge "
"the writer" % cid)
# ---- H7 — transference-health / tentative framing (the count is the coach's private EVIDENCE;
# what reaches the writer is the coach's noticing — confident about the pattern, open about
# its meaning). ENFORCEABLE FLOOR (shape checks); the full confident-observation + writer-owns-
# the-meaning property is a SKILL-contract obligation (the human terminus), stated in §6a/docs.
# Shares the per-id override slug with H3 (one framing override per observation).
for obj, _idx in valid:
cid = obj.get("id")
prose = (obj.get("observation") or "").strip()
if cid in ov_ids:
continue
# (a) a third-person trait-attribution VERDICT ("Writer defers endings", "she is an avoider")
if _TRAIT_VERDICT_RE.search(prose):
warns.append("H7 tentative-framing: %s's observation asserts a trait VERDICT about the "
"writer ('writer defers …', 'is an … -avoider') — hand the meaning to the "
"writer as an open question, do not assert it as a trait" % cid)
# (b) a bare-scoreboard rendering (a lone `<label>: <int>` tally, no interpretive prose)
elif _is_scoreboard(prose):
warns.append("H7 anti-scoreboard: %s's observation renders as a bare `<label>: <count>` "
"scoreboard — the count is the coach's evidence, not the writer's tally; state "
"it as remembered attention plus an open question" % cid)
# (c) confident-observation with NO invitational half (the meaning was asserted, not handed over)
elif prose and not _INVITATION_RE.search(prose):
warns.append("H7 tentative-framing: %s's observation offers no invitation to the writer's "
"reading (a question / 'does that land?') — be confident about the pattern but "
"hand its meaning to the writer" % cid)
# ---- W1 — local-only hygiene (no external/telemetry reference)
visible = _HTML_COMMENT_RE.sub("", text or "")
if re.search(r"https?://", visible, re.IGNORECASE):
warns.append("W1 local-only: the Coaching History references an external http(s) URL — the "
"artifact is local-only and makes no external call")
# ---- H5 — single-home / no coach-only shadow (Fable condition a). Non-overridable (i)-(v).
if root is not None:
h5_errs, h5_warns = _h5_scan(root, sidecar_obj, strict, self_history_text=text)
errs.extend(h5_errs)
warns.extend(h5_warns)
# ---- Report
lines.append("coaching-history: %d observation(s)%s" % (
len(observations), "" if len(valid) == len(observations)
else " (%d well-formed)" % len(valid)))
for obj, _idx in valid:
lines.append(" %-7s %-20s count=%s" % (obj.get("id"), obj.get("pattern"), obj.get("count")))
for e in errs:
lines.append(" ERROR: %s" % e)
for w in warns:
lines.append(" %s: %s" % ("ERROR (--strict)" if strict else "WARN", w))
if errs or (strict and warns):
lines.append("coaching-history: FAIL (%d error(s)%s)"
% (len(errs), ", %d strict warn(s)" % len(warns) if (strict and warns) else ""))
return 1, lines
if warns:
lines.append("WARN: coaching-history: %d advisory gap(s) — see H3/H7/W1/H5 above" % len(warns))
else:
lines.append("coaching-history: PASS (schema + provenance + descriptive + tentative-framing + "
"no-severity-leak + single-home + local-only)")
return 0, lines
def _h2_check(obj, deferred_hist):
"""H2 for one valid observation: len(evidence) >= count, grammar-valid evidence, cited sessions
ACTUALLY CONSECUTIVE (a gap fails). Anti-fabrication VERIFICATION against a record is per-pattern:
* deferral-recurrence — VERIFIED: each cited session must corroborate a recorded deferred
disposition (`deferred_hist` = {F-id: set(sessions)} from _deferred_history). A fabricated
session fails here.
* phase-incompletion — NOT verifiable against any record (no per-session phase-completion history
is persisted anywhere — see _GOVERNED_VERIFIED_PATTERNS). H2 does the shape + consecutive checks
it CAN do and does NOT claim a record verification it cannot perform; the self-reported nature is
surfaced to the writer by the MANDATORY H2 self-reported caveat (in check(), not here). A
fabricated phase-incompletion (e.g. sessions 101/102) is shape-valid — the caveat is what keeps
it honest, since there is no ground truth to reject it against.
Returns a list of error strings."""
errs = []
cid = obj.get("id")
pattern = obj.get("pattern")
count = obj.get("count")
evidence = obj.get("evidence")
if not isinstance(evidence, list):
return errs # a non-array evidence is already an H1 schema type error
if isinstance(count, int) and not isinstance(count, bool) and len(evidence) < count:
errs.append("H2 provenance: %s has len(evidence)=%d < count=%d — the streak is not fully "
"evidenced" % (cid, len(evidence), count))
sessions = []
for item in evidence:
if not isinstance(item, str):
errs.append("H2 provenance: %s has a non-string evidence item %r" % (cid, item))
continue
if pattern == "deferral-recurrence":
m = _EV_DEFERRAL_RE.match(item)
if not m:
errs.append("H2 provenance: %s evidence %r does not match the grammar "
"`<F-id> deferred @ session <n>`" % (cid, item))
continue
fid, sess = m.group(1), int(m.group(2))
if not art.FID_RE.fullmatch(fid):
errs.append("H2 provenance: %s cites a malformed finding id %r" % (cid, fid))
continue
recorded = deferred_hist.get(fid, set())
if sess not in recorded:
errs.append("H2 provenance: %s cites `%s deferred @ session %d` but no recorded "
"deferred disposition for %s at session %d — a fabricated streak member"
% (cid, fid, sess, fid, sess))
sessions.append(sess)
elif pattern == "phase-incompletion":
m = _EV_PHASE_RE.match(item)
if not m:
errs.append("H2 provenance: %s evidence %r does not match the grammar "
"`phase <label> incomplete @ session <n>`" % (cid, item))
continue
# NO record verification is possible: per-session phase-completion is persisted nowhere
# (see _GOVERNED_VERIFIED_PATTERNS). We do the shape + consecutive checks only; the
# self-reported nature is made honest to the writer by the MANDATORY caveat in check(). We
# deliberately do NOT invent a fake "verified" here — a shape-check is not a verification.
sessions.append(int(m.group(2)))
else:
continue # pattern already refused by H1
# consecutive-session proof: the cited ordinals must form an unbroken run (no gap, no dup)
if sessions:
uniq = sorted(set(sessions))
if len(uniq) != len(sessions):
errs.append("H2 provenance: %s cites a session ordinal twice — a streak member is not "
"distinct" % cid)
elif uniq != list(range(uniq[0], uniq[0] + len(uniq))):
errs.append("H2 provenance: %s cites non-consecutive sessions %s — a gap resets the streak"
% (cid, uniq))
return errs
def _has_block(text, btype):
"""True if `text` carries a real apodictic:<btype> block (a parsed carrier, not a prose mention) —
the content_advisory._has_block discipline (meta-linter M2)."""
if art is None:
return ("apodictic:%s" % btype) in (text or "")
return any(bt == btype for bt, _o, _e in art.parse_blocks(text or ""))
def _is_scoreboard(prose):
"""True if the human-facing `observation` reads as a bare scoreboard tally — a lone
`<label>: <int>` / `<label> (×N)` / `<label> — N times` with no interpretive/invitational tissue —
rather than the coach's remembered-attention-plus-open-question form. Conservative: fires only when
EVERY non-empty line matches the scoreboard shape (so real prose that merely contains a number is
never flagged), or when the whole thing is a single short scoreboard clause. The transference-health
rule's enforceable floor (the count is the coach's private evidence, never the writer's scoreboard)."""
text = (prose or "").strip()
if not text:
return False
lines = [ln for ln in text.splitlines() if ln.strip()]
return bool(lines) and all(_SCOREBOARD_RE.match(ln) for ln in lines)
# --------------------------------------------------------------------------- H5 single-home scan
def _h5_scan(project_root, sidecar_obj, strict, self_history_text=None):
"""H5 (Fable condition a): scan project root + runs/* for a projected coaching observation, plus the
sidecar. Returns (errs, warns). `self_history_text` is unused here — the ONE Coaching_History
artifact is identified by the glob and excluded from the projection scan (it IS the single home).
Codex F1: the projection scan covers EVERY *.md in scope (not a fragile authored-artifact allowlist
that let a projection into the editorial letter / synthesis escape and survive `delete`):
* (i) a parsed apodictic:coaching_observation block and (ii) the literal schema-id string are
self-identifying apodictic markers — scanned over ALL *.md minus the one Coaching_History file
(incl. the frozen manuscript snapshot: the writer's prose never legitimately carries a
coaching_observation block or the schema-id, so a hit there IS a pasted projection). ERROR.
* (iii) the evidence-grammar standalone scan + the bare CH-NN WARN token carry manuscript
false-positive risk (chapter "CH-12"; prose "@ session"), so they are scanned over all *.md
MINUS the positively-identified manuscript snapshot(s). A real projection is already caught by
(i)/(ii); (iii) is the secondary net for grammar-without-block leakage (§6a paraphrase)."""
errs, warns = [], []
# Every *.md anywhere under the project root, at ANY DEPTH (recursive — Codex round-1 class sweep
# item 3: a projection or shadow Coaching_History file nested in a subdir must not escape).
all_md = _all_md_files(project_root)
# (iv) — exactly one Coaching_History file in scope. A second file (at any depth) IS the shadow.
hist_files = [f for f in all_md if re.search(r"_Coaching_History_", os.path.basename(f))]
if len(hist_files) > 1:
errs.append("H5.iv single-home: %d *_Coaching_History_*.md files in scope (%s) — single-home "
"means exactly one; a second file is the shadow artifact"
% (len(hist_files), ", ".join(os.path.basename(f) for f in hist_files)))
hist_abs = {os.path.abspath(f) for f in hist_files}
manuscript_abs = {os.path.abspath(f) for f in all_md
if re.search(r"_Manuscript_Snapshot_", os.path.basename(f))}
# EVERY *.md in scope (all depths), minus the one Coaching_History artifact (the single home is
# exempt from its own content). The exhaustive net Codex F1 requires — no type, no depth escapes it.
for path in all_md:
ap = os.path.abspath(path)
if ap in hist_abs:
continue
body = _read(path)
if body is None:
continue
name = os.path.basename(path)
# (i) a parsed coaching_observation block anywhere but the one history file — ALL .md, no
# manuscript exclusion (a block in the manuscript snapshot IS a pasted projection).
if _has_block(body, "coaching_observation"):
errs.append("H5.i single-home: %s carries an apodictic:coaching_observation block — "
"observations live ONLY in the Coaching History artifact (no projection)"
% name)
# (ii) the schema-id INSIDE an apodictic block comment outside the artifact — a malformed
# block that (i)'s JSON parser rejected but that still carries the id (a real projection).
# Scoped to the block-comment carrier (apodictic_artifacts.BLOCK_RE, the SSoT) so a
# BARE-PROSE mention of the schema name — e.g. a project whose subject IS apodictic —
# is NOT a false projection (it carries no observation). A de-structured raw paste with
# no comment wrapper is caught by (iii)'s evidence grammar. (ii) is non-overridable, so
# it must not fire on legitimate prose that merely names the schema.
if art is not None:
_id_in_block = any(_SCHEMA_ID in payload for _bt, payload in art.BLOCK_RE.findall(body))
else:
_id_in_block = _SCHEMA_ID in body # degraded (no BLOCK_RE): conservative substring
if _id_in_block:
errs.append("H5.ii single-home: %s carries the schema-id %r inside an apodictic block "
"comment outside the Coaching History artifact" % (name, _SCHEMA_ID))
# (iii)/(CH-token) carry manuscript false-positive risk — SKIP the frozen manuscript snapshot.
if ap in manuscript_abs:
continue
# (iii) an evidence-grammar string outside the artifact (the secondary grammar-leak net)
if _EVIDENCE_GRAMMAR_RE.search(body):
errs.append("H5.iii single-home: %s carries a coaching-history evidence-grammar string "
"(`… @ session <n>`) outside the Coaching History artifact" % name)
# WARN (overridable) — a bare boundary-guarded CH-NN token (chapter-shorthand false-positive)
for tok in set(_CH_TOKEN_RE.findall(body)):
if not override_targets(body, "coaching-residue", re.escape(tok)):
warns.append("H5 bare CH-token: %s references %s outside the Coaching History "
"artifact (bare-token; chapter-shorthand false-positive risk — "
"override <!-- override: coaching-residue %s — <why> --> if intended)"
% (name, tok, tok))
# (v) — sidecar walk: NO coaching material beyond exactly `coaching_history_seq: <int>`.
if sidecar_obj is not None:
errs.extend(_h5_sidecar_walk(sidecar_obj))
return errs, warns
def _h5_sidecar_walk(sidecar_obj):
"""H5 (v): a DEPTH-COMPLETE recursive key/value walk over the parsed sidecar JSON. The machine-facing
sidecar is exactly where a coach-only shadow would accumulate invisibly, so this is strict — and the
walk must be complete across ALL depths (the incomplete-verification class the reviews keep catching:
check every location, not the known ones). ERROR on any of:
* a `coaching*` key anywhere OTHER than exactly `coaching_history_seq`;
* `coaching_history_seq` whose VALUE (at any depth) is not an integer — a non-int under the blessed
name is a smuggled shadow payload;
* `coaching_history_seq` sitting at a NON-HOME depth — its only legitimate homes are top-level and
`execution.*` (the scalar-in-sidecar precedent). At any deeper/other key it is itself a shadow,
whatever its value;
* any string VALUE anywhere matching the CH-id regex, the schema-id, or the evidence grammar.
Returns a list of errors."""
errs = []
for key in _walk_json_keys(sidecar_obj):
if key.lower().startswith("coaching") and key != _SEQ_KEY:
errs.append("H5.v sidecar shadow: the sidecar carries a coaching-shaped key %r — the ONLY "
"permitted coaching-history key is %r (an int)" % (key, _SEQ_KEY))
# Every `coaching_history_seq` value at ANY depth must be an integer (depth-complete — Codex P2 class).
for v in _find_key_values(sidecar_obj, _SEQ_KEY):
if not (isinstance(v, int) and not isinstance(v, bool)):
errs.append("H5.v sidecar shadow: %r has a non-integer value %r somewhere in the sidecar — a "
"non-int under the blessed key is a smuggled shadow" % (_SEQ_KEY, v))
# `coaching_history_seq` is legitimate ONLY at top-level or execution.*; anywhere else it is a shadow.
legit = 0
if isinstance(sidecar_obj, dict):
if _SEQ_KEY in sidecar_obj:
legit += 1
ex = sidecar_obj.get("execution")
if isinstance(ex, dict) and _SEQ_KEY in ex:
legit += 1
total = sum(1 for _ in _find_key_values(sidecar_obj, _SEQ_KEY))
if total > legit:
errs.append("H5.v sidecar shadow: %r appears at a NON-HOME depth in the sidecar (its only "
"legitimate homes are top-level and execution.*) — a seq nested elsewhere is a "
"coach-only shadow / re-derivation seed" % _SEQ_KEY)
for val in _walk_json_strings(sidecar_obj):
if _CH_TOKEN_RE.search(val) or (_SCHEMA_ID in val) or _EVIDENCE_GRAMMAR_RE.search(val):
errs.append("H5.v sidecar shadow: the sidecar carries coaching-observation material in a "
"string value (%r) — the sidecar is machine-facing; observations live ONLY in "
"the writer-visible Coaching History artifact" % (val[:80]))
return errs
# --------------------------------------------------------------------------- H6 recompute
def _h6_recompute(project_root, sidecar_obj, strict):
"""H6 (Fable condition b): under the `<!-- coaching-history: deleted -->` tombstone, RECOMPUTE the
deletion from artifacts — never trust the marker (the PR #161 recorded-field rule, disposition_check
DP2.6). Deletion is honored iff: the full H5 scan is empty AND no surviving Coaching_History file AND
no coaching_history_seq in the sidecar. Any residue = ERROR, NO override accepted (deletion honoring
grants no override relief — the laundering-firewall posture). Returns (code, lines)."""
lines, errs = [], []
lines.append("coaching-history: tombstone present — RECOMPUTING deletion from artifacts "
"(marker not trusted)")
# (1) no surviving Coaching_History file (recursive — any depth)
for f in _history_files(project_root):
errs.append("H6 residue: a Coaching History artifact survives deletion — %s (delete removes "
"every *_Coaching_History_*.md from the root and all subdirs)" % os.path.basename(f))
# (2) no coaching_history_seq in the sidecar
if sidecar_obj is not None:
seq = _sidecar_seq(sidecar_obj)
if seq is not None:
errs.append("H6 residue: coaching_history_seq=%r survives in the sidecar — deletion drops "
"the seq (a surviving counter is a re-derivation seed)" % seq)
# (3) the full H5 scan must come up empty — a projection surviving in ANY authored artifact or the
# sidecar is residue. Under the tombstone even the bare-token WARN escalates to ERROR ONLY via
# --strict; the (i)-(iii)/(iv)/(v) signatures are hard-FAIL regardless.
h5_errs, h5_warns = _h5_scan(project_root, sidecar_obj, strict)
for e in h5_errs:
errs.append("H6 residue: %s" % e)
if strict:
for w in h5_warns:
errs.append("H6 residue (--strict): %s" % w)
elif h5_warns:
for w in h5_warns:
lines.append(" WARN: %s" % w)
for e in errs:
lines.append(" ERROR: %s" % e)
if errs:
lines.append("coaching-history: FAIL (%d deletion-residue error(s) — no override accepted)"
% len(errs))
return 1, lines
lines.append("coaching-history: PASS (deletion honored — recomputed clean: no artifact, no seq, "
"no projection)")
return 0, lines
# --------------------------------------------------------------------------- consent resolution
def _resolve_consent(project_root, artifact_text=None):
"""Resolve the consent state from Diagnostic_State.md (the marker home) + the artifact self-marker.
Returns "deleted" | "opted-in" | "none". `opted-in` + `deleted` co-present anywhere = raise a flag
the caller turns into an ERROR (contradictory consent state)."""
state_md = _read(os.path.join(project_root, _STATE_MD)) or ""
art_text = artifact_text or ""
opted = bool(_OPT_IN_RE.search(state_md) or _OPT_IN_RE.search(art_text))
deleted = bool(_DELETED_RE.search(state_md) or _DELETED_RE.search(art_text))
if opted and deleted:
return "contradiction"
if deleted:
return "deleted"
if opted:
return "opted-in"
return "none"
# --------------------------------------------------------------------------- resolution / run
def resolve(paths):
"""Resolve the CLI paths to (project_root, artifact_or_None). One dir arg = the project root
(H5/H6 scan scope); the artifact is the newest *_Coaching_History_*.md in scope. Explicit files:
the project_root is the artifact's dir and the artifact is the first file carrying a parsed
coaching_observation block (or, failing that, the first *_Coaching_History_* name)."""
if len(paths) == 1 and os.path.isdir(paths[0]):
root = paths[0]
return root, _newest(_history_files(root))
# explicit files
artifact = None
for p in paths:
if _has_block(_read(p) or "", "coaching_observation"):
artifact = p
break
if artifact is None:
for p in paths:
if re.search(r"_Coaching_History_", os.path.basename(p)):
artifact = p
break
root = os.path.dirname(artifact) or "." if artifact else (os.path.dirname(paths[0]) or ".")
return root, artifact
def run(paths, strict=False):
if art is None:
return 2, ["coaching-history: apodictic_artifacts unavailable — cannot run"]
project_root, artifact = resolve(paths)
sidecar_obj, sidecar_ok = _load_sidecar(project_root)
artifact_text = _read(artifact) if artifact else None
consent = _resolve_consent(project_root, artifact_text)
if consent == "contradiction":
return 1, ["coaching-history: FAIL (1 error(s))",
" ERROR: H6 consent contradiction: Diagnostic_State.md carries BOTH "
"`<!-- coaching-history: opted-in -->` and `<!-- coaching-history: deleted -->` — "
"deletion revokes consent; the two states cannot coexist"]
if not sidecar_ok:
# a present-but-unparseable sidecar is fail-closed under any consent state (H5.v cannot be
# verified, H2 provenance cannot resolve).
return 1, ["coaching-history: FAIL (1 error(s))",
" ERROR: H0 unparseable sidecar: %s is present but not valid JSON — the single-home "
"walk and provenance cannot be verified" % _SIDECAR_NAME]
if consent == "deleted":
return _h6_recompute(project_root, sidecar_obj, strict)
if consent == "none":
return 2, ["coaching-history: no opt-in marker (`<!-- coaching-history: opted-in -->` in "
"%s or the artifact) and no tombstone — the surface is opt-in; nothing to check"
% _STATE_MD]
# opted-in: the artifact must exist to check.
if artifact is None or artifact_text is None:
return 2, ["coaching-history: opted-in but no *_Coaching_History_*.md artifact resolved — "
"nothing to check yet"]
return check(artifact_text, project_root=project_root, sidecar_obj=sidecar_obj, strict=strict,
consent="opted-in")
# --------------------------------------------------------------------------- the `delete` command
def delete(project_root):
"""`coaching_history.py delete <project_root>` — honor the writer's deletion in one move:
(1) remove every *_Coaching_History_*.md from the project root AND runs/* archives;
(2) drop coaching_history_seq from the sidecar (top-level and execution.*);
(3) flip the Diagnostic_State.md consent marker to `<!-- coaching-history: deleted -->`
(deletion revokes consent — no silent re-derivation; regeneration needs a fresh opt-in).
Returns (code, lines). Idempotent: re-running on an already-deleted project is a clean no-op."""
lines = []
if not os.path.isdir(project_root):
return 2, ["coaching-history delete: %s is not a directory" % project_root]
# (1) remove the artifact(s) — recursive, ANY depth (a shadow file nested in a subdir must go too)
removed = []
for f in _history_files(project_root):
try:
os.remove(f)
removed.append(os.path.basename(f))
except OSError as ex:
return 1, ["coaching-history delete: FAIL — could not remove %s (%s)" % (f, ex)]
lines.append(" removed %d Coaching History artifact(s): %s"
% (len(removed), ", ".join(removed) if removed else "(none)"))
# (2) drop coaching_history_seq from the sidecar
sc_path = os.path.join(project_root, _SIDECAR_NAME)
if os.path.exists(sc_path):
raw = _read(sc_path)
if raw is None:
return 1, ["coaching-history delete: FAIL — %s is present but unreadable" % _SIDECAR_NAME]
try:
sc = json.loads(raw)
except json.JSONDecodeError as ex:
return 1, ["coaching-history delete: FAIL — %s is not valid JSON (%s); refusing to write"
% (_SIDECAR_NAME, ex)]
# Strip coaching_history_seq RECURSIVELY, at ANY depth (Codex round-1 P2) — not just the two
# known homes (top-level + execution.*). A seq nested under a non-execution key must not survive.
dropped = _strip_key_recursive(sc, _SEQ_KEY) if isinstance(sc, (dict, list)) else False
if dropped:
with open(sc_path, "w", encoding="utf-8", newline="\n") as fh:
json.dump(sc, fh, indent=2, ensure_ascii=False)
fh.write("\n")
lines.append(" dropped %s from the sidecar" % _SEQ_KEY)
else:
lines.append(" no %s in the sidecar (nothing to drop)" % _SEQ_KEY)
else:
lines.append(" no sidecar present (nothing to drop)")
# (3) flip the Diagnostic_State.md consent marker to the tombstone
state_path = os.path.join(project_root, _STATE_MD)
state = _read(state_path) if os.path.exists(state_path) else None
if state is None:
# no state file — write a minimal one carrying the tombstone so the deletion is discoverable
state = ""
tombstone = "<!-- coaching-history: deleted -->"
if _OPT_IN_RE.search(state):
state = _OPT_IN_RE.sub(tombstone, state)
elif not _DELETED_RE.search(state):
# no opt-in and no existing tombstone — append the tombstone
state = (state.rstrip("\n") + "\n\n" + tombstone + "\n") if state.strip() else (tombstone + "\n")
# if a tombstone already present and no opt-in, leave as-is (idempotent)
with open(state_path, "w", encoding="utf-8", newline="\n") as fh:
fh.write(state)
lines.append(" flipped %s consent marker to the tombstone (deletion revokes consent)" % _STATE_MD)
lines.append("coaching-history delete: DONE — the record is the writer's; re-run "
"`coaching-history <project_root>` to verify the H6 recompute is clean")
return 0, lines
# --------------------------------------------------------------------------- self-test
def run_self_test():
import json as _j
import shutil
import tempfile
rc = {"v": 0}
def chk(name, cond):
print(" %s: %s" % (name, "OK" if cond else "FAIL"))
if not cond:
rc["v"] = 1
# non-UTF8 read degrades to None (never a traceback) — the swept adjacent-exception class
_fd, _nu = tempfile.mkstemp(suffix=".md")
with os.fdopen(_fd, "wb") as _fh:
_fh.write(b"\xff\xfenot utf-8\xff")
chk("non_utf8_read_returns_none", _read(_nu) is None)
os.unlink(_nu)
OPT = "<!-- coaching-history: opted-in -->\n\n"
def obs(cid="CH-01", pattern="deferral-recurrence", count=3,
evidence=None, observation="From what I've been tracking across our sessions, the same "
"thread got set aside the last three running. Does that match "
"how it feels from where you sit, or is something else pulling "
"first?"):
if evidence is None:
evidence = ["F-P5-01 deferred @ session 1", "F-P5-01 deferred @ session 2",
"F-P5-01 deferred @ session 3"]
o = {"schema": _SCHEMA_ID, "id": cid, "pattern": pattern, "count": count,
"evidence": list(evidence), "observation": observation}
return "<!-- apodictic:coaching_observation\n%s\n-->" % _j.dumps(o)
# a sidecar carrying the recorded deferred dispositions H2 corroborates against. The folded map is
# last-event-wins, so we attach the full deferral history for the consecutive-run proof.
def sidecar(seq=1, deferred=None):
recs = {}
if deferred:
for fid, sessions in deferred.items():
recs[fid] = {"schema": "apodictic.finding_disposition.v1", "id": fid,
"disposition": "deferred", "reason": "held", "source": "author",
"session": sessions[-1], "ts": "2026-01-01", "trigger": "next pass",
"sessions": list(sessions)}
sc = {"project": "P", _SEQ_KEY: seq, "execution": {"finding_dispositions": recs}}
return sc
DEF3 = {"F-P5-01": [1, 2, 3]}
# ---- H1 / H2 clean (the canonical good observation)
code, _ = check(OPT + obs(), project_root=None, sidecar_obj=sidecar(deferred=DEF3),
scan_root=None)
chk("clean_deferral_recurrence", code == 0)
# phase-incompletion floor is 2
phase_obs = obs(cid="CH-02", pattern="phase-incompletion", count=2,
evidence=["phase Structural Root Causes incomplete @ session 4",
"phase Structural Root Causes incomplete @ session 5"],
observation="From my notes across sessions, the structural-root-causes phase has "
"stayed open the last two. Does that reflect the plan, or is it asking "
"for attention?") # phase-incompletion is self-reported -> caveat required
chk("clean_phase_incompletion",
check(OPT + phase_obs, sidecar_obj=sidecar(deferred=DEF3), strict=True)[0] == 0)
# ---- H1 — schema
chk("h1_bad_pattern", check(OPT + obs(pattern="stuck-point-cluster"), sidecar_obj=sidecar(deferred=DEF3))[0] == 1)
chk("h1_bad_id", check(OPT + obs(cid="CH-1"), sidecar_obj=sidecar(deferred=DEF3))[0] == 1)
chk("h1_bad_json",
check(OPT + '<!-- apodictic:coaching_observation\n{"schema":"apodictic.coaching_observation.v1"\n-->',
sidecar_obj=sidecar(deferred=DEF3))[0] == 1)
chk("h1_dup_id",
check(OPT + obs() + "\n" + obs(), sidecar_obj=sidecar(deferred=DEF3))[0] == 1)
# per-pattern count floor: deferral-recurrence with count=2 fails (floor is 3)
chk("h1_deferral_floor",
check(OPT + obs(count=2, evidence=["F-P5-01 deferred @ session 1", "F-P5-01 deferred @ session 2"]),
sidecar_obj=sidecar(deferred={"F-P5-01": [1, 2]}))[0] == 1)
# ---- H2 — provenance / anti-fabrication
# a streak with a session GAP (1,2,4) fails
gap = obs(count=3, evidence=["F-P5-01 deferred @ session 1", "F-P5-01 deferred @ session 2",
"F-P5-01 deferred @ session 4"])
chk("h2_session_gap",
check(OPT + gap, sidecar_obj=sidecar(deferred={"F-P5-01": [1, 2, 4]}))[0] == 1)
# len(evidence) < count fails
short = obs(count=3, evidence=["F-P5-01 deferred @ session 1", "F-P5-01 deferred @ session 2"])
chk("h2_evidence_lt_count",
check(OPT + short, sidecar_obj=sidecar(deferred=DEF3))[0] == 1)
# a cited session with NO recorded deferred disposition = fabricated streak member
chk("h2_fabricated_member",
check(OPT + obs(), sidecar_obj=sidecar(deferred={"F-P5-01": [1, 2]}))[0] == 1)
# malformed evidence grammar fails
chk("h2_bad_grammar",
check(OPT + obs(evidence=["F-P5-01 was deferred sometimes", "F-P5-01 deferred @ session 2",
"F-P5-01 deferred @ session 3"]),
sidecar_obj=sidecar(deferred=DEF3))[0] == 1)
# GOVERNED surface: the full deferral run reconstructs from gate_events[].disposition_deltas even
# though the fold keeps only the latest — a 3-session streak with NO `sessions` history list on the
# folded record still resolves from the event log (spec §4 governed path).
def gov_sidecar():
def delta(sess):
return {"disposition_deltas": {"F-P5-01": {
"schema": "apodictic.finding_disposition.v1", "id": "F-P5-01",
"disposition": "deferred", "reason": "held", "source": "author",
"session": sess, "ts": "2026-01-0%d" % sess, "trigger": "next pass"}}}
return {"project": "P", _SEQ_KEY: 1, "execution": {
"gate_events": [delta(1), delta(2), delta(3)],
# the fold keeps only the last-event-wins record (session 3, no history list)
"finding_dispositions": {"F-P5-01": {
"schema": "apodictic.finding_disposition.v1", "id": "F-P5-01",
"disposition": "deferred", "reason": "held", "source": "author",
"session": 3, "ts": "2026-01-03", "trigger": "next pass"}}}}
chk("h2_governed_gate_events_reconstructs_streak",
check(OPT + obs(), sidecar_obj=gov_sidecar())[0] == 0)
# ---- F2 / P1 — the SELF-REPORTED honesty caveat. A self-reported streak WITHOUT a caveat WARNs
# (ERROR --strict); WITH the caveat is clean; a GOVERNED deferral-recurrence is exempt; a per-id
# override silences it. "Self-reported" = non-governed deferral-recurrence, OR phase-incompletion
# on ANY project (it has no governed verification path — no per-session phase-completion record).
no_caveat = obs(observation="The same thread got set aside the last three sessions running. "
"Does that track for you?") # confident + invitation, but NO caveat
code, lines = check(OPT + no_caveat, sidecar_obj=sidecar(deferred=DEF3))
chk("f2_nongoverned_no_caveat_warns", code == 0 and any("H2 self-reported caveat" in ln for ln in lines))
chk("f2_nongoverned_no_caveat_strict_fails",
check(OPT + no_caveat, sidecar_obj=sidecar(deferred=DEF3), strict=True)[0] == 1)
# WITH the caveat (the default obs() carries "From what I've been tracking across our sessions") clean
chk("f2_nongoverned_with_caveat_clean",
not any("H2 self-reported caveat" in ln for ln in check(OPT + obs(), sidecar_obj=sidecar(deferred=DEF3))[1]))
# GOVERNED deferral-recurrence: the caveat is NOT required (the per-session event records verify it)
chk("f2_governed_deferral_exempt_from_caveat",
not any("H2 self-reported caveat" in ln for ln in check(OPT + no_caveat, sidecar_obj=gov_sidecar())[1]))
# a per-id override silences the caveat WARN (the writer accepted the honest framing another way)
ovc = "<!-- override: coaching-observation CH-01 — caveat stated in the artifact preamble -->\n"
chk("f2_caveat_override_silences",
not any("H2 self-reported caveat" in ln for ln in
check(OPT + ovc + no_caveat, sidecar_obj=sidecar(deferred=DEF3), strict=True)[1]))
# ---- P1 (Codex round-1) — phase-incompletion has NO governed verification path, so it is
# SELF-REPORTED on ANY project and MUST carry the caveat (even governed). The exact repro: a
# FABRICATED phase-incompletion at non-existent sessions (101/102) without a caveat FAILs
# --strict — a shape-check is not a verification.
def phase_ob(sessions, observation, cid="CH-01"):
ev = ["phase Structural Root Causes incomplete @ session %d" % s for s in sessions]
o = {"schema": _SCHEMA_ID, "id": cid, "pattern": "phase-incompletion", "count": len(sessions),
"evidence": ev, "observation": observation}
return "<!-- apodictic:coaching_observation\n%s\n-->" % _j.dumps(o)
fab = phase_ob([101, 102], "The structural phase stayed open. Does that track?") # no caveat, fabricated
code, lines = check(OPT + fab, sidecar_obj=gov_sidecar()) # even GOVERNED
chk("p1_fabricated_phase_governed_warns_no_caveat",
code == 0 and any("H2 self-reported caveat" in ln for ln in lines))
chk("p1_fabricated_phase_governed_strict_fails",
check(OPT + fab, sidecar_obj=gov_sidecar(), strict=True)[0] == 1) # the exact Codex repro caught
chk("p1_fabricated_phase_nongoverned_strict_fails",
check(OPT + fab, sidecar_obj=sidecar(deferred=DEF3), strict=True)[0] == 1)
# with the honest caveat, a phase-incompletion is WARN-clean (honestly self-reported) on any project
fab_caveat = phase_ob([101, 102], "From what I've been tracking across sessions, the structural "
"phase stayed open. Does that track?")
chk("p1_phase_with_caveat_governed_clean",
check(OPT + fab_caveat, sidecar_obj=gov_sidecar(), strict=True)[0] == 0)
chk("p1_phase_with_caveat_nongoverned_clean",
check(OPT + fab_caveat, sidecar_obj=sidecar(deferred=DEF3), strict=True)[0] == 0)
# ---- H3 — descriptive, not judgmental (WARN; ERROR --strict; per-id override)
blame = obs(observation="You always avoid the hard structural work — you fail to finish it.")
code, lines = check(OPT + blame, sidecar_obj=sidecar(deferred=DEF3))
chk("h3_trait_blame_warn", code == 0 and any("H3 descriptive" in ln for ln in lines))
chk("h3_trait_blame_strict_fails", check(OPT + blame, sidecar_obj=sidecar(deferred=DEF3), strict=True)[0] == 1)
ov = "<!-- override: coaching-observation CH-01 — the writer asked for blunt framing -->\n"
chk("h3_override_silences",
not any("H3 descriptive" in ln for ln in
check(OPT + ov + blame, sidecar_obj=sidecar(deferred=DEF3), strict=True)[1]))
# the reused author_fingerprint prescriptive regex also fires
presc = obs(observation="You should fix your prose and tighten your range this week.")
chk("h3_prescriptive_fires",
any("H3 descriptive" in ln for ln in check(OPT + presc, sidecar_obj=sidecar(deferred=DEF3))[1]))
# ---- H4 — no editorial-severity leak
sev = OPT + obs() + "\n## Notes\n\nThis is a Must-Fix.\n"
chk("h4_severity_leak", check(sev, sidecar_obj=sidecar(deferred=DEF3))[0] == 1)
finding = ('<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","id":"F-P5-01","mechanism":"m",'
'"severity":"Must-Fix","confidence":"HIGH","evidence_refs":["c"],"fix_class":"x",'
'"risk_if_fixed":"y"}\n-->')
chk("h4_finding_block", check(OPT + obs() + "\n" + finding, sidecar_obj=sidecar(deferred=DEF3))[0] == 1)
# ---- W1 — local-only
chk("w1_external_url",
any("W1 local-only" in ln for ln in
check(OPT + obs() + "\nSee https://example.com/tracker\n", sidecar_obj=sidecar(deferred=DEF3))[1]))
# ---- H7 — transference-health / tentative framing (WARN; ERROR --strict; H3 per-id override)
# (a) a third-person trait VERDICT fires
verdict = obs(observation="Writer defers endings. The author is an ending-avoider.")
code, lines = check(OPT + verdict, sidecar_obj=sidecar(deferred=DEF3))
chk("h7_trait_verdict_warn", code == 0 and any("H7 tentative-framing" in ln for ln in lines))
chk("h7_trait_verdict_strict_fails",
check(OPT + verdict, sidecar_obj=sidecar(deferred=DEF3), strict=True)[0] == 1)
# (b) a bare-scoreboard rendering fires
board = obs(observation="deferral-recurrence: 3")
chk("h7_scoreboard_warn",
any("H7 anti-scoreboard" in ln for ln in check(OPT + board, sidecar_obj=sidecar(deferred=DEF3))[1]))
board2 = obs(observation="Ending work (×3)")
chk("h7_scoreboard_xn_warn",
any("H7 anti-scoreboard" in ln for ln in check(OPT + board2, sidecar_obj=sidecar(deferred=DEF3))[1]))
# (c) a confident observation with NO invitation fires (the meaning was asserted, not handed over)
noinvite = obs(observation="The same thread was set aside three sessions running.")
chk("h7_no_invitation_warn",
any("H7 tentative-framing" in ln for ln in check(OPT + noinvite, sidecar_obj=sidecar(deferred=DEF3))[1]))
# the canonical confident-observation + open-question form (with the caveat) is CLEAN (no H7 warn)
chk("h7_tentative_form_clean",
not any("H7 " in ln for ln in check(OPT + obs(), sidecar_obj=sidecar(deferred=DEF3))[1]))
# the H3 per-id override also silences an H7 framing warn (one framing override per observation)
ovh7 = "<!-- override: coaching-observation CH-01 — the writer asked for the raw count -->\n"
chk("h7_override_silences",
not any("H7 " in ln for ln in
check(OPT + ovh7 + board, sidecar_obj=sidecar(deferred=DEF3), strict=True)[1]))
# a brief-but-real observation with an invitation (+ the non-governed caveat) must NOT be punished
# as a scoreboard. Match the H7 finding-LINE ("H7 " with the trailing space), not the summary line
# "see H3/H7/W1/H5 above" (which always contains "H7" as a substring).
chk("h7_brief_real_prose_clean",
not any("H7 " in ln for ln in
check(OPT + obs(observation="From my notes across our sessions, it slid three times "
"running — does that track?"),
sidecar_obj=sidecar(deferred=DEF3))[1]))
# =========================================================================================
# H5 / H6 — the two Fable ethics gates, exercised against the spec's HOSTILE fixtures on disk.
# =========================================================================================
def build_project(d, seq=1, opted=True, tombstone=False, deferred=DEF3):
"""A minimal valid opted-in project: Diagnostic_State.md (+ marker), the sidecar, one
Coaching_History artifact."""
state = ""
if opted:
state += "<!-- coaching-history: opted-in -->\n"
if tombstone:
state += "<!-- coaching-history: deleted -->\n"
state += "# Diagnostic State\n\nSession 5. See the Coaching Log.\n"
with open(os.path.join(d, _STATE_MD), "w", encoding="utf-8", newline="\n") as fh:
fh.write(state)
with open(os.path.join(d, _SIDECAR_NAME), "w", encoding="utf-8", newline="\n") as fh:
_j.dump(sidecar(seq=seq, deferred=deferred), fh, indent=2)
fh.write("\n")
hp = os.path.join(d, "P_Coaching_History_run5.md")
with open(hp, "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Coaching History\n\n" + OPT + obs() + "\n")
return hp
# ---- positive: a clean opted-in project passes end to end
d = tempfile.mkdtemp()
try:
build_project(d)
chk("h5_clean_project_passes", run([d], strict=True)[0] == 0)
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- H5.i — a Session_Plan carrying a projected observation block FAILS (the projection leak)
d = tempfile.mkdtemp()
try:
build_project(d)
with open(os.path.join(d, "Session_Plan_01.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Session Plan\n\n" + obs() + "\n")
code, lines = run([d])
chk("h5i_projection_leak_fails", code == 1 and any("H5.i" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- H5.iii — a Session_Plan carrying a bare evidence-grammar string FAILS (no block, just grammar)
d = tempfile.mkdtemp()
try:
build_project(d)
with open(os.path.join(d, "Session_Plan_02.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Session Plan\n\nRecall: F-P5-01 deferred @ session 2, keep it in mind.\n")
code, lines = run([d])
chk("h5iii_evidence_grammar_leak_fails", code == 1 and any("H5.iii" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# ==== Codex F1 REGRESSION: a projection into an UNLISTED artifact type (editorial letter, the
# synthesis) must FAIL H5 — the old authored-artifact allowlist let these escape and survive
# `delete`. Now every *.md in scope is scanned for (i)/(ii). ====
# (F1-a) an observation block projected into *_Core_DE_Synthesis_* FAILS H5 (was escaping)
d = tempfile.mkdtemp()
try:
build_project(d)
with open(os.path.join(d, "P_Core_DE_Synthesis_run5.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Core DE Synthesis\n\n" + obs() + "\n")
code, lines = run([d])
chk("f1_synthesis_projection_fails", code == 1
and any("H5.i" in ln and "Synthesis" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# (F1-b) an observation block projected into the editorial letter FAILS H5, AND after `delete` the
# H6 recompute CATCHES the surviving projection (does NOT falsely report "deletion honored").
d = tempfile.mkdtemp()
try:
build_project(d)
with open(os.path.join(d, "P_Editorial_Letter_run5.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Editorial Letter\n\n" + obs() + "\n")
chk("f1_letter_projection_fails", run([d])[0] == 1)
# delete removes the Coaching_History file + seq + tombstone, but NOT the letter projection
delete(d)
code, lines = run([d])
chk("f1_letter_projection_caught_by_h6_recompute",
code == 1 and any("H6 residue" in ln for ln in lines)
and not any("deletion honored" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# (F1-c) the frozen manuscript snapshot is POSITIVELY excluded from (iii)/CH-token — a chapter
# "CH-12" ref and prose "@ session" in the writer's manuscript must NOT trip H5 (--strict).
# (i)/(ii) still apply to it — but a real manuscript carries neither a block nor the schema-id.
d = tempfile.mkdtemp()
try:
build_project(d)
with open(os.path.join(d, "P_Manuscript_Snapshot_run5.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Chapter 12\n\nThe council met CH-12. He arrived @ session's end at the hall.\n")
chk("f1_manuscript_snapshot_not_tripped", run([d], strict=True)[0] == 0)
# …but a block ACTUALLY pasted into the snapshot (a projection, not prose) IS caught (i)
with open(os.path.join(d, "P_Manuscript_Snapshot_run5.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Chapter 12\n\n" + obs() + "\n")
chk("f1_manuscript_block_still_caught", run([d])[0] == 1)
finally:
shutil.rmtree(d, ignore_errors=True)
# (Codex round-1 class sweep item 3) DEPTH-COMPLETE .md scan: a projection nested in a subdir at
# ANY depth (drafts/ch3/, runs/r2/artifacts/, a/b/c/d/) must be caught — the .md scan is recursive
# (os.walk), not glob(root/*.md)+glob(runs/*/*.md). A nested SHADOW Coaching_History file counts too.
d = tempfile.mkdtemp()
try:
build_project(d)
for depth in ("drafts/ch3", "runs/r2/artifacts", "x/y/z/w"):
sub = os.path.join(d, *depth.split("/"))
os.makedirs(sub, exist_ok=True)
p = os.path.join(sub, "leak.md")
with open(p, "w", encoding="utf-8", newline="\n") as fh:
fh.write("# nested\n" + obs() + "\n")
chk("f3sweep_deep_projection_caught::%s" % depth.replace("/", "_"), run([d])[0] == 1)
os.remove(p)
# a nested SHADOW Coaching_History file (H5.iv counts it recursively)
sub = os.path.join(d, "drafts")
os.makedirs(sub, exist_ok=True)
with open(os.path.join(sub, "P_Coaching_History_old.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("<!-- coaching-history: opted-in -->\n" + obs() + "\n")
code, lines = run([d])
chk("f3sweep_nested_shadow_history_caught",
code == 1 and any("H5.iv" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# …and `delete` + the H6 recompute reach the same depths: a deep shadow must not survive deletion.
d = tempfile.mkdtemp()
try:
build_project(d)
sub = os.path.join(d, "runs", "r9", "deep")
os.makedirs(sub, exist_ok=True)
with open(os.path.join(sub, "P_Coaching_History_archived.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("<!-- coaching-history: opted-in -->\n" + obs() + "\n")
delete(d)
chk("f3sweep_delete_reaches_deep_shadow", len(_history_files(d)) == 0)
chk("f3sweep_deep_delete_then_recompute_honored", run([d])[0] == 0)
finally:
shutil.rmtree(d, ignore_errors=True)
# (F1-d) a BARE-PROSE mention of the schema-id (a project whose subject IS apodictic — design
# notes naming the schema) must NOT trip the non-overridable H5.ii; a schema-id inside an
# apodictic block comment (a real, if malformed, projection) still MUST.
d = tempfile.mkdtemp()
try:
build_project(d)
with open(os.path.join(d, "Design_Notes.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Notes on the tool\n\nThe `apodictic.coaching_observation.v1` schema is a "
"closed-key block; this document merely discusses it.\n")
chk("f1_bare_schema_mention_not_tripped", run([d], strict=True)[0] == 0)
with open(os.path.join(d, "Design_Notes.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Notes\n\n<!-- apodictic:coaching_observation\n{bad json \"schema\": "
"\"apodictic.coaching_observation.v1\"}\n-->\n")
chk("f1_malformed_block_schema_id_caught", run([d])[0] == 1)
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- H5.v — a sidecar shadow field (execution.coaching_notes) FAILS (the coach-only shadow)
d = tempfile.mkdtemp()
try:
build_project(d)
sc_path = os.path.join(d, _SIDECAR_NAME)
sc = _j.loads(_read(sc_path))
sc["execution"]["coaching_notes"] = {"CH-01": "the writer keeps deferring"}
with open(sc_path, "w", encoding="utf-8", newline="\n") as fh:
_j.dump(sc, fh, indent=2)
code, lines = run([d])
chk("h5v_sidecar_shadow_field_fails", code == 1 and any("H5.v" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- H5.v — a shadow smuggled as a STRING VALUE anywhere in the sidecar (no coaching* key) FAILS
d = tempfile.mkdtemp()
try:
build_project(d)
sc_path = os.path.join(d, _SIDECAR_NAME)
sc = _j.loads(_read(sc_path))
sc["last_session"] = {"focus": "CH-01 pattern: F-P5-01 deferred @ session 2"}
with open(sc_path, "w", encoding="utf-8", newline="\n") as fh:
_j.dump(sc, fh, indent=2)
code, lines = run([d])
chk("h5v_sidecar_string_value_shadow_fails", code == 1 and any("H5.v" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- H5.iv — TWO Coaching_History files FAIL (the shadow artifact)
d = tempfile.mkdtemp()
try:
build_project(d)
with open(os.path.join(d, "P_Coaching_History_run4.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Coaching History (older)\n\n" + OPT + obs() + "\n")
code, lines = run([d])
chk("h5iv_two_artifacts_fails", code == 1 and any("H5.iv" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- H5 WARN — a bare CH-token in coach prose is WARN (chapter-shorthand false-positive), overridable
d = tempfile.mkdtemp()
try:
build_project(d)
with open(os.path.join(d, "Session_Plan_03.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Session Plan\n\nSee CH-12 for the scene we discussed.\n")
code, lines = run([d])
chk("h5_bare_token_warn_not_error", code == 0 and any("bare CH-token" in ln for ln in lines))
# under --strict the bare token escalates to ERROR
chk("h5_bare_token_strict_fails", run([d], strict=True)[0] == 1)
# …and an override silences it
with open(os.path.join(d, "Session_Plan_03.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Session Plan\n\nSee CH-12 for the scene.\n"
"<!-- override: coaching-residue CH-12 — chapter shorthand, not a coaching id -->\n")
chk("h5_bare_token_override_silences", run([d], strict=True)[0] == 0)
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- H5.iii boundary — a legitimate finding-disposition marker must NOT trip H5.iii
d = tempfile.mkdtemp()
try:
build_project(d)
with open(os.path.join(d, "P_Coaching_Log_run5.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Coaching Log\n\n<!-- deferred: F-P5-01 until: beta reads — waiting on reader -->\n")
code, lines = run([d], strict=True)
chk("h5iii_disposition_marker_not_a_leak", code == 0)
finally:
shutil.rmtree(d, ignore_errors=True)
# =========================================================================================
# H6 — deletion honored, RECOMPUTE not trust. Hostile fixtures + a positive + the delete round-trip.
# =========================================================================================
# ---- H6 — tombstoned but an archived session plan still carries `deferred @ session 4` (residue)
d = tempfile.mkdtemp()
try:
build_project(d, tombstone=True, opted=False)
os.remove(os.path.join(d, "P_Coaching_History_run5.md")) # artifact gone…
# …but the sidecar seq is dropped for THIS fixture so only the archived residue trips H6
sc_path = os.path.join(d, _SIDECAR_NAME)
sc = _j.loads(_read(sc_path)); sc.pop(_SEQ_KEY, None)
with open(sc_path, "w", encoding="utf-8", newline="\n") as fh:
_j.dump(sc, fh, indent=2)
runs = os.path.join(d, "runs", "r5_coaching"); os.makedirs(runs)
with open(os.path.join(runs, "Session_Plan_03.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Archived Session Plan\n\nF-P5-01 deferred @ session 4\n")
code, lines = run([d])
chk("h6_archived_residue_fails", code == 1 and any("H6 residue" in ln for ln in lines))
# H6 accepts NO override — a coaching-residue override must NOT rescue the (iii) signature
with open(os.path.join(runs, "Session_Plan_03.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Archived Session Plan\n\nF-P5-01 deferred @ session 4\n"
"<!-- override: coaching-residue F-P5-01 — nope -->\n")
chk("h6_no_override_accepted", run([d])[0] == 1)
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- H6 — tombstoned but coaching_history_seq survives in the sidecar (residue)
d = tempfile.mkdtemp()
try:
build_project(d, tombstone=True, opted=False, seq=7)
os.remove(os.path.join(d, "P_Coaching_History_run5.md"))
code, lines = run([d])
chk("h6_surviving_seq_fails", code == 1 and any("coaching_history_seq" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- P2 (Codex round-1) DEPTH-COMPLETE seq residue. A coaching_history_seq nested under a NON-HOME
# key (e.g. last_session.*) survived delete + the H6 recompute falsely honored — the F1/F2
# known-location-not-all-locations class, recurring at depth.
# (P2-a) a tombstoned project with a nested seq surviving -> the H6 recompute CATCHES it (not honored)
d = tempfile.mkdtemp()
try:
build_project(d, tombstone=True, opted=False)
os.remove(os.path.join(d, "P_Coaching_History_run5.md"))
sc_path = os.path.join(d, _SIDECAR_NAME)
sc = _j.loads(_read(sc_path))
sc.pop(_SEQ_KEY, None) # drop the legit home…
sc.setdefault("last_session", {})["coaching_history_seq"] = 9 # …but nest one at a non-home depth
with open(sc_path, "w", encoding="utf-8", newline="\n") as fh:
_j.dump(sc, fh, indent=2)
code, lines = run([d])
chk("p2_nested_seq_caught_by_h6_recompute",
code == 1 and any("coaching_history_seq" in ln for ln in lines)
and not any("deletion honored" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# (P2-b) `delete` strips the nested seq RECURSIVELY (any depth), so the recompute then honors
d = tempfile.mkdtemp()
try:
build_project(d, seq=3)
sc_path = os.path.join(d, _SIDECAR_NAME)
sc = _j.loads(_read(sc_path))
sc.setdefault("last_session", {})["coaching_history_seq"] = 9 # a nested seq beside the home one
sc.setdefault("complexity_signals", {})["coaching_history_seq"] = 2 # and a second nesting
with open(sc_path, "w", encoding="utf-8", newline="\n") as fh:
_j.dump(sc, fh, indent=2)
delete(d)
sc2 = _j.loads(_read(sc_path))
# NO coaching_history_seq survives at ANY depth
chk("p2_delete_strips_nested_seq_recursively",
sum(1 for _ in _find_key_values(sc2, _SEQ_KEY)) == 0)
chk("p2_delete_then_recompute_honored", run([d])[0] == 0)
finally:
shutil.rmtree(d, ignore_errors=True)
# (P2-c) H5.v flags a nested seq at a non-home depth on a LIVE (opted-in) project too (not just H6)
d = tempfile.mkdtemp()
try:
build_project(d)
sc_path = os.path.join(d, _SIDECAR_NAME)
sc = _j.loads(_read(sc_path))
sc.setdefault("last_session", {})["coaching_history_seq"] = 9 # non-home nesting
with open(sc_path, "w", encoding="utf-8", newline="\n") as fh:
_j.dump(sc, fh, indent=2)
code, lines = run([d])
chk("p2_h5v_flags_nonhome_nested_seq", code == 1 and any("H5.v" in ln and "NON-HOME" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# (P2-d) a nested seq with a NON-INT value anywhere is caught (depth-complete type check)
d = tempfile.mkdtemp()
try:
build_project(d)
sc_path = os.path.join(d, _SIDECAR_NAME)
sc = _j.loads(_read(sc_path))
sc.setdefault("last_session", {})["coaching_history_seq"] = {"CH-01": "shadow payload"}
with open(sc_path, "w", encoding="utf-8", newline="\n") as fh:
_j.dump(sc, fh, indent=2)
chk("p2_h5v_flags_nonint_nested_seq", run([d])[0] == 1)
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- opted-in + deleted both present = contradiction ERROR
d = tempfile.mkdtemp()
try:
build_project(d, tombstone=True, opted=True)
code, lines = run([d])
chk("h6_consent_contradiction_fails", code == 1 and any("consent contradiction" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- positive: a cleanly deleted project (no artifact, no seq, tombstone) RECOMPUTES clean
d = tempfile.mkdtemp()
try:
build_project(d, tombstone=True, opted=False)
os.remove(os.path.join(d, "P_Coaching_History_run5.md"))
sc_path = os.path.join(d, _SIDECAR_NAME)
sc = _j.loads(_read(sc_path)); sc.pop(_SEQ_KEY, None)
with open(sc_path, "w", encoding="utf-8", newline="\n") as fh:
_j.dump(sc, fh, indent=2)
code, lines = run([d])
chk("h6_clean_delete_passes", code == 0 and any("deletion honored" in ln for ln in lines))
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- the delete-command ROUND-TRIP: build a valid project, run delete, assert H6 recompute passes
d = tempfile.mkdtemp()
try:
build_project(d, seq=3)
# also stage an archived artifact + an archived projection to prove delete reaches runs/*
runs = os.path.join(d, "runs", "r4_coaching"); os.makedirs(runs)
with open(os.path.join(runs, "P_Coaching_History_run4.md"), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Archived Coaching History\n\n" + OPT + obs() + "\n")
# pre-delete: opted-in with TWO artifacts is an H5.iv fail (sanity: the project has residue)
chk("roundtrip_predelete_has_artifacts", len(_history_files(d)) == 2)
dcode, dlines = delete(d)
chk("roundtrip_delete_ok", dcode == 0)
chk("roundtrip_artifacts_gone", len(_history_files(d)) == 0)
chk("roundtrip_seq_dropped", _sidecar_seq(_j.loads(_read(os.path.join(d, _SIDECAR_NAME)))) is None)
state = _read(os.path.join(d, _STATE_MD))
chk("roundtrip_tombstone_set", bool(_DELETED_RE.search(state)) and not _OPT_IN_RE.search(state))
# the validator now RECOMPUTES the deletion as honored
code, lines = run([d])
chk("roundtrip_h6_recompute_passes", code == 0 and any("deletion honored" in ln for ln in lines))
# idempotent: a second delete is a clean no-op that still recomputes clean
chk("roundtrip_delete_idempotent", delete(d)[0] == 0 and run([d])[0] == 0)
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- opt-in gate: no marker anywhere → no-op exit 2 (nothing to check), like content-advisory
d = tempfile.mkdtemp()
try:
with open(os.path.join(d, _STATE_MD), "w", encoding="utf-8", newline="\n") as fh:
fh.write("# Diagnostic State\n\nNo coaching-history consent here.\n")
chk("optin_gate_noop_exit2", run([d])[0] == 2)
finally:
shutil.rmtree(d, ignore_errors=True)
# ---- unparseable sidecar is fail-closed
d = tempfile.mkdtemp()
try:
build_project(d)
with open(os.path.join(d, _SIDECAR_NAME), "w", encoding="utf-8", newline="\n") as fh:
fh.write("{not json")
chk("unparseable_sidecar_fail_closed", run([d])[0] == 1)
finally:
shutil.rmtree(d, ignore_errors=True)
print("Self-test: PASS" if rc["v"] == 0 else "Self-test: FAIL")
return rc["v"]
def main(argv):
if "--self-test" in argv:
return run_self_test()
args = [a for a in argv[1:] if a != "coaching-history"]
if args and args[0] == "delete":
targets = [a for a in args[1:] if not a.startswith("--")]
if len(targets) != 1:
print("Usage: coaching_history.py delete <project_root>")
return 2
code, lines = delete(targets[0])
for ln in lines:
print(ln)
return code
strict = "--strict" in args
paths = [a for a in args if not a.startswith("--")]
if not paths:
print("Usage: coaching_history.py coaching-history <project_root|files...> [--strict] "
"| delete <project_root> | --self-test")
return 2
code, lines = run(paths, strict=strict)
for ln in lines:
print(ln)
return code
if __name__ == "__main__":
sys.exit(main(sys.argv))
SHA-256: 5f33728f74dd3953e9e41a068bfed493debfa1567c5a49b926b54441c9113bf5