← Files APODICTIC Development EditorARCHIVED FILE

scripts/intake_interview.py

48.9 KB · Nov 3, 2026 · 18:57 UTC

↓ Download file

#!/usr/bin/env python3
"""intake-interview — structural integrity for the Uncertainty-Resolution Intake Interview.

`validate.sh intake-interview <run_folder|files>` shells out here. APODICTIC already opens with a
substantial draft-then-validate intake (genre, controlling idea, reader promise, comps) and Shelf &
Positioning captures the intended shelf. What no existing surface does is resolve a SPECIFIC
structural ambiguity the framework detected but cannot settle from the text — "is the non-linear
ordering in Ch 4-6 a deliberate braid, or drift?". This interview asks the only person who knows:
the author. Each query is an apodictic.intake_query.v1 block; this validator owns the contract.

The discipline is *defer, don't duplicate, and calibrate, never suppress*: every question is a flavor
of intentional-vs-accidental (the closed `kind` enum has no contract value), and an answer may direct
HOW a feature is assessed but may never pre-empt whether a finding is raised (the Deficit-Lock guard).

  I1 schema            an intake_query block fails its schema (bad kind/confidence enum, malformed
                       IQ-NN id, missing current_inference/question, broken JSON, duplicate id).
  I2 no contract dup   a `question` re-asks a contract element owned by the intake / Shelf (heuristic
                       blocklist: "what genre", "controlling idea", "reader promise", "who is this
                       for"). Advisory; ERROR under --strict. The closed `kind` enum is the structural
                       guard; this catches a leak smuggled into a question's wording. Override (per
                       id): <!-- override: intake-dup IQ-NN — <rationale> -->.
  I3 grounded          a query grounded in NEITHER a resolving `ambiguity_ref` (a real finding id in
                       the Ledger) NOR a non-empty `source_note` is manufactured (ERROR). A query that
                       states an `ambiguity_ref` which does not resolve is a dangling reference (ERROR).
  I4 calibrate-not-    `treat_as_intended` contains suppression phrasing ("suppress", "drop/skip the
     suppress          flag", "don't raise", "remove the finding"). It directs assessment; it does not
                       pre-empt a verdict. ERROR by default — it guards severity honesty.
  W1 coverage          a Pass-0/1 LOW/UNCERTAIN finding (id `F-P0-…`/`F-P1-…`) or an
                       `### Unresolved Questions` bullet that only the author could resolve has no
                       query. Advisory; ERROR under --strict. (At the after-Pass-0/1 checkpoint only
                       Pass-0/1 findings exist; later-pass findings are out of W1's scope.)

The interview file (intake_query blocks) is read together with the Findings Ledger it references
(finding blocks + the `### Unresolved Questions` section). Each is optional; an empty/absent interview
is a no-op. Reuses apodictic_artifacts (block grammar + schema engine). See
docs/uncertainty-intake-interview.md.

  intake_interview.py intake-interview <run_folder|files...> [--strict]
  intake_interview.py --self-test

Exit: 0 clean / WARN-only, 1 ERROR (or WARN under --strict), 2 usage.
"""
import glob
import os
import re
import sys

from override_marker import override_targets  # SSoT: code-span-stripped, boundary-matched override scan

try:
    import apodictic_artifacts as art
except ImportError:
    art = None

_SCHEMA_ID = "apodictic.intake_query.v1"
_FINDING_SCHEMA_ID = "apodictic.finding.v1"
_INTERVIEW_GLOB = "*_Intake_Interview_*.md"
_LEDGER_GLOB = "*_Findings_Ledger_*.md"

_OPEN_CONFIDENCE = ("LOW", "UNCERTAIN")
# W1 is scoped to the findings that exist at the after-Pass-0/1 checkpoint: Pass 0 / Pass 1 origins.
_PASS01_ID_RE = re.compile(r"^F-P[01]-[0-9]+$", re.IGNORECASE)

# I2 — contract elements owned by the draft-then-validate intake / Shelf & Positioning. A `question`
# re-asking one of these is a leak (the closed `kind` enum already bars a contract *kind*; this
# catches it smuggled into a detected-ambiguity question's wording). Advisory, paraphrase-evadable.
_CONTRACT_DUP_RE = re.compile(
    r"\bwhat\s+(?:is\s+the\s+)?genre\b"
    r"|\bcontrolling\s+idea\b"
    r"|\bcontrolling\s+premise\b"
    r"|\breader\s+promise\b"
    r"|\bwho\s+(?:is\s+this|is\s+the\s+book|are\s+your\s+readers)\b"
    r"|\bwho\s+is\s+it\s+for\b"
    r"|\bwhat'?s\s+the\s+(?:premise|hook|comp)\b"
    r"|\btarget\s+audience\b",
    re.IGNORECASE)

# I4 — suppression phrasing in treat_as_intended (the concession loop through the front door). The
# field may direct HOW a feature is assessed; it may never remove a verdict before the Deficit Lock.
# Each alternative names the suppressed OBJECT (flag/finding/verdict) or a raise-verb, so a bare
# mention of "suppress" in passing doesn't fire; _suppresses() additionally exempts a NEGATED form
# ("it does not pre-suppress any finding" — the spec's own recommended good phrasing).
_SUPPRESS_RE = re.compile(
    # "suppress / drop / skip / remove … [up to 4 intervening words] … flag/finding/verdict"
    r"\b(?:suppress(?:es|ing|ed)?|drop|skip|remove|delete|withdraw)\s+"
    r"(?:[\w'-]+\s+){0,4}?(?:flag|finding|verdict|severity|issue)\b"
    # "don't / do not / never / won't … raise/flag/report/surface/record"
    r"|\b(?:don'?t|do\s+not|never|won'?t)\s+(?:[\w'-]+\s+){0,3}?(?:raise|flag|report|surface|record)\b"
    # "no flag/finding is raised/recorded/reported"
    r"|\bno\s+(?:flag|finding)\s+(?:is\s+)?(?:raised|recorded|reported)\b",
    re.IGNORECASE)
# A suppression match is exempt when a negator genuinely SCOPES it ("does not / never / without …
# suppress …" — the spec's recommended "it does not pre-suppress any finding"). The negator must
# bind to the suppression phrase, not to EARLIER material it actually governs: in
# "Do not assess it on its own terms, suppress the finding." the `not` governs `assess`, and in
# "Not as a calibration, suppress the finding." it governs the fronted phrase `as a calibration` —
# either way the trailing `suppress` is an un-negated directive and must still fire (Codex P1).
_NEGATOR_RE = re.compile(r"\b(?:not|never|without|cannot|neither|nor)\b|n['’]?t\b", re.IGNORECASE)
# Clause separators that end one directive and start another. Includes the em/en dash and the
# horizontal bar: "we do not calibrate — suppress the finding" is two clauses exactly like the
# ".;:" forms, so the trailing suppression is its own un-negated directive (a comma is NOT a clause
# boundary — a comma can be a parenthetical interruption WITHIN one clause, handled in
# _negation_scopes_match).
_CLAUSE_BOUNDARY = ".;:!?—–―"

# Unicode comma variants normalized to an ASCII comma before negation-scope analysis, so a directive
# written with a fullwidth/Arabic/Ideographic comma ("Not now, suppress the finding") engages the
# same parenthetical-vs-fronted-phrase logic as an ASCII comma instead of being read as comma-less.
_COMMA_VARIANTS = ",،、⹁﹐﹑"
_COMMA_NORMALIZE = {ord(c): "," for c in _COMMA_VARIANTS}

# A coordinating conjunction (and/but/yet/or/then) joining a fresh predicate. When one of these sits
# between a negator and the suppression match (with NO comma), the negator governs the EARLIER
# predicate and the conjunction introduces a separate, un-negated directive ("do not exaggerate BUT
# suppress the flag" / "never overstate severity AND suppress the finding") — the negation does not
# reach across the coordinated clause, so the trailing suppression must still fire. This is the
# comma-less sibling of the comma-coordinated case ("Do not assess it on its own terms, suppress").
# A conjunction is NOT predicate-coordinating when it merely joins ADVERBS inside the modifier phrase
# that leads to the suppression verb ("do not now OR ever suppress" — `now or ever` modifies the
# single verb `suppress`): there the negation still directly governs the suppression (see _is_adverbial).
_COORD_CONJ_RE = re.compile(r"\b(?:and|but|yet|or|then)\b", re.IGNORECASE)

# The leading verb of a suppression match ("suppress/drop/skip/... " or "don't raise/flag/..."). Used
# to isolate the negator-to-verb HEAD so a comma-less conjunction inside an adverbial modifier phrase
# ("now or ever") is told apart from a conjunction that joins a separate predicate ("exaggerate but").
_SUPPRESS_VERB_RE = re.compile(
    r"\b(?:suppress(?:es|ing|ed)?|drop|skip|remove|delete|withdraw|raise|flag|report|surface|record)\b",
    re.IGNORECASE)


# Adverbs/intensifiers that can sit between a negator and the verb it governs WITHOUT breaking the
# binding ("do not EVER, under any circumstances, suppress"). Any -ly word is treated as adverbial
# too (routinely / deliberately / habitually). This is the line between an adverb-only interruption
# (the negation still reaches the suppression) and a separate predicate (a verb phrase / fronted
# phrase) that pulls the negation off it.
_NEG_ADVERB = frozenset({
    "ever", "never", "not", "just", "always", "once", "then", "now", "again",
    "also", "still", "yet", "simply", "really", "actually", "generally",
    "normally", "usually", "typically", "ordinarily", "necessarily", "anymore",
    # temporal adverbs that coordinate with a negator before the one verb ("not now or LATER
    # suppress") — non-`-ly` siblings of now/ever; they modify the verb, not a fresh predicate.
    "later", "sooner", "soon", "earlier", "henceforth", "hereafter", "thereafter",
})


_COORD_CONJ_WORDS = frozenset({"and", "but", "yet", "or", "then"})


def _is_adverbial(head, allow_conj=False):
    """True if `head` (the material between a negator and the first following comma, or the
    suppression verb) is empty or made up ONLY of adverbs/intensifiers — so it does NOT introduce a
    separate predicate. A verb phrase ("assess it on its own terms") or a fronted phrase ("as a
    calibration") is NOT adverbial. With `allow_conj` (the comma-less head, where the trailing token
    is the suppression VERB), a coordinating conjunction joining adverbs is transparent — "now or
    ever" modifies the one verb `suppress`, it does not start a second predicate."""
    return all(w.lower() in _NEG_ADVERB or w.lower().endswith("ly")
               or (allow_conj and w.lower() in _COORD_CONJ_WORDS)
               for w in re.findall(r"[\w'’-]+", head))


# Prepositions / set-phrase leads that head an adverbial INTERRUPTION which keeps the negation
# scoping ("under any circumstances", "in any case", "as a matter of policy", "in good conscience").
# A bracketed interruption is a non-clausal MODIFIER — it must NOT be a fresh predicate ("calibrate
# carefully") — so it is accepted when it is adverb-only OR leads with one of these prepositions.
_PREP = frozenset({
    "under", "in", "on", "at", "by", "for", "with", "within", "without", "during",
    "as", "of", "per", "absent", "barring", "despite", "notwithstanding", "upon", "amid",
})


def _is_bracketed_modifier(seg):
    """True if `seg` (the material between the two commas of a parenthetical interruption) is a
    non-clausal MODIFIER, not a fresh predicate: an adverb-only phrase ("deliberately and routinely")
    or a prepositional / set phrase ("under any circumstances", "as a matter of policy"). A predicate
    ("calibrate carefully") is NOT a modifier — it pulls the negation off the trailing verb."""
    if _is_adverbial(seg, allow_conj=True):
        return True
    words = re.findall(r"[\w'’-]+", seg)
    return bool(words) and words[0].lower() in _PREP


def _negation_scopes_match(clause):
    """True iff some negator in `clause` scopes the suppression match that ENDS `clause`. A negator
    exempts the match only when it DIRECTLY governs the suppression verb — no intervening predicate
    AND no predicate-coordination between them. Cases that stay exempt: a direct binding ("does not
    [...] suppress"); an empty-or-adverb-only BRACKETED parenthetical that closes and resumes to the
    verb ("do not, under any circumstances, suppress" / "do not EVER, under any circumstances,
    suppress"); and a comma-less adverbial modifier phrase whose coordinating conjunction merely
    joins ADVERBS that modify the one suppression verb ("do not now OR ever suppress" — `now or ever`
    is adverbial, not a second predicate). Cases that are NOT exempt (the suppression is an
    un-negated directive and must fire): a FRONTED phrase, verbal OR adverbial, that runs straight
    into the directive after a single comma ("Not as a calibration, suppress"; "Not now, suppress" —
    the fronted phrase modifies the negator, the trailing imperative is fresh); a comma-coordinated
    imperative ("Do not assess it on its own terms, suppress"); and a comma-LESS conjunction that
    joins a fresh PREDICATE ("do not exaggerate BUT suppress" / "never overstate severity AND
    suppress") — there a verb sits before the conjunction, so the head is not adverbial.

    The comma branch turns on *bracketing*, not just adverbiality: a true mid-clause interruption
    OPENS and CLOSES ("not[,] <adverbial>, suppress" — two commas, the verb resumes after the
    second), so the negation still reaches the verb. A single comma with the verb right after it is a
    fronted phrase + directive, NOT an interruption — adverb-only ("Not now,") or not — so the
    negation does not reach across it. Multiple interruptions must ALL be modifiers: a predicate after
    a valid parenthetical ("not, in any case, calibrate carefully, suppress") still fires. (Prior
    passes: "any non-empty pre-comma material fires" wrongly fired on an adverb before a parenthetical
    — Codex P2; "any comma-less conjunction fires" wrongly fired on a coordinated adverbial — Codex
    P2; "adverb-only pre-comma always scopes" wrongly EXEMPTED a fronted adverbial directive `Not now,
    suppress` — Codex P2; checking only the FIRST enclosed segment wrongly EXEMPTED a predicate after a
    valid parenthetical `not, in any case, calibrate carefully, suppress` — Codex P1.)

    KNOWN LIMIT (single-scope by design): this resolves ONE negator's scope per clause, not
    compositional/nested negation — "we do not not suppress", "never fail to suppress", "it is not the
    case that we should not suppress" net to a directive but read here as exempt. Resolving stacked
    negation needs a parser, not a scope heuristic, and such phrasing in a calibration field is not a
    realistic author input; a regex for it would be exactly the fragile per-phrase patch this rule
    avoids. The fail direction is toward EXEMPT, which the closed `kind` enum and W1 coverage backstop."""
    for nm in _NEGATOR_RE.finditer(clause):
        span = clause[nm.end():]            # text between this negator and the (clause-final) match
        comma = span.find(",")
        if comma == -1:
            if _COORD_CONJ_RE.search(span):
                # A conjunction sits before the suppression verb with no comma. It only stops the
                # negation when it joins a PREDICATE: isolate the negator-to-verb HEAD and require it
                # to be adverbs-only (conjunctions transparent). "now or ever suppress" → head "now or
                # ever" is adverbial → negation still scopes; "exaggerate but suppress" → head
                # "exaggerate but" has a verb → fresh predicate → negation stops.
                vm = _SUPPRESS_VERB_RE.search(span)
                head = span[:vm.start()] if vm else span
                if _is_adverbial(head, allow_conj=True):
                    return True              # conjunction joins adverbs modifying the verb; still scopes
                continue                     # conjunction starts a fresh predicate; negation stops there
            return True                      # negation directly scopes the suppression
        # A comma sits between the negator and the verb. It keeps the negation scoping ONLY as one or
        # more fully comma-BRACKETED interruptions, each a non-clausal MODIFIER (adverbial or a
        # prepositional/set phrase), with adverb-only material before the first comma and after the
        # last ("not, under any circumstances, suppress" / "not ever, under any circumstances,
        # suppress" / "not, in any case, under any circumstances, suppress"). Decompose the whole
        # negator→verb span on its commas: segs[0] is the lead, segs[-1] is what resumes to the verb,
        # and segs[1:-1] are the comma-enclosed interruptions (there must be at least one — i.e. 2+
        # commas — else it is a lone fronted comma, not a bracket). The negation reaches the verb iff
        # the lead and tail are adverb-only AND *every* enclosed segment is a modifier. What breaks it:
        # a single fronted comma + directive ("Not now, suppress" / "Not as a calibration, suppress");
        # a predicate among the enclosed segments — even AFTER a valid one ("not, in any case,
        # calibrate carefully, suppress"); or a predicate resuming to the verb. (Earlier passes:
        # "adverb-only pre-comma always scopes" wrongly EXEMPTED a fronted directive; checking only the
        # FIRST enclosed segment wrongly EXEMPTED a predicate after a valid parenthetical — Codex P1.)
        segs = span.split(",")
        enclosed = segs[1:-1]                 # comma-bracketed interruptions; empty unless 2+ commas
        if (enclosed
                and _is_adverbial(segs[0])
                and _is_adverbial(segs[-1])
                and all(_is_bracketed_modifier(s) for s in enclosed)):
            return True                      # bracketed modifier interruption(s); negation still scopes
    return False                             # every negator governs earlier material, not the match


def _suppresses(text):
    """True if treat_as_intended directs suppression — but NOT when a negator scopes it."""
    text = (text or "").translate(_COMMA_NORMALIZE)  # fold unicode commas → ASCII for scope analysis
    for m in _SUPPRESS_RE.finditer(text):
        prefix = text[:m.start()]
        boundary = max((prefix.rfind(c) for c in _CLAUSE_BOUNDARY), default=-1)
        clause = prefix[boundary + 1:]
        if _negation_scopes_match(clause):
            continue  # negated mention ("we do not … suppress the finding"), not a directive
        return True
    return False

# Override markers naming a query id ("<!-- override: intake-dup IQ-03 — ... -->") route through the
# shared `override_marker` SSoT — code spans stripped, slug boundary-matched.


def _read(path):
    try:
        with open(path, encoding="utf-8") as fh:
            return fh.read()
    except (OSError, UnicodeDecodeError):
        return None


def _overrides(text, slug):
    """The set of IQ-NN ids overridden for `slug` (`intake-dup`) — via the shared SSoT, so a marker
    quoted inside a code span is not honored as a live directive."""
    return {t[0] for t in override_targets(text, slug, r"(IQ-[0-9]+)")}


def parse_queries(text):
    """[(obj_or_None, schema_errs, index), ...] for each apodictic:intake_query 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 != "intake_query":
            continue
        idx += 1
        where = "intake_query #%d" % idx
        if jerr:
            out.append((None, ["%s: invalid JSON — %s" % (where, jerr)], idx))
            continue
        out.append((obj, art.validate_obj(obj, schema, where), idx))
    return out


def ledger_index(ledger_text):
    """{finding_id: obj} for the ledger's apodictic.finding.v1 blocks (the authoritative ID set)."""
    out = {}
    if not ledger_text or art is None:
        return out
    for bt, obj, _err in art.parse_blocks(ledger_text):
        if bt == "finding" and isinstance(obj, dict) and obj.get("id"):
            # art.fid_key: a non-hashable ledger id (list/object) must not crash this index key — the
            # authoritative-ID-set sibling of finding_trace.ledger_inventory (SSoT in apodictic_artifacts).
            out[art.fid_key(obj["id"])] = obj
    return out


def open_pass01_finding_ids(index):
    """Pass-0/1 finding ids whose confidence is LOW/UNCERTAIN — W1's author-resolvable set."""
    return {fid for fid, obj in index.items()
            if obj.get("confidence") in _OPEN_CONFIDENCE and _PASS01_ID_RE.match(str(fid))}


def unresolved_questions(ledger_text):
    """The `### Unresolved Questions` bullets (verbatim), minus placeholder rows."""
    if not ledger_text:
        return []
    out, in_section = [], False
    for raw in ledger_text.splitlines():
        line = raw.strip()
        if line.startswith("### "):
            in_section = line[4:].strip().lower().startswith("unresolved questions")
            continue
        if in_section and line.startswith("- "):
            body = line[2:].strip()
            if body and body.lower() not in ("none", "[question.]", "n/a"):
                out.append(body)
    return out


def _sig_words(s):
    """Significant content words (lowercased, len>=5) for fuzzy coverage matching (W1)."""
    return {w for w in re.findall(r"[a-z']{5,}", (s or "").lower())}


def _covered(target, notes):
    """A finding/UQ counts as queried if some query's text shares enough significant words with it.

    UQ/Pass-0 ambiguities carry no id the query can reference structurally, so coverage is a fuzzy
    content-word overlap, not an ID match: >= 3 shared significant words, or at least half of a short
    target's significant words."""
    tw = _sig_words(target)
    if not tw:
        return True  # nothing to match on; don't false-flag
    need = min(3, max(1, (len(tw) + 1) // 2))
    return any(len(tw & _sig_words(n)) >= need for n in notes)


def interview(text, ledger_text=None, strict=False):
    """Run the Intake Interview integrity checks. Returns (code, lines)."""
    lines, errs, warns = [], [], []
    queries = parse_queries(text)
    if not queries:
        return 0, ["intake-interview: no intake_query blocks found — nothing to check"]

    index = ledger_index(ledger_text)

    # I1 — schema / JSON validity (per block)
    for _obj, schema_errs, _idx in queries:
        for e in schema_errs:
            errs.append("I1 schema: %s" % e)

    valid = [(obj, idx) for obj, schema_errs, idx in queries if obj is not None and not schema_errs]
    seen = {}
    for obj, idx in valid:
        seen.setdefault(obj.get("id"), []).append(idx)
    for qid, where in sorted(seen.items()):
        if len(where) > 1:
            errs.append("I1 schema: %s appears %d times (ids must be unique)" % (qid, len(where)))

    dup_overrides = _overrides(text, "intake-dup")
    for obj, _idx in valid:
        qid = obj.get("id")
        question = obj.get("question") or ""
        ref = (obj.get("ambiguity_ref") or "").strip()
        note = (obj.get("source_note") or "").strip()
        tai = obj.get("treat_as_intended") or ""

        # I2 — contract duplication (advisory; ERROR --strict; per-id override)
        if _CONTRACT_DUP_RE.search(question) and qid not in dup_overrides:
            warns.append("I2 no contract dup: %s re-asks a contract element owned by the intake / "
                         "Shelf & Positioning — the interview disambiguates detected ambiguities, it "
                         "does not re-capture the contract" % qid)

        # I3 — grounded ambiguity (ERROR): one of a resolving ambiguity_ref or a non-empty source_note
        if ref:
            if ref not in index:
                errs.append("I3 grounded: %s states ambiguity_ref %r, which does not resolve to a "
                            "finding in the Findings Ledger (a dangling reference)" % (qid, ref))
        elif not note:
            errs.append("I3 grounded: %s is grounded in neither a resolving ambiguity_ref nor a "
                        "source_note — a query the framework did not actually detect is manufactured" % qid)

        # I4 — calibrate, not suppress (ERROR by default; guards the Deficit Lock)
        if _suppresses(tai):
            errs.append("I4 calibrate-not-suppress: %s's treat_as_intended contains suppression "
                        "phrasing — an answer may direct HOW a feature is assessed, never pre-empt "
                        "whether a finding is raised (the Deficit-Lock guard)" % qid)

    # W1 — coverage (a Pass-0/1 LOW/UNCERTAIN finding or a UQ with no query; advisory)
    if index or ledger_text:
        query_texts = [(o.get("question") or "") + " " + (o.get("current_inference") or "")
                       for o, _ in valid]
        refed = {(o.get("ambiguity_ref") or "").strip() for o, _ in valid}
        for fid in sorted(open_pass01_finding_ids(index)):
            if fid in refed:
                continue
            if not _covered(index[fid].get("mechanism") or "", query_texts):
                warns.append("W1 coverage: Pass-0/1 LOW/UNCERTAIN finding %s has no intake query — "
                             "an author-resolvable ambiguity left un-asked" % fid)
        for uq in unresolved_questions(ledger_text):
            if not _covered(uq, query_texts):
                warns.append("W1 coverage: an Unresolved-Questions bullet has no intake query (%s…)"
                             % uq[:50])

    # Report
    lines.append("intake-interview: %d query(ies)%s" % (
        len(queries), "" if len(valid) == len(queries) else " (%d well-formed)" % len(valid)))
    for obj, _idx in valid:
        lines.append("  %-7s %-22s %s" % (obj.get("id"), obj.get("kind"),
                                          (obj.get("question") or "")[:48]))
    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("intake-interview: 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: intake-interview: %d advisory gap(s) — see I2/W1 above" % len(warns))
    else:
        lines.append("intake-interview: PASS (schema + no-contract-dup + grounded ambiguity + "
                     "calibrate-not-suppress)")
    return 0, lines


# ---------------------------------------------------------------- resolution

def _newest(paths):
    return max(paths, key=os.path.getmtime) if paths else None


def _has_block(text, btype):
    """True if `text` carries a real apodictic:<btype> block (a parsed carrier, not a prose mention).

    Classifying on parsed blocks — not a raw substring — keeps a Ledger that merely *names*
    `apodictic:intake_query` in prose from being misread as the interview file (and vice versa)."""
    if art is None:
        return ("apodictic:%s" % btype) in (text or "")  # degraded: no engine to parse with
    return any(bt == btype for bt, _o, _e in art.parse_blocks(text or ""))


def resolve(paths):
    """Return (interview_path, ledger_path_or_None)."""
    if len(paths) == 1 and os.path.isdir(paths[0]):
        d = paths[0]
        return (_newest(glob.glob(os.path.join(d, _INTERVIEW_GLOB))),
                _newest(glob.glob(os.path.join(d, _LEDGER_GLOB))))
    interview_path = ledger_path = None
    for p in paths:
        text = _read(p) or ""
        if _has_block(text, "intake_query"):
            interview_path = interview_path or p
        elif _has_block(text, "finding") or "Findings Ledger" in text or "Unresolved Questions" in text:
            ledger_path = ledger_path or p
    if interview_path is None and paths:
        interview_path = paths[0]
    return interview_path, ledger_path


def run(paths, strict=False):
    interview_path, ledger_path = resolve(paths)
    if not interview_path:
        return 2, ["intake-interview: no Intake Interview artifact found (need a "
                   "*_Intake_Interview_*.md or a file with apodictic:intake_query blocks)"]
    text = _read(interview_path)
    if text is None:
        return 2, ["intake-interview: cannot read %s" % interview_path]
    return interview(text, ledger_text=_read(ledger_path) if ledger_path else None, strict=strict)


# ---------------------------------------------------------------- self-test

def run_self_test():
    import json as _j
    rc = {"v": 0}

    def chk(name, cond):
        print("  %s: %s" % (name, "OK" if cond else "FAIL"))
        if not cond:
            rc["v"] = 1

    # non-UTF8 artifact: _read must degrade to the unreadable path (None), never
    # a traceback (the disposition_check adjacent-exception class, swept repo-wide)
    import tempfile as _tf
    _fd, _nu = _tf.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)

    def query(qid, kind="timeline-order", ambiguity_ref="", source_note="",
              current_inference="Non-linear ordering across Ch 4-6 reads as possibly unintentional.",
              confidence="LOW", question="Is the non-linear ordering in Chapters 4-6 deliberate?",
              treat_as_intended="Pass 2 assesses the braid as intended structure, on its own terms."):
        obj = {"schema": _SCHEMA_ID, "id": qid, "kind": kind, "current_inference": current_inference,
               "confidence": confidence, "question": question}
        if ambiguity_ref:
            obj["ambiguity_ref"] = ambiguity_ref
        if source_note:
            obj["source_note"] = source_note
        if treat_as_intended:
            obj["treat_as_intended"] = treat_as_intended
        return "<!-- apodictic:intake_query\n%s\n-->" % _j.dumps(obj)

    def finding(fid, conf="LOW", mech="non-linear ordering across chapters four to six may read as drift"):
        obj = {"schema": _FINDING_SCHEMA_ID, "id": fid, "mechanism": mech, "severity": "Should-Fix",
               "confidence": conf, "evidence_refs": ["Ch 4"], "fix_class": "x", "risk_if_fixed": "y"}
        return "<!-- apodictic:finding\n%s\n-->" % _j.dumps(obj)

    LEDGER = finding("F-P2-04")  # a structured ambiguity to reference (not pass-0/1, so not a W1 target)

    # clean: ref-grounded query against a resolving finding
    chk("clean_ref_grounded", interview(query("IQ-01", ambiguity_ref="F-P2-04"), LEDGER)[0] == 0)
    # regression: a non-hashable ledger id must not crash the authoritative-ID index (fid_key SSoT)
    chk("ledger_index_nonhashable_id_no_crash",
        isinstance(ledger_index("<!-- apodictic:finding\n" + _j.dumps(
            {"schema": "apodictic.finding.v1", "id": [1, 2], "severity": "Must-Fix", "mechanism": "m"})
            + "\n-->"), dict))
    # clean: source_note-grounded query (no ledger needed)
    chk("clean_note_grounded",
        interview(query("IQ-01", source_note="Pass 0 flagged a tonal shift at the Ch 7 break."))[0] == 0)

    # I1 — schema
    chk("i1_bad_kind", interview(query("IQ-01", kind="contract", source_note="x"))[0] == 1)
    chk("i1_bad_confidence", interview(query("IQ-01", confidence="SURE", source_note="x"))[0] == 1)
    chk("i1_bad_id", interview(query("IQ-1", source_note="x"))[0] == 1)
    chk("i1_missing_question",
        interview(query("IQ-01", source_note="x").replace('"question"', '"q"'))[0] == 1)
    code, lines = interview('<!-- apodictic:intake_query\n{"schema":"apodictic.intake_query.v1"\n-->')
    chk("i1_bad_json", code == 1 and any("I1 schema" in ln for ln in lines))
    code, lines = interview(query("IQ-01", source_note="x") + "\n" + query("IQ-01", source_note="y"))
    chk("i1_duplicate_id", code == 1 and any("appears 2 times" in ln for ln in lines))

    # I2 — contract duplication (advisory; ERROR --strict; override)
    code, lines = interview(query("IQ-01", source_note="x", question="What is the controlling idea of the book?"))
    chk("i2_contract_dup_advisory", code == 0 and any("I2 no contract dup" in ln for ln in lines))
    chk("i2_contract_dup_strict_fails",
        interview(query("IQ-01", source_note="x", question="Who is this book for?"), strict=True)[0] == 1)
    ov = "<!-- override: intake-dup IQ-01 — quoting the author's own framing, not re-asking -->\n"
    chk("i2_override_silences",
        not any("I2" in ln for ln in interview(ov + query("IQ-01", source_note="x", question="what genre is this?"))[1]))
    # code-span decoy (bypass closed by the SSoT migration): an override quoted inside a code span is a
    # documentation example, not a live directive — I2 must still fire (use a proven contract-dup question).
    dup_q = query("IQ-01", source_note="x", question="What is the controlling idea of the book?")
    chk("i2_inline_codespan_override_does_not_silence",
        any("I2" in ln for ln in interview("`" + ov.strip() + "`\n" + dup_q)[1]))
    chk("i2_fenced_codespan_override_does_not_silence",
        any("I2" in ln for ln in interview("```\n" + ov.strip() + "\n```\n" + dup_q)[1]))

    # I3 — grounded ambiguity (ERROR)
    code, lines = interview(query("IQ-01"))  # neither ref nor note
    chk("i3_manufactured", code == 1 and any("manufactured" in ln for ln in lines))
    code, lines = interview(query("IQ-01", ambiguity_ref="F-P9-99"), LEDGER)  # dangling ref
    chk("i3_dangling_ref", code == 1 and any("dangling reference" in ln for ln in lines))
    # a dangling ref is an error even if a source_note is also present (the broken ref must be fixed)
    chk("i3_dangling_ref_with_note",
        interview(query("IQ-01", ambiguity_ref="F-P9-99", source_note="x"), LEDGER)[0] == 1)

    # I4 — calibrate, not suppress (ERROR by default)
    code, lines = interview(query("IQ-01", source_note="x",
                                  treat_as_intended="suppress the timeline-drift flag for Ch 4-6"))
    chk("i4_suppress_errors", code == 1 and any("I4 calibrate-not-suppress" in ln for ln in lines))
    chk("i4_dont_raise_errors",
        interview(query("IQ-01", source_note="x", treat_as_intended="don't raise the finding"))[0] == 1)
    chk("i4_drop_flag_errors",
        interview(query("IQ-01", source_note="x", treat_as_intended="drop the flag entirely"))[0] == 1)
    # legitimate calibration phrasing does not trip I4
    chk("i4_assess_on_terms_clean",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="assess the braid on its own terms as intended structure"))[0] == 0)
    # the spec's own recommended good phrasing (a NEGATED suppression mention) must NOT trip I4
    chk("i4_negated_suppress_clean",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="Pass 2 assesses the braid as intended structure, on its "
                        "own terms (it does not pre-suppress any finding)."))[0] == 0)
    # negated calibration phrasing at ARBITRARY distance / across commas+parens must stay clean
    for phrase in (
        "This calibrates the lens only; we do not, under any circumstances, suppress the finding.",
        "this does not mean we should drop the finding",
        "never instruct analysis to drop the finding",
        "won't ever ask Pass 2 to suppress the flag",
        "the answer must not (per I4) suppress the flag",
        "assess it on its own terms without suppressing the finding",
        # Codex P2: an adverb ("ever") or an -ly adverb ("routinely") before a parenthetical must NOT
        # pull the negation off the suppression — these stay exempt (a negated statement, not a directive).
        "we do not ever, under any circumstances, suppress the finding.",
        "we do not routinely, as a matter of policy, drop the finding"):
        chk("i4_negated_clean::%s" % phrase[:22],
            interview(query("IQ-01", source_note="x", treat_as_intended=phrase))[0] == 0)
    # but a real directive in a LATER clause still fires (negation in a prior clause doesn't cover it)
    chk("i4_directive_after_negated_clause_fires",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="we never overstate things. Suppress the timeline flag."))[0] == 1)
    # comma-coordinated (not period-separated): the negator governs the FIRST imperative; the
    # trailing suppression is still an un-negated directive and must fire (Codex P1).
    chk("i4_comma_coordinated_directive_fires",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="Do not assess it on its own terms, suppress the finding."))[0] == 1)
    # a FRONTED negated NON-verbal phrase ("Not as a calibration, ...") — the negator governs the
    # fronted phrase, the trailing suppression is still a directive and must fire (Codex P1, 3rd pass)
    chk("i4_fronted_negated_phrase_fires",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="Not as a calibration, suppress the finding."))[0] == 1)
    # comma-LESS coordinated directive: the negator governs the EARLIER predicate, a coordinating
    # conjunction (and/but/yet/or/then) introduces a fresh un-negated suppression directive — the
    # negation does NOT reach across the coordinated clause, so each of these must fire (Codex P2,
    # the comma-less sibling of i4_comma_coordinated_directive_fires).
    for phrase in (
        "do not exaggerate but suppress the flag",
        "never overstate severity and suppress the finding",
        "never inflate the issue yet suppress the finding",
        "do not calibrate it or drop the flag",
        "do not assess it on its own terms then suppress the finding"):
        chk("i4_comma_less_coordinated_fires::%s" % phrase[:22],
            interview(query("IQ-01", source_note="x", treat_as_intended=phrase))[0] == 1)
    # Codex P2 (comma-less coordinated ADVERBIAL — the inverse of the case above): a coordinating
    # conjunction that joins ADVERBS modifying the single suppression verb ("now OR ever suppress")
    # is NOT a fresh predicate, so the negation still scopes and these legitimate non-suppression
    # statements must stay EXEMPT. Pre-fix, _COORD_CONJ_RE saw `or`/`and`, dropped the negation, and
    # I4 false-fired.
    for phrase in (
        "we do not now or ever suppress the finding",
        "we will never now or ever suppress the finding",
        "do not ever or always suppress the finding",
        "we do not routinely or deliberately drop the flag",
        "we do not now and never will suppress the finding",
        # adversarial pass (h124, final sweep): a non-`-ly` temporal adverb coordinated with another
        # ("now or LATER", "sooner or later") must be transparent like "now or ever" — pre-fix `later`
        # was neither in _NEG_ADVERB nor `-ly`, so the head read as a fresh predicate and false-FIRED.
        "we cannot now or later suppress the finding",
        "we will not sooner or later suppress the finding",
        "we do not now or soon suppress the finding"):
        chk("i4_comma_less_coordinated_adverbial_clean::%s" % phrase[:22],
            interview(query("IQ-01", source_note="x", treat_as_intended=phrase))[0] == 0)
    # adversarial pass (h124): an em/en dash is a clause boundary like ".;:" — a negated predicate
    # before the dash does NOT scope a fresh suppression directive after it, so each must FIRE.
    # Pre-fix the dash was not in _CLAUSE_BOUNDARY, so the whole "do not … — suppress" was one clause
    # and the negator falsely scoped the trailing directive (false-EXEMPT, the 4th negation sibling).
    for phrase in (
        "we do not calibrate lightly — suppress the finding.",
        "we do not calibrate lightly – suppress the finding.",
        "do not overstate it — drop the flag"):
        chk("i4_dash_boundary_directive_fires::%s" % phrase[:22],
            interview(query("IQ-01", source_note="x", treat_as_intended=phrase))[0] == 1)
    # adversarial pass (h124): a FRONTED ADVERBIAL phrase that runs straight into the directive after
    # a SINGLE comma ("Not now, suppress") is a fresh imperative, NOT a bracketed parenthetical — the
    # negation does not reach across it, so it must FIRE. The exempt parenthetical case is "not,
    # <adverbial>, suppress" (the interruption OPENS and CLOSES, verb resumes after a 2nd comma).
    # Pre-fix, "adverb-only pre-comma always scopes" wrongly EXEMPTED this fronted directive.
    for phrase in (
        "Not now, suppress the finding.",
        "Never again, drop the flag.",
        "Not yet, suppress the finding."):
        chk("i4_fronted_adverbial_directive_fires::%s" % phrase[:22],
            interview(query("IQ-01", source_note="x", treat_as_intended=phrase))[0] == 1)
    # adversarial pass (h124, self-review hole): a comma-bracketed segment that is itself a PREDICATE
    # ("Not now, calibrate carefully, suppress") is a fresh imperative, NOT an adverbial interruption,
    # so the negation does not reach the trailing directive and it must FIRE. (The exempt parenthetical
    # is a MODIFIER between the commas — "under any circumstances" / "deliberately and routinely".) A
    # too-weak "just look for a 2nd comma" guard would have false-EXEMPTED this — the SHALLOW-CHECK trap.
    chk("i4_predicate_between_commas_fires",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="Not now, calibrate carefully, suppress the finding"))[0] == 1)
    # …while a genuine MODIFIER between the commas (adverbial OR prepositional set-phrase) keeps the
    # negation scoping and stays EXEMPT.
    for phrase in (
        "we do not, deliberately and routinely, suppress the finding",
        "we do not, as a matter of policy, drop the finding",
        "we cannot, in good conscience, suppress the finding",
        "we do not, in any case, under any circumstances, suppress the finding"):
        chk("i4_bracketed_modifier_clean::%s" % phrase[:22],
            interview(query("IQ-01", source_note="x", treat_as_intended=phrase))[0] == 0)
    # adversarial pass (h124, Codex P1 — the sibling the "first valid parenthetical exempts" fix
    # missed): with MULTIPLE comma-bracketed segments, a PREDICATE after a *valid* modifier still pulls
    # the negation off the verb, so it must FIRE. Pre-fix the scope check returned on the first enclosed
    # modifier ("in any case") and never inspected the later predicate ("calibrate carefully").
    for phrase in (
        "we do not, in any case, calibrate carefully, suppress the finding",     # Codex's exact repro
        "we do not, under any circumstances, assess it on its own terms, suppress the finding",
        "we cannot, as a matter of policy, weigh it leniently, drop the flag",
        "do not, in good conscience, reread the passage, suppress the finding"):
        chk("i4_predicate_after_parenthetical_fires::%s" % phrase[:18],
            interview(query("IQ-01", source_note="x", treat_as_intended=phrase))[0] == 1)
    # …and the converse must hold: two or more STACKED genuine modifiers (no predicate) keep the
    # negation scoping and stay EXEMPT — the fix must not over-correct multi-parenthetical exemptions.
    for phrase in (
        "we do not, in any case, under any circumstances, deliberately suppress the finding",
        "we do not, as a matter of policy, in good conscience, drop the finding"):
        chk("i4_stacked_modifiers_clean::%s" % phrase[:18],
            interview(query("IQ-01", source_note="x", treat_as_intended=phrase))[0] == 0)
    # adversarial pass (h124): a UNICODE comma (fullwidth/Arabic/…) must engage the same
    # parenthetical-vs-fronted logic as an ASCII comma. Pre-fix, span.find(",") never saw a fullwidth
    # comma, so EVERY directive written with one fell to the comma-less branch and false-EXEMPTED.
    chk("i4_unicode_comma_fronted_fires",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="Not now, suppress the finding."))[0] == 1)
    chk("i4_unicode_comma_coordinated_fires",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="Do not assess it, suppress the finding"))[0] == 1)
    chk("i4_unicode_comma_parenthetical_clean",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="we do not, under any circumstances, suppress the finding"))[0] == 0)
    # adversarial pass (h124): "neither … nor suppress / neither suppress nor drop" is genuinely
    # negated (does NOT suppress) — `neither`/`nor` are negators in the same family as not/never and
    # must EXEMPT. Pre-fix they were not in _NEGATOR_RE, so the suppression match false-FIRED.
    for phrase in (
        "we neither calibrate nor suppress the finding",
        "neither suppress nor drop the finding",
        "the analysis will neither inflate nor suppress the finding"):
        chk("i4_neither_nor_clean::%s" % phrase[:22],
            interview(query("IQ-01", source_note="x", treat_as_intended=phrase))[0] == 0)
    # …but `neither`/`nor` must not over-exempt ACROSS a clause boundary: a real directive in a later
    # clause still fires even when an earlier clause uses nor.
    chk("i4_neither_nor_cross_clause_directive_fires",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="we will not calibrate. Nor will we hesitate; suppress the finding."))[0] == 1)
    # a bare passing mention of the word "suppress" (no object) does not fire
    chk("i4_bare_mention_clean",
        interview(query("IQ-01", source_note="x",
                        treat_as_intended="the author worried this might suppress reader sympathy; "
                        "assess the arc on its own terms"))[0] == 0)

    # W1 — coverage (Pass-0/1 LOW finding + UQ; advisory)
    P01_LEDGER = (finding("F-P1-02", mech="the prologue's register sits oddly against the body's voice")
                  + "\n\n### Unresolved Questions\n\n- Does the framing device in Chapter 1 read as deliberate or as an artifact of an earlier draft?\n")
    code, lines = interview(query("IQ-01", source_note="unrelated note about something else entirely zzz"), P01_LEDGER)
    chk("w1_uncovered_finding_and_uq", code == 0
        and any("W1 coverage" in ln and "F-P1-02" in ln for ln in lines)
        and any("W1 coverage" in ln and "Unresolved" in ln for ln in lines))
    chk("w1_uncovered_strict_fails",
        interview(query("IQ-01", source_note="unrelated zzz"), P01_LEDGER, strict=True)[0] == 1)
    # covering both the finding and the UQ clears W1
    covered = (query("IQ-01", ambiguity_ref="F-P1-02", kind="register-straddle",
                     question="Is the prologue's register deliberately set against the body's voice?")
               + "\n" + query("IQ-02", kind="structural-device", source_note="the Chapter 1 framing device",
                              current_inference="The Chapter 1 framing device may be an earlier-draft artifact.",
                              question="Is the framing device in Chapter 1 deliberate or a leftover from an earlier draft?"))
    chk("w1_full_coverage_clean", not any("W1" in ln for ln in interview(covered, P01_LEDGER)[1]))
    # a later-pass (non-0/1) LOW finding is NOT a W1 target
    chk("w1_later_pass_finding_not_flagged",
        not any("W1 coverage" in ln and "F-P5" in ln
                for ln in interview(query("IQ-01", source_note="x"), finding("F-P5-09"))[1]))

    # no blocks -> no-op
    chk("no_queries_noop", interview("# Notes\nnothing structured\n")[0] == 0)

    # run-folder + explicit-file resolution
    import tempfile
    import shutil
    d = tempfile.mkdtemp()
    try:
        ip = os.path.join(d, "Proj_Intake_Interview_run.md")
        with open(ip, "w", encoding="utf-8", newline="") as fh:
            fh.write("# Intake Interview\n" + query("IQ-01", ambiguity_ref="F-P2-04") + "\n")
        lp = os.path.join(d, "Proj_Findings_Ledger_run.md")
        with open(lp, "w", encoding="utf-8", newline="") as fh:
            fh.write("# Findings Ledger\n" + LEDGER + "\n")
        chk("run_folder_resolution", run([d])[0] == 0)
        chk("explicit_files_resolution", run([ip, lp])[0] == 0)
        chk("explicit_files_order_independent", run([lp, ip])[0] == 0)
        chk("missing_artifact_usage", run([os.path.join(d, "nope.md")])[0] == 2)
        # a Ledger that NAMES apodictic:intake_query in prose must not be misread as the interview,
        # masking a real violation (resolver classifies on parsed blocks, not raw substring)
        lp2 = os.path.join(d, "Proj_Findings_Ledger_prose.md")
        with open(lp2, "w", encoding="utf-8", newline="") as fh:
            fh.write("# Findings Ledger\nThis ledger feeds the apodictic:intake_query loop.\n" + LEDGER + "\n")
        ip2 = os.path.join(d, "Proj_Intake_Interview_viol.md")
        with open(ip2, "w", encoding="utf-8", newline="") as fh:
            fh.write("# Intake Interview\n" + query("IQ-01", ambiguity_ref="F-P2-04",
                     treat_as_intended="suppress the timeline flag") + "\n")
        chk("resolver_prose_mention_ledger_first", run([lp2, ip2])[0] == 1)  # I4 still caught
        chk("resolver_prose_mention_interview_first", run([ip2, lp2])[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 != "intake-interview"]
    strict = "--strict" in args
    paths = [a for a in args if not a.startswith("--")]
    if not paths:
        print("Usage: intake_interview.py intake-interview <run_folder|files...> [--strict] | --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: 1de85356d2cdc9e2d02320a080f97f410ed11dab3eb6d3a27e68e87ca003992d