Skip to content
Cockpit0.29.0
A snapshot: read at commit 707a04c7ce8 on 2026-10-18T00:06:01Z by majordomus 0.29.0. Nothing on this page is read again. See it live

lib/knowledge.sh

An object of kind implementation

What it is

URI
majordomus://implementation/lib/knowledge.sh
Kind
implementation
Identity
lib/knowledge.sh
Path
lib/knowledge.sh
Provenance
declared
Media type
text/plain
As a capability
implementation.lib/knowledge.sh

Front matter

{}

Content

#!/usr/bin/env bash
# shellcheck disable=SC2034  # MJ_DOCTRINE_SKIPPED is read by the dispatcher in doctrine.sh
# sourced by several commands; guard against re-sourcing
[ -n "${MJ_LIB_knowledge:-}" ] && return 0 || MJ_LIB_knowledge=1
# knowledge — a compiler over what this repository already states.
#
# It is not a wiki, not a database, not a memory service, and not a second place to write
# things down. Every source it reads is a file somebody already maintains; everything it
# produces is derived and regenerable, and none of it outranks the file it came from.
#
# Which files are sources is declared twice, by two owners: the repository declares its
# shared knowledge in its AI layer, the tool declares the operational records it writes.
#
# This file implements discovery, extraction and the graph over the sources, and — since
# ADR 0091 — the one writer the kind has: the deriver that turns the ledger lines an
# episode stamped into candidate records under the tracked tree, the acts that promote or
# reject a candidate, and the check that every record is evidenced and well-formed.
#
# Discovery answers one question: which files are knowledge sources, in which class, at
# which content hash. It answers it from the version-control index rather than from a
# filesystem walk, because a walk returns build output, vendored trees and untracked files,
# and returns them in an order that differs between two machines.

MJ_KSRC_FLAT=""

# Two declarations, one list. The repository's AI layer declares the shared sources — the
# tracked files that are repository knowledge — under its knowledge section; the tool ships
# the operational classes, the records it writes itself under the state directory. The
# repository's classes come first, and the scope is decided by which file declared a class:
# shared for the repository's, operational for the tool's. Neither file names the other.
mj_knowledge_repo_sources()  { printf '%s' "$MJ_KNOWLEDGE_DIR/sources.yaml"; }
mj_knowledge_state_sources() { printf '%s' "$MJ_SHARE_DIR/knowledge-sources.yaml"; }

mj_ksrc_load() {
  [ -n "$MJ_KSRC_FLAT" ] && [ -f "$MJ_KSRC_FLAT" ] && return 0
  local f n=0 tmp
  MJ_KSRC_FLAT="$(mktemp "${TMPDIR:-/tmp}/mj.ksrc.XXXXXX")"
  tmp="$(mktemp "${TMPDIR:-/tmp}/mj.ksr.XXXXXX")"
  for f in "$(mj_knowledge_repo_sources)" "$(mj_knowledge_state_sources)"; do
    [ -f "$f" ] || { [ "$f" = "$(mj_knowledge_state_sources)" ] && mj_die "$MJ_EX_INTERNAL" "knowledge source list missing: $f"; continue; }
    mj_yaml_flatten "$f" > "$tmp" 2>/dev/null || mj_die "$MJ_EX_CONTRACT" "knowledge source list does not parse: $(mj_rel "$f")"
    [ "$(mj_yget "$tmp" version)" = 1 ] || mj_die "$MJ_EX_CONTRACT" "knowledge source list version must be 1: $(mj_rel "$f")"
    # renumber this file's classes after the ones already loaded, and stamp the scope
    local scope=operational i=0
    [ "$f" = "$(mj_knowledge_repo_sources)" ] && scope=shared
    while [ -n "$(mj_yget "$tmp" "sources.$i.id")" ]; do
      sed -n "s/^sources\.$i\./sources.$n./p" "$tmp" >> "$MJ_KSRC_FLAT"
      printf 'sources.%s.scope=%s\n' "$n" "$scope" >> "$MJ_KSRC_FLAT"
      i=$((i + 1)); n=$((n + 1))
    done
  done
  rm -f "$tmp"
  # the classes become variables once; every field read below is an expansion
  mj_yload "$MJ_KSRC_FLAT" ksrc; MJ_KSRC_COUNT="$n"
  return 0
}
MJ_KSRC_COUNT=0
mj_ksrc()       { mj_yv ksrc "sources.$1.$2"; }
mj_ksrc_count() { printf '%s' "$MJ_KSRC_COUNT"; }

# ---------------------------------------------------------------- discovery
# Prints one tab-separated row per discovered source:
#   class <TAB> scope <TAB> kind <TAB> sha256 <TAB> repository-relative path
#
# Order is the class order of the source list, then path order within a class. Both are
# deterministic and neither depends on the filesystem: the index is already sorted, and
# the state listings are sorted here under the C collation so that two machines with
# different locales agree.
#
# Sets MJ_KDISC_EMPTY to the space-separated ids of required classes that found nothing.
MJ_KDISC_EMPTY=""
MJ_KDISC_COUNT=0
# The tracked sources the working tree no longer holds, one path per line. Each is a node
# that would have been and is not, so it is reported rather than skipped in silence (I0812).
MJ_KDISC_GONE=""
mj_knowledge_discover() {
  local want_scope="${1:-all}" i n cls kind scope disc spec req found
  mj_ksrc_load
  MJ_KDISC_EMPTY=""; MJ_KDISC_COUNT=0; MJ_KDISC_GONE=""
  # every class lists its files into one buffer; one hashing process then covers all of
  # them, and the rows come out in the order the classes produced them
  local buf; buf="$(mktemp "${TMPDIR:-/tmp}/mj.kdb.XXXXXX")"
  mj_kdisc_collect "$want_scope" > "$buf" || { rm -f "$buf"; return "$MJ_EX_INTERNAL"; }
  if [ -s "$buf" ]; then
    awk -F'\t' '{ printf "%s%c", $4, 0 }' "$buf" | mj_sha256_many > "$buf.hash"
    awk -F'\t' 'NR == FNR { h[$2] = $1; next } { printf "%s\t%s\t%s\t%s\t%s\n", $1, $2, $3, h[$4], $5 }' "$buf.hash" "$buf"
  fi
  rm -f "$buf" "$buf.hash"
  return 0
}
mj_kdisc_collect() {
  local want_scope="$1" i n cls kind scope disc spec req found
  n="$(mj_ksrc_count)"; i=0
  while [ "$i" -lt "$n" ]; do
    cls="$(mj_ksrc "$i" id)"; kind="$(mj_ksrc "$i" kind)"; scope="$(mj_ksrc "$i" scope)"
    disc="$(mj_ksrc "$i" discovery)"; spec="$(mj_ksrc "$i" pathspec)"; req="$(mj_ksrc "$i" required)"
    i=$((i + 1))
    case "$want_scope" in
      all) ;;
      "$scope") ;;
      *) continue ;;
    esac
    found=0
    case "$disc" in
      vcs)   mj_kdisc_vcs   "$cls" "$kind" "$scope" "$spec" && found=1 ;;
      state) mj_kdisc_state "$cls" "$kind" "$scope" "$spec" && found=1 ;;
      *) mj_err "knowledge: source class '$cls' declares unknown discovery '$disc'"; return "$MJ_EX_INTERNAL" ;;
    esac
    # A required class that finds nothing is a finding, not a silence: the whole point of a
    # curated list is that a path can be forgotten, and a forgotten path looks exactly like
    # a repository that does not have that file.
    [ "$found" = 0 ] && [ "$req" = true ] && MJ_KDISC_EMPTY="$MJ_KDISC_EMPTY $cls"
  done
  MJ_KDISC_EMPTY="${MJ_KDISC_EMPTY# }"
  return 0
}

# One row per tracked file matching a pathspec. Read NUL-delimited, because a pathname may
# contain a space, a newline or a non-ASCII character, and the historical way to lose one
# is to let the shell split on whitespace.
mj_kdisc_vcs() {
  local cls="$1" kind="$2" scope="$3" spec="$4" p any=0
  while IFS= read -r -d '' p; do
    [ -n "$p" ] || continue
    # tracked but deleted in the working tree: no node, and a finding that says so
    [ -f "$MJ_ROOT/$p" ] || { MJ_KDISC_GONE="$MJ_KDISC_GONE$p"$'\n'; continue; }
    printf '%s\t%s\t%s\t%s\t%s\n' "$cls" "$scope" "$kind" "$MJ_ROOT/$p" "$p"
    MJ_KDISC_COUNT=$((MJ_KDISC_COUNT + 1)); any=1
  done < <(mj_git ls-files -z -- "$spec" 2>/dev/null)
  [ "$any" = 1 ]
}

# One row per file in a state directory, or the single file itself. Operational records are
# discovered here rather than from the index because the state directory is never tracked;
# the pathspec is relative to it, and nothing else on the filesystem is looked at.
mj_kdisc_state() {
  local cls="$1" kind="$2" scope="$3" spec="$4" abs f any=0 rel
  abs="$MJ_STATE_DIR/$spec"; rel="$(mj_rel "$MJ_STATE_DIR")/$spec"
  if [ -f "$abs" ]; then
    printf '%s\t%s\t%s\t%s\t%s\n' "$cls" "$scope" "$kind" "$abs" "$rel"
    MJ_KDISC_COUNT=$((MJ_KDISC_COUNT + 1))
    return 0
  fi
  [ -d "$abs" ] || return 1
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    [ -f "$abs/$f" ] || continue
    printf '%s\t%s\t%s\t%s\t%s\n' "$cls" "$scope" "$kind" "$abs/$f" "$rel/$f"
    MJ_KDISC_COUNT=$((MJ_KDISC_COUNT + 1)); any=1
  done < <(ls -1 "$abs" 2>/dev/null | LC_ALL=C sort)
  [ "$any" = 1 ]
}

# ---------------------------------------------------------------- observe
# What a session met, as one typed ledger line (ADR 0118). An act like `decision add`: the
# worker states it, the tool checks its shape and records it, and nothing is judged here —
# the episode boundary derives the candidate. Every refusal writes nothing.
MJ_KNOWLEDGE_OBSERVATION_KINDS='friction workaround defect drift repetition'

mj_knowledge_observe_cmd() {
  local kind="" subject="" statement="" evidence="" ev_json="" ref task_id=none
  while [ $# -gt 0 ]; do case "$1" in
    --kind) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--kind needs a value"; kind="$2"; shift 2 ;;
    --kind=*) kind="${1#--kind=}"; shift ;;
    --subject) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--subject needs a value"; subject="$2"; shift 2 ;;
    --subject=*) subject="${1#--subject=}"; shift ;;
    --evidence) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--evidence needs a value"
                evidence="$evidence$2"$'\n'; shift 2 ;;
    --evidence=*) evidence="$evidence${1#--evidence=}"$'\n'; shift ;;
    -*) mj_die "$MJ_EX_USAGE" "knowledge observe: unknown option $1" ;;
    *) [ -z "$statement" ] || mj_die "$MJ_EX_USAGE" "knowledge observe: the statement must be one argument (quote it)"
       statement="$1"; shift ;;
  esac; done
  case " $MJ_KNOWLEDGE_OBSERVATION_KINDS " in
    *" $kind "*) [ -n "$kind" ] || mj_die "$MJ_EX_USAGE" "knowledge observe: --kind is required ($MJ_KNOWLEDGE_OBSERVATION_KINDS)" ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge observe: --kind must be one of: $MJ_KNOWLEDGE_OBSERVATION_KINDS (got '$kind')" ;;
  esac
  [ -n "$subject" ] || mj_die "$MJ_EX_USAGE" "knowledge observe: --subject is required: a repository path, or capability:|rule:|command:|gate:<id>"
  mj_knowledge_subject_ok "$subject" \
    || mj_die "$MJ_EX_USAGE" "knowledge observe: '$subject' is not a subject: a repository-relative path without '..', or capability:|rule:|command:|gate: followed by an identifier"
  [ -n "$statement" ] || mj_die "$MJ_EX_USAGE" "knowledge observe: the statement is required, as one quoted argument"
  mj_is_multiline "$statement$subject" && mj_die "$MJ_EX_USAGE" "knowledge observe: the statement and the subject must be single-line"
  printf '%s\n' "$statement" | grep -Eq "$MJ_KNOWLEDGE_TRANSCRIPT" \
    && mj_die "$MJ_EX_USAGE" "knowledge observe: the statement opens like a turn of a conversation; an observation is an assertion, never a transcript"
  while IFS= read -r ref; do
    [ -n "$ref" ] || continue
    printf '%s' "$ref" | grep -Eq '^(file|test|commit|issue|claim):[^[:space:]]+$' \
      || mj_die "$MJ_EX_USAGE" "knowledge observe: evidence '$ref' is not a reference (file:|test:|commit:|issue:|claim: followed by what it names)"
    ev_json="$ev_json${ev_json:+,}\"$(mj_json_esc "$ref")\""
  done <<EOF_EV
$evidence
EOF_EV
  mj_require_installed
  # The deriver selects an observation by its episode stamp, and the ledger stamps a line only
  # while an episode is open: an observation without one would be written and never derived.
  [ -n "$(mj_open_session_id)" ] \
    || mj_die "$MJ_EX_CONTRACT" "knowledge observe: no episode is open in this checkout, so the observation would never be derived; open one first: majordomus session start"
  mj_load_current 2>/dev/null && [ "$(mj_cur outcome)" = active ] && task_id="$(mj_cur id)"
  mj_ledger_append observation.recorded \
    "\"task_id\":\"$task_id\",\"kind\":\"$kind\",\"subject\":\"$(mj_json_esc "$subject")\",\"statement\":\"$(mj_json_esc "$statement")\",\"evidence\":[$ev_json]"
  printf 'observed: %s %s — %s\n' "$kind" "$subject" "$statement"
}

# Which owner should look first at an observation about $1, from its structure alone, in
# ADR 0118's order. The tool's own share/ is the platform only when it lies inside this
# repository — the tool's own source tree; in a repository that adopted the tool, share/ is
# the project's own and the tool's lives outside the checkout. Prints the route.
mj_knowledge_route() {
  local subject="$1" share root
  case "$subject" in
    command:*|capability:*|rule:majordomus.*) printf platform; return 0 ;;
    rule:*|gate:*) printf enforcement; return 0 ;;
  esac
  case "$subject" in "$(mj_rel "$MJ_RULES_DIR")/vendor/"*) printf platform; return 0 ;; esac
  share="$(cd "$MJ_SHARE_DIR" 2>/dev/null && pwd -P)"; root="$(cd "$MJ_ROOT" && pwd -P)"
  if [ -n "$share" ]; then
    case "$share" in
      "$root"/*) case "$subject" in "${share#"$root"/}"/*|"${share#"$root"/}") printf platform; return 0 ;; esac ;;
    esac
  fi
  [ -n "${MJ_LIB_changed:-}" ] || . "$MJ_LIB_DIR/changed.sh"
  mj_changed_is_generated "$subject" && { printf generator; return 0; }
  case "$subject" in docs/*|docs) printf documentation; return 0 ;; esac
  printf project
}

# A subject is what an observation is about, in a form a route can be derived from without
# reading prose: a repository-relative path, or a typed identifier.
mj_knowledge_subject_ok() {
  case "$1" in
    capability:?*|rule:?*|command:?*|gate:?*)
      printf '%s' "${1#*:}" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/@-]*$' ;;
    /*|*..*|*:*) return 1 ;;
    *) printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._][A-Za-z0-9._/-]*$' ;;
  esac
}

# ---------------------------------------------------------------- command
mj_cmd_knowledge() {
  local sub="${1:-sources}"
  case "$sub" in
    --help|-h|help) mj_knowledge_usage; return 0 ;;
    sources|nodes|edges|search|show|related|derive|candidates|promote|reject|check|validate|observe) shift || true ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge: unknown subcommand '$sub' (see: majordomus knowledge --help)" ;;
  esac
  mj_require_installed
  case "$sub" in
    sources)    mj_knowledge_sources "$@" ;;
    nodes)      mj_knowledge_nodes_cmd "$@" ;;
    edges)      mj_knowledge_edges_cmd "$@" ;;
    search)     mj_knowledge_search_cmd "$@" ;;
    show)       mj_knowledge_show_cmd "$@" ;;
    related)    mj_knowledge_related_cmd "$@" ;;
    derive)     mj_knowledge_derive_cmd "$@" ;;
    candidates) mj_knowledge_candidates_cmd "$@" ;;
    promote)    mj_knowledge_promote_cmd "$@" ;;
    reject)     mj_knowledge_reject_cmd "$@" ;;
    check)      mj_knowledge_check_cmd "$@" ;;
    validate)   mj_knowledge_validate_cmd "$@" ;;
    observe)    mj_knowledge_observe_cmd "$@" ;;
  esac
}

mj_knowledge_usage() {
  cat <<H
usage: majordomus knowledge <subcommand> [options]

  sources [--scope shared|operational|all] [--json]
        the curated source classes and the files each one discovers    (read-only)
  nodes [--scope shared|operational|all] [--kind <k>] [--json]
        one node per canonical object, with its identity and kind      (read-only)
  edges [--scope ...] [--type <t>] [--json]
        one edge per stated relationship, with where it was observed   (read-only)
  search <query> [--scope ...] [--json]
        the nodes the query matches, best rung of the ladder first, each with the
        field it matched on: id, title, title-prefix, path, body        (read-only)
  show <id> [--scope ...] [--json]
        one node with its kind, source path and content hash           (read-only)
  related <id> [--scope ...] [--json]
        the edges into and out of one node, each with its provenance   (read-only)
  derive [--episode <id>] [--dry-run] [--json]
        write what one episode's ledger lines state into candidate records under
        the knowledge section; deterministic, idempotent, no model
  candidates [--json]
        the records awaiting review, with the branch of the episode each came from (read-only)
  promote <id> [--class <fact|convention|constraint|memory|lesson>] < evidence.md
        move a candidate to curated/ as verified; the evidence on stdin is required
  reject <id> --reason "<why>" [--by <id>]
        supersede a candidate in place, recording why and what replaces it
  check [--json]
        every record is schema-valid, evidenced and resolves what it names  (read-only)
  validate [--json]
        the graph itself: duplicate ids, edges with a missing end or no provenance,
        declared paths and tracked sources that no longer exist; each finding with
        its severity and whether the broken reference is owned or an author's (read-only)
  observe --kind <friction|workaround|defect|drift|repetition> --subject <ref>
          [--evidence <ref>]... "<one-line statement>"
        record what this session met: one observation.recorded line in the ledger, inside a
        task or outside one; the episode boundary turns it into a candidate (ADR 0118)
        subject: a repository path, or capability:|rule:|command:|gate:<id>
        evidence: file:|test:|commit:|issue:|claim:<ref>

  Discovery is driven by the version-control index for repository knowledge and by the
  state directories Majordomus owns for operational records. An untracked file is not a
  source. derive, promote and reject write under the knowledge section and append to the
  ledger; nothing is ever staged.

  scopes: shared knowledge may be projected to a public surface; operational records are
  this checkout's own and are never part of a shared projection. Default: all.

  The executable answers the read side over every surface: majordomus-cli knowledge
  candidates, knowledge record <id>, knowledge status.
H
}

mj_knowledge_sources() {
  local scope=all a
  while [ $# -gt 0 ]; do case "$1" in
    --scope) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--scope needs a value"; scope="$2"; shift 2 ;;
    --scope=*) scope="${1#--scope=}"; shift ;;
    --help|-h) mj_knowledge_usage; return 0 ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge sources: unknown option $1" ;;
  esac; done
  case "$scope" in shared|operational|all) ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge sources: --scope must be shared, operational or all" ;;
  esac

  local out; out="$(mktemp "${TMPDIR:-/tmp}/mj.kdi.XXXXXX")"
  mj_knowledge_discover "$scope" > "$out" || { rm -f "$out"; return "$MJ_EX_INTERNAL"; }

  if [ "$MJ_JSON" = 1 ]; then
    local first=1 c s k h p
    printf '{"schema":1,"scope":"%s","sources":[' "$scope"
    while IFS="$MJ_TAB" read -r c s k h p; do
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"class":"%s","scope":"%s","kind":"%s","hash":"%s","path":"%s"}' \
        "$c" "$s" "$k" "$h" "$(mj_json_esc "$p")"
    done < "$out"
    printf '],"empty_required":['
    first=1; for a in $MJ_KDISC_EMPTY; do [ "$first" = 1 ] || printf ','; printf '"%s"' "$a"; first=0; done
    printf ']}\n'
  else
    local c s k h p
    while IFS="$MJ_TAB" read -r c s k h p; do
      printf '%-11s %-11s %-10s %s  %s\n' "$c" "$s" "$k" "${h:0:12}" "$p"
    done < "$out"
    printf 'knowledge sources: %s file(s) in scope %s\n' "$(mj_lines "$out")" "$scope"
    for a in $MJ_KDISC_EMPTY; do
      mj_warn knowledge "$a" "required source class discovered nothing" "majordomus knowledge sources --json"
    done
  fi
  rm -f "$out"
  return 0
}

# ---------------------------------------------------------------- extraction
# Turn the discovered sources into the rows lib/knowledge.awk reads. This function does the
# reading; it decides nothing. What a node is, what its id is and what kind it carries are
# all settled in the awk, so there is one implementation of those semantics rather than one
# here and another in whatever calls it next.
#
# Structured sources are flattened through the same restricted YAML subset the policy and
# the project model use, so a file that this tool refuses everywhere else is refused here
# too rather than being parsed by a second, laxer reader.
mj_knowledge_rows() {
  local src="$1" cls scope kind hash path abs tab
  tab="$(printf '\t')"
  # Every tracked path, so that a link can be told apart three ways: it resolves to a node,
  # it resolves to a real file this compiler does not model, or it resolves to nothing. The
  # list comes from the index rather than from the filesystem, for the same reason discovery
  # does — repository truth, and no untracked file mistaken for a valid target.
  mj_git ls-files -z 2>/dev/null | tr '\0' '\n' | awk 'NF { printf "T\t%s\n", $0 }'
  # One source row per discovered file, then the content rows by kind, each kind's files
  # read by one process: the YAML kinds and the front-matter kinds flattened by one awk
  # each, the documents' headings and links by one awk, the line stores by one awk. The
  # extractor keys every content row by its path, so the grouping changes nothing it sees.
  local tmp; tmp="$(mktemp -d "${TMPDIR:-/tmp}/mj.kn.XXXXXX")"
  mkdir -p "$tmp/yaml" "$tmp/front"
  : > "$tmp/yaml.map"; : > "$tmp/front.map"; : > "$tmp/docs"; : > "$tmp/lines"
  # `claimed` holds the identities already emitted, as `<kind>:<path>`.
  local ny=0 nf=0 claimed=""
  while IFS="$tab" read -r cls scope kind hash path; do
    [ -n "$path" ] || continue
    abs="$MJ_ROOT/$path"
    # A section's README declares itself a context document, whatever class discovered it
    # (share/kinds.yaml: `declared: [context]`). It is read as the kind it declares, not as
    # an instance of the kind that lives beside it, which would be a node nobody meant.
    #
    # Two things had to be true for that sentence to hold, and neither was. The kind was set
    # to `document` — the opposite of "the kind it declares" — and both discovering classes
    # still produced a row, so one file arrived twice under one identity and the extractor
    # refused it: eleven `document:<path> claimed by <path> and by <path>` failures in this
    # repository, naming the same path on both sides because the claimants differed only in
    # the class nobody printed. The declaration decides the kind, and it decides it once.
    if mj_is_context_doc "$abs"; then
      # Scoped to the declared-kind case on purpose, and the scope is the whole argument.
      # Here the file itself resolves the ambiguity — it says `kind: context`, so a second
      # class discovering it adds no information and its row is dropped. Where nothing
      # declares anything, two classes claiming one path stay a refusal:
      # `test/cases/73_knowledge_nodes.sh` plants a second `kind: policy` class over
      # `.ai/repo/policy.yaml` and expects exit 10, because "a duplicate id is a defect, not
      # a last-one-wins merge". Generalising this dedupe to any same-kind pair was tried and
      # turned that case red — it would have deleted a promise rather than kept one.
      case "$claimed" in
        *"|context:$path|"*) continue ;;
      esac
      claimed="$claimed|context:$path|"
      kind=context
    fi
    printf 'S\t%s\t%s\t%s\t%s\t%s\n' "$cls" "$scope" "$kind" "$hash" "$path"
    case "$kind" in
      decision|question) printf '%s\n' "$abs" >> "$tmp/lines" ;;
      # a document is read for its heading and the links it makes. A context document is read
      # the same way: it is prose that carries a contract, and its heading is its title.
      document|context) printf '%s\n' "$abs" >> "$tmp/docs" ;;
      implementation|test) ;;
      # a knowledge record declares its identity and its provenance in front matter, so it
      # is read the way a rule or an ADR is: the node is knowledge:<id>, and every
      # derived_from and relation it states becomes an edge (ADR 0091). The section's
      # README is a context document, reclassified above, and is still read as prose.
      # A feature, a moment, an area and an audience are Markdown objects that declare their
      # identity in front matter, exactly as an ADR does; a feature also names what it is
      # made of there, and those references are its edges. A domain is the same shape.
      session|handover|checkpoint|prompt|rule|adr|skill|use-case|application|knowledge|feature|moment|area|audience|domain) nf=$((nf + 1)); printf '%s\t%s\n' "$nf" "$path" >> "$tmp/front.map"; printf '%s\n' "$abs" >> "$tmp/front.list" ;;
      policy|scope|profile|milestone|issue|claim|doctrine|deployment|intent|mesh-declaration|derive-offload|workspace|release-record) ny=$((ny + 1)); printf '%s\t%s\n' "$ny" "$path" >> "$tmp/yaml.map"; printf '%s\n' "$abs" >> "$tmp/yaml.list" ;;
      *) ;;   # a kind this reader has no rule for gets no content rows; the extractor says so once
    esac
  done < "$src"
  local group
  for group in yaml front; do
    [ -s "$tmp/$group.list" ] || continue
    set -- ; while IFS= read -r abs; do set -- "$@" "$abs"; done < "$tmp/$group.list"
    if [ "$group" = front ]; then mj_yaml_flatten_many "$tmp/front" --numbered --front "$@"
    else mj_yaml_flatten_many "$tmp/yaml" --numbered "$@"; fi
    # a file that does not parse has no rows and one warning; a record without front
    # matter is not an error here, its flat is simply empty, as mj_record_front left it
    awk -F'\t' -v map="$tmp/$group.map" -v errs="$tmp/$group/.errors" -v front="$([ "$group" = front ] && echo 1 || echo 0)" '
      BEGIN { while ((getline l < map) > 0) { split(l, a, "\t"); path[a[1]] = a[2] }; close(map)
              while ((getline l < errs) > 0) { split(l, a, "\t"); bad[a[1]] = a[2] }; close(errs)
              for (n in path) if (n in bad && !(front && bad[n] == "no front matter"))
                printf "X\tWARN\tunparsed_source\t%s\tdoes not parse as the restricted YAML subset; no node was extracted from it\n", path[n] }
      FNR == 1 { n = FILENAME; sub(/.*\//, "", n); p = path[n] }
      n in bad { next }
      { eq = index($0, "="); k = substr($0, 1, eq - 1); v = substr($0, eq + 1); gsub(/\t/, " ", v); printf "F\t%s\t%s\t%s\n", p, k, v }' \
      "$tmp/$group"/[0-9]*
  done
  if [ -s "$tmp/docs" ]; then
    set -- ; while IFS= read -r abs; do set -- "$@" "$abs"; done < "$tmp/docs"
    awk -v root="$MJ_ROOT/" '
      FNR == 1 { p = FILENAME; sub("^" root, "", p); fence = 0; seen = 0 }
      /^[ \t]*(```|~~~)/ { fence = !fence; next }
      fence { next }
      /^# / && !seen { t = substr($0, 3); gsub(/\t/, " ", t); printf "D\t%s\t%s\n", p, t; seen = 1 }
      {
        line = $0
        while (match(line, /\[[^]]*\]\([^)]+\)/)) {
          chunk = substr(line, RSTART, RLENGTH)
          line = substr(line, RSTART + RLENGTH)
          t = chunk
          sub(/^\[[^]]*\]\(/, "", t); sub(/\)$/, "", t)
          sub(/[ \t].*$/, "", t)              # a link title after the target
          sub(/#.*$/, "", t)                  # a fragment names a place in a file, not a file
          if (t == "") continue
          if (t ~ /^[a-zA-Z][a-zA-Z0-9+.-]*:/) continue   # any scheme
          if (t ~ /^\/\//) continue                       # protocol relative
          if (t ~ /^\//) continue                         # absolute: not a repository path
          gsub(/\t/, " ", t)
          printf "K\t%s\t%s\t%s\n", p, FNR, t
        }
      }' "$@"
  fi
  if [ -s "$tmp/lines" ]; then
    set -- ; while IFS= read -r abs; do set -- "$@" "$abs"; done < "$tmp/lines"
    awk -v root="$MJ_ROOT/" 'FNR == 1 { p = FILENAME; sub("^" root, "", p) } { gsub(/\t/, " "); printf "L\t%s\t%s\t%s\n", p, FNR, $0 }' "$@"
  fi
  rm -rf "$tmp"
}

# Inline links, and only inline links: `[text](target)` as the author wrote it.
#
# Fenced code is dropped first. A path inside a code sample is an example of a path, not a
# reference to one, and letting one become an edge fills the graph with relationships nobody
# asserted. Reference-style links and bare URLs are left alone: the first is not resolvable
# without a second pass over the file, and the second is not a repository reference.
#
# A target with a scheme, a protocol-relative target and a bare anchor are all skipped —
# none of them names a file in this repository. A fragment on a real path is trimmed,
# because `docs/CLI.md#session` is a reference to `docs/CLI.md`.
mj_knowledge_links() {
  local path="$1" file="$2"
  awk -v p="$path" '
    /^[ \t]*(```|~~~)/ { fence = !fence; next }
    fence { next }
    {
      line = $0
      while (match(line, /\[[^]]*\]\([^)]+\)/)) {
        chunk = substr(line, RSTART, RLENGTH)
        line = substr(line, RSTART + RLENGTH)
        t = chunk
        sub(/^\[[^]]*\]\(/, "", t); sub(/\)$/, "", t)
        sub(/[ \t].*$/, "", t)              # a link title after the target
        sub(/#.*$/, "", t)                  # a fragment names a place in a file, not a file
        if (t == "") continue
        if (t ~ /^[a-zA-Z][a-zA-Z0-9+.-]*:/) continue   # any scheme
        if (t ~ /^\/\//) continue                       # protocol relative
        if (t ~ /^\//) continue                         # absolute: not a repository path
        gsub(/\t/, " ", t)
        printf "K\t%s\t%s\t%s\n", p, NR, t
      }
    }' "$file"
}

# Flatten one file and emit its keys. A file that does not parse is reported and skipped:
# one malformed input must not cost the whole build, and it must not be silently absent
# either.
mj_knowledge_flat_rows() {
  local path="$1" file="$2" flat
  flat="$(mktemp "${TMPDIR:-/tmp}/mj.kn.XXXXXX")"
  if mj_yaml_flatten "$file" > "$flat" 2>/dev/null; then
    awk -F= -v p="$path" '{ k = $1; sub(/^[^=]*=/, "", $0); gsub(/\t/, " ", $0)
                            printf "F\t%s\t%s\t%s\n", p, k, $0 }' "$flat"
  else
    printf 'X\tWARN\tunparsed_source\t%s\tdoes not parse as the restricted YAML subset; no node was extracted from it\n' "$path"
  fi
  rm -f "$flat"
}

# The files the index refused, one `<path>\t<code>\t<message>` line each, as the index
# decided them. Returns 2, printing nothing, when the index could not be asked.
#
# Whether a file conforms to its kind's schema has one judge: the executable's index, which
# validates every discovered file against the schema its kind names and turns each failure
# into an error diagnostic instead of an object. This reader used to have no opinion at all
# — it read a curated note as prose, so a note the schema refuses (no `class`, or a `class`
# outside the closed enum) was a node here and nothing in the index, and the two readers of
# one layer disagreed about what the layer holds (ADR 0010). Teaching awk JSON Schema would
# have made a second judge that drifts from the first; asking the first is the move ADR 0035
# made for the lease, and the direction the repository takes shell automation in general.
#
# Never a build: an executable that is not there is not compiled for a read, as `context`
# does not. The caller says the schema went unchecked rather than implying it passed.
#
# Returns 3, printing nothing, when the caller's environment said not to ask:
# MAJORDOMUS_KNOWLEDGE_SCHEMA=unasked. Whether the index *could* be asked is a fact about
# the machine — an executable built, jq installed — and a record that must read the same
# on every machine (a use-case scenario's evidence, #713) cannot carry it. That environment
# decides the question for itself instead, as it decides reasoning's mode (ADR 0098), and
# the reader says the check was not asked for, which is neither a pass nor a machine's
# shortfall. Any other value, or none, asks the index.
mj_knowledge_refused() {
  [ "${MAJORDOMUS_KNOWLEDGE_SCHEMA:-index}" = unasked ] && return 3
  mj_has jq || return 2
  local bin share out
  # shellcheck source=rust_bin.sh
  . "$MJ_LIB_DIR/rust_bin.sh"
  bin="$(mj_rust_bin "$MJ_HOME")"
  [ -x "$bin" ] || return 2
  share="$(mj_rust_share "$MJ_HOME")"
  out="$( ( [ -z "$share" ] || export MAJORDOMUS_SHARE="$share"
            "$bin" run repository.info --repo "$MJ_ROOT" --format json ) 2>/dev/null )" || return 2
  printf '%s' "$out" | jq -r '
    .output.diagnostics[]? | select(.severity == "error" and (.path // "") != "")
    | [.path, .code, (.message | gsub("[\t\n]"; " "))] | @tsv' 2>/dev/null || return 2
}

# The node set, sorted. Sorting happens here rather than in the awk because awk has no
# portable sort, and it is done under the C collation so that two machines with different
# locales produce the same bytes.
#
# $2, when given, is a file naming the part of the graph a caller needs, one `kind:<kind>`
# or `path:<repository-relative path>` line each: every discovered source neither line
# names is dropped before anything is read. It narrows what is compiled, never what a
# kept source means — a kept file is extracted exactly as in the whole graph. `context`
# asks for its knowledge pointers this way (I0814), because compiling every source for a
# handful of pointers costs seconds on each briefing.
mj_knowledge_nodes() {
  local scope="${1:-all}" keep="${2:-}" disc rows refused rc
  disc="$(mktemp "${TMPDIR:-/tmp}/mj.kd.XXXXXX")"
  rows="$(mktemp "${TMPDIR:-/tmp}/mj.kr.XXXXXX")"
  refused="$(mktemp "${TMPDIR:-/tmp}/mj.kf.XXXXXX")"
  local t0
  t0="$(mj_phase_begin knowledge:discover)"
  mj_knowledge_discover "$scope" > "$disc" || { rm -f "$disc" "$rows" "$refused"; return "$MJ_EX_INTERNAL"; }
  if [ -n "$keep" ]; then
    awk -F'\t' 'NR == FNR { k[$0] = 1; next } (("kind:" $3) in k) || (("path:" $5) in k)' "$keep" "$disc" > "$disc.kept"
    mv "$disc.kept" "$disc"
  fi
  mj_phase_end knowledge:discover "$t0"; t0="$(mj_phase_begin knowledge:refused)"
  # A file the index refused is not a node here either: its discovery row is dropped and the
  # index's own verdict is reported against it, code and message as the index wrote them.
  rc=0; mj_knowledge_refused > "$refused" || rc=$?
  if [ "$rc" = 0 ] && [ -s "$refused" ]; then
    awk -F'\t' 'NR == FNR { r[$1] = 1; next } !($5 in r)' "$refused" "$disc" > "$disc.kept"
    mv "$disc.kept" "$disc"
  fi
  mj_phase_end knowledge:refused "$t0"; t0="$(mj_phase_begin knowledge:rows)"
  {
    if [ "$rc" = 0 ]; then
      # A tracked source the working tree no longer holds is one fact, reported once below
      # as source_missing; the index's "unreadable" verdict on the same path is that fact
      # again, so it is not repeated as a failure.
      # (The list goes in through ENVIRON: it may be empty, and an empty first file
      # would make NR == FNR true of the second.)
      MJ_KG="$MJ_KDISC_GONE" awk -F'\t' 'BEGIN { n = split(ENVIRON["MJ_KG"], g, "\n"); for (i = 1; i <= n; i++) if (g[i] != "") gone[g[i]] = 1 }
        !($1 in gone) { printf "X\tFAIL\trefused_source\t%s\tthe index refused it (%s): %s; no node was extracted from it\n", $1, $2, $3 }' "$refused"
    elif [ "$rc" = 3 ]; then
      printf 'X\tINFO\tschema_unasked\t.\tthe schema check was not asked for (MAJORDOMUS_KNOWLEDGE_SCHEMA=unasked), so no file was checked against the schema of its kind\n'
    else
      printf 'X\tWARN\tschema_unchecked\t.\tthe index could not be asked (no executable built, or no jq), so no file was checked against the schema of its kind; run bin/majordomus-cli once to build it\n'
    fi
    # A source git tracks and the working tree no longer holds is a deletion in progress:
    # reported, never a failure, because work in progress is reported and not blocked.
    [ -z "$MJ_KDISC_GONE" ] || printf '%s' "$MJ_KDISC_GONE" | LC_ALL=C sort -u | awk 'NF {
      printf "X\tWARN\tsource_missing\t%s\tis a tracked source the working tree no longer holds; no node was extracted from it\n", $0 }'
    mj_knowledge_rows "$disc"
  } > "$rows"
  rm -f "$refused"
  mj_phase_end knowledge:rows "$t0"; t0="$(mj_phase_begin knowledge:extract)"
  awk -f "$MJ_LIB_DIR/knowledge.awk" "$rows" | LC_ALL=C sort
  mj_phase_end knowledge:extract "$t0"
  rm -f "$disc" "$rows"
}

# The nodes subcommand. Read-only: it derives on demand and writes nothing, because a query
# that rebuilds an index as a side effect is a write wearing a read's name.
mj_knowledge_nodes_cmd() {
  local scope=all want_kind="" out n=0 fails=0
  while [ $# -gt 0 ]; do case "$1" in
    --scope) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--scope needs a value"; scope="$2"; shift 2 ;;
    --scope=*) scope="${1#--scope=}"; shift ;;
    --kind) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--kind needs a value"; want_kind="$2"; shift 2 ;;
    --kind=*) want_kind="${1#--kind=}"; shift ;;
    --help|-h) mj_knowledge_usage; return 0 ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge nodes: unknown option $1" ;;
  esac; done
  case "$scope" in shared|operational|all) ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge nodes: --scope must be shared, operational or all" ;;
  esac

  out="$(mktemp "${TMPDIR:-/tmp}/mj.kno.XXXXXX")"
  mj_knowledge_nodes "$scope" > "$out" || { rm -f "$out"; return "$MJ_EX_INTERNAL"; }
  local t0; t0="$(mj_phase_begin knowledge:format)"

  local t a b c d e g first=1
  if [ "$MJ_JSON" = 1 ]; then
    printf '{"schema":1,"scope":"%s","nodes":[' "$scope"
    while IFS="$MJ_TAB" read -r t a b c d e g; do
      [ "$t" = N ] || continue
      [ -n "$want_kind" ] && [ "$b" != "$want_kind" ] && continue
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"id":"%s","kind":"%s","scope":"%s","source":"%s","hash":"%s","title":"%s"}' \
        "$(mj_json_esc "$a")" "$b" "$c" "$(mj_json_esc "$d")" "$e" "$(mj_json_esc "$g")"
    done < "$out"
    printf '],"findings":['
    first=1
    while IFS="$MJ_TAB" read -r t a b c d; do
      [ "$t" = X ] || continue
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"level":"%s","code":"%s","subject":"%s","message":"%s"}' \
        "$a" "$b" "$(mj_json_esc "$c")" "$(mj_json_esc "$d")"
    done < "$out"
    printf ']}\n'
  else
    while IFS="$MJ_TAB" read -r t a b c d e g; do
      [ "$t" = N ] || continue
      [ -n "$want_kind" ] && [ "$b" != "$want_kind" ] && continue
      printf '%-11s %-11s %s  %-52s %s\n' "$b" "$c" "${e:0:12}" "$a" "$g"
      n=$((n + 1))
    done < "$out"
    printf 'knowledge nodes: %s in scope %s%s\n' "$n" "$scope" "${want_kind:+, kind $want_kind}"
    while IFS="$MJ_TAB" read -r t a b c d; do
      [ "$t" = X ] || continue
      case "$a" in FAIL) mj_fail knowledge "$c" "$d" "majordomus knowledge nodes --json"; fails=$((fails + 1)) ;;
                   INFO) mj_info knowledge "$c" "$d" ;;
                   *)    mj_warn knowledge "$c" "$d" "majordomus knowledge nodes --json" ;; esac
    done < "$out"
  fi
  rm -f "$out"
  mj_phase_end knowledge:format "$t0"
  [ "$fails" = 0 ] || return "$MJ_EX_CONTRACT"
  return 0
}

# The edges subcommand. Every row carries the file, and where there is one the field or
# line, in which the relationship was observed. Nothing here is inferred.
mj_knowledge_edges_cmd() {
  local scope=all want_type="" out n=0 fails=0
  while [ $# -gt 0 ]; do case "$1" in
    --scope) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--scope needs a value"; scope="$2"; shift 2 ;;
    --scope=*) scope="${1#--scope=}"; shift ;;
    --type) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--type needs a value"; want_type="$2"; shift 2 ;;
    --type=*) want_type="${1#--type=}"; shift ;;
    --help|-h) mj_knowledge_usage; return 0 ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge edges: unknown option $1" ;;
  esac; done
  case "$scope" in shared|operational|all) ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge edges: --scope must be shared, operational or all" ;;
  esac

  out="$(mktemp "${TMPDIR:-/tmp}/mj.kge.XXXXXX")"
  mj_knowledge_nodes "$scope" > "$out" || { rm -f "$out"; return "$MJ_EX_INTERNAL"; }

  local t a b c d first=1
  if [ "$MJ_JSON" = 1 ]; then
    printf '{"schema":1,"scope":"%s","edges":[' "$scope"
    while IFS="$MJ_TAB" read -r t a b c d; do
      [ "$t" = E ] || continue
      [ -n "$want_type" ] && [ "$c" != "$want_type" ] && continue
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"from":"%s","to":"%s","type":"%s","provenance":"%s"}' \
        "$(mj_json_esc "$a")" "$(mj_json_esc "$b")" "$c" "$(mj_json_esc "$d")"
    done < "$out"
    printf ']}\n'
  else
    while IFS="$MJ_TAB" read -r t a b c d; do
      [ "$t" = E ] || continue
      [ -n "$want_type" ] && [ "$c" != "$want_type" ] && continue
      printf '%-15s %-46s %-46s %s\n' "$c" "$a" "$b" "$d"
      n=$((n + 1))
    done < "$out"
    printf 'knowledge edges: %s in scope %s%s\n' "$n" "$scope" "${want_type:+, type $want_type}"
    while IFS="$MJ_TAB" read -r t a b c d; do
      [ "$t" = X ] || continue
      case "$a" in FAIL) mj_fail knowledge "$c" "$d" "majordomus knowledge edges --json"; fails=$((fails + 1)) ;;
                   INFO) mj_info knowledge "$c" "$d" ;;
                   *)    mj_warn knowledge "$c" "$d" "majordomus knowledge edges --json" ;; esac
    done < "$out"
  fi
  rm -f "$out"
  [ "$fails" = 0 ] || return "$MJ_EX_CONTRACT"
  return 0
}

# ================================================================ validation (I0812)
# `knowledge validate` judges the graph the compiler above produces, and the doctrine
# `majordomus.knowledge-graph` asks the same judge from doctor and watch. There is one
# judge: every finding is one the compiler already emits while it extracts, and this
# section only classifies it. A second copy of the logic would be the copy that stops
# being run.
#
# WHICH BREAK IS OWNED. An edge or a path Majordomus derives from a structured field — an
# issue's milestone or depends_on, a claim's source, implementation and test, a rule's
# dependency, a declared path — is a relationship Majordomus itself defines, so a broken one
# is a broken promise and fails. A link an author wrote in prose, and a `references` edge
# taken from one, is the author's: it may deliberately point at something outside the
# repository, so a target that resolves to nothing is reported and never fails. A ledger or
# git target (a task, a commit, a session) is outside this graph altogether and is judged by
# `majordomus.knowledge-integrity`, which reads the ledger and git.
#
# NO STORED INDEX. Nothing here is compared against a stored graph, because none is written:
# every read compiles the checkout from the version-control index, so there is no generated
# index to go stale and no stored hash to mismatch (I0810, the manifest that would have made
# one, was cancelled for exactly that reason). The command says so on every run rather than
# leaving the absence to be inferred, and it writes nothing.
#
# Prints the ownership of one finding code: owned | source | external | model.
mj_knowledge_finding_owner() {
  case "$1" in
    duplicate_id|dangling_edge|missing_target|edge_incomplete|edge_type|refused_source) printf owned ;;
    source_missing) printf source ;;
    unresolved_reference|broken_link) printf external ;;
    *) printf model ;;
  esac
}

MJ_KNOWLEDGE_VALIDATE_REPRO="majordomus knowledge validate"

mj_knowledge_validate_cmd() {
  while [ $# -gt 0 ]; do case "$1" in
    --help|-h) mj_knowledge_usage; return 0 ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge validate: unknown option $1" ;;
  esac; done
  local out nn ne fails=0 warns=0 t lvl code subj msg owner first=1
  out="$(mktemp "${TMPDIR:-/tmp}/mj.kva.XXXXXX")"
  mj_knowledge_nodes all > "$out" || { rm -f "$out"; return "$MJ_EX_INTERNAL"; }
  nn="$(grep -c "^N$MJ_TAB" "$out" || true)"; ne="$(grep -c "^E$MJ_TAB" "$out" || true)"
  if [ "$MJ_JSON" = 1 ]; then
    printf '{"schema":1,"nodes":%s,"edges":%s,"index":"none","findings":[' "${nn:-0}" "${ne:-0}"
  fi
  while IFS="$MJ_TAB" read -r t lvl code subj msg; do
    [ "$t" = X ] || continue
    owner="$(mj_knowledge_finding_owner "$code")"
    case "$lvl" in FAIL) fails=$((fails + 1)) ;; WARN) warns=$((warns + 1)) ;; esac
    if [ "$MJ_JSON" = 1 ]; then
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"level":"%s","code":"%s","owner":"%s","subject":"%s","message":"%s","reproduce":"%s"}' \
        "$lvl" "$code" "$owner" "$(mj_json_esc "$subj")" "$(mj_json_esc "$msg")" "$MJ_KNOWLEDGE_VALIDATE_REPRO"
    else
      case "$lvl" in
        FAIL) mj_fail knowledge "$subj" "$code ($owner): $msg" "$MJ_KNOWLEDGE_VALIDATE_REPRO" ;;
        INFO) mj_info knowledge "$subj" "$code: $msg" ;;
        *)    mj_warn knowledge "$subj" "$code ($owner): $msg" "$MJ_KNOWLEDGE_VALIDATE_REPRO" ;;
      esac
    fi
  done < "$out"
  rm -f "$out"
  if [ "$MJ_JSON" = 1 ]; then
    printf '],"failures":%s,"warnings":%s}\n' "$fails" "$warns"
  else
    printf 'knowledge validate: %s node(s), %s edge(s); %s failure(s), %s warning(s); no stored index (every read compiles the checkout, so none can be stale); nothing written\n' \
      "${nn:-0}" "${ne:-0}" "$fails" "$warns"
  fi
  [ "$fails" = 0 ] || return "$MJ_EX_CONTRACT"
  return 0
}

# The doctrine: the same compile and the same classification, under doctor and watch. It
# compiles into a temporary file it removes, so the answer to "can the graph be built here"
# leaves nothing behind. The compile runs in a subshell, so a source list that does not
# parse is reported as an unbuildable graph instead of ending the command that asked. The
# schema check is not asked here: it builds the executable's whole index, which doctor
# already judges, and this doctrine is about the graph's own references.
mj_validate_knowledge_graph() {
  local out err rc=0 nn ne t lvl code subj msg owner ext=0 model=0 why
  out="$(mktemp "${TMPDIR:-/tmp}/mj.kvg.XXXXXX")"; err="$(mktemp "${TMPDIR:-/tmp}/mj.kvge.XXXXXX")"
  ( export MAJORDOMUS_KNOWLEDGE_SCHEMA=unasked; mj_knowledge_nodes all ) > "$out" 2> "$err" || rc=$?
  if [ "$rc" != 0 ]; then
    why="$(grep -v '^[[:space:]]*$' "$err" | head -n 1)"
    mj_doctrine_fail knowledge "graph" "the graph cannot be built in this checkout (exit $rc)${why:+: $why}" "$MJ_KNOWLEDGE_VALIDATE_REPRO"
    rm -f "$out" "$err"; return 0
  fi
  nn="$(grep -c "^N$MJ_TAB" "$out" || true)"; ne="$(grep -c "^E$MJ_TAB" "$out" || true)"
  mj_doctrine_ok knowledge "graph" "buildable here: ${nn:-0} node(s), ${ne:-0} edge(s), compiled into a temporary file and removed; no stored index to go stale"
  while IFS="$MJ_TAB" read -r t lvl code subj msg; do
    [ "$t" = X ] || continue
    owner="$(mj_knowledge_finding_owner "$code")"
    case "$owner" in
      owned|source) mj_doctrine_fail knowledge "$subj" "$code ($owner): $msg" "$MJ_KNOWLEDGE_VALIDATE_REPRO" ;;
      external) ext=$((ext + 1)) ;;
      *) [ "$lvl" = INFO ] || model=$((model + 1)) ;;
    esac
  done < "$out"
  [ "$ext" = 0 ] || mj_info knowledge "links" "$ext link(s) written in prose resolve to nothing in the graph; an author's link may point outside it, so none fails ($MJ_KNOWLEDGE_VALIDATE_REPRO lists them)"
  [ "$model" = 0 ] || mj_info knowledge "model" "$model finding(s) about what the compiler models rather than what a file breaks ($MJ_KNOWLEDGE_VALIDATE_REPRO lists them)"
  rm -f "$out" "$err"
  return 0
}

# ================================================================ lookup (I0811)
# search, show and related answer a question about the nodes and edges above and write
# nothing. There is no stored index to go stale: every call compiles the nodes from the
# version-control index and the owned state directories, exactly as `nodes` does, so what a
# lookup reads is what the checkout holds at that moment and a read can never rebuild
# anything as a side effect.
#
# The order of a search is a rule, not a score. A node is placed on the first rung it
# reaches, and the rungs are tried in this order:
#
#   id            the query is the node's canonical id (case-insensitive)
#   title         the query is the node's title (case-insensitive)
#   title-prefix  the node's title begins with the query
#   path          the node's source path contains the query
#   body          the node's source file contains the query, literally
#
# Within a rung, nodes are in byte order of their id. Every result names the rung it was
# placed on, so the reason a node is where it is can be read off the answer. Nothing
# similar, nothing weighted, nothing embedded: a ranking nobody can explain is a ranking
# nobody can debug. The nodes do not carry tags, so there is no tag rung.
MJ_KNOWLEDGE_LADDER="id title title-prefix path body"

# Shared option parsing for the three lookups: one positional argument, a scope.
mj_knowledge_lookup_args() {
  local what="$1"; shift
  MJ_KQ_SCOPE=all; MJ_KQ_ARG=""
  while [ $# -gt 0 ]; do case "$1" in
    --scope) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--scope needs a value"; MJ_KQ_SCOPE="$2"; shift 2 ;;
    --scope=*) MJ_KQ_SCOPE="${1#--scope=}"; shift ;;
    --help|-h) mj_knowledge_usage; exit 0 ;;
    -*) mj_die "$MJ_EX_USAGE" "knowledge $what: unknown option $1" ;;
    *) [ -z "$MJ_KQ_ARG" ] || mj_die "$MJ_EX_USAGE" "knowledge $what: one argument, not two ('$MJ_KQ_ARG', '$1')"
       MJ_KQ_ARG="$1"; shift ;;
  esac; done
  case "$MJ_KQ_SCOPE" in shared|operational|all) ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge $what: --scope must be shared, operational or all" ;;
  esac
  [ -n "$MJ_KQ_ARG" ] || mj_die "$MJ_EX_USAGE" "knowledge $what: name what to look up (see: majordomus knowledge --help)"
}

mj_knowledge_search_cmd() {
  mj_knowledge_lookup_args search "$@"
  local q="$MJ_KQ_ARG" scope="$MJ_KQ_SCOPE" out ranked rest hits n=0
  out="$(mktemp "${TMPDIR:-/tmp}/mj.ksq.XXXXXX")"
  ranked="$(mktemp "${TMPDIR:-/tmp}/mj.ksr.XXXXXX")"
  rest="$(mktemp "${TMPDIR:-/tmp}/mj.kss.XXXXXX")"
  hits="$(mktemp "${TMPDIR:-/tmp}/mj.ksh.XXXXXX")"
  mj_knowledge_nodes "$scope" > "$out" || { rm -f "$out" "$ranked" "$rest" "$hits"; return "$MJ_EX_INTERNAL"; }

  # rungs 1-4 from the node row itself; the sources of every node they did not place are
  # the only files the body rung reads
  awk -F'\t' -v q="$q" -v ranked="$ranked" -v rest="$rest" '
    BEGIN { lq = tolower(q) }
    $1 != "N" { next }
    {
      id = $2; title = $7; src = $5; rung = 0
      if (tolower(id) == lq) { rung = 1; m = "id" }
      else if (tolower(title) == lq) { rung = 2; m = "title" }
      else if (substr(tolower(title), 1, length(lq)) == lq) { rung = 3; m = "title-prefix" }
      else if (index(tolower(src), lq) > 0) { rung = 4; m = "path" }
      if (rung) printf "%d\t%s\t%s\t%s\t%s\t%s\n", rung, m, id, $3, title, src > ranked
      else printf "%s\t%s\t%s\t%s\n", src, id, $3, title > rest
    }' "$out"
  if [ -s "$rest" ]; then
    cut -f1 "$rest" | LC_ALL=C sort -u \
      | ( cd "$MJ_ROOT" && while IFS= read -r p; do [ -f "$p" ] && printf '%s\n' "$p"; done ) \
      | ( cd "$MJ_ROOT" && tr '\n' '\0' | xargs -0 grep -F -i -l -- "$q" 2>/dev/null ) > "$hits" || true
    awk -F'\t' 'NR == FNR { hit[$0] = 1; next } ($1 in hit) { printf "5\tbody\t%s\t%s\t%s\t%s\n", $2, $3, $4, $1 }' \
      "$hits" "$rest" >> "$ranked"
  fi
  LC_ALL=C sort -t "$MJ_TAB" -k1,1n -k3,3 "$ranked" -o "$ranked"

  local rung m id kind title src first=1
  if [ "$MJ_JSON" = 1 ]; then
    printf '{"schema":1,"query":"%s","scope":"%s","ladder":["id","title","title-prefix","path","body"],"results":[' \
      "$(mj_json_esc "$q")" "$scope"
    while IFS="$MJ_TAB" read -r rung m id kind title src; do
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"id":"%s","kind":"%s","title":"%s","source":"%s","matched":"%s","rung":%s}' \
        "$(mj_json_esc "$id")" "$kind" "$(mj_json_esc "$title")" "$(mj_json_esc "$src")" "$m" "$rung"
    done < "$ranked"
    printf ']}\n'
  else
    while IFS="$MJ_TAB" read -r rung m id kind title src; do
      printf '%-12s %-11s %-52s %s\n' "$m" "$kind" "$id" "$title"
      n=$((n + 1))
    done < "$ranked"
    printf 'knowledge search: %s node(s) match "%s" in scope %s (ladder: %s)\n' "$n" "$q" "$scope" "$MJ_KNOWLEDGE_LADDER"
  fi
  rm -f "$out" "$ranked" "$rest" "$hits"
  return 0
}

mj_knowledge_show_cmd() {
  mj_knowledge_lookup_args show "$@"
  local want="$MJ_KQ_ARG" scope="$MJ_KQ_SCOPE" out row
  out="$(mktemp "${TMPDIR:-/tmp}/mj.ksw.XXXXXX")"
  mj_knowledge_nodes "$scope" > "$out" || { rm -f "$out"; return "$MJ_EX_INTERNAL"; }
  row="$(awk -F'\t' -v w="$want" '$1 == "N" && $2 == w { print; exit }' "$out")"
  rm -f "$out"
  [ -n "$row" ] || mj_die "$MJ_EX_MISSING" "knowledge show: no node '$want' in scope $scope (find one with: majordomus knowledge search <query>)"
  local t id kind sc src hash title
  IFS="$MJ_TAB" read -r t id kind sc src hash title <<EOF
$row
EOF
  if [ "$MJ_JSON" = 1 ]; then
    printf '{"schema":1,"node":{"id":"%s","kind":"%s","scope":"%s","source":"%s","hash":"%s","title":"%s"}}\n' \
      "$(mj_json_esc "$id")" "$kind" "$sc" "$(mj_json_esc "$src")" "$hash" "$(mj_json_esc "$title")"
  else
    printf 'id      %s\nkind    %s\nscope   %s\ntitle   %s\nsource  %s\nhash    %s\n' "$id" "$kind" "$sc" "$title" "$src" "$hash"
  fi
  return 0
}

mj_knowledge_related_cmd() {
  mj_knowledge_lookup_args related "$@"
  local want="$MJ_KQ_ARG" scope="$MJ_KQ_SCOPE" out edges n=0
  out="$(mktemp "${TMPDIR:-/tmp}/mj.kre.XXXXXX")"
  mj_knowledge_nodes "$scope" > "$out" || { rm -f "$out"; return "$MJ_EX_INTERNAL"; }
  awk -F'\t' -v w="$want" '$1 == "N" && $2 == w { f = 1 } END { exit !f }' "$out" || {
    rm -f "$out"
    mj_die "$MJ_EX_MISSING" "knowledge related: no node '$want' in scope $scope (find one with: majordomus knowledge search <query>)"; }
  # out first, then in; within a direction, the compiler's byte order
  edges="$(awk -F'\t' -v w="$want" '
    $1 == "E" && $2 == w { o[++no] = "out\t" $4 "\t" $2 "\t" $3 "\t" $5 }
    $1 == "E" && $3 == w { i[++ni] = "in\t" $4 "\t" $2 "\t" $3 "\t" $5 }
    END { for (k = 1; k <= no; k++) print o[k]; for (k = 1; k <= ni; k++) print i[k] }' "$out")"
  rm -f "$out"
  local dir type from to prov first=1
  if [ "$MJ_JSON" = 1 ]; then
    printf '{"schema":1,"id":"%s","scope":"%s","edges":[' "$(mj_json_esc "$want")" "$scope"
    while IFS="$MJ_TAB" read -r dir type from to prov; do
      [ -n "$dir" ] || continue
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"direction":"%s","type":"%s","from":"%s","to":"%s","provenance":"%s"}' \
        "$dir" "$type" "$(mj_json_esc "$from")" "$(mj_json_esc "$to")" "$(mj_json_esc "$prov")"
    done <<EOF
$edges
EOF
    printf ']}\n'
  else
    while IFS="$MJ_TAB" read -r dir type from to prov; do
      [ -n "$dir" ] || continue
      if [ "$dir" = out ]; then printf '%-4s %-15s %-46s %s\n' "$dir" "$type" "$to" "$prov"
      else printf '%-4s %-15s %-46s %s\n' "$dir" "$type" "$from" "$prov"; fi
      n=$((n + 1))
    done <<EOF
$edges
EOF
    printf 'knowledge related: %s edge(s) into and out of %s in scope %s\n' "$n" "$want" "$scope"
  fi
  return 0
}

# ================================================================ the writer (ADR 0091)
# Everything above reads. What follows is the one place the knowledge kind is written by
# the tool, and it writes exactly one thing: what an episode's ledger lines already state,
# as candidate records a person then promotes or rejects.
#
# The evidence is the ledger and git, and nothing else. A decision recorded, a question
# resolved, a task finished with a typed outcome — each is a line the writer stamped with
# the episode's id at the moment it happened, and each says something true about the
# repository that is otherwise reachable on one machine until the retention cap removes
# it. Nothing here reads a conversation, a handover, a prompt or a checkpoint: a record
# derived from a derived record would be a projection of a projection, and a record
# derived from prose would be a summary, which is the thing project.never-store-transcripts
# refuses.
#
# Same ledger, same git, same bytes. The record's id is the episode followed by a digest
# of the evidence it came from, so two worktrees never write one name for different
# content and a second run over the same evidence rewrites the same file byte for byte;
# its date is the day of the evidence line, not the clock; and no random value enters the
# file. That is what lets a provider hook run it: it can be wrong only if a ledger line is
# wrong, and the ledger has its own gate.
#
# `verified` is never written here. A candidate becomes verified through `knowledge
# promote` with evidence on standard input, or through a person editing the file; the
# deriver skips an id that has been promoted or superseded, so neither act is undone at
# the next episode boundary.

mj_knowledge_candidates_dir() { printf '%s/candidates' "$MJ_KNOWLEDGE_DIR"; }
mj_knowledge_curated_dir()    { printf '%s/curated' "$MJ_KNOWLEDGE_DIR"; }
# Where a trunk episode's candidates wait for a branch to carry them (ADR 0091, amendment
# of 2026-10-15): the tracked section on the trunk is a file only a pull request may bring.
mj_knowledge_outbox_dir()     { printf '%s/candidates' "$(mj_outbox_dir)"; }
mj_knowledge_ledger()         { printf '%s/ledger.jsonl' "$MJ_STATE_DIR"; }
MJ_KNOWLEDGE_CLASSES="fact convention constraint memory lesson"
MJ_KNOWLEDGE_STATUSES="candidate verified superseded"
MJ_KNOWLEDGE_EPISTEMICS="observed inferred decided"
MJ_KNOWLEDGE_ORIGINS="authored extracted"
MJ_KNOWLEDGE_RELATIONS="relates_to depends_on documents supports contradicts supersedes derived_from"
# The line shape that marks a conversation, shared with the working-context store
# (lib/session_context.sh): a record whose title, description or body opens a line this
# way is a transcript wearing a record's front matter.
MJ_KNOWLEDGE_TRANSCRIPT='^(transcript|messages|assistant|completion|response)[: ]'

# The episode to derive from: the one named, else the one open for this process. Empty
# means none, and the caller says so rather than guessing at the pointer.
mj_knowledge_episode_resolve() {
  if [ -n "${1:-}" ]; then printf '%s' "$1"; return 0; fi
  mj_open_session_id
}

# One pass over the ledger, selecting the lines the episode stamped and turning each
# derivable one into a tab-separated evidence row:
#
#   kind  task_id  ts  canon  title  description  class  epistemics  tag  outcome
#
# Free text is unescaped here, once, from the JSON the writer produced: `\"` becomes `"`,
# `\\` becomes `\`, and a `\n` becomes a space, because a record's title is one line.
# mj_json_field is not used for it — it cuts at the first escaped quote — and nothing
# re-escapes until the record is composed, where YAML quoting is applied exactly once.
# `canon` is the canonical evidence string the record id is digested from, its lines
# joined with the unit separator so that the row stays one line.
#
# The mapping is closed, and it is the ADR's table: a recorded decision is a convention
# that was decided; a resolved question is an observed fact; a task that ended blocked,
# failed or without a match is an inferred lesson; a task that completed with a
# verification command is an observed fact. Any other line yields nothing.
mj_knowledge_evidence() {
  local sid="$1" led; led="$(mj_knowledge_ledger)"
  [ -f "$led" ] || return 0
  awk -v sid="$sid" '
    BEGIN { OFS = "\t"; US = sprintf("%c", 31) }
    function jstr(s, key,   i, n, c, out, at) {
      at = index(s, "\"" key "\":\"")
      if (at == 0) return ""
      i = at + length(key) + 4; n = length(s); out = ""
      while (i <= n) {
        c = substr(s, i, 1)
        if (c == "\\") {
          i++; c = substr(s, i, 1)
          if (c == "n" || c == "t" || c == "r") out = out " "
          else out = out c
        } else if (c == "\"") break
        else out = out c
        i++
      }
      gsub(/\t/, " ", out)
      return out
    }
    function jraw(s, key,   at, r) {
      at = index(s, "\"" key "\":")
      if (at == 0) return ""
      r = substr(s, at + length(key) + 3); sub(/[,}].*$/, "", r)
      return r
    }
    index($0, "\"session\":\"" sid "\"") == 0 { next }
    {
      e = jstr($0, "event"); ts = jstr($0, "ts"); task = jstr($0, "task_id")
      if (task == "") task = "none"       # a row never carries an empty column
      if (e == "decision.recorded") {
        d = jstr($0, "decision"); if (d == "") next
        if (task == "" || task == "none") desc = "Recorded as a decision outside a task in episode " sid "."
        else desc = "Recorded as a decision under task " task " in episode " sid "."
        print "decision", task, ts, "decision.recorded" US d, d, desc, "convention", "decided", "decision", ""
      } else if (e == "question.resolved") {
        q = jstr($0, "question"); a = jstr($0, "answer"); if (a == "") next
        print "question", task, ts, "question.resolved" US q US a, a, \
          "Answers the question \"" q "\" resolved under task " task " in episode " sid ".", "fact", "observed", "question", ""
      } else if (e == "task.finished") {
        o = jstr($0, "outcome")
        if (o == "blocked" || o == "failed" || o == "no_match")
          print "task", task, ts, "task.finished" US task US o, "Task " task " ended " o, \
            "The task reached the outcome " o " in episode " sid ".", "lesson", "inferred", "task", o
        else if (o == "completed" && index($0, "\"verify\":{") > 0)
          print "task", task, ts, "task.finished" US task US o, \
            "Task " task " completed; verified by: " jstr($0, "command") " (exit " jraw($0, "exit") ")", \
            "The verification command passed when the task finished in episode " sid ".", "fact", "observed", "task", o
      } else if (e == "observation.recorded") {
        # ADR 0118: what a worker met. The subject and the evidence travel in the last two
        # columns; the canonical evidence is the kind, the subject and the statement, so the
        # same observation in one episode is one file and in another episode another.
        k = jstr($0, "kind"); subj = jstr($0, "subject"); st = jstr($0, "statement")
        if (k == "" || subj == "" || st == "") next
        ev = ""; at = index($0, "\"evidence\":[")
        if (at > 0) {
          ev = substr($0, at + 12); sub(/\].*$/, "", ev); gsub(/"/, "", ev); gsub(/,/, " ", ev)
        }
        if (ev == "") ev = "-"
        print "observation", task, ts, "observation.recorded" US k US subj US st, st, \
          "Observed as " k " about " subj " in episode " sid ".", "lesson", "observed", "observation", subj, ev
      }
    }' "$led"
}

# The first non-blank line of one level-one section of a note, or nothing. Used for the
# `# Reason` a task that ended blocked or failed left under state/completed/.
mj_knowledge_note_line() {
  [ -f "$1" ] || return 0
  awk -v want="$2" '
    /^# / { cur = substr($0, 3); sub(/[ \t]+$/, "", cur); on = (cur == want); next }
    on && NF { print; exit }' "$1"
}

# <episode>-<twelve hex characters of sha256 over the canonical evidence>. The digest is of
# the evidence, not of the record: a change in how a title is worded does not move the
# file, and the same decision text always lands in the same place.
#
# The canonical evidence names what identifies the FACT, never what identifies the run that
# observed it. A decision is identified by its text and a resolved question by its question
# and answer, so neither digests the task id: a task id carries the minute it was opened, so
# digesting it would give the same decision a different file in every repository that
# recorded it, which is the opposite of what an id is for. The task stays in `derived_from`,
# where it belongs as provenance. A `task.finished` row is the exception and keeps the task
# id in its evidence, because there the task is the only thing that distinguishes one
# outcome from another.
mj_knowledge_record_id() {
  local sid="$1" canon="$2" digest
  digest="$(printf '%s' "$canon" | tr '\037' '\n' | mj_sha256 /dev/stdin | cut -c1-12)"
  printf '%s-%s' "$sid" "$digest"
}

# The commits the episode made that touched the rules or the decisions sections, one
# `sha<TAB>path` row per path, in the order the commits were made. An episode whose
# opening commit is not in this history — a rebase in between — names none: the list
# would be computed across a history that no longer connects.
mj_knowledge_episode_commits() {
  local sid="$1" led line start rules adrs sha
  led="$(mj_knowledge_ledger)"; [ -f "$led" ] || return 0
  line="$(grep -F '"event":"session.started"' "$led" 2>/dev/null | grep -F "\"session\":\"$sid\"" | head -n 1)"
  [ -n "$line" ] || return 0
  start="$(mj_json_field "$line" head)"
  [ -n "$start" ] && [ "$start" != NONE ] || return 0
  [ "$(mj_git_head)" != NONE ] || return 0
  mj_git merge-base --is-ancestor "$start" HEAD 2>/dev/null || return 0
  rules="$(mj_rel "$MJ_RULES_DIR")"; adrs="$(mj_rel "$MJ_ADRS_DIR")"
  for sha in $(mj_git rev-list --reverse "$start..HEAD" 2>/dev/null); do
    mj_git diff-tree --no-commit-id --name-only -r "$sha" -- "$rules" "$adrs" 2>/dev/null \
      | awk -v s="$sha" 'NF { printf "%s\t%s\n", s, $0 }'
  done
  return 0
}

# The commit rows reduced to what a record carries: one `commit:<sha>` per commit, in
# order, and one `file:<path>` per distinct path the tree still tracks, sorted under the
# C collation. Computed once per derivation, not once per record.
mj_knowledge_episode_refs() {
  local commits="$1" p
  [ -s "$commits" ] || return 0
  cut -f1 "$commits" | awk '!seen[$0]++ { print "commit:" $0 }'
  cut -f2 "$commits" | LC_ALL=C sort -u | while IFS= read -r p; do
    [ -n "$p" ] || continue
    mj_git ls-files --error-unmatch -- "$p" >/dev/null 2>&1 && printf 'file:%s\n' "$p"
  done
  return 0
}

# YAML double-quoted scalar content: backslash and double quote escaped, once.
mj_knowledge_yq() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }

# One record, composed to OUT from one evidence row and the episode's reference list, in
# the exact key order the section's README documents. Prints the record id.
mj_knowledge_compose() {
  local sid="$1" row="$2" refs="$3" out="$4"
  local kind task ts canon title desc class epi tag rest evrefs id about="" route="" ref
  IFS="$MJ_TAB" read -r kind task ts canon title desc class epi tag rest evrefs <<EOF
$row
EOF
  # The tenth column is a task's outcome, which belongs to the derive loop; for an
  # observation it is the subject, and the eleventh its evidence references (ADR 0118).
  if [ "$kind" = observation ]; then about="$rest"; route="$(mj_knowledge_route "$about")"; fi
  # A lesson carries the reason the task's own note gave, when it left one.
  if [ "$kind" = task ] && [ "$class" = lesson ] && [ -n "$task" ] && [ "$task" != none ]; then
    local reason; reason="$(mj_knowledge_note_line "$MJ_STATE_DIR/completed/$task.md" Reason)"
    [ -n "$reason" ] && title="$title: $reason"
  fi
  id="$(mj_knowledge_record_id "$sid" "$canon")"
  {
    printf -- '---\nschema: knowledge/v1\nid: %s\nkind: knowledge\nclass: %s\n' "$id" "$class"
    printf 'title: "%s"\ndescription: "%s"\n' "$(mj_knowledge_yq "$title")" "$(mj_knowledge_yq "$desc")"
    printf 'status: candidate\nepistemics: %s\ndate: %s\ntags:\n  - derived\n  - %s\n' "$epi" "${ts%%T*}" "$tag"
    if [ -n "$about" ]; then printf 'about: "%s"\nroute: %s\n' "$(mj_knowledge_yq "$about")" "$route"; fi
    printf 'provenance:\n  origin: extracted\n  derived_from:\n    - session:%s\n' "$sid"
    # A decision has no identity of its own in the ledger; the task it was recorded under
    # is the reference, as the schema's own comment uses it. A decision recorded outside a
    # task names the episode and nothing else (a literal `none` is not a task).
    if [ -n "$task" ] && [ "$task" != none ]; then
      case "$kind" in decision) printf '    - decision:%s\n' "$task" ;; *) printf '    - task:%s\n' "$task" ;; esac
    fi
    [ -s "$refs" ] && sed -n 's/^commit:/    - commit:/p' "$refs"
    # An observation's own evidence, where it resolves now: a commit git knows, a file or a
    # test the tree tracks. The rest — an issue, a claim, a path since removed — stays on
    # the ledger line and in the body, where nothing has to resolve it.
    if [ "$kind" = observation ] && [ "${evrefs:--}" != - ]; then
      for ref in $evrefs; do
        case "$ref" in
          commit:*) mj_git cat-file -e "${ref#commit:}^{commit}" 2>/dev/null && printf '    - %s\n' "$ref" ;;
          file:*|test:*) mj_git ls-files --error-unmatch -- "${ref#*:}" >/dev/null 2>&1 && printf '    - %s\n' "$ref" ;;
        esac
      done
    fi
    if [ -s "$refs" ] && grep -q '^file:' "$refs"; then
      printf 'relations:\n'
      grep '^file:' "$refs" | while IFS= read -r p; do printf '  - type: relates_to\n    target: %s\n' "$p"; done
    fi
    printf -- '---\n\n# %s\n\n%s\n' "$title" "$desc"
    if [ "$kind" = observation ] && [ "${evrefs:--}" != - ]; then
      printf '\n# Evidence\n\n'
      for ref in $evrefs; do printf -- '- `%s`\n' "$ref"; done
    fi
  } > "$out"
  printf '%s' "$id"
}

# The atomic, idempotent write: compose into a staging file inside the directory, compare,
# and rename over the id-named file only when the bytes differ. The `.tmp.` prefix is the
# one .gitignore already refuses and mj_sweep_record_temps already collects, so a writer
# killed between the two steps leaves nothing that can reach a commit. Nothing here stages.
# Prints `written` or `unchanged`; returns 1 when the directory cannot be written.
mj_knowledge_write_record() {
  local dir="$1" id="$2" src="$3" tmp final
  final="$dir/$id.md"
  mkdir -p "$dir" 2>/dev/null || return 1
  mj_sweep_record_temps "$dir"
  tmp="$(mktemp "$dir/.tmp.XXXXXX" 2>/dev/null)" || return 1
  cat "$src" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
  chmod 644 "$tmp" 2>/dev/null || true
  if [ -f "$final" ] && cmp -s "$tmp" "$final"; then rm -f "$tmp"; printf 'unchanged'; return 0; fi
  mv -f "$tmp" "$final" 2>/dev/null || { rm -f "$tmp"; return 1; }
  printf 'written'
}

# The candidates directory exists the moment a record needs it, and it carries its
# contract from the distribution when it is created here: a directory of the layer
# without one is a finding (ADR 0011), and the episode that wrote the first candidate did
# not cause that.
mj_knowledge_candidates_ready() {
  local dir; dir="$(mj_knowledge_candidates_dir)"
  [ -d "$dir" ] && return 0
  mkdir -p "$dir" 2>/dev/null || return 1
  [ -f "$dir/README.md" ] && return 0
  [ -f "$MJ_SKELETON_DIR/ai/repo/knowledge/candidates/README.md" ] || return 0
  cp "$MJ_SKELETON_DIR/ai/repo/knowledge/candidates/README.md" "$dir/README.md" 2>/dev/null || true
  return 0
}

mj_knowledge_record_status() { sed -n 's/^status: //p' "$1" 2>/dev/null | head -n 1; }

# majordomus knowledge derive [--episode <id>] [--dry-run]
mj_knowledge_derive_cmd() {
  local episode="" dry=0
  while [ $# -gt 0 ]; do case "$1" in
    --episode) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--episode needs a value"; episode="$2"; shift 2 ;;
    --episode=*) episode="${1#--episode=}"; shift ;;
    --dry-run) dry=1; shift ;;
    --help|-h) mj_knowledge_usage; return 0 ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge derive: unknown option $1" ;;
  esac; done
  mj_load_policy || mj_die "$MJ_EX_CONTRACT" "policy does not parse (run: majordomus doctor)"
  local sid; sid="$(mj_knowledge_episode_resolve "$episode")"
  local dry_json=false; [ "$dry" = 1 ] && dry_json=true
  if [ -z "$sid" ]; then
    if [ "$MJ_JSON" = 1 ]; then printf '{"schema":1,"episode":null,"dry_run":%s,"written":[],"unchanged":[],"skipped":[]}\n' "$dry_json"
    else printf 'knowledge derive: no episode to derive from; open one or pass --episode <id>\n'; fi
    return 0
  fi
  local t0; t0="$(mj_phase_begin knowledge:derive)"
  local tmp cdir kdir wdir; tmp="$(mktemp -d "${TMPDIR:-/tmp}/mj.kdv.XXXXXX")"
  cdir="$(mj_knowledge_candidates_dir)"; kdir="$(mj_knowledge_curated_dir)"
  # What is written goes to wdir: the tracked section, or on the trunk the outbox. What is
  # already decided (promoted, superseded, the same bytes tracked) is still read from cdir.
  wdir="$cdir"; mj_on_trunk && wdir="$(mj_knowledge_outbox_dir)"
  mj_knowledge_evidence "$sid" > "$tmp/rows"
  mj_knowledge_episode_commits "$sid" > "$tmp/commits"
  mj_knowledge_episode_refs "$tmp/commits" > "$tmp/refs"

  local row id rel w=0 u=0 s=0 W="" U="" S="" paths="" verb kind task ts canon title desc rest
  while IFS= read -r row <&3; do
    [ -n "$row" ] || continue
    IFS="$MJ_TAB" read -r kind task ts canon title desc rest <<EOF
$row
EOF
    id="$(mj_knowledge_compose "$sid" "$row" "$tmp/refs" "$tmp/rec")"
    rel="$(mj_rel "$wdir")/$id.md"
    mj_count knowledge_evidence
    # Refused at derive time rather than written for the validator to fail: a title or a
    # description that opens like a turn of a conversation is not an assertion.
    if printf '%s\n%s\n' "$title" "$desc" | grep -Eq "$MJ_KNOWLEDGE_TRANSCRIPT"; then
      verb="skipped (transcript)"; s=$((s + 1)); S="$S$rel"$'\n'
    elif [ -f "$kdir/$id.md" ]; then
      verb="skipped (promoted)"; s=$((s + 1)); S="$S$rel"$'\n'
    elif [ -f "$cdir/$id.md" ] && [ "$(mj_knowledge_record_status "$cdir/$id.md")" != candidate ]; then
      verb="skipped (superseded)"; s=$((s + 1)); S="$S$rel"$'\n'
    elif [ "$wdir" != "$cdir" ] && [ -f "$cdir/$id.md" ] && cmp -s "$tmp/rec" "$cdir/$id.md"; then
      # tracked already, byte for byte: nothing for the outbox to carry
      verb=unchanged; u=$((u + 1)); U="$U$(mj_rel "$cdir")/$id.md"$'\n'
    elif [ "$dry" = 1 ]; then
      if [ -f "$wdir/$id.md" ] && cmp -s "$tmp/rec" "$wdir/$id.md"; then verb=unchanged; u=$((u + 1)); U="$U$rel"$'\n'
      else verb="would write"; w=$((w + 1)); W="$W$rel"$'\n'; fi
    else
      if [ "$wdir" = "$cdir" ]; then
        mj_knowledge_candidates_ready || { rm -rf "$tmp"; mj_die "$MJ_EX_INTERNAL" "knowledge derive: cannot create $(mj_rel "$cdir")/"; }
      else
        mkdir -p "$wdir" 2>/dev/null || { rm -rf "$tmp"; mj_die "$MJ_EX_INTERNAL" "knowledge derive: cannot create $(mj_rel "$wdir")/"; }
      fi
      verb="$(mj_knowledge_write_record "$wdir" "$id" "$tmp/rec")" \
        || { rm -rf "$tmp"; mj_die "$MJ_EX_INTERNAL" "knowledge derive: could not write $rel"; }
      case "$verb" in
        written) w=$((w + 1)); W="$W$rel"$'\n'; paths="$paths${paths:+ }$rel" ;;
        *) u=$((u + 1)); U="$U$rel"$'\n' ;;
      esac
    fi
    case "$verb" in
      "skipped ("*) [ "$MJ_JSON" = 1 ] || printf 'skipped %s %s\n' "$rel" "${verb#skipped }" ;;
      *) [ "$MJ_JSON" = 1 ] || printf '%s %s\n' "$verb" "$rel" ;;
    esac
  done 3< "$tmp/rows"
  rm -rf "$tmp"

  # The ledger line, even when nothing was written: "derived, nothing new" and "never
  # derived" are different facts, and the stopped-writer check tells them apart by it.
  [ "$dry" = 1 ] || mj_ledger_append knowledge.derived \
    "\"episode\":\"$(mj_json_esc "$sid")\",\"written\":$w,\"unchanged\":$u,\"skipped\":$s,\"paths\":\"$(mj_json_esc "$paths")\""
  mj_phase_end knowledge:derive "$t0"

  if [ "$MJ_JSON" = 1 ]; then
    printf '{"schema":1,"episode":"%s","dry_run":%s,"written":[%s],"unchanged":[%s],"skipped":[%s]}\n' \
      "$(mj_json_esc "$sid")" "$dry_json" "$(mj_knowledge_json_list "$W")" "$(mj_knowledge_json_list "$U")" "$(mj_knowledge_json_list "$S")"
  else
    printf 'knowledge derive: %s written, %s unchanged, %s skipped for episode %s\n' "$w" "$u" "$s" "$sid"
  fi
  return 0
}

# newline-separated values -> the inside of a JSON array of strings
mj_knowledge_json_list() {
  printf '%s' "$1" | sed '/^$/d' | while IFS= read -r v; do printf '"%s",' "$(mj_json_esc "$v")"; done | sed 's/,$//'
}

# ---------------------------------------------------------------- the review queue
# Which branch an episode worked on: the tracked session record when the episode has
# closed and been committed, the open episode's own file while it is still open, and the
# ledger's opening line for everything in between. One `sid<TAB>branch` row per episode
# this checkout knows, first source wins. A candidate whose episode none of them knows is
# named as such by every reader, never attributed to the current branch by proximity.
mj_knowledge_branch_map() {
  # shellcheck source=session.sh
  . "$MJ_LIB_DIR/session.sh"
  local store led; store="$(mj_session_store)"; led="$(mj_knowledge_ledger)"
  {
    if [ -d "$store" ]; then
      awk 'FNR == 1 { fm = 0; b = ""; s = "" }
           FNR == 1 && $0 == "---" { fm = 1; next }
           fm && $0 == "---" { if (s != "") print s "\t" b; fm = 0; nextfile }
           fm && /^branch: / { b = substr($0, 9) }
           fm && /^session_id: / { s = substr($0, 13) }' "$store"/*.md 2>/dev/null
    fi
    if [ -d "$(mj_session_open_dir)" ]; then
      awk 'FNR == 1 { if (s != "") print s "\t" b; b = ""; s = "" }
           /^branch: / { b = substr($0, 9) }
           /^session_id: / { s = substr($0, 13) }
           END { if (s != "") print s "\t" b }' "$(mj_session_open_dir)"/*.yaml 2>/dev/null
    fi
    if [ -f "$led" ]; then
      awk "$MJ_LEDGER_FIELD_AWK"'index($0, "\"event\":\"session.started\"") == 0 { next }
           index($0, "\"session\":\"") == 0 { next }
           { s = mjfield($0, "session"); b = mjfield($0, "branch")
             if (s != "") print s "\t" b }' "$led"
    fi
  } | awk -F'\t' '!seen[$1]++'
}

# Every record under candidates/, whatever its status, one row each and sorted by id:
#
#   id  class  status  epistemics  date  episode  branch  path  title
#
# The directory is read, not the index: a candidate the hook wrote a moment ago must be
# listed before anybody has staged it, which the version-control discovery cannot do. The
# section's README is a context document and is not a record. Front matter is flattened
# for all files in one process; a file that does not parse yields no row here and a
# finding under `knowledge check`.
mj_knowledge_candidate_rows() {
  local dir; dir="$(mj_knowledge_candidates_dir)"
  [ -d "$dir" ] || return 0
  local tmp f n=0; tmp="$(mktemp -d "${TMPDIR:-/tmp}/mj.kcr.XXXXXX")"; mkdir -p "$tmp/flat"
  : > "$tmp/map"
  set --
  for f in "$dir"/*.md; do
    [ -f "$f" ] || continue
    mj_is_context_doc "$f" && continue
    set -- "$@" "$f"; n=$((n + 1)); printf '%s\t%s\n' "$n" "$(mj_rel "$f")" >> "$tmp/map"
  done
  [ $# -gt 0 ] || { rm -rf "$tmp"; return 0; }
  mj_yaml_flatten_many "$tmp/flat" --numbered --front "$@"
  mj_knowledge_branch_map > "$tmp/branches"
  awk -F'\t' '
    function unq(s,   i, n, c, out) {
      # the flattener strips the quotes and keeps the escapes; a listing shows the text
      n = length(s); out = ""
      for (i = 1; i <= n; i++) { c = substr(s, i, 1); if (c == "\\" && i < n) { i++; c = substr(s, i, 1) } out = out c }
      return out
    }
    # an empty column is written as `-`: a tab is whitespace to the shell, and a reader
    # splitting on it would fold two empty fields into none and shift every column after
    function nz(x) { return (x == "") ? "-" : x }
    function flush() {
      if (cur == "") return
      ep = ""; for (i = 0; (("provenance.derived_from." i) in v); i++) if (v["provenance.derived_from." i] ~ /^session:/) { ep = substr(v["provenance.derived_from." i], 9); break }
      b = (ep in branch) ? branch[ep] : ""
      if (("id" in v) && v["id"] != "")
        printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", v["id"], nz(v["class"]), nz(v["status"]), nz(v["epistemics"]), nz(v["date"]), nz(ep), nz(b), path[cur], nz(unq(v["title"])), nz(unq(v["about"])), nz(v["route"])
      delete v; cur = ""
    }
    FILENAME == map { path[$1] = $2; next }
    FILENAME == branches { branch[$1] = $2; next }
    FNR == 1 { flush(); cur = FILENAME; sub(/.*\//, "", cur) }
    { eq = index($0, "="); v[substr($0, 1, eq - 1)] = substr($0, eq + 1) }
    END { flush() }' map="$tmp/map" branches="$tmp/branches" "$tmp/map" "$tmp/branches" "$tmp/flat"/[0-9]* 2>/dev/null \
    | LC_ALL=C sort -t "$MJ_TAB" -k1,1
  rm -rf "$tmp"
  return 0
}

# The lines the start briefing prints for one branch: `- <id>  <title>` per record with
# status candidate whose episode worked on BRANCH. An empty BRANCH selects the candidates
# whose episode this checkout cannot place, so that they are named rather than hidden.
mj_knowledge_briefing_lines() {
  mj_knowledge_candidate_rows | awk -F'\t' -v b="${1:--}" '$3 == "candidate" && $7 == b { printf "- %s  %s\n", $1, $9 }'
}

# The knowledge section of a briefing, on stdout: the candidates whose episode worked on
# BRANCH and nobody has yet judged (ADR 0091), the count on one line and the ids beneath it
# bounded to five, else the absence sentence; then, when any, the candidates whose episode
# this checkout cannot place, named apart rather than folded into the branch or dropped —
# hidden is the one thing a review queue must not be. Defined once, so that the start
# briefing (lib/derive.sh) and `majordomus context` (lib/context.sh) print the same bytes.
# It bounds through mj_derive_bounded and counts through mj_derive_nlines: both callers hold
# lib/derive.sh, and the shell resolves the names when this is called, not when it is read.
mj_knowledge_briefing_section() {
  local cand unplaced
  cand="$(mj_knowledge_briefing_lines "$1")"
  if [ -n "$cand" ]; then
    printf 'Knowledge candidates awaiting review on this branch: %s\n' "$(mj_derive_nlines "$cand")"
    printf '%s\n' "$cand" | mj_derive_bounded 5 "candidate(s)"
  else
    printf 'No knowledge candidates await review on this branch. That is an answer, not a gap: `majordomus knowledge candidates` lists every branch.\n'
  fi
  unplaced="$(mj_knowledge_briefing_lines "")"
  if [ -n "$unplaced" ]; then
    printf '%s candidate(s) whose episode is not known in this checkout:\n' "$(mj_derive_nlines "$unplaced")"
    printf '%s\n' "$unplaced" | mj_derive_bounded 5 "candidate(s)"
  fi
  return 0
}

# majordomus knowledge candidates
mj_knowledge_candidates_cmd() {
  while [ $# -gt 0 ]; do case "$1" in
    --help|-h) mj_knowledge_usage; return 0 ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge candidates: unknown option $1" ;;
  esac; done
  local all out groups n=0; all="$(mktemp "${TMPDIR:-/tmp}/mj.kca.XXXXXX")"
  out="$all.c"; groups="$all.g"
  mj_knowledge_candidate_rows > "$all"
  awk -F'\t' '$3 == "candidate"' "$all" > "$out"
  # ADR 0118: what the candidates are about, grouped. A group is every record that names one
  # subject — awaiting review, or rejected and kept — with the episodes behind them, so a
  # recurring defect reads as one thing and a rejection is listed beside what came after it.
  # Rows: about, route, candidate ids, rejected ids, episodes (space-joined, sorted, '-' none).
  awk -F'\t' '
    $10 == "-" { next }
    $3 != "candidate" && $3 != "superseded" { next }
    { a = $10; route[a] = $11
      if ($3 == "candidate") cand[a] = cand[a] " " $1; else rej[a] = rej[a] " " $1
      if ($6 != "-" && !((a, $6) in seen)) { seen[a, $6] = 1; eps[a] = eps[a] " " $6 } }
    END { for (a in route) {
            c = cand[a]; r = rej[a]; e = eps[a]; sub(/^ /, "", c); sub(/^ /, "", r); sub(/^ /, "", e)
            printf "%s\t%s\t%s\t%s\t%s\n", a, route[a], (c == "" ? "-" : c), (r == "" ? "-" : r), (e == "" ? "-" : e) } }' "$all" \
    | LC_ALL=C sort -t "$MJ_TAB" -k1,1 > "$groups"
  local id cls st epi date ep br path title about route c r e first=1 j
  if [ "$MJ_JSON" = 1 ]; then
    printf '{"schema":1,"candidates":['
    while IFS="$MJ_TAB" read -r id cls st epi date ep br path title about route; do
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"id":"%s","class":"%s","status":"%s","epistemics":"%s","date":"%s","episode":"%s","branch":%s,"path":"%s","title":"%s"' \
        "$(mj_json_esc "$id")" "$cls" "$st" "$epi" "$date" "$([ "$ep" != - ] && mj_json_esc "$ep")" \
        "$([ "$br" != - ] && printf '"%s"' "$(mj_json_esc "$br")" || printf 'null')" "$(mj_json_esc "$path")" "$(mj_json_esc "$title")"
      [ "${about:--}" = - ] || printf ',"about":"%s","route":"%s"' "$(mj_json_esc "$about")" "$route"
      printf '}'
    done < "$out"
    printf '],"groups":['; first=1
    while IFS="$MJ_TAB" read -r about route c r e; do
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"about":"%s","route":"%s"' "$(mj_json_esc "$about")" "$route"
      for j in candidates:"$c" rejected:"$r" episodes:"$e"; do
        printf ',"%s":[%s]' "${j%%:*}" "$( [ "${j#*:}" = - ] || printf '%s\n' ${j#*:} | while IFS= read -r v; do printf '"%s",' "$(mj_json_esc "$v")"; done | sed 's/,$//')"
      done
      printf '}'
    done < "$groups"
    printf ']}\n'
  else
    while IFS="$MJ_TAB" read -r id cls st epi date ep br path title about route; do
      printf '%-46s %-10s %-10s %-24s %s\n' "$id" "$cls" "$date" "${br:--}" "$title"
      n=$((n + 1))
    done < "$out"
    printf 'knowledge candidates: %s awaiting review\n' "$n"
    while IFS="$MJ_TAB" read -r about route c r e; do
      printf 'about %s  route %s  awaiting %s  rejected %s  episodes %s\n' "$about" "$route" \
        "$( [ "$c" = - ] && printf 0 || printf '%s\n' $c | grep -c .)" \
        "$( [ "$r" = - ] && printf 0 || printf '%s\n' $r | grep -c .)" \
        "$( [ "$e" = - ] && printf 0 || printf '%s\n' $e | grep -c .)"
    done < "$groups"
  fi
  rm -f "$all" "$out" "$groups"
  return 0
}

# ---------------------------------------------------------------- the acts
# A person promotes or rejects; the tool records that they did. `verified` is written
# here and by a person editing the file, and nowhere else.

# majordomus knowledge promote <id> [--class <c>] < evidence.md
mj_knowledge_promote_cmd() {
  local id="" class=""
  while [ $# -gt 0 ]; do case "$1" in
    --class) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--class needs a value"; class="$2"; shift 2 ;;
    --class=*) class="${1#--class=}"; shift ;;
    --help|-h) mj_knowledge_usage; return 0 ;;
    -*) mj_die "$MJ_EX_USAGE" "knowledge promote: unknown option $1" ;;
    *) [ -z "$id" ] || mj_die "$MJ_EX_USAGE" "knowledge promote: one id, not two"; id="$1"; shift ;;
  esac; done
  [ -n "$id" ] || mj_die "$MJ_EX_USAGE" "knowledge promote: the candidate id is required (usage: majordomus knowledge promote <id> < evidence.md)"
  if [ -n "$class" ]; then case " $MJ_KNOWLEDGE_CLASSES " in *" $class "*) ;;
    *) mj_die "$MJ_EX_CONTRACT" "knowledge promote: --class must be one of: $MJ_KNOWLEDGE_CLASSES" ;; esac; fi
  local cdir kdir src; cdir="$(mj_knowledge_candidates_dir)"; kdir="$(mj_knowledge_curated_dir)"; src="$cdir/$id.md"
  [ -f "$src" ] || mj_die "$MJ_EX_MISSING" "knowledge promote: no candidate with id $id under $(mj_rel "$cdir")/"
  local status; status="$(mj_knowledge_record_status "$src")"
  [ "$status" = candidate ] || mj_die "$MJ_EX_CONTRACT" "knowledge promote: $id has status $status, not candidate; only a candidate is promoted"
  grep -q '^  origin: ' "$src" || mj_die "$MJ_EX_CONTRACT" "knowledge promote: $id carries no provenance.origin, and a verified record must say where it came from"

  # An act without evidence is an assertion, and a record is never a transcript.
  local ev; ev="$(mktemp "${TMPDIR:-/tmp}/mj.kev.XXXXXX")"
  if [ ! -t 0 ]; then cat > "$ev"; fi
  grep -q '[^[:space:]]' "$ev" 2>/dev/null \
    || { rm -f "$ev"; mj_die "$MJ_EX_CONTRACT" "knowledge promote: the evidence is required on stdin; an act without evidence is an assertion"; }
  grep -Eq "$MJ_KNOWLEDGE_TRANSCRIPT" "$ev" \
    && { rm -f "$ev"; mj_die "$MJ_EX_CONTRACT" "knowledge promote: the evidence carries a conversation (a line opening with transcript, messages, assistant, completion or response); a record is an assertion, never a transcript"; }

  local sid today rec; sid="$(mj_open_session_id)"; today="$(mj_now)"; today="${today%%T*}"
  rec="$(mktemp "${TMPDIR:-/tmp}/mj.kpr.XXXXXX")"
  {
    printf -- '---\n'
    mj_record_front "$src" | awk -v cls="$class" -v date="$today" -v sid="$sid" '
      /^status: / { print "status: verified"; next }
      /^class: /  { if (cls != "") { print "class: " cls; next } }
      /^date: /   { print "date: " date; next }
      /^  derived_from:/ { indf = 1; print; next }
      indf && /^    - / { if ($0 == "    - session:" sid) have = 1; print; next }
      indf { if (sid != "" && !have) print "    - session:" sid; indf = 0 }
      { print }
      END { if (indf && sid != "" && !have) print "    - session:" sid }'
    printf -- '---\n'
    mj_record_body "$src"
    printf '\n# Evidence\n\n'
    cat "$ev"; [ -n "$(tail -c1 "$ev")" ] && printf '\n'
  } > "$rec"
  rm -f "$ev"
  mj_knowledge_write_record "$kdir" "$id" "$rec" >/dev/null \
    || { rm -f "$rec"; mj_die "$MJ_EX_INTERNAL" "knowledge promote: could not write $(mj_rel "$kdir")/$id.md"; }
  rm -f "$rec" "$src"
  local rel; rel="$(mj_rel "$kdir")/$id.md"
  mj_ledger_append knowledge.promoted "\"id\":\"$(mj_json_esc "$id")\",\"path\":\"$(mj_json_esc "$rel")\""
  printf '%s\n' "$rel"
}

# majordomus knowledge reject <id> --reason "<why>" [--by <id>]
mj_knowledge_reject_cmd() {
  local id="" reason="" by=""
  while [ $# -gt 0 ]; do case "$1" in
    --reason) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--reason needs a value"; reason="$2"; shift 2 ;;
    --reason=*) reason="${1#--reason=}"; shift ;;
    --by) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--by needs a value"; by="$2"; shift 2 ;;
    --by=*) by="${1#--by=}"; shift ;;
    --help|-h) mj_knowledge_usage; return 0 ;;
    -*) mj_die "$MJ_EX_USAGE" "knowledge reject: unknown option $1" ;;
    *) [ -z "$id" ] || mj_die "$MJ_EX_USAGE" "knowledge reject: one id, not two"; id="$1"; shift ;;
  esac; done
  [ -n "$id" ] || mj_die "$MJ_EX_USAGE" "knowledge reject: the candidate id is required"
  printf '%s' "$reason" | grep -q '[^[:space:]]' || mj_die "$MJ_EX_USAGE" "knowledge reject: --reason is required and must say why"
  local cdir kdir src; cdir="$(mj_knowledge_candidates_dir)"; kdir="$(mj_knowledge_curated_dir)"; src="$cdir/$id.md"
  [ -f "$src" ] || mj_die "$MJ_EX_MISSING" "knowledge reject: no candidate with id $id under $(mj_rel "$cdir")/"
  local status; status="$(mj_knowledge_record_status "$src")"
  [ "$status" = candidate ] || mj_die "$MJ_EX_CONTRACT" "knowledge reject: $id has status $status, not candidate; only a candidate is rejected"
  if [ -n "$by" ]; then
    [ -f "$cdir/$by.md" ] || [ -f "$kdir/$by.md" ] \
      || mj_die "$MJ_EX_CONTRACT" "knowledge reject: --by names $by, and no record with that id exists under candidates/ or curated/"
  fi
  local rec; rec="$(mktemp "${TMPDIR:-/tmp}/mj.krj.XXXXXX")"
  {
    printf -- '---\n'
    mj_record_front "$src" | awk -v by="$by" '
      /^status: / { print "status: superseded"; if (by != "") print "superseded_by: " by; next }
      /^superseded_by: / { next }
      { print }'
    printf -- '---\n'
    mj_record_body "$src"
    printf '\n# Rejected\n\n%s\n' "$reason"
  } > "$rec"
  mj_knowledge_write_record "$cdir" "$id" "$rec" >/dev/null \
    || { rm -f "$rec"; mj_die "$MJ_EX_INTERNAL" "knowledge reject: could not rewrite $(mj_rel "$src")"; }
  rm -f "$rec"
  local fields; fields="\"id\":\"$(mj_json_esc "$id")\",\"reason\":\"$(mj_json_esc "$reason")\""
  [ -n "$by" ] && fields="$fields,\"superseded_by\":\"$(mj_json_esc "$by")\""
  mj_ledger_append knowledge.rejected "$fields"
  printf '%s\n' "$(mj_rel "$src")"
}

# ---------------------------------------------------------------- the check
# Every record under candidates/ and curated/, judged in one pass: the front matter of all
# of them flattened by one process, every reference they name collected once and resolved
# in batches — the ledger read once for every session and task, git asked once for every
# commit — and the findings written to a file the command and the validator both read.
#
# mj_knowledge_check_files OUTDIR FILE...   findings: <relpath>\t<code>\t<message>
#                                            records:  <relpath> per file judged
mj_knowledge_check_files() {
  local tmp="$1"; shift
  mkdir -p "$tmp/flat"; : > "$tmp/findings"; : > "$tmp/records"; : > "$tmp/map"; : > "$tmp/historical"
  [ $# -gt 0 ] || return 0
  local f n=0 t0
  for f in "$@"; do n=$((n + 1)); printf '%s\t%s\n' "$n" "$(mj_rel "$f")" >> "$tmp/map"; printf '%s\n' "$(mj_rel "$f")" >> "$tmp/records"; done
  t0="$(mj_phase_begin knowledge:check-flatten)"
  mj_yaml_flatten_many "$tmp/flat" --numbered --front "$@"
  [ -f "$tmp/flat/.errors" ] || : > "$tmp/flat/.errors"
  # the bodies, one process: a conversation, and the section a rejection records
  awk 'FNR == 1 { n++; fences = 0 }
       /^---[ \t]*$/ && fences < 2 { fences++; next }
       fences < 2 { next }
       /^(transcript|messages|assistant|completion|response)[: ]/ { if (!t[n]++) print n "\tTRANSCRIPT" }
       /^# Rejected[ \t]*$/ { if (!r[n]++) print n "\tREJECTED" }' "$@" > "$tmp/body"
  mj_phase_end knowledge:check-flatten "$t0"
  local cand; cand="$(mj_rel "$(mj_knowledge_candidates_dir)")/"
  t0="$(mj_phase_begin knowledge:check-shape)"
  # the flattened files, listed rather than globbed into awk: a record that did not parse has
  # none, and an empty glob handed to awk as a literal name is an error, not an empty input
  local flat=() ff; for ff in "$tmp/flat"/[0-9]*; do if [ -f "$ff" ]; then flat+=("$ff"); fi; done
  # A validator that does not run has not passed anything. Its stderr is kept and, if it
  # fails, every record is reported as unvalidated with the reason, so neither `knowledge
  # check` nor doctor can read an empty finding list as a clean store.
  if ! awk -F'\t' -v cand="$cand" -v classes=" $MJ_KNOWLEDGE_CLASSES " -v statuses=" $MJ_KNOWLEDGE_STATUSES " \
      -v epis=" $MJ_KNOWLEDGE_EPISTEMICS " -v origins=" $MJ_KNOWLEDGE_ORIGINS " -v rels=" $MJ_KNOWLEDGE_RELATIONS " '
    function fail(code, msg) { printf "F\t%s\t%s\t%s\n", path[cur], code, msg }
    function has(k) { return (k in v) && v[k] != "" }
    function idpat(s) { return s ~ /^[a-z][a-z0-9-]*$/ }
    function flush(   i, k, key, ok, base, t) {
      if (cur == "") return
      if (cur in bad) { fail("shape", "does not parse as knowledge/v1 front matter: " bad[cur]); done[cur] = 1; delete v; cur = ""; return }
      done[cur] = 1
      for (key in v) {
        ok = 0
        for (i = 1; i <= nallow; i++) if (key ~ allow[i]) { ok = 1; break }
        if (!ok) fail("unknown_key", "unknown key `" key "`; the schema knowledge/v1 declares no such field")
      }
      if (v["schema"] != "knowledge/v1") fail("schema", "schema must be knowledge/v1, not `" v["schema"] "`")
      if (v["kind"] != "knowledge") fail("kind", "kind must be knowledge, not `" v["kind"] "`")
      if (!has("id")) fail("id", "id is required")
      else {
        if (!idpat(v["id"])) fail("id", "id `" v["id"] "` is not lower-case letters, digits and hyphens")
        base = path[cur]; sub(/.*\//, "", base); sub(/\.md$/, "", base)
        if (base != v["id"]) fail("id", "id `" v["id"] "` does not equal the file name `" base "`; the file name is the identity")
        if (v["id"] in seen) { fail("duplicate_id", "id `" v["id"] "` is also claimed by " seen[v["id"]]); printf "F\t%s\tduplicate_id\tid `%s` is also claimed by %s\n", seen[v["id"]], v["id"], path[cur] }
        else seen[v["id"]] = path[cur]
      }
      if (!has("title")) fail("title", "title is required and must be one assertion")
      if (!has("description")) fail("description", "description is required")
      if (index(classes, " " v["class"] " ") == 0) fail("class", "class `" v["class"] "` is not one of:" classes)
      if (index(statuses, " " v["status"] " ") == 0) fail("status", "status `" v["status"] "` is not one of:" statuses)
      if (index(epis, " " v["epistemics"] " ") == 0) fail("epistemics", "epistemics `" v["epistemics"] "` is not one of:" epis)
      if (v["date"] !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) fail("date", "date `" v["date"] "` is not YYYY-MM-DD")
      for (i = 0; ("tags." i) in v; i++) if (!idpat(v["tags." i])) fail("tags", "tag `" v["tags." i] "` is not lower-case letters, digits and hyphens")
      if (has("provenance.origin") && index(origins, " " v["provenance.origin"] " ") == 0) fail("provenance", "provenance.origin `" v["provenance.origin"] "` is not one of:" origins)
      if (v["status"] == "verified" && !has("provenance.origin")) fail("provenance", "verified, and carries no provenance.origin; nothing is confirmed by having been written down")
      if (v["provenance.origin"] == "extracted" && !(("provenance.derived_from.0") in v)) fail("provenance", "origin extracted, and derived_from names no evidence")
      if (index(path[cur], cand) == 1 && v["status"] == "verified") fail("status", "claims verified under " cand "; the deriver never writes verified, and promotion moves a record to curated/")
      if (v["status"] == "superseded") {
        if (has("superseded_by")) { if (!idpat(v["superseded_by"])) fail("superseded_by", "superseded_by `" v["superseded_by"] "` is not a record id"); else printf "R\t%s\tknowledge:%s\tsuperseded_by\n", path[cur], v["superseded_by"] }
        else if (!((cur "\tREJECTED") in body)) fail("superseded", "superseded, and names neither superseded_by nor a `# Rejected` section saying why")
      } else if (has("superseded_by")) fail("superseded_by", "names superseded_by while its status is " v["status"] "; superseded_by is present exactly when the status is superseded")
      for (i = 0; ("provenance.derived_from." i) in v; i++) {
        t = v["provenance.derived_from." i]
        if (t !~ /^[a-z]+:.+$/) fail("reference", "derived_from `" t "` is not a typed reference (<kind>:<identity>)")
        else printf "R\t%s\t%s\tprovenance.derived_from.%d\n", path[cur], t, i
      }
      for (i = 0; ("relations." i ".type") in v || ("relations." i ".target") in v; i++) {
        if (index(rels, " " v["relations." i ".type"] " ") == 0) fail("relation", "relations." i ".type `" v["relations." i ".type"] "` is not one of:" rels)
        t = v["relations." i ".target"]
        if (t !~ /^[a-z]+:.+$/) fail("reference", "relations." i ".target `" t "` is not a typed reference (<kind>:<identity>)")
        else printf "R\t%s\t%s\trelations.%d.target\n", path[cur], t, i
      }
      if (v["title"] ~ /^(transcript|messages|assistant|completion|response)[: ]/) fail("transcript", "the title opens like a turn of a conversation; a record is an assertion, never a transcript")
      if (v["description"] ~ /^(transcript|messages|assistant|completion|response)[: ]/) fail("transcript", "the description opens like a turn of a conversation; a record is an assertion, never a transcript")
      if ((cur "\tTRANSCRIPT") in body) fail("transcript", "the body carries a conversation (a line opening with transcript, messages, assistant, completion or response)")
      delete v; cur = ""
    }
    FILENAME == mapf { path[$1] = $2; next }
    FILENAME == errf { bad[$1] = $2; next }
    FILENAME == bodyf { body[$1 "\t" $2] = 1; next }
    FILENAME == allowf { if ($0 !~ /^#/ && $0 != "") allow[++nallow] = $0; next }
    FNR == 1 { flush(); cur = FILENAME; sub(/.*\//, "", cur) }
    { eq = index($0, "="); v[substr($0, 1, eq - 1)] = substr($0, eq + 1) }
    END {
      flush()
      # a record that did not parse has no flattened file and was never flushed: say so here,
      # once. `next` is not allowed in END by gawk or mawk (only BWK awk tolerates it), and a
      # program that does not compile there killed `knowledge check` with no output at all.
      for (k in bad) if ((k in path) && !(k in done)) { cur = k; fail("shape", "does not parse as knowledge/v1 front matter: " bad[k]) }
    }' \
    mapf="$tmp/map" errf="$tmp/flat/.errors" bodyf="$tmp/body" allowf="$MJ_ALLOW_DIR/knowledge.txt" \
    "$tmp/map" "$tmp/flat/.errors" "$tmp/body" "$MJ_ALLOW_DIR/knowledge.txt" ${flat[@]+"${flat[@]}"} \
    2> "$tmp/shape.err" > "$tmp/shape"; then
    local why; why="$(head -n 2 "$tmp/shape.err" | tr '\t\n' '  ')"
    awk -v why="${why:-awk exited non-zero and said nothing}" \
      '{ printf "F\t%s\tvalidator\tnot validated: the knowledge/v1 validator did not run: %s\n", $0, why }' \
      "$tmp/records" > "$tmp/shape"
  fi
  mj_phase_end knowledge:check-shape "$t0"
  sed -n 's/^F\t//p' "$tmp/shape" > "$tmp/findings"
  # every reference, resolved once, and one finding per record that names one nothing resolves
  t0="$(mj_phase_begin knowledge:check-resolve)"
  sed -n 's/^R\t//p' "$tmp/shape" > "$tmp/refs"
  if [ -s "$tmp/refs" ]; then
    cut -f2 "$tmp/refs" | LC_ALL=C sort -u | mj_knowledge_resolve_refs > "$tmp/resolved"
    # a historical reference resolves, and is named apart: <record> <reference> <field>
    awk -F'\t' -v hist="$tmp/historical" 'FILENAME == res { st[$1] = $2; next }
      { r = st[$2]
        if (r == "ok") next
        if (r == "historical") { printf "%s\t%s\t%s\n", $1, $2, $3 > hist; next }
        if (r == "refused") printf "%s\treference\t%s names `%s`, and `none` is not an identity: a decision recorded outside a task names the episode only\n", $1, $3, $2
        else if (r == "unknown") printf "%s\treference\t%s names `%s`, whose kind nothing in this layer resolves\n", $1, $3, $2
        else printf "%s\treference\t%s names `%s`, which resolves to nothing: not a record, not a ledger object, not a commit, not a tracked file, not a file in this history\n", $1, $3, $2 }' \
      res="$tmp/resolved" "$tmp/resolved" "$tmp/refs" >> "$tmp/findings"
  fi
  mj_phase_end knowledge:check-resolve "$t0"
  LC_ALL=C sort -t "$MJ_TAB" -k1,1 -o "$tmp/findings" "$tmp/findings"
  return 0
}

# A file reference the tree no longer holds, found in git history (I1987). A record's
# provenance is a statement about when it was written, and deleting the file it cites later
# does not make the citation false: OSCILLA's 5467193 removed tests/engine.cjs and
# tests/smoke.cjs, two notes cited them, and `knowledge check` failed with no remedy but
# rewriting the evidence. Two forms resolve here:
#
#   file:<path>@<commit>   pinned: the path at that commit (7 to 40 hex digits), and only there
#   file:<path>            the path at the newest commit reachable from HEAD that touched it
#
# Prints `<commit>:<path>`, the blob that answers (`git show` reads it), and returns 1 when
# no commit of this history ever held the path — a reference to a file that never existed
# still resolves to nothing. Literal pathspecs: `file:*.md` names one path, not a glob. A
# shallow clone that lacks the commit cannot vouch for it and answers missing, as it does for
# a `commit:` reference.
mj_knowledge_file_in_history() {
  local ident="$1" path commit last
  case "$ident" in
    *@*)
      path="${ident%@*}"; commit="${ident##*@}"
      if [ -n "$path" ] && printf '%s' "$commit" | grep -Eqx '[0-9a-f]{7,40}'; then
        mj_git cat-file -e "$commit:$path" 2>/dev/null || return 1
        printf '%s:%s\n' "$commit" "$path"; return 0
      fi ;;
  esac
  case "$ident" in ''|/*|..|../*|*/..|*/../*) return 1 ;; esac
  last="$(mj_git --literal-pathspecs rev-list -1 HEAD -- "$ident" 2>/dev/null)" || return 1
  [ -n "$last" ] || return 1
  # the newest commit that touched it either still holds it or is the one that deleted it
  if mj_git cat-file -e "$last:$ident" 2>/dev/null; then printf '%s:%s\n' "$last" "$ident"
  else printf '%s^:%s\n' "$last" "$ident"; fi
}

# Distinct typed references on stdin, one per line; prints
# `ref<TAB>ok|historical|missing|refused|unknown`. `historical` is a file or test the tree no
# longer holds and git history does (mj_knowledge_file_in_history): it resolves, and the check
# names it rather than passing it in silence.
# The batches: one pass over the ledger for every episode and task named, one
# `git cat-file --batch-check` for every commit, one listing of ids for the rules, the
# decisions and the project model; a file is a stat, and a git lookup only when the stat
# finds nothing.
mj_knowledge_resolve_refs() {
  local tmp led; tmp="$(mktemp -d "${TMPDIR:-/tmp}/mj.krr.XXXXXX")"; led="$(mj_knowledge_ledger)"
  LC_ALL=C sort -u > "$tmp/refs"
  [ -s "$tmp/refs" ] || { rm -rf "$tmp"; return 0; }
  : > "$tmp/have"
  if grep -Eq '^(session|task|decision):' "$tmp/refs"; then
    mj_count knowledge_resolve_ledger
    if [ -f "$led" ]; then
      awk "$MJ_LEDGER_FIELD_AWK"'{
             if (index($0, "\"session\":\"")) { s = mjfield($0, "session"); if (s != "" && !ss[s]++) print "session\t" s }
             if (index($0, "\"session_id\":\"")) { s = mjfield($0, "session_id"); if (s != "" && !ss[s]++) print "session\t" s }
             if (index($0, "\"task_id\":\"")) { t = mjfield($0, "task_id"); if (t != "" && !tt[t]++) print "task\t" t } }' "$led" >> "$tmp/have"
    fi
    mj_knowledge_branch_map | cut -f1 | sed 's/^/session\t/' >> "$tmp/have"
    [ -f "$MJ_STATE_DIR/current.yaml" ] && sed -n 's/^id: /task\t/p' "$MJ_STATE_DIR/current.yaml" | head -n 1 >> "$tmp/have"
    local f; for f in "$MJ_STATE_DIR"/completed/*.md; do [ -f "$f" ] && printf 'task\t%s\n' "$(basename "$f" .md)"; done >> "$tmp/have"
  fi
  if grep -q '^commit:' "$tmp/refs"; then
    mj_count knowledge_resolve_git
    sed -n 's/^commit://p' "$tmp/refs" | mj_git cat-file --batch-check 2>/dev/null \
      | awk '$2 != "missing" && NF >= 3 { print "commit\t" $1 }' >> "$tmp/have"
  fi
  if grep -Eq '^(rule|adr):' "$tmp/refs"; then
    grep -rh --include='*.md' '^id: ' "$MJ_RULES_DIR" 2>/dev/null | sed 's/^id: */rule\t/' >> "$tmp/have"
    grep -rh --include='*.md' '^id: ' "$MJ_ADRS_DIR" 2>/dev/null | sed 's/^id: */adr\t/' >> "$tmp/have"
  fi
  if grep -q '^issue:' "$tmp/refs"; then
    grep -rh --include='*.yaml' '^id: ' "$MJ_PROJECT_DIR" 2>/dev/null | sed 's/^id: */issue\t/' >> "$tmp/have"
  fi
  local ref kind ident st cdir kdir; cdir="$(mj_knowledge_candidates_dir)"; kdir="$(mj_knowledge_curated_dir)"
  while IFS= read -r ref; do
    kind="${ref%%:*}"; ident="${ref#*:}"; st=missing
    case "$kind" in
      file|test)
        if [ -e "$MJ_ROOT/$ident" ]; then st=ok
        elif mj_knowledge_file_in_history "$ident" >/dev/null; then st=historical; fi ;;
      knowledge) { [ -f "$cdir/$ident.md" ] || [ -f "$kdir/$ident.md" ]; } && st=ok ;;
      session|commit|rule|adr|issue) grep -Fxq "$kind$MJ_TAB$ident" "$tmp/have" && st=ok ;;
      task|decision) if [ "$ident" = none ]; then st=refused; else grep -Fxq "task$MJ_TAB$ident" "$tmp/have" && st=ok; fi ;;
      *) st=unknown ;;
    esac
    printf '%s\t%s\n' "$ref" "$st"
  done < "$tmp/refs"
  rm -rf "$tmp"
  return 0
}

# One reference: 0 when it resolves, in the tree or in its history; 1 otherwise.
mj_knowledge_ref_resolves() {
  case "$(printf '%s\n' "$1" | mj_knowledge_resolve_refs | cut -f2)" in ok|historical) return 0 ;; esac
  return 1
}

# The records the check covers: every Markdown file under candidates/ and curated/ that
# is not the section's context document, sorted, absolute paths one per line.
mj_knowledge_record_files() {
  local d f
  for d in "$(mj_knowledge_candidates_dir)" "$(mj_knowledge_curated_dir)"; do
    [ -d "$d" ] || continue
    for f in "$d"/*.md; do
      [ -f "$f" ] || continue
      mj_is_context_doc "$f" && continue
      printf '%s\n' "$f"
    done
  done | LC_ALL=C sort
}

# One record, judged alone: prints `FAIL<TAB><code><TAB><message>` per finding.
mj_knowledge_check_record() {
  local tmp; tmp="$(mktemp -d "${TMPDIR:-/tmp}/mj.kck.XXXXXX")"
  mj_knowledge_check_files "$tmp" "$1"
  awk -F'\t' '{ printf "FAIL\t%s\t%s\n", $2, $3 }' "$tmp/findings"
  rm -rf "$tmp"
}

# Every record, judged together. Leaves findings and records under OUTDIR.
mj_knowledge_check_all() {
  local tmp="$1" f
  set --
  while IFS= read -r f; do [ -n "$f" ] && set -- "$@" "$f"; done <<EOF
$(mj_knowledge_record_files)
EOF
  mj_knowledge_check_files "$tmp" "$@"
}

# majordomus knowledge check
mj_knowledge_check_cmd() {
  while [ $# -gt 0 ]; do case "$1" in
    --help|-h) mj_knowledge_usage; return 0 ;;
    *) mj_die "$MJ_EX_USAGE" "knowledge check: unknown option $1" ;;
  esac; done
  local tmp n f failing; tmp="$(mktemp -d "${TMPDIR:-/tmp}/mj.kch.XXXXXX")"
  mj_knowledge_check_all "$tmp"
  n="$(mj_lines "$tmp/records")"
  failing="$(cut -f1 "$tmp/findings" | LC_ALL=C sort -u | grep -c . || true)"
  local rel code msg first=1
  if [ "$MJ_JSON" = 1 ]; then
    printf '{"schema":1,"records":%s,"failing":%s,"findings":[' "$n" "$failing"
    while IFS="$MJ_TAB" read -r rel code msg; do
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"path":"%s","code":"%s","message":"%s"}' "$(mj_json_esc "$rel")" "$code" "$(mj_json_esc "$msg")"
    done < "$tmp/findings"
    # the references that resolved through git history alone, so a reader sees which
    # provenance outlived its file
    printf '],"historical":['; first=1
    local ref field
    while IFS="$MJ_TAB" read -r rel ref field; do
      [ -n "$rel" ] || continue
      [ "$first" = 1 ] || printf ','; first=0
      printf '{"path":"%s","reference":"%s","field":"%s"}' "$(mj_json_esc "$rel")" "$(mj_json_esc "$ref")" "$(mj_json_esc "$field")"
    done < "$tmp/historical"
    printf ']}\n'
  else
    while IFS= read -r f; do
      [ -n "$f" ] || continue
      if grep -q "^$(printf '%s' "$f" | sed 's/[][\.*^$]/\\&/g')$MJ_TAB" "$tmp/findings"; then
        grep "^$(printf '%s' "$f" | sed 's/[][\.*^$]/\\&/g')$MJ_TAB" "$tmp/findings" | while IFS="$MJ_TAB" read -r rel code msg; do
          mj_fail knowledge "$rel" "$msg" "majordomus knowledge check"
        done
      else
        local hist
        hist="$(awk -F'\t' -v f="$f" '$1 == f { printf "%s%s", (n++ ? ", " : ""), $2 }' "$tmp/historical")"
        if [ -n "$hist" ]; then
          mj_ok knowledge "$f" "valid against knowledge/v1; every reference resolves, historical (deleted from the tree, held by git history): $hist"
        else
          mj_ok knowledge "$f" "valid against knowledge/v1; every reference resolves"
        fi
      fi
    done < "$tmp/records"
    printf 'knowledge check: %s record(s), %s failing\n' "$n" "$failing"
  fi
  rm -rf "$tmp"
  [ "$failing" = 0 ] || return "$MJ_EX_CONTRACT"
  return 0
}

# ---------------------------------------------------------------- doctrine
# Three validators, declared by three rules of the standard package
# (majordomus.knowledge-observed, majordomus.knowledge-integrity,
# majordomus.candidates-reviewed) and dispatched from the doctrine registry. Each returns 0
# and reports only through mj_doctrine_fail / ok / skip.

# The ADR 0052 invariant, applied to this writer: while episodes keep closing, a knowledge
# derivation follows every close. Two halves. The wiring half reads the source: the close
# path and the compaction adapter must call the deriver while their switches are on. The
# freshness half reads the ledger: the newest closed episode is named by a knowledge.derived
# line, or it is younger than the stale threshold. It compares episode ids, never line
# order or timestamps between the two events, because the close derives before it appends
# session.closed. It judges only once a derivation has run in this checkout: the day the
# writer arrives, no checkout turns red for what it could not yet have done.
mj_validate_knowledge_lifecycle() {
  local led bad=0; led="$(mj_knowledge_ledger)"
  if [ "$(mj_pol session.knowledge_on_end)" != false ] && ! grep -q 'mj_cmd_knowledge derive' "$MJ_LIB_DIR/session.sh" 2>/dev/null; then
    mj_doctrine_fail knowledge "lib/session.sh" "session.knowledge_on_end is on and the close path never calls the deriver" "grep -n 'mj_cmd_knowledge derive' lib/session.sh"; bad=1
  fi
  if [ "$(mj_pol session.knowledge_on_compact)" != false ] && ! grep -q 'mj_cmd_knowledge derive' "$MJ_LIB_DIR/capture.sh" 2>/dev/null; then
    mj_doctrine_fail knowledge "lib/capture.sh" "session.knowledge_on_compact is on and the compaction adapter never calls the deriver" "grep -n 'mj_cmd_knowledge derive' lib/capture.sh"; bad=1
  fi
  [ "$bad" = 0 ] && mj_doctrine_ok knowledge "wiring" "the close path and the compaction adapter call the deriver"

  if [ ! -f "$led" ]; then mj_doctrine_ok knowledge "deriver" "no ledger yet; no episode has closed here"; return 0; fi
  local closed; closed="$(grep -c '"event":"session.closed"' "$led" 2>/dev/null || true)"; : "${closed:=0}"
  if [ "$closed" = 0 ]; then mj_doctrine_ok knowledge "deriver" "no episode has closed here; nothing to judge"; return 0; fi
  if [ "$(mj_pol session.knowledge_on_end)" = false ]; then
    mj_doctrine_skip knowledge "deriver" "session.knowledge_on_end is false; the deriver is switched off here and its silence is not judged"
    [ "$bad" = 0 ] && MJ_DOCTRINE_SKIPPED=1
    return 0
  fi
  local derived; derived="$(grep -c '"event":"knowledge.derived"' "$led" 2>/dev/null || true)"; : "${derived:=0}"
  if [ "$derived" = 0 ]; then
    mj_doctrine_skip knowledge "deriver" "no knowledge derivation has run in this checkout yet, so the newest close is not judged; the first `majordomus knowledge derive` starts the judgement"
    [ "$bad" = 0 ] && MJ_DOCTRINE_SKIPPED=1
    return 0
  fi
  local line stamp ts age stale
  line="$(grep -F '"event":"session.closed"' "$led" | tail -n 1)"
  stamp="$(mj_json_field "$line" session)"; ts="$(mj_json_field "$line" ts)"
  if [ -z "$stamp" ]; then
    mj_doctrine_skip knowledge "deriver" "the newest session.closed line names no episode; nothing to compare a derivation against"
    [ "$bad" = 0 ] && MJ_DOCTRINE_SKIPPED=1
    return 0
  fi
  if grep -F '"event":"knowledge.derived"' "$led" | grep -Fq "\"episode\":\"$stamp\""; then
    mj_doctrine_ok knowledge "deriver" "the newest closed episode ($stamp) was followed by a knowledge derivation"
    return 0
  fi
  if ! stale="$(mj_pol_req session.freshness.stale_minutes)"; then
    mj_doctrine_fail knowledge "deriver" "policy declares no session.freshness.stale_minutes" "add it under session.freshness: in $(mj_rel "$MJ_POLICY_FILE"); see share/skeleton/policy.yaml"; return 0
  fi
  age="$(mj_age_minutes "$ts" || true)"
  if [ -z "$age" ]; then
    mj_doctrine_fail knowledge "deriver" "the newest session.closed line cannot be dated ($ts), so nothing can say whether the writer is still running" "majordomus history --event session.closed"
  elif [ "$age" -ge "$stale" ]; then
    mj_doctrine_fail knowledge "deriver" \
      "episodes close and no knowledge.derived followed the newest ($stamp, $(mj_age_human "$age")); the writer has stopped. fix: majordomus knowledge derive --episode $stamp" \
      "majordomus history --event session.closed; majordomus history --event knowledge.derived"
  else
    mj_doctrine_ok knowledge "deriver" "the newest closed episode ($stamp) awaits its derivation ($(mj_age_human "$age"), under the $(mj_duration_human "$stale") this repository calls stale)"
  fi
  return 0
}

# Every record is schema-valid, evidenced, uniquely identified, resolves what it names and
# carries no conversation. `majordomus knowledge check` is the same judgement as a command.
mj_validate_knowledge_integrity() {
  local cdir kdir; cdir="$(mj_knowledge_candidates_dir)"; kdir="$(mj_knowledge_curated_dir)"
  if [ ! -d "$cdir" ] && [ ! -d "$kdir" ]; then
    mj_doctrine_ok knowledge "records" "no knowledge records; nothing to validate"; return 0
  fi
  local tmp n rel code msg bad=0; tmp="$(mktemp -d "${TMPDIR:-/tmp}/mj.kvi.XXXXXX")"
  mj_knowledge_check_all "$tmp"
  n="$(mj_lines "$tmp/records")"
  while IFS="$MJ_TAB" read -r rel code msg; do
    [ -n "$rel" ] || continue
    mj_doctrine_fail knowledge "$rel" "$code: $msg" "majordomus knowledge check"; bad=1
  done < "$tmp/findings"
  rm -rf "$tmp"
  [ "$bad" = 0 ] && mj_doctrine_ok knowledge "records" "$n record(s) valid against knowledge/v1; every reference resolves" "majordomus knowledge check"
  return 0
}

# Candidates are reviewed, not accumulated. More records awaiting review than the policy's
# cap, or one waiting longer than the policy's age, is a finding that names them; both
# numbers are declared once in the policy. A candidate's review age is the age of its
# queue entry — the oldest knowledge.derived line naming the file, else the commit that
# added it, else its own date — and the finding says which. A candidate nobody has staged
# reaches no other surface, and is named for the reason an unstaged session record is.
mj_validate_knowledge_accumulation() {
  local cap max_age cdir rel; cdir="$(mj_knowledge_candidates_dir)"; rel="$(mj_rel "$cdir")"
  if ! cap="$(mj_pol_req knowledge.candidates_max_files)"; then
    mj_doctrine_fail knowledge "candidates" "policy declares no knowledge.candidates_max_files" "add a knowledge: block to $(mj_rel "$MJ_POLICY_FILE"); see share/skeleton/policy.yaml"; return 0
  fi
  if ! max_age="$(mj_pol_req knowledge.candidate_max_age_minutes)"; then
    mj_doctrine_fail knowledge "candidates" "policy declares no knowledge.candidate_max_age_minutes" "add it under knowledge: in $(mj_rel "$MJ_POLICY_FILE"); see share/skeleton/policy.yaml"; return 0
  fi
  [ -d "$cdir" ] || { mj_doctrine_ok knowledge "candidates" "none await review, cap $cap"; return 0; }
  local rows n; rows="$(mktemp "${TMPDIR:-/tmp}/mj.kva.XXXXXX")"
  mj_knowledge_candidate_rows | awk -F'\t' '$3 == "candidate"' > "$rows"
  n="$(mj_lines "$rows")"
  if [ "$n" -gt "$cap" ]; then
    mj_doctrine_fail knowledge "candidates" "$n candidate(s) await review, over cap $cap: $(cut -f1 "$rows" | head -n 3 | tr '\n' ' ')$( [ "$n" -gt 3 ] && printf '…' )" "majordomus knowledge candidates"
  else
    mj_doctrine_ok knowledge "candidates" "$n await review, cap $cap"
  fi
  # the queue entry's age, from the ledger in one pass: the first derivation naming each path
  local led entered; led="$(mj_knowledge_ledger)"; entered="$(mktemp "${TMPDIR:-/tmp}/mj.kve.XXXXXX")"
  if [ -f "$led" ]; then
    awk "$MJ_LEDGER_FIELD_AWK"'index($0, "\"event\":\"knowledge.derived\"") == 0 { next }
         { ts = mjfield($0, "ts"); p = index($0, "\"paths\":\"") ? mjfield($0, "paths") : ""
           n = split(p, a, " "); for (i = 1; i <= n; i++) if (a[i] != "" && !(a[i] in seen)) { seen[a[i]] = 1; print a[i] "\t" ts } }' "$led" > "$entered"
  fi
  local id cls st epi date ep br path title ts src age old=0 named=""
  while IFS="$MJ_TAB" read -r id cls st epi date ep br path title; do
    ts="$(awk -F'\t' -v p="$path" '$1 == p { print $2; exit }' "$entered")"; src="the derivation that wrote it"
    if [ -z "$ts" ]; then
      # in UTC, whatever zone the committer was in: the clock this is compared against is UTC
      ts="$(TZ=UTC mj_git log --diff-filter=A --date=iso-strict-local --format=%cd -1 -- "$path" 2>/dev/null | sed 's/+00:00$/Z/')"
      src="the commit that added it"
    fi
    case "$ts" in *Z) ;; *) ts="" ;; esac
    if [ -z "$ts" ]; then ts="${date}T00:00:00Z"; src="its own date"; fi
    age="$(mj_age_minutes "$ts" || true)"
    [ -n "$age" ] && [ "$age" -ge "$max_age" ] || continue
    old=$((old + 1)); [ "$old" -le 3 ] && named="$named $id"
    mj_doctrine_fail knowledge "$path" "awaiting review for $(mj_age_human "$age"), older than knowledge.candidate_max_age_minutes ($(mj_duration_human "$max_age")), dated by $src; promote it or reject it" "majordomus knowledge candidates"
  done < "$rows"
  [ "$old" = 0 ] && [ "$n" -gt 0 ] && mj_doctrine_ok knowledge "review age" "no candidate has waited past $(mj_duration_human "$max_age")"
  rm -f "$rows" "$entered"
  # staged nowhere, seen nowhere
  local orphans="" no=0 name
  while IFS= read -r name; do
    [ -n "$name" ] || continue
    mj_is_context_doc "$cdir/$name" && continue     # the section's own contract is not a record
    no=$((no + 1)); [ "$no" -le 3 ] && orphans="$orphans $name"
  done <<EOF
$(cd "$cdir" 2>/dev/null && git ls-files --others --exclude-standard -- '*.md' 2>/dev/null)
EOF
  [ "$no" -gt 0 ] && mj_doctrine_fail knowledge "$rel/" \
    "$no candidate(s) exist here and are committed nowhere, so they reach no other surface:$orphans$( [ "$no" -gt 3 ] && printf ' …' ). fix: git add $rel && majordomus derive" \
    "git status --porcelain $rel"
  return 0
}