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/common.sh
An object of kind implementation
What it is
- URI
majordomus://implementation/lib/common.sh- Kind
implementation- Identity
lib/common.sh- Path
lib/common.sh- Provenance
- declared
- Media type
text/plain- As a capability
- implementation.lib/common.sh
Front matter
{}Content
#!/usr/bin/env bash
# shellcheck disable=SC2034 # the MJ_RES_* resolution outputs are read by the commands that
# source this file: handover, checkpoint, context, prompt, start, doctor and watch.
# common.sh — shared helpers for majordomus. Sourced, never executed.
# Targets bash 3.2 and BSD userland: no associative arrays, no mapfile, no GNU-only flags.
MJ_EX_OK=0; MJ_EX_USAGE=2; MJ_EX_CONTRACT=10; MJ_EX_DRIFT=11
MJ_EX_MISSING=12; MJ_EX_INTERNAL=13; MJ_EX_REFUSED=15
export MJ_EX_OK MJ_EX_USAGE MJ_EX_CONTRACT MJ_EX_DRIFT MJ_EX_MISSING MJ_EX_INTERNAL MJ_EX_REFUSED
# shellcheck source=sha256.sh
. "$(dirname "${BASH_SOURCE[0]}")/sha256.sh"
MJ_REPO=""; MJ_JSON=0; MJ_ARGS=()
MJ_FINDINGS=0; MJ_FAILS=0
# ---------------------------------------------------------------- output
mj_err() { printf 'majordomus: %s\n' "$*" >&2; }
mj_die() { local code="$1"; shift; mj_err "$*"; exit "$code"; }
# mj_finding LEVEL category subject message [reproduce]
mj_finding() {
local level="$1" cat="$2" subj="$3" msg="$4" rep="${5:-}"
MJ_FINDINGS=$((MJ_FINDINGS + 1))
case "$level" in FAIL|DRIFT) MJ_FAILS=$((MJ_FAILS + 1)) ;; esac
if [ "$MJ_JSON" = 1 ]; then
printf '{"level":"%s","category":"%s","subject":"%s","message":"%s","reproduce":"%s"}\n' \
"$level" "$(mj_json_esc "$cat")" "$(mj_json_esc "$subj")" "$(mj_json_esc "$msg")" "$(mj_json_esc "$rep")"
else
if [ -n "$rep" ]; then
printf '%-4s %-11s %s — %s [reproduce: %s]\n' "$level" "$cat" "$subj" "$msg" "$rep"
else
printf '%-4s %-11s %s — %s\n' "$level" "$cat" "$subj" "$msg"
fi
fi
}
mj_ok() { mj_finding OK "$@"; }
mj_fail() { mj_finding FAIL "$@"; }
mj_warn() { mj_finding WARN "$@"; }
mj_info() { mj_finding INFO "$@"; }
mj_drift() { mj_finding DRIFT "$@"; }
# The fingerprint of a set of files: one line "<path> <hash>" per path in the order given,
# then the hash of that stream. One hashing process for the whole list rather than one per
# file — the difference between a fifth of a second and eight, and the question is asked on
# every deployment and on every obligation checked. Paths are used as given, so the caller
# decides what they are relative to. The line format is the one source_hash has always been
# computed from, so a value produced here is comparable with every source.json ever written.
mj_inputs_hash() {
printf '%s\0' "$@" | mj_sha256_many | awk -F'\t' '{ printf "%s %s\n", $2, $1 }' \
| mj_sha256_hex
}
# The git tree object id of the working tree exactly as it stands: HEAD's tree with every
# modification, addition and deletion applied. Computed in a temporary index, so the real
# index keeps its staged content and its stat cache; ignored paths stay out, which is what
# .ai/local/ is. The temporary index is seeded from HEAD, never copied from the real one: a
# copy carries its assume-unchanged and skip-worktree flags and its stat cache, and `add -A`
# trusts those, so an edit to a flagged file would be neither recorded nor noticed moving.
# Seeding from HEAD rehashes every file (about a second on this repository) and sees them all.
# Empty when git cannot answer, so a caller degrades to knowing nothing rather than to
# believing something.
#
# Not mj_inputs_hash: that fingerprints a list of paths the caller already knows, and the
# question here is what the whole tree was, including a file no list would have named
# because it did not exist yet. A tree id is also git's own answer to that question,
# comparable with every other tree id without this tool being present to explain it.
mj_worktree_tree_id() {
local idx out
idx="$(mktemp "${TMPDIR:-/tmp}/mj-index.XXXXXX")" || return 0
rm -f "$idx"
out="$( cd "$MJ_ROOT" || exit 1
export GIT_INDEX_FILE="$idx"
if git rev-parse --verify -q HEAD >/dev/null 2>&1; then git read-tree HEAD 2>/dev/null || exit 1; fi
git add -A 2>/dev/null || exit 1
git write-tree 2>/dev/null )"
rm -f "$idx"
printf '%s' "$out"
}
mj_json_esc() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\n'; }
# ---------------------------------------------------------------- options
mj_parse_global_opts() {
MJ_ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--repo) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--repo needs a path"; MJ_REPO="$2"; shift 2 ;;
--repo=*) MJ_REPO="${1#--repo=}"; shift ;;
--json) MJ_JSON=1; shift ;;
*) MJ_ARGS+=("$1"); shift ;;
esac
done
export MJ_JSON
}
# ---------------------------------------------------------------- repo & git
mj_repo_root() {
local root
if [ -n "$MJ_REPO" ]; then
root="$(cd "$MJ_REPO" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null)" || return 1
else
root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
fi
printf '%s' "$root"
}
mj_require_repo() {
MJ_ROOT="$(mj_repo_root)" || mj_die "$MJ_EX_USAGE" "not inside a git repository (use --repo <path>)"
export MJ_ROOT
# hooks inherited from a parent process must never redirect our git calls
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX
mj_require_share_is_ours
mj_resolve_layout
}
mj_require_installed() {
mj_require_repo
case "$MJ_LAYOUT" in
ai) [ -f "$MJ_POLICY_FILE" ] || mj_die "$MJ_EX_MISSING" "no $(mj_rel "$MJ_POLICY_FILE") in $MJ_ROOT; the manifest names it (run: majordomus init)" ;;
legacy) mj_die "$MJ_EX_MISSING" "project data lives under .majordomus/ (the pre-.ai layout); run: majordomus migrate" ;;
*) mj_die "$MJ_EX_MISSING" "no .ai/manifest.yaml in $MJ_ROOT (run: majordomus init)" ;;
esac
}
# ---------------------------------------------------------------- paths
# Two roots, never one variable. MJ_HOME is the tool distribution — bin, lib, share — and
# is read-only: it may be a PATH install, a checkout under ~/tools, a package, or an
# optional .majordomus/ submodule inside the managed repository, and every form behaves
# the same because nothing below writes into it. MJ_ROOT is the managed repository, whose
# AI layer lives under .ai/: repo/ is tracked canonical context, local/ is checkout-local
# and ignored. Every path the commands read or write is resolved here and nowhere else.
# a script that sources this file for its helpers alone (the test suite, the site generator)
# need not have set MJ_BIN_DIR; the distribution is the directory this file lives under.
# Never taken from the environment: a majordomus started by another majordomus (a verify
# command, a hook) must read its own distribution, not the one that started it.
if [ -n "${MJ_BIN_DIR:-}" ]; then MJ_HOME="$(cd "$MJ_BIN_DIR/.." && pwd)"
else MJ_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; fi
# The distribution's data: kinds, schemas, allow-lists, the skeleton, the standard rule
# package. MAJORDOMUS_SHARE names it, the same variable the Rust executable reads, so one
# distribution serves both halves of the tool and a test can point them at a fixture
# together. Unset, it is the share directory beside this executable, which is what an
# ordinary run wants and what makes the tool work from any checkout.
MJ_SHARE_DIR="${MAJORDOMUS_SHARE:-$MJ_HOME/share}"
MJ_SKELETON_DIR="$MJ_SHARE_DIR/skeleton"
MJ_ALLOW_DIR="$MJ_SHARE_DIR/allow"
MJ_STD_RULES_DIR="$MJ_SHARE_DIR/standard/majordomus"
MJ_PROVIDERS_DEFAULT_DIR="$MJ_SHARE_DIR/providers"
export MJ_SHARE_DIR MJ_SKELETON_DIR MJ_ALLOW_DIR MJ_STD_RULES_DIR MJ_PROVIDERS_DEFAULT_DIR
# MAJORDOMUS_SHARE is inherited, and a shell that entered another checkout exports that
# checkout's share/. Carried into a linked worktree it makes this distribution's code read
# another distribution's schemas, allow-lists and skeleton, and the difference between the
# two is then reported as drift in the repository: a pre-commit hook refusing a commit over
# generated artifacts that are not stale is what that looks like from the outside, and it
# has cost whole sessions.
# Only what is decidable is refused: the named share lies in a working tree of *this*
# repository that is not this working tree, and since every worktree carries its own
# share/, nothing about that can be intended. A share under no working tree at all is an
# installed distribution — a package, a PATH install, a test fixture — which is what the
# variable is for; a share in some other repository's working tree is the same case, a tool
# checkout serving a repository elsewhere. Neither is distinguishable from a legitimate
# installation, so neither is refused. Two worktrees of one repository are told apart from
# two repositories by the git common directory, which every worktree of one repository
# shares and no two repositories do.
# Cheap where it always runs: the variable unset, or naming a directory under this
# repository, costs one string comparison and no process.
mj_require_share_is_ours() {
[ -n "${MAJORDOMUS_SHARE:-}" ] || return 0
local share root wt mine theirs
share="$(cd "$MJ_SHARE_DIR" 2>/dev/null && pwd -P)" || return 0 # absent: the reader that needs it says so
root="$(cd "$MJ_ROOT" 2>/dev/null && pwd -P)" || return 0
case "$share" in "$root"|"$root"/*) return 0 ;; esac
wt="$(git -C "$share" rev-parse --show-toplevel 2>/dev/null)" || return 0
[ -n "$wt" ] || return 0
mine="$(mj_git_repo_id)"; theirs="$(mj_git_repo_id "$wt")"
[ -n "$mine" ] && [ "$mine" = "$theirs" ] || return 0
mj_die "$MJ_EX_REFUSED" "MAJORDOMUS_SHARE names $MJ_SHARE_DIR, the distribution of $wt — another worktree of this repository, not this one ($root). Its schemas and allow-lists are not this checkout's, and what they disagree about is reported as drift here. Run with: env -u MAJORDOMUS_SHARE (or re-enter this directory, so that direnv exports the share beside the tool it also puts on the path)"
}
MJ_LAYOUT=""; MJ_AI_DIR=""; MJ_AI_MANIFEST=""; MJ_AI_REPO_DIR=""; MJ_AI_LOCAL_DIR=""
MJ_STATE_DIR=""; MJ_POLICY_FILE=""; MJ_SCOPE_FILE=""; MJ_PROFILES_DIR=""; MJ_PROMPTS_DIR=""; MJ_PROJECT_DIR=""
MJ_RULES_DIR=""; MJ_KNOWLEDGE_DIR=""; MJ_ADRS_DIR=""; MJ_SKILLS_DIR=""; MJ_WORKFLOWS_DIR=""
MJ_SESSIONS_DIR=""; MJ_DEPLOYMENTS_DIR=""
MJ_PROVIDERS_DIR=""; MJ_TEMPLATES_DIR=""; MJ_CACHE_DIR=""
# a repository path, relative to the repository root, for messages and records
mj_rel() { printf '%s' "${1#"$MJ_ROOT/"}"; }
# is repository-relative path $1 inside the AI layer (the tool's own files)?
mj_is_ai_path() { local ai="${MJ_AI_DIR#"$MJ_ROOT/"}"; case "$1" in "$ai"|"$ai"/*) return 0 ;; esac; return 1; }
# Resolve the repository layout. Never fails: a repository with no AI layer resolves to
# the paths init would create, so init and doctor can name them.
# ai .ai/manifest.yaml exists; every section path is read from it
# legacy project data under .majordomus/ and no manifest: the pre-.ai layout. Nothing
# reads it; the value only makes every command refuse and name `migrate`
mj_resolve_layout() {
MJ_AI_DIR="$MJ_ROOT/.ai"; MJ_AI_MANIFEST="$MJ_AI_DIR/manifest.yaml"
if [ -f "$MJ_AI_MANIFEST" ]; then
MJ_LAYOUT=ai
mj_load_manifest || mj_die "$MJ_EX_CONTRACT" "$(mj_rel "$MJ_AI_MANIFEST") is not a manifest this version reads: $MJ_MANIFEST_ERROR"
MJ_AI_REPO_DIR="$MJ_AI_DIR/$(mj_man repo.path)"
MJ_AI_LOCAL_DIR="$MJ_AI_DIR/$(mj_man local.path)"
MJ_POLICY_FILE="$MJ_AI_DIR/$(mj_man sections.policy)"
# the scope section is optional: a layer written before it existed names none, and the
# Rust executable then reads the distribution's default; the variable stays empty
MJ_SCOPE_FILE=""; [ -n "$(mj_man sections.scope)" ] && MJ_SCOPE_FILE="$MJ_AI_DIR/$(mj_man sections.scope)"
MJ_PROFILES_DIR="$MJ_AI_DIR/$(mj_man sections.profiles)"
MJ_RULES_DIR="$MJ_AI_DIR/$(mj_man sections.rules)"
MJ_PROMPTS_DIR="$MJ_AI_DIR/$(mj_man sections.prompts)"
MJ_SKILLS_DIR="$MJ_AI_DIR/$(mj_man sections.skills)"
MJ_WORKFLOWS_DIR="$MJ_AI_DIR/$(mj_man sections.workflows)"
MJ_KNOWLEDGE_DIR="$MJ_AI_DIR/$(mj_man sections.knowledge)"
MJ_ADRS_DIR="$MJ_AI_DIR/$(mj_man sections.adrs)"
# the sessions section is optional: a layer written before it existed names none, and a
# closed episode then stays in the checkout-local half where it always was
MJ_SESSIONS_DIR=""; [ -n "$(mj_man sections.sessions)" ] && MJ_SESSIONS_DIR="$MJ_AI_DIR/$(mj_man sections.sessions)"
# the deployments section is optional in the same way: a repository that deploys
# nothing declares none, and the variable stays empty
MJ_DEPLOYMENTS_DIR=""; [ -n "$(mj_man sections.deployments)" ] && MJ_DEPLOYMENTS_DIR="$MJ_AI_DIR/$(mj_man sections.deployments)"
MJ_PROJECT_DIR="$MJ_AI_DIR/$(mj_man sections.project)"
MJ_PROVIDERS_DIR="$MJ_AI_REPO_DIR/providers"
MJ_TEMPLATES_DIR="$MJ_AI_REPO_DIR/templates"
MJ_STATE_DIR="$MJ_AI_LOCAL_DIR/state"
MJ_CACHE_DIR="$MJ_AI_LOCAL_DIR/cache"
else
# Project data under .majordomus/ is the pre-.ai layout, which nothing reads any more:
# the name is detected so that mj_require_installed can refuse with the migration
# named and `migrate` can find its source, and the paths are the .ai ones either way.
# A .majordomus/ holding a tool distribution (bin/majordomus) is an installation,
# never project data.
MJ_LAYOUT=""
[ -d "$MJ_ROOT/.majordomus" ] && [ ! -f "$MJ_ROOT/.majordomus/bin/majordomus" ] && MJ_LAYOUT=legacy
MJ_AI_REPO_DIR="$MJ_AI_DIR/repo"; MJ_AI_LOCAL_DIR="$MJ_AI_DIR/local"
MJ_POLICY_FILE="$MJ_AI_REPO_DIR/policy.yaml"; MJ_SCOPE_FILE="$MJ_AI_REPO_DIR/scope.yaml"; MJ_PROFILES_DIR="$MJ_AI_REPO_DIR/profiles"
MJ_RULES_DIR="$MJ_AI_REPO_DIR/rules"; MJ_PROMPTS_DIR="$MJ_AI_REPO_DIR/prompts"
MJ_SKILLS_DIR="$MJ_AI_REPO_DIR/skills"; MJ_WORKFLOWS_DIR="$MJ_AI_REPO_DIR/workflows"
MJ_KNOWLEDGE_DIR="$MJ_AI_REPO_DIR/knowledge"; MJ_ADRS_DIR="$MJ_AI_REPO_DIR/adrs"
MJ_PROJECT_DIR="$MJ_AI_REPO_DIR/project"; MJ_PROVIDERS_DIR="$MJ_AI_REPO_DIR/providers"
MJ_DEPLOYMENTS_DIR=""
MJ_TEMPLATES_DIR="$MJ_AI_REPO_DIR/templates"; MJ_STATE_DIR="$MJ_AI_LOCAL_DIR/state"
MJ_CACHE_DIR="$MJ_AI_LOCAL_DIR/cache"
fi
export MJ_LAYOUT MJ_AI_DIR MJ_AI_MANIFEST MJ_AI_REPO_DIR MJ_AI_LOCAL_DIR MJ_STATE_DIR
export MJ_POLICY_FILE MJ_SCOPE_FILE MJ_PROFILES_DIR MJ_PROMPTS_DIR MJ_PROJECT_DIR MJ_RULES_DIR MJ_KNOWLEDGE_DIR
export MJ_ADRS_DIR MJ_SKILLS_DIR MJ_WORKFLOWS_DIR MJ_PROVIDERS_DIR MJ_TEMPLATES_DIR MJ_CACHE_DIR
return 0
}
# The manifest is the section registry of the AI layer: which format it is, where the
# tracked and local halves are, and where each section lives. Nothing walks .ai/ to find
# out. A manifest newer than this executable, or one with a key nothing reads, is refused
# with the reason rather than read partially.
MJ_MAN_FLAT=""; MJ_MANIFEST_ERROR=""
MJ_MANIFEST_SCHEMA="ai-repository/v1"
mj_load_manifest() {
local k
MJ_MANIFEST_ERROR=""
[ -n "$MJ_MAN_FLAT" ] && [ -f "$MJ_MAN_FLAT" ] && return 0
MJ_MAN_FLAT="$(mktemp "${TMPDIR:-/tmp}/mj.man.XXXXXX")"
if ! mj_yaml_flatten "$MJ_AI_MANIFEST" > "$MJ_MAN_FLAT" 2>/dev/null; then
MJ_MANIFEST_ERROR="does not parse"; return 1
fi
if [ "$(mj_yget "$MJ_MAN_FLAT" schema)" != "$MJ_MANIFEST_SCHEMA" ]; then
MJ_MANIFEST_ERROR="schema '$(mj_yget "$MJ_MAN_FLAT" schema)' is not $MJ_MANIFEST_SCHEMA (this executable reads $MJ_MANIFEST_SCHEMA)"; return 1
fi
k="$(mj_yaml_unknown_keys "$MJ_MAN_FLAT" "$MJ_ALLOW_DIR/manifest.txt" || true)"
[ -z "$k" ] || { MJ_MANIFEST_ERROR="unknown key(s): $(printf '%s' "$k" | tr '\n' ' ')"; return 1; }
for k in repo.path local.path sections.policy sections.profiles sections.rules sections.prompts \
sections.skills sections.workflows sections.knowledge sections.adrs sections.project; do
[ -n "$(mj_yget "$MJ_MAN_FLAT" "$k")" ] || { MJ_MANIFEST_ERROR="missing key $k"; return 1; }
done
return 0
}
mj_man() { [ -n "${MJ_MAN_FLAT:-}" ] || return 0; mj_yget "$MJ_MAN_FLAT" "$1"; }
# The provider template for a projection: the repository's own override under its AI
# layer when it has one, otherwise the adapter the distribution ships.
mj_provider_template() {
if [ -f "$MJ_PROVIDERS_DIR/$1.tmpl" ]; then printf '%s' "$MJ_PROVIDERS_DIR/$1.tmpl"
elif [ -f "$MJ_PROVIDERS_DEFAULT_DIR/$1.tmpl" ]; then printf '%s' "$MJ_PROVIDERS_DEFAULT_DIR/$1.tmpl"
else return 1; fi
}
# The layout as data — one line per path variable, name then repository-relative path — for
# whatever needs to name the files the commands touch without repeating this file.
mj_layout_table() {
local v
for v in MJ_AI_DIR MJ_AI_REPO_DIR MJ_AI_LOCAL_DIR MJ_STATE_DIR MJ_POLICY_FILE MJ_SCOPE_FILE MJ_PROFILES_DIR \
MJ_PROMPTS_DIR MJ_PROJECT_DIR MJ_RULES_DIR MJ_KNOWLEDGE_DIR MJ_ADRS_DIR MJ_SKILLS_DIR \
MJ_WORKFLOWS_DIR MJ_PROVIDERS_DIR MJ_TEMPLATES_DIR MJ_CACHE_DIR; do
p="${!v}"
[ -n "$p" ] || continue
printf '%s\t%s\n' "$v" "$(mj_rel "$p")"
done
}
# --no-pager, always: git pages whenever its stdout is a terminal, and `log`, `diff` and
# `show` all reach this wrapper. Most call sites capture the output and so never noticed,
# but the one that does not blocks on a keypress -- which a person eventually sends and an
# agent never does. The pager is a presentation choice this tool does not make.
mj_git() { mj_count git; git --no-pager -C "$MJ_ROOT" "$@"; }
# The repository a checkout belongs to, as an absolute path: the git common directory,
# which every worktree of one repository shares and no two repositories do. $1 names the
# checkout to ask; the default is MJ_ROOT, and the value records write is that one.
mj_git_repo_id() { local r="${1:-$MJ_ROOT}"; mj_count git; git -C "$r" rev-parse --git-common-dir 2>/dev/null | { read -r d; case "$d" in /*) printf '%s' "$d" ;; *) printf '%s/%s' "$r" "$d" ;; esac; }; }
# ---------------------------------------------------------------- personal
# A personal layer (ADR 0145): the same layer a plain init makes, kept as one person's own
# in a repository other people share. Nothing shared is changed — no tracked file, no
# .gitignore line — because the layer is covered in this repository's own exclude file,
# $GIT_COMMON_DIR/info/exclude, which git never commits, pushes or clones.
#
# There is no setting for it. A layer is personal when that file covers it in the block
# below: the cover is the mechanism and the record at once, read each time it is asked.
# Whether the cover still holds is a second question, answered by mj_personal_verify: a
# file of the layer that got tracked anyway is reported, not read as a change of mind.
# Proposing the layer to the team is `init --extend --shared`, which removes the block.
#
# The common directory, not .git: in a linked worktree .git is a file, and the exclude file
# every worktree of the repository obeys is the common one.
MJ_PERSONAL_BEGIN='# >>> majordomus personal >>>'
MJ_PERSONAL_END='# <<< majordomus personal <<<'
mj_personal_exclude_file() { printf '%s/info/exclude' "$(mj_git_repo_id)"; }
# The patterns the block holds now, one a line; nothing when there is no block.
mj_personal_covered() {
awk -v b="$MJ_PERSONAL_BEGIN" -v e="$MJ_PERSONAL_END" \
'$0==b{inb=1;next} $0==e{inb=0;next} inb' "$(mj_personal_exclude_file)" 2>/dev/null
}
mj_personal_active() {
[ -n "${MJ_AI_DIR:-}" ] && [ -f "$MJ_AI_DIR/manifest.yaml" ] || return 1
mj_personal_covered | grep -qxF -- "/$(mj_rel "$MJ_AI_DIR")/"
}
# Cover repository-relative paths: each anchored pattern once, inside the block, and every
# other line of the file left exactly as it was. A directory is given with its slash.
mj_personal_cover() {
local f tmp p have
f="$(mj_personal_exclude_file)"
mkdir -p "$(dirname "$f")" || return 1
[ -f "$f" ] || : > "$f"
have="$(mj_personal_covered)"
for p in "$@"; do
p="/${p#/}"
printf '%s\n' "$have" | grep -qxF -- "$p" || have="${have:+$have
}$p"
done
# beside the file, so that the rename which publishes it never crosses a filesystem
tmp="$f.mj-tmp.$$"
awk -v b="$MJ_PERSONAL_BEGIN" -v e="$MJ_PERSONAL_END" \
'$0==b{inb=1;next} $0==e{inb=0;next} !inb' "$f" > "$tmp" || { rm -f "$tmp"; return 1; }
{ [ -s "$tmp" ] && [ -n "$(tail -c1 "$tmp")" ] && printf '\n'
printf '%s\n%s\n%s\n' "$MJ_PERSONAL_BEGIN" "$have" "$MJ_PERSONAL_END"; } >> "$tmp"
mv "$tmp" "$f"
}
# A writer outside the layer asks before it writes: under a personal layer a path is covered
# first, and a path git already tracks is refused, because that file is the project's and a
# change to it would be a change to what everyone shares. Elsewhere this is a no-op, so
# every writer calls it unconditionally.
mj_personal_guard() {
local p
mj_personal_active || return 0
mj_personal_refuse_tracked "$@"
for p in "$@"; do mj_personal_cover "$p" "$p.mj-tmp" || return 1; done
}
# Is this path the project's? Under a personal layer, whatever git tracks is.
mj_personal_is_the_projects() {
mj_personal_active && mj_git ls-files --error-unmatch -- "$1" >/dev/null 2>&1
}
# The refusal alone, with nothing written: what a writer asks while it is still planning,
# so that a dry run says the same thing a real one would and no advice to force is ever
# printed for a file that is somebody else's.
mj_personal_refuse_tracked() {
local p
for p in "$@"; do
mj_personal_is_the_projects "$p" || continue
mj_die "$MJ_EX_REFUSED" "$p is tracked, so it is the project's, and this layer is personal: writing it would change what everyone shares. Leave it as it is and point that projection at a file of your own in $(mj_rel "$MJ_POLICY_FILE") (CLAUDE.local.md is the one Claude Code reads beside the project's), or share the layer with the team (majordomus init --extend --shared)"
done
}
# What `init --shared` runs: the cover is lifted from everything except the files that are
# one person's by their own name — `*.local` and `*.local.*`, the convention CLAUDE.local.md
# and .envrc.local follow. Those stay covered, because handing the layer to the team is not
# handing it a personal projection. Prints the lifted paths, then a line `kept <path>` each.
# With the layer's own pattern gone the layer is no longer personal, whatever stays covered.
mj_personal_uncover() {
local f tmp have keep lift p
f="$(mj_personal_exclude_file)"
[ -f "$f" ] || return 0
have="$(mj_personal_covered)"; keep=''; lift=''
while IFS= read -r p; do
[ -n "$p" ] || continue
case "${p%.mj-tmp}" in
*.local|*.local.*) keep="${keep:+$keep
}$p" ;;
*) lift="${lift:+$lift
}$p" ;;
esac
done <<EOF
$have
EOF
tmp="$f.mj-tmp.$$"
awk -v b="$MJ_PERSONAL_BEGIN" -v e="$MJ_PERSONAL_END" \
'$0==b{inb=1;next} $0==e{inb=0;next} !inb' "$f" > "$tmp" || { rm -f "$tmp"; return 1; }
if [ -n "$keep" ]; then
{ [ -s "$tmp" ] && [ -n "$(tail -c1 "$tmp")" ] && printf '\n'
printf '%s\n%s\n%s\n' "$MJ_PERSONAL_BEGIN" "$keep" "$MJ_PERSONAL_END"; } >> "$tmp"
fi
mv "$tmp" "$f" || return 1
[ -z "$lift" ] || printf '%s\n' "$lift"
[ -z "$keep" ] || printf '%s\n' "$keep" | sed -e '/\.mj-tmp$/d' -e 's/^/kept /'
}
# Does the cover hold? One finding a fact, through the callbacks the caller names (doctor's
# and init's differ only in how a finding is printed). Returns 1 when something shared shows it.
mj_personal_verify() {
# The reporter is named by a word, not by its two functions: `doctrine` reports through the
# doctrine's ok and fail (so a class decides what a violation costs), anything else through
# the plain ones. A caller passing the functions themselves put the name of a reporting
# helper on a line that reports nothing, which scripts/ci/finding-reproduce-check reads as
# a finding with no command to reproduce it.
local reporter="" ok_fn fail_fn rel bad=0 p seen
[ "${1:-}" = doctrine ] && reporter=doctrine_
ok_fn="mj_${reporter}ok"; fail_fn="mj_${reporter}fail"
rel="$(mj_rel "$MJ_AI_DIR")"
if [ -n "$(mj_git ls-files -- "$rel" 2>/dev/null | head -n 1)" ]; then
"$fail_fn" personal "$rel/" "carries tracked files; a personal layer is tracked nowhere (share it with: majordomus init --extend --shared)" "git ls-files $rel"; bad=1
fi
if ! mj_git check-ignore -q "$rel/manifest.yaml" 2>/dev/null; then
"$fail_fn" personal "$rel/" "is not covered; the next 'git add -A' would stage it" "majordomus init --extend --personal"; bad=1
fi
seen=''
while IFS= read -r p; do
[ -n "$p" ] || continue
p="${p#/}"
[ -z "$(mj_git status --porcelain --untracked-files=all -- "$p" 2>/dev/null | head -n 1)" ] || seen="$seen $p"
done <<EOF
$(mj_personal_covered)
EOF
if [ -n "$seen" ]; then
"$fail_fn" personal "$(mj_rel "$(mj_personal_exclude_file)")" "git status still shows:$seen" "git status --porcelain --untracked-files=all"; bad=1
fi
[ "$bad" = 0 ] && "$ok_fn" personal "$rel/" "tracked nowhere, ignored through $(mj_personal_exclude_file), and absent from git status ($(mj_personal_covered | grep -c .) path(s) covered)"
return "$bad"
}
mj_git_branch() { mj_git symbolic-ref --short HEAD 2>/dev/null || printf 'DETACHED'; }
# --verify, because plain `rev-parse HEAD` in a repository with no commits prints the
# literal string "HEAD" on stdout and *then* fails. The fallback would append to that,
# producing an identity field with an embedded newline and a permanently corrupt ledger line.
# Is a name a defined shell function? A dispatcher keyed by data — a doctrine's validator,
# an obligation's establisher — asks this before calling, so that a declaration with
# nothing behind it is a reported defect and not a "command not found" on stderr and a
# wrong answer downstream. It lives here rather than beside either dispatcher: it is a
# shell predicate, every lib is entitled to it, and evidence.sh reaching for doctrine.sh's
# copy is how the obligations of a live scenario came to report "no evidence recorded" for
# a fact git could have settled.
mj_is_function() { type "$1" 2>/dev/null | head -n1 | grep -q 'function'; }
mj_git_head() { local h; h="$(mj_git rev-parse --verify HEAD 2>/dev/null)" || h=""; [ -n "$h" ] || h=NONE; printf '%s' "$h"; }
mj_git_dirty() { [ -z "$(mj_git status --porcelain=v1 2>/dev/null)" ] && printf 'clean' || printf 'dirty'; }
mj_branch_key() { mj_git_branch | sed 's/[^A-Za-z0-9._-]/-/g'; }
# divergence label of a recorded head vs current HEAD:
# exact | advanced | diverged | different_context | unknown
mj_git_label() {
local rec_head="$1" rec_branch="$2" cur_head cur_branch
cur_head="$(mj_git_head)"; cur_branch="$(mj_git_branch)"
if [ "$rec_branch" != "$cur_branch" ]; then printf 'different_context'; return; fi
if [ "$rec_head" = "$cur_head" ]; then printf 'exact'; return; fi
if mj_git merge-base --is-ancestor "$rec_head" "$cur_head" 2>/dev/null; then printf 'advanced'; return; fi
printf 'diverged'
}
# files touched since a base commit: uncommitted, plus what this line's own commits
# changed. A merge from the trunk is not the task's work — its second parent carries
# everybody else's files — so the walk follows the first parent and leaves the merge
# commits out. Diffing base..HEAD instead told a task that had merged master that it
# touched every file master did, and the scope doctrine refused the push for exactly the
# merge the trunk asks for. The cost is that a conflict resolved inside a merge commit is
# not counted; the working tree and the commits around it are.
#
# A fast-forward or a rebase onto the trunk brings other people's work in without a merge
# commit: `git pull --ff-only` after `start`, or `git pull --rebase` on a branch, puts the
# trunk's commits on this line's first parent, and the walk above counted every file they
# changed as the task's. The push was refused "outside claimed scope" for work the trunk
# had already integrated, and the only way out was to close the task and start another.
# So a commit is left out when both of these hold: the trunk's remote-tracking branch
# already contains it, and this checkout did not make it. "Made here" is git's own record,
# the HEAD reflog's commit, cherry-pick, revert, am and rebase-pick entries; a commit that
# arrived by a fast-forward, or that a rebase used as its base, has no such entry. A commit
# the task made and pushed to the trunk is therefore still the task's. Without a trunk
# (no remote, or no refs/remotes/<remote>/HEAD) nothing is left out, as before.
#
# One path per line, the path as git stores it, so a caller reads lines and never words:
# a file named "Futuristic PR Drain Developer Dashboard.png" is one path. Both git
# commands are read with -z, because without it `status --porcelain` wraps a path that
# holds a space in quotes and `log --name-only` octal-escapes one outside ASCII, and either
# way the name no longer matches the scope it is inside (2026-10-11, a pre-push refused
# four images as twenty out-of-scope "paths").
mj_git_touched() {
local base="$1" trunk all foreign own keep
{ mj_git_dirty_paths
if [ -n "$base" ] && [ "$base" != "NONE" ]; then
trunk="$(mj_git_trunk_ref)"
foreign=""
if [ -n "$trunk" ]; then
all="$(mj_git rev-list --first-parent --no-merges "$base..HEAD" 2>/dev/null | LC_ALL=C sort)"
keep="$(mj_git rev-list --first-parent --no-merges "$base..HEAD" --not "$trunk" 2>/dev/null | LC_ALL=C sort)"
# on the trunk already, and not made in this checkout
foreign="$(LC_ALL=C comm -23 <(printf '%s\n' "$all" | sed '/^$/d') <(printf '%s\n' "$keep" | sed '/^$/d'))"
if [ -n "$foreign" ]; then
own="$(mj_git_made_here)"
foreign="$(LC_ALL=C comm -23 <(printf '%s\n' "$foreign") <(printf '%s\n' "$own" | sed '/^$/d'))"
fi
fi
if [ -z "$foreign" ]; then
mj_git log -z --first-parent --no-merges --name-only --format= "$base..HEAD" 2>/dev/null \
| tr '\0' '\n'
else
LC_ALL=C comm -23 <(printf '%s\n' "$all" | sed '/^$/d') <(printf '%s\n' "$foreign") \
| sed '/^$/d' | mj_git log -z --no-walk --stdin --name-only --format= 2>/dev/null \
| tr '\0' '\n'
fi
fi
} | LC_ALL=C sort -u | sed '/^$/d'
}
# The paths the working tree and the index change, one per line, as git stores them.
# Read with -z for the reason above; a rename or a copy is followed by its source path,
# which is not a changed path of its own, so the entry after one is skipped. The path kept
# is the new one, as `sed 's/^.* -> //'` kept it before.
mj_git_dirty_paths() {
local e skip=0
mj_git status --porcelain=v1 -z 2>/dev/null | while IFS= read -r -d '' e; do
if [ "$skip" = 1 ]; then skip=0; continue; fi
case "${e:0:2}" in *R* | *C*) skip=1 ;; esac
printf '%s\n' "${e:3}"
done
}
# The trunk's remote-tracking branch, as a full ref, or nothing. The default branch is
# git's own record of it — refs/remotes/<remote>/HEAD, written by clone and by
# `git remote set-head` — on the remote this branch tracks, else the first remote.
mj_git_trunk_ref() {
local remote def
remote="$(mj_git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true)"
remote="${remote%%/*}"
[ -n "$remote" ] || remote="$(mj_git remote 2>/dev/null | head -n 1)"
[ -n "$remote" ] || return 0
def="$(mj_git symbolic-ref -q "refs/remotes/$remote/HEAD" 2>/dev/null || true)"
[ -n "$def" ] && mj_git rev-parse -q --verify "$def^{commit}" >/dev/null 2>&1 && printf '%s' "$def"
return 0
}
# Is this checkout on the trunk? True when HEAD is the local branch named like the trunk's
# remote-tracking branch (`main` for refs/remotes/origin/main). No trunk known (no remote,
# or no refs/remotes/<remote>/HEAD) and a detached HEAD are both "not on the trunk", so a
# repository without a remote keeps writing its records where it always has. ADR 0091,
# amendment of 2026-10-15: a trunk that changes only through pull requests cannot commit
# what an episode boundary writes there, so a trunk episode stages it in the outbox.
mj_on_trunk() {
local trunk br
trunk="$(mj_git_trunk_ref)"
[ -n "$trunk" ] || return 1
br="$(mj_git symbolic-ref -q --short HEAD 2>/dev/null)" || return 1
trunk="${trunk#refs/remotes/}"
[ "${trunk#*/}" = "$br" ]
}
# Where a trunk episode stages what it would have written into a tracked section: the
# closed session record under sessions/, the derived knowledge candidates under
# candidates/. Checkout-local and never tracked; a branch episode moves it into the tracked
# sections, and that branch's pull request carries it to the trunk.
mj_outbox_dir() { printf '%s/outbox' "$MJ_STATE_DIR"; }
# "<sessions> <candidates>": how many records wait in the outbox. `context` and `doctor`
# name a non-empty outbox, so a clone that never branches again still says what it holds.
mj_outbox_counts() {
local d s=0 c=0 f
d="$(mj_outbox_dir)"
for f in "$d"/sessions/*.md; do [ -f "$f" ] && s=$((s + 1)); done
for f in "$d"/candidates/*.md; do [ -f "$f" ] && c=$((c + 1)); done
printf '%s %s' "$s" "$c"
}
# Every commit this checkout made, sorted: the HEAD reflog's entries that created one.
# A linked worktree has a HEAD reflog of its own, so this is this checkout's, not the
# repository's. `pull --rebase (pick)` and `rebase -i (pick)` carry the action in
# parentheses; a fast-forward (`pull: Fast-forward`, `merge x: Fast-forward`), a reset
# and a rebase's start and finish move HEAD to a commit somebody else made.
mj_git_made_here() {
mj_git reflog show --format='%H %gs' HEAD 2>/dev/null \
| awk '{ h = $1; $1 = ""; s = substr($0, 2)
if (s ~ /^(commit|cherry-pick|revert|am)([ :(]|$)/ || s ~ /\((pick|reword|edit|squash|fixup)\):/) print h }' \
| LC_ALL=C sort -u
}
# ---------------------------------------------------------------- misc
# one tab, computed once: a loop that writes IFS="$MJ_TAB" per iteration forks a
# subshell per line, which is where a listing of a few hundred rows lost a second
MJ_TAB="$(printf '\t')"
# The clock, in one place, so that a test can move it. Ageing is a first-class dimension of
# this subsystem (ADR 0052) and the thresholds it is judged against are measured in days;
# a suite that proved them by waiting would take a week to run once. MAJORDOMUS_NOW, when
# set to an ISO-8601 UTC instant, is that instant for every reader here. It is deliberately
# not a policy key: policy is a repository decision and this is a test instrument.
mj_now() {
if [ -n "${MAJORDOMUS_NOW:-}" ]; then printf '%s' "$MAJORDOMUS_NOW"; return; fi
date -u +%Y-%m-%dT%H:%M:%SZ
}
# ---------------------------------------------------------------- timing
# Phase timing and work counters, on only when MJ_TIMING is set: every phase a command
# declares is written as one line to a file created here, in the shell that reports at
# exit, never inside a command substitution. The clock is EPOCHREALTIME where bash has
# it (bash 5), otherwise whole seconds, and the report says which. Off, the cost of a phase
# or a count is one test. There is no third clock: the one that used to stand between them
# was perl, and nothing in this repository runs Perl.
MJ_TIMING_FILE=""; MJ_TIMING_T0=""
if [ -n "${EPOCHREALTIME:-}" ]; then MJ_TIMING_CLOCK=epochrealtime
else MJ_TIMING_CLOCK=seconds; fi
mj_ms() {
case "$MJ_TIMING_CLOCK" in
epochrealtime) local t="${EPOCHREALTIME/./}"; printf '%s' "${t%???}" ;;
*) printf '%s000' "$(date +%s)" ;;
esac
}
mj_timing_on() { [ -n "${MJ_TIMING:-}" ]; }
if mj_timing_on && [ -z "${MJ_TIMING_FILE:-}" ]; then
MJ_TIMING_FILE="$(mktemp "${TMPDIR:-/tmp}/mj.timing.XXXXXX")"; export MJ_TIMING_FILE
MJ_TIMING_T0="$(mj_ms)"
fi
# t0="$(mj_phase_begin <name>)" ... mj_phase_end <name> "$t0"
mj_phase_begin() { mj_timing_on || return 0; mj_ms; }
mj_phase_end() {
mj_timing_on || return 0
[ -n "${2:-}" ] || return 0
printf 'phase\t%s\t%s\n' "$1" "$(( $(mj_ms) - $2 ))" >> "$MJ_TIMING_FILE"
}
# mj_count <name>: one unit of a kind of work worth counting (a parse, a git call)
mj_count() { mj_timing_on || return 0; printf 'count\t%s\t1\n' "$1" >> "$MJ_TIMING_FILE"; }
# The report: phases ranked by time, counters summed, on stderr so the command's own
# output is untouched, in the same shape whatever the command was. Under --json it is the
# same data as one line of JSON (docs/SCHEMAS.md, "The timing report"), so a check can
# compare one run's breakdown with another's; the text form is for a person reading it.
# Both forms are read from the one aggregation below, in the one order, so neither can
# carry a phase or a counter the other lacks.
mj_timing_report() {
mj_timing_on || return 0
[ -n "$MJ_TIMING_FILE" ] && [ -f "$MJ_TIMING_FILE" ] || return 0
local tab total rows
tab="$(printf '\t')"
total="$(( $(mj_ms) - MJ_TIMING_T0 ))"
# kind <TAB> amount <TAB> calls <TAB> name: phases by time, then counters by count
rows="$(
awk -F'\t' '$1=="phase" { t[$2]+=$3; n[$2]++ } END { for (k in t) printf "phase\t%d\t%d\t%s\n", t[k], n[k], k }' "$MJ_TIMING_FILE" \
| LC_ALL=C sort -t "$tab" -k2,2nr -k4,4
awk -F'\t' '$1=="count" { c[$2]+=$3 } END { for (k in c) printf "count\t%d\t0\t%s\n", c[k], k }' "$MJ_TIMING_FILE" \
| LC_ALL=C sort -t "$tab" -k2,2nr -k4,4
)"
if [ "${MJ_JSON:-0}" = 1 ]; then
printf '%s\n' "$rows" | awk -F'\t' -v clock="$MJ_TIMING_CLOCK" -v total="$total" '
function q(s) { gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); gsub(/\t/, "\\t", s); return "\"" s "\"" }
$1 == "phase" { p = p (p == "" ? "" : ",") "{\"name\":" q($4) ",\"ms\":" $2 ",\"calls\":" $3 "}" }
$1 == "count" { c = c (c == "" ? "" : ",") "{\"name\":" q($4) ",\"count\":" $2 "}" }
END { printf "{\"timing\":{\"clock\":%s,\"total_ms\":%d,\"phases\":[%s],\"counters\":[%s]}}\n", q(clock), total, p, c }' >&2
else
{
printf 'TIMING clock=%s total=%s ms\n' "$MJ_TIMING_CLOCK" "$total"
printf '%s\n' "$rows" | awk -F'\t' '
$1 == "phase" { printf "phase %8d ms %4d x %s\n", $2, $3, $4 }
$1 == "count" { printf "count %8d %s\n", $2, $4 }'
} >&2
fi
rm -f "$MJ_TIMING_FILE"
}
# The same instant as mj_now, in the form a record's filename uses. It reads the same
# override for the same reason: a record dated six days ago in its front matter and today in
# its name is a fixture no resolver would agree with itself about.
mj_now_compact() {
if [ -n "${MAJORDOMUS_NOW:-}" ]; then printf '%s' "$MAJORDOMUS_NOW" | tr -d ':-'; return; fi
date -u +%Y%m%dT%H%M%SZ
}
mj_rand16() { od -An -N8 -tx1 /dev/urandom | tr -d ' \n'; }
mj_sha256() {
mj_sha256_tool 2>/dev/null || mj_die "$MJ_EX_MISSING" "$MJ_SHA256_MISSING"
mj_sha256_hex "$1"
}
# NR counts a final line that carries no newline; wc -l does not, and a projection
# written without a trailing newline would be measured one line under its budget.
mj_lines() { awk 'END{ print NR + 0 }' "$1"; }
mj_has() { command -v "$1" >/dev/null 2>&1; }
# policy + profiles, in that order, for the policy hash. An empty profiles/ is a policy
# error that doctor reports, not a cat(1) failure that aborts whoever asked for the hash.
#
# Each file is hashed in its normal form (mj_yaml_normal), not as bytes: a comment or a
# blank line changes no setting, so it must not move the stamp and regenerate every
# bootstrap (I1991). The executable computes the same form (policy::normal_form), and the
# two are held equal by apps/majordomus-cli/tests/providers_projection.rs.
mj_policy_cat() {
local pf
mj_yaml_normal < "$MJ_POLICY_FILE"
for pf in "$MJ_PROFILES_DIR"/*.yaml; do
if [ -f "$pf" ]; then mj_yaml_normal < "$pf"; fi
done
return 0
}
# The normal form of a file in the layer's YAML subset, stdin to stdout: trailing spaces,
# tabs and carriage returns dropped; blank lines and whole-line comments dropped; a
# trailing comment (a `#` after a space or tab) dropped from a line that holds no quote,
# since in a quoted scalar ` #` may be text. Indentation, order and every value are kept,
# so two files with one normal form are one setting. The subset refuses block scalars, so
# no `#` line is ever content. Every line ends in a newline.
mj_yaml_normal() {
awk -v sq="'" -v dq='"' '
{ sub(/[ \t\r]+$/, "") }
/^[ \t]*$/ || /^[ \t]*#/ { next }
index($0, sq) == 0 && index($0, dq) == 0 && match($0, /[ \t]#/) {
$0 = substr($0, 1, RSTART - 1); sub(/[ \t]+$/, "")
}
{ print }'
}
# normalise a repo-relative path: strip ./ and trailing /, collapse //, refuse escapes
# Normalise a repository-relative path into the variable named by $1 — no subprocess. The
# context scan asks this question once per document per tracked file, tens of thousands of
# times in a repository this size, and a `sed` per question was most of the two minutes
# `majordomus context` took: the answer is parameter expansion, and the value goes into a
# variable rather than through a pipe, so a hot caller pays neither a fork nor an exec.
# Returns 1 when the path is empty, absolute, or escapes the repository.
mj_norm_path_into() {
local p="$2"
p="${p#./}"
while [[ "$p" == *//* ]]; do p="${p//\/\//\/}"; done
p="${p%/}"
case "$p" in ""|/*|../*|*/../*|*/..|..) return 1 ;; esac
printf -v "$1" '%s' "$p"
}
# The same, printed: for a caller that reads it with $(...). One fork for the substitution,
# none inside it.
mj_norm_path() {
local out
mj_norm_path_into out "$1" || return 1
printf '%s' "$out"
}
# does path a contain path b (or equal)? Both sides are normalised first: state files can be
# hand-edited, so a trailing slash or ./ in a scope entry must not silently exclude everything.
mj_path_contains() {
local a b
mj_norm_path_into a "$1" || a="$1"
mj_norm_path_into b "$2" || b="$2"
case "$b" in "$a"|"$a"/*) return 0 ;; esac; return 1
}
# "15m" / "2h" / "90s" -> seconds
mj_duration_secs() {
local v="$1" n u
n="${v%[smh]}"; u="${v#"$n"}"
case "$n" in ''|*[!0-9]*) return 1 ;; esac
case "$u" in s|'') printf '%s' "$n" ;; m) printf '%s' $((n*60)) ;; h) printf '%s' $((n*3600)) ;; *) return 1 ;; esac
}
# ISO timestamp -> epoch seconds (BSD and GNU date)
mj_epoch() {
local ts="$1"
if date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$ts" +%s 2>/dev/null; then return; fi
date -u -d "$ts" +%s 2>/dev/null
}
# ---------------------------------------------------------------- YAML subset
# Flattens a restricted YAML subset into "dotted.path=value" lines.
# Supported: `key: value`, nested maps by 2-space indent, block lists (`- item`),
# lists of maps (`- key: value` + indented keys), inline lists `[a, b]`, quotes,
# comments. Tabs, anchors, multi-line scalars and flow maps are rejected.
# mj_yaml_flatten <file> one file, flat lines on stdout, ERROR on stderr and exit 3
# mj_yaml_flatten_many <outdir> [--front] <file>...
# many files in one awk process: <outdir>/<basename without extension> holds each
# file's flat lines; a file that does not parse is named in <outdir>/.errors with its
# message and its output is left empty. --front reads only the YAML front matter of
# Markdown files. One process for a hundred records is what a loader pays instead of
# a hundred, which is the difference between a plan that validates in a second and
# one that validates in five.
MJ_FLATTEN_AWK='
function trim(s){ sub(/^[ \t]+/,"",s); sub(/[ \t]+$/,"",s); return s }
# inside single quotes YAML has one escape, a doubled quote for one quote
function unq(v){
if (v ~ /^\047.*\047$/) { v=substr(v,2,length(v)-2); gsub(/\047\047/,"\047",v); return v }
if (v ~ /^".*"$/) return substr(v,2,length(v)-2)
sub(/[ \t]+#.*$/,"",v); return trim(v)
}
function join(a,b){ return (a=="" ? b : a "." b) }
function put(l){ if (outdir == "") print l; else print l > out }
function fail(m){
if (outdir == "") { print "ERROR:" m > "/dev/stderr"; exit 3 }
print name "\t" m >> (outdir "/.errors"); failed = 1; skipped[name] = 1
}
function emit_val(p,v, n,i,parts){
if (v ~ /^\[.*\]$/) {
v=substr(v,2,length(v)-2); v=trim(v)
if (v=="") { put(p "=[]"); return }
n=split(v,parts,/[ \t]*,[ \t]*/)
for(i=1;i<=n;i++) put(p "." (i-1) "=" unq(parts[i]))
return
}
put(p "=" unq(v))
}
function clear_from(i, k){ for(k in ctx) if(k+0>i) delete ctx[k]; for(k in pend) if(k+0>=i) delete pend[k] }
BEGIN{ ctx[0]="" }
outdir != "" && FNR == 1 {
if (out != "") close(out)
if (numbered) name = ++nfile
else { name = FILENAME; sub(/.*\//, "", name); sub(/\.[A-Za-z0-9]+$/, "", name) }
out = outdir "/" name; printf "" > out
delete ctx; delete pend; delete cnt; ctx[0] = ""; failed = 0; infront = 0; front_done = 0
if (front) { if ($0 != "---") { fail("no front matter"); nextfile } infront = 1; next }
}
outdir != "" && failed { nextfile }
outdir != "" && front && infront && FNR > 1 && $0 == "---" { infront = 0; front_done = 1; nextfile }
{
line=$0
if (line ~ /\t/) { fail("tab character on line " FNR); if (outdir != "") nextfile; }
if (line ~ /^[ \t]*(#|$)/) next
if (line ~ /^---[ \t]*$/) next
match(line,/^ */); ind=RLENGTH; s=substr(line,ind+1)
if (ind % 2 != 0) { fail("odd indentation on line " FNR); if (outdir != "") nextfile; }
if (s ~ /^- /) {
if (!(ind in pend)) { fail("list item without a parent key on line " FNR); if (outdir != "") nextfile; }
parent=pend[ind]; idx=cnt[parent]+0; cnt[parent]=idx+1
item=trim(substr(s,3)); ip=parent "." idx
if (item ~ /^[A-Za-z_][A-Za-z0-9_-]*:([ \t]|$)/) {
k=item; sub(/:.*$/,"",k); v=item; sub(/^[^:]*:[ \t]*/,"",v)
for(kk in ctx) if(kk+0>ind+2) delete ctx[kk]
ctx[ind+2]=ip
if (v=="") { pend[ind+2]=join(ip,k); pend[ind+4]=join(ip,k); ctx[ind+4]=join(ip,k) }
else emit_val(join(ip,k),v)
} else emit_val(ip,item)
next
}
if (s !~ /^[A-Za-z_][A-Za-z0-9_-]*:([ \t]|$)/) { fail("cannot parse line " FNR ": " s); if (outdir != "") nextfile; }
if (!(ind in ctx)) { fail("unexpected indentation on line " FNR); if (outdir != "") nextfile; }
k=s; sub(/:.*$/,"",k); v=s; sub(/^[^:]*:[ \t]*/,"",v)
clear_from(ind)
p=join(ctx[ind],k)
if (v=="") { pend[ind]=p; pend[ind+2]=p; ctx[ind+2]=p }
else emit_val(p,v)
}'
mj_yaml_flatten() {
mj_count yaml_flatten
awk -v outdir="" -v front=0 "$MJ_FLATTEN_AWK" "$1"
}
# --numbered names the outputs 1, 2, 3 ... in argument order, for callers whose files
# may share a basename; the caller keeps the number-to-path map.
mj_yaml_flatten_many() {
local outdir="$1" front=0 numbered=0; shift
while :; do case "${1:-}" in --front) front=1; shift ;; --numbered) numbered=1; shift ;; *) break ;; esac; done
[ $# -gt 0 ] || return 0
mj_count yaml_flatten_many
rm -f "$outdir/.errors"
awk -v outdir="$outdir" -v front="$front" -v numbered="$numbered" "$MJ_FLATTEN_AWK" "$@"
}
# mj_sha256_many: NUL-separated paths on stdin, one "hash<TAB>path" line per file, one
# hashing process for all of them. Hashing one file per process costs more than reading
# it, and the knowledge compiler hashes every source it discovers.
mj_sha256_many() {
mj_count sha256_many
mj_sha256_tool 2>/dev/null || mj_die "$MJ_EX_MISSING" "$MJ_SHA256_MISSING"
mj_sha256_xargs -0 | awk '{ h = $1; sub(/^[^ ]+ /, ""); printf "%s\t%s\n", h, $0 }'
}
# value of a flattened key (first match); empty if absent
# One process each. These run thousands of times per command — the rule loader alone asks
# for well over a thousand keys — and the sed|sed|head form they replaced cost four forks
# per lookup, which was most of a command's wall time.
# ---------------------------------------------------------------- flat file -> variables
# mj_yload <flat> <prefix>: every key=value of a flattened file becomes one shell variable
# <prefix>__<key>, with each "." of the key written "__" and each "-" written "___", so a
# reader that needs many keys of one file pays one awk and one loop of builtins instead of
# one awk per key. Keys outside [A-Za-z0-9_.-] are skipped: they fail every allow-list
# anyway and could not be variable names. Nothing read from a file is ever evaluated: awk
# emits name<TAB>value, the name is checked once more here, and printf -v assigns it.
mj_yassign() { # name<TAB>value lines on stdin -> variables, in this shell
local line n
while IFS= read -r line; do
n="${line%%"$MJ_TAB"*}"
case "$n" in ''|*[!A-Za-z0-9_]*) continue ;; esac
printf -v "$n" '%s' "${line#*"$MJ_TAB"}"
done
}
mj_yload() {
mj_yassign < <(awk -F= -v p="$2" '{
k = $1; if (k !~ /^[A-Za-z0-9_.-]+$/) next
gsub(/-/, "___", k); gsub(/\./, "__", k)
v = $0; sub(/^[^=]*=/, "", v)
printf "%s__%s\t%s\n", p, k, v }' "$1")
}
# mj_yload_dir <dir> <prefix>: every file of a flat directory at once, one awk and one
# loop; the variables are <prefix>_<file name>__<key>, the file name sanitised like a key
mj_yload_dir() {
mj_yassign < <(awk -F= -v p="$2" 'FNR == 1 { pre = FILENAME; sub(/.*\//, "", pre); gsub(/-/, "___", pre); gsub(/\./, "__", pre); pre = p "_" pre }
{ k = $1; if (k !~ /^[A-Za-z0-9_.-]+$/) next
gsub(/-/, "___", k); gsub(/\./, "__", k)
v = $0; sub(/^[^=]*=/, "", v)
printf "%s__%s\t%s\n", pre, k, v }' "$1"/*)
}
# mj_yv <prefix> <key>: the value loaded for a key, empty when absent
# printed as a line, like the flat file's own line, so a caller that collects several
# values in a command substitution gets one per line; an absent key prints nothing
mj_yv() { local k="${2//-/___}" n; k="${k//./__}"; n="$1__$k"; [ -n "${!n:-}" ] && printf '%s\n' "${!n}"; return 0; }
# mj_yvlist <prefix> <key>: the values under key.0, key.1 ... with no process per item
mj_yvlist() { local i=0 k n; k="${2//-/___}"; k="${k//./__}"; while n="$1__${k}__$i"; [ -n "${!n:-}" ]; do printf '%s\n' "${!n}"; i=$((i+1)); done; }
mj_yget() { awk -v k="$2" 'index($0, k "=") == 1 { print substr($0, length(k) + 2); exit }' "$1"; }
# list values under a key prefix: key.0, key.1 ...
mj_ylist() { awk -v k="$2" 'index($0, k ".") == 1 { r = substr($0, length(k) + 2); if (r ~ /^[0-9]+=/) { sub(/^[0-9]+=/, "", r); print r } }' "$1"; }
# keys not matching any regex in an allowlist file -> printed; returns 1 if any
mj_yaml_unknown_keys() {
# one grep per file, not one per key: the keys are cut out in one pass and the ones no
# allow-list pattern matches are the unknown ones. On a plan of a hundred records the
# per-key shape ran thousands of grep processes and was most of plan validate.
# the allow-list carries a generated-file banner as `#` comments; every reader of one
# strips them, so a comment can never be read as a pattern
local flat="$1" allow="$2" out
out="$(cut -d= -f1 "$flat" | grep -vE -f <(grep -v '^#' "$allow") || true)"
[ -n "$out" ] || return 0
printf '%s\n' "$out"
return 1
}
# ---------------------------------------------------------------- regions and stamps
# A generated target describes itself. In file mode its first line is a stamp naming the
# policy hash it was rendered from and the hash of the content below it; in region mode the
# begin marker carries the same two hashes for the region body. Nothing else records what
# was generated: a fresh clone carries the evidence inside the target, so a hand edit is
# detected wherever the file is, and there is no provenance file to keep in step with it.
#
# A region projection owns only the text between two markers in a file it does not
# otherwise control, so Majordomus can be adopted by a repository that already has a
# hand-written CLAUDE.md. The marker is not part of the owned content, so re-hashing the
# policy alone is never a hand edit.
MJ_REGION_BEGIN_RE='^<!-- majordomus:begin( [0-9a-f]+)?( [0-9a-f]+)? -->$'
MJ_REGION_END_RE='^<!-- majordomus:end -->$'
MJ_REGION_END='<!-- majordomus:end -->'
MJ_STAMP_RE='^<!-- generated by `majordomus update` from .* \(policy [0-9a-f]+, content [0-9a-f]+\) .* -->$'
# the first line of a file-mode target: mj_stamp_line POLICY_SHA CONTENT_SHA
mj_stamp_line() {
printf -- '<!-- generated by `majordomus update` from %s (policy %s, content %s) — do not edit; edit the policy and regenerate -->\n' \
"$(mj_rel "$MJ_POLICY_FILE")" "${1:0:12}" "${2:0:16}"
}
# the stamp a target carries, as "policy content" on stdout; exit 1 when it carries none
# mj_stamp_read FILE MODE
mj_stamp_read() {
local line
if [ "$2" = region ]; then
line="$(grep -E "$MJ_REGION_BEGIN_RE" "$1" 2>/dev/null | head -n 1)"
set -- $line; [ $# -ge 5 ] || return 1
printf '%s %s' "$3" "$4"
else
line="$(head -n 1 "$1" 2>/dev/null)"
printf '%s' "$line" | grep -qE "$MJ_STAMP_RE" || return 1
printf '%s' "$line" | sed -E 's/^.*\(policy ([0-9a-f]+), content ([0-9a-f]+)\).*$/\1 \2/'
fi
}
# the content a stamp covers: everything below the stamp line, or the region body
# mj_owned_content FILE MODE -> stdout; exit codes as mj_region_extract for region mode
mj_owned_content() {
if [ "$2" = region ]; then mj_region_extract "$1"
elif mj_stamp_read "$1" file >/dev/null; then tail -n +2 "$1"
else cat "$1"; fi
}
# prints the region body of file $1
# exit 0 found · 1 no begin marker · 2 malformed (unclosed, out of order, or repeated)
mj_region_extract() {
awk -v b="$MJ_REGION_BEGIN_RE" -v e="$MJ_REGION_END_RE" '
$0 ~ b { if (seen) { bad = 2; exit } seen = 1; inside = 1; next }
$0 ~ e { if (!inside) { bad = 2; exit } inside = 0; closed = 1; next }
inside { print }
END { if (bad) exit bad; if (!seen) exit 1; if (!closed) exit 2 }' "$1"
}
# does file $1 carry a begin marker at all?
mj_region_present() { grep -qE "$MJ_REGION_BEGIN_RE" "$1" 2>/dev/null; }
# host file $1 (may be absent), region body file $2, marker payload $3 ("policy content"
# hashes) -> whole new file on stdout. An existing region is replaced in place; otherwise
# the region is appended.
mj_region_splice() {
local host="$1" body="$2" sha="$3"
if [ -f "$host" ] && mj_region_present "$host"; then
awk -v b="$MJ_REGION_BEGIN_RE" -v e="$MJ_REGION_END_RE" -v body="$body" -v sha="$sha" '
$0 ~ b { print "<!-- majordomus:begin " sha " -->"
while ((getline l < body) > 0) print l
close(body)
print "<!-- majordomus:end -->"
skip = 1; next }
$0 ~ e { skip = 0; next }
!skip { print }' "$host"
else
if [ -f "$host" ]; then cat "$host"; printf '\n'; fi
printf -- '<!-- majordomus:begin %s -->\n' "$sha"
cat "$body"
printf '%s\n' "$MJ_REGION_END"
fi
}
# mj_projection_status TARGET MODE PROVIDER
mj_projection_status() {
local tgt="$1" mode="$2" prov="$3" owned rc stamp
mj_provider_template "$prov" >/dev/null || { printf 'no_template'; return 0; }
[ -f "$MJ_ROOT/$tgt" ] || { printf 'absent'; return 0; }
owned="$(mktemp "${TMPDIR:-/tmp}/mj.own.XXXXXX")"
rc=0; mj_owned_content "$MJ_ROOT/$tgt" "$mode" > "$owned" 2>/dev/null || rc=$?
case "$rc" in
0) ;;
1) rm -f "$owned"; printf 'region_absent'; return 0 ;;
*) rm -f "$owned"; printf 'malformed'; return 0 ;;
esac
if ! stamp="$(mj_stamp_read "$MJ_ROOT/$tgt" "$mode")"; then rm -f "$owned"; printf 'unstamped'; return 0; fi
if [ "$(mj_sha256 "$owned" | cut -c1-16)" = "$(printf '%s' "${stamp#* }" | cut -c1-16)" ]; then printf 'ok %s' "${stamp%% *}"; else printf 'hand_edited %s' "${stamp%% *}"; fi
rm -f "$owned"
return 0
}
# projection mode of index $1: "file" (whole file) or "region" (between markers)
mj_projection_mode() {
local m; m="$(mj_pol "projections.$1.mode")"
case "$m" in ""|file) printf 'file' ;; region) printf 'region' ;; *) printf '%s' "$m" ;; esac
}
# ---------------------------------------------------------------- event vocabulary
# The ledger is append-only and canonical, so a line written into it is permanent. An event
# name that no reader recognises is therefore a durable record that is silently ignored,
# which is indistinguishable from the event never having happened. The vocabulary is closed
# for the same reason an unknown policy key is an error.
MJ_EV_FLAT=""
mj_events_load() {
[ -n "$MJ_EV_FLAT" ] && [ -f "$MJ_EV_FLAT" ] && return 0
local reg="$MJ_BIN_DIR/../share/events.yaml"
[ -f "$reg" ] || mj_die "$MJ_EX_INTERNAL" "event registry missing: $reg"
MJ_EV_FLAT="$(mktemp "${TMPDIR:-/tmp}/mj.ev.XXXXXX")"
mj_yaml_flatten "$reg" > "$MJ_EV_FLAT" 2>/dev/null \
|| mj_die "$MJ_EX_INTERNAL" "event registry does not parse: $reg"
[ "$(mj_yget "$MJ_EV_FLAT" version)" = 1 ] || mj_die "$MJ_EX_INTERNAL" "event registry version must be 1"
}
# These read the loaded registry. mj_events_load must run in the calling shell first: a load
# inside a command substitution would set MJ_EV_FLAT in the subshell only, re-flattening the
# file on every call and leaking a temporary file each time.
mj_event_ids() { sed -n 's/^events\.[0-9]*\.id=//p' "$MJ_EV_FLAT"; }
mj_event_known() { mj_event_ids | grep -Fxq "$1"; }
# the index of one event, so its other fields can be read: one pass, not one awk per index
mj_event_index() {
awk -F= -v want="$1" '/^events\.[0-9]+\.id=/ { v = $0; sub(/^[^=]*=/, "", v); if (v == want) { split($1, k, "."); print k[2]; found = 1; exit } }
END { exit found ? 0 : 1 }' "$MJ_EV_FLAT"
}
mj_event_requires() {
local i; i="$(mj_event_index "$1")" || return 0
mj_ylist "$MJ_EV_FLAT" "events.$i.requires"
}
# ---------------------------------------------------------------- ledger
# The keys this function writes itself. A payload may not carry one of them: JSON permits a
# duplicated key and says nothing about which wins, so a line with two `"event"` keys means
# different things to different readers — and this repository had exactly that. The receipt
# every lifecycle hook writes passed a payload field literally named `event` (the provider's
# own event name), producing
#
# {"ts":...,"event":"provider.event.received",...,"provider":"claude-code","event":"start"}
#
# which `history --validate` read as the event `start`, a name share/events.yaml does not
# declare, and refused with exit 10. Written by d5d541850; it never reached CI because no
# runner's ledger holds a provider receipt.
#
# Declared here rather than checked at each call site, because the whole point is that no
# caller can get it wrong. Every entry is matched with its opening quote, so a longer key
# ending in a reserved name (`provider_session`, `session_path`, `ahead`) is not a
# collision; mj_json_esc turns a quote inside a value into `\"`, so a reserved name appearing
# inside a string is not one either.
MJ_LEDGER_ENVELOPE_KEYS='ts event head branch by session'
# mj_ledger_append event 'extra json fields without braces'
# The event must be one share/events.yaml declares, must carry the payload keys that entry
# requires, and must not carry a key from the envelope. All three are internal errors rather
# than findings: a command that writes an event the vocabulary does not define, or that
# overwrites the envelope it is being wrapped in, is a bug in Majordomus and not a fact
# about the repository being supervised.
mj_ledger_append() {
local ev="$1" extra="${2:-}" line sid k
mj_events_load
mj_event_known "$ev" || mj_die "$MJ_EX_INTERNAL" \
"unregistered event '$ev'; declare it in share/events.yaml or use one of: $(mj_event_ids | tr '\n' ' ')"
for k in $MJ_LEDGER_ENVELOPE_KEYS; do
case "$extra" in *"\"$k\":"*)
mj_die "$MJ_EX_INTERNAL" \
"event '$ev' carries the payload field '$k', which is a ledger envelope key; every line would carry it twice and readers would disagree about which one it means — rename the field (the provider receipt's own '$k' became 'provider_$k')" ;;
esac
done
for k in $(mj_event_requires "$ev"); do
case "$extra" in *"\"$k\":"*) ;;
*) mj_die "$MJ_EX_INTERNAL" "event '$ev' is missing the required field '$k'" ;;
esac
done
line="{\"ts\":\"$(mj_now)\",\"event\":\"$ev\",\"head\":\"$(mj_git_head)\",\"branch\":\"$(mj_json_esc "$(mj_git_branch)")\",\"by\":\"majordomus/$MJ_VERSION\""
sid="$(mj_open_session_id)"
[ -n "$sid" ] && line="$line,\"session\":\"$sid\""
[ -n "$extra" ] && line="$line,$extra"
# the local half is never tracked, so a second worktree of the same repository starts
# without it; the first write creates it
mkdir -p "$MJ_STATE_DIR"
printf '%s}\n' "$line" >> "$MJ_STATE_DIR/ledger.jsonl"
}
# ---------------------------------------------------------------- the open episode
# One execution episode per provider session, not one per checkout.
#
# The episode used to be a singleton: one state/session-current.yaml, and a second provider
# session opening one in the same checkout was folded into the first. Its ledger lines were
# stamped with the other episode's id, and either window's end event closed the episode for
# both. Measured on 2026-09-09 in this repository: seven concurrent sessions, one record
# between them.
#
# So an open episode is a file of its own, named by the provider session that opened it,
# under state/sessions-open/. An episode nobody named is keyed `hand`, and there is at most
# one of those: a provider that sends no session identity is indistinguishable from a person
# at a terminal, and inventing a distinction there would multiply episodes nobody can close.
#
# state/session-current.yaml stays, as a symlink into that store — the pointer to the
# episode of this checkout. It is the path `continuity.state`, `doctor`, the schema and the
# prose already name, and every reader of it (a sed, a YAML flattener, a whole-document
# reader in Rust) follows a symlink without knowing it did. A symlink and not a copy,
# because two accounts of one open episode is exactly the drift this record exists to
# avoid.
#
# Which episode is this process's, in order:
#
# 1. MJ_SESSION_KEY — the provider session named on the command line, which a provider
# hook always passes. Strict: that episode or none. This is what keeps an end event
# from closing an episode another provider session opened.
# 2. the provider session this process is running inside, when the provider exports one
# (MAJORDOMUS_PROVIDER_SESSION, or the variable the lifecycle adapter declares) AND an
# episode with that key is open here. It falls through when there is none, because a
# worker sitting in a checkout whose episode was opened by hand still has that one.
# 3. the pointer.
#
# Two episodes open in one checkout and a process that can name neither is the one case
# nothing here can resolve: the pointer names the one opened last, and that is a guess the
# status output says out loud rather than a fact.
mj_session_open_dir() { printf '%s' "$MJ_STATE_DIR/sessions-open"; }
mj_session_pointer() { printf '%s' "$MJ_STATE_DIR/session-current.yaml"; }
# A provider session reduced to one path segment. The value is the provider's own string,
# so nothing here lets one name a file outside the store; an empty or unnameable one is the
# hand-opened episode.
mj_session_key() {
local v
v="$(printf '%s' "${1:-}" | tr -c 'A-Za-z0-9._-' '-' | tr -s '-' | cut -c1-64 | sed -e 's/^-*//' -e 's/-*$//')"
if [ -n "$v" ]; then printf '%s' "$v"; else printf 'hand'; fi
}
mj_session_key_file() { printf '%s/%s.yaml' "$(mj_session_open_dir)" "$(mj_session_key "${1:-}")"; }
# The provider session this process is running inside, when one is observable. The variable
# names are the lifecycle adapters' own column, so the provider table stays the single
# declaration of what a provider is; capture.sh is sourced here rather than at the top
# because this is on the ledger's write path and the prompt path needs none of it.
mj_provider_session_env() {
if [ -n "${MAJORDOMUS_PROVIDER_SESSION:-}" ]; then printf '%s' "$MAJORDOMUS_PROVIDER_SESSION"; return 0; fi
if [ -n "${MJ_LIB_DIR:-}" ] && [ -f "$MJ_LIB_DIR/capture.sh" ]; then
# shellcheck source=capture.sh
[ -n "${MJ_LIB_capture:-}" ] || . "$MJ_LIB_DIR/capture.sh"
local n v
for n in $(mj_lifecycle_session_vars); do
# the name comes from the adapter table, never from anything a payload carries.
# Indirect expansion, not eval: eval would run whatever the name expanded to, and
# SECURITY.md forbids it outright (test/cases/08_no_forbidden_constructs.sh).
v="${!n:-}"
if [ -n "$v" ]; then printf '%s' "$v"; return 0; fi
done
fi
return 0
}
# The file holding this process's open episode, whether or not it exists.
mj_session_here_file() {
local k f
if [ -n "${MJ_SESSION_KEY:-}" ]; then mj_session_key_file "$MJ_SESSION_KEY"; return 0; fi
k="$(mj_provider_session_env)"
if [ -n "$k" ]; then
f="$(mj_session_key_file "$k")"
if [ -f "$f" ]; then printf '%s' "$f"; return 0; fi
fi
mj_session_pointer
return 0
}
# The open session in THIS worktree, or nothing. Read with two seds rather than the YAML
# parser: this runs on every ledger append, and the two fields it needs are top-level
# scalars written by one command.
#
# Which episode wrote an event is a fact the machine knows, so it is stamped like `head`
# and `branch` are, rather than reconstructed later from a time range. A time range cannot
# tell two workers apart — the ledger is one file per repository, and a session that
# selected by time alone claimed another session's checkpoints the first time this was run
# for real. A line with no session belongs to no episode, which is the honest answer for
# work done outside one: sessions are optional, and nothing is attributed by proximity.
mj_open_session_id() {
local f w
f="$(mj_session_here_file)"
[ -f "$f" ] || return 0
w="$(sed -n 's/^worktree: //p' "$f" | head -n 1)"
[ -n "$w" ] && [ "$w" != "$MJ_ROOT" ] && return 0
sed -n 's/^session_id: //p' "$f" | head -n 1
}
# ---------------------------------------------------------------- current task
mj_load_current() {
MJ_CUR="$MJ_STATE_DIR/current.yaml"
[ -f "$MJ_CUR" ] || return 1
MJ_CUR_FLAT="$(mktemp "${TMPDIR:-/tmp}/mj.cur.XXXXXX")"
mj_yaml_flatten "$MJ_CUR" > "$MJ_CUR_FLAT" || return 2
mj_allow_warn current "$MJ_CUR_FLAT" "$MJ_ALLOW_DIR/current.txt" "$(mj_rel "$MJ_CUR")"
return 0
}
# Hold a flattened local-state file to its schema, by way of the allow-list `generate allow`
# derives from it. The local half of the layer is not indexed, so no kind applies a schema
# there; this is where those schemas are applied instead, and it is what keeps a schema from
# being a file that describes nothing. A warning rather than a failure, deliberately: these
# records are this checkout's own, an unknown key means the record is stale or foreign, and
# neither is a reason to refuse the command a person is running.
mj_allow_warn() {
local kind="$1" flat="$2" allow="$3" rel="$4" k
[ -f "$allow" ] || return 0
k="$(mj_yaml_unknown_keys "$flat" "$allow" || true)"
[ -z "$k" ] || mj_warn "$kind" "$rel" "unknown key(s) the $kind schema does not declare: $(printf '%s' "$k" | tr '\n' ' ')" "majordomus doctor"
return 0
}
mj_cur() { [ -n "${MJ_CUR_FLAT:-}" ] || return 0; mj_yget "$MJ_CUR_FLAT" "$1"; }
mj_load_policy() {
MJ_POL_FLAT="$(mktemp "${TMPDIR:-/tmp}/mj.pol.XXXXXX")"
mj_yaml_flatten "$MJ_POLICY_FILE" > "$MJ_POL_FLAT" || return 1
}
mj_pol() { [ -n "${MJ_POL_FLAT:-}" ] || return 0; mj_yget "$MJ_POL_FLAT" "$1"; }
# A policy value the code depends on. There is no default here on purpose: a fallback
# written beside the reader is a second source of truth for the same number, and a reader
# that silently substitutes its own value enforces something the configuration does not
# say. A missing key is a policy error, and doctor names the key.
# A required policy value. Almost every caller uses this inside a command substitution,
# where mj_die can only exit the subshell — the parent then carries on with an empty
# string, which is how an installation with an older policy produced
# "[: : integer expected" and a finding that read "17 lines over budget " with no number.
# A helper that is meant to fail closed and fails open in its usual position is worse
# than no helper, so this returns non-zero and the caller decides: a command dies, a
# validator reports. Callers must check, and mj_validate_policy_keys enforces that they do.
mj_pol_req() {
local v; v="$(mj_pol "$1")"
if [ -z "$v" ]; then
printf 'majordomus: policy is missing required key %s (%s)\n' "$1" "$(mj_rel "$MJ_POLICY_FILE")" >&2
return "$MJ_EX_CONTRACT"
fi
printf '%s' "$v"
}
mj_load_profile() {
local name="$1"
# cleared first: a failed load must not leave the previous profile readable as this one
rm -f "${MJ_PRO_FLAT:-}" 2>/dev/null || true; MJ_PRO_FLAT=""
[ -f "$MJ_PROFILES_DIR/$name.yaml" ] || return 1
MJ_PRO_FLAT="$(mktemp "${TMPDIR:-/tmp}/mj.pro.XXXXXX")"
mj_yaml_flatten "$MJ_PROFILES_DIR/$name.yaml" > "$MJ_PRO_FLAT" || return 2
}
mj_pro() { [ -n "${MJ_PRO_FLAT:-}" ] || return 0; mj_yget "$MJ_PRO_FLAT" "$1"; }
# ---------------------------------------------------------------- temporary files
# Every temporary file a command makes lives under one directory of the process's own, and
# the exit trap removes that directory. Before this, the trap removed the four files it knew
# by name while some hundred and fifty `mktemp` call sites made others: the manifest and
# event flattenings, the session and context-document workspaces, the project model's
# directory of two hundred and sixty files, and everything made inside a command
# substitution, whose subshell has no exit trap to run. One session start left 25 entries
# and 1335 files in TMPDIR; a provider hook runs the tool on every prompt, so the pile grew
# for as long as the machine stayed up, and on 2026-10-07 macOS spent 7 min 38 s of a login
# deleting it, because it empties TMPDIR at boot before anything else may start.
#
# The call sites are not rewritten. They ask for "${TMPDIR:-/tmp}/<name>", and the function
# below answers that request under the root instead; a template anywhere else — beside a
# record about to be published, inside a workspace already under the root, in any directory
# below TMPDIR rather than in it — is passed through untouched, because that location is the
# point of it. TMPDIR itself is not moved:
# a verify command, an editor or a server this process starts inherits the environment and
# may outlive the directory.
#
# The root is made once, before a command runs, and not on first use: a first use inside a
# command substitution would name a root only that subshell knows. The entry point makes it
# after its dispatch; a script that sources this library gets it as it sources it (below).
# Sourcing the library again keeps the root this process already has.
case "${MJ_TMP_ROOT:-}" in */mj."$$".*) ;; *) MJ_TMP_ROOT="" ;; esac
mj_tmp_root_init() {
local base="${TMPDIR:-/tmp}"
MJ_TMP_ROOT="$(command mktemp -d "${base%/}/mj.$$.XXXXXX")"
# A root whose process is gone is removed by the next process to start. The exit trap
# cannot be the only remover: a shell that dies of PIPE runs none, and
# `majordomus doctor | grep -q OK` — a reader that closes the pipe once it has its
# answer — is how half the cases ask a question, so each such command left its root
# behind whole, as does a KILL. Trapping PIPE would remove it, and would also put a
# "write error: Broken pipe" line on stderr that was never there. So the root carries its
# process id, and here every root of this user's whose process no longer exists goes. A
# process id that has been reused keeps a dead root until that process ends too.
#
# Gone means the system said "no such process" and nothing else. `kill -0` also fails when
# this process may not signal that one, and a session inside a sandbox may signal nobody:
# read as "gone", that refusal removed the roots of every living command of this user's,
# and on 2026-10-12 three derives died mid-run with their temporary files deleted under
# them. Any other answer — permission, or a wording this cannot read — keeps the root:
# a root kept too long costs disk, and a root removed too soon costs somebody's work.
local stale pid why
for stale in "${base%/}"/mj.[0-9]*.??????; do
[ -d "$stale" ] && [ -O "$stale" ] || continue
pid="${stale##*/mj.}"; pid="${pid%.*}"
case "$pid" in ''|*[!0-9]*) continue ;; esac
why="$(LC_ALL=C; export LC_ALL; kill -0 "$pid" 2>&1)" && continue
case "$why" in *[Nn]'o such process'*) rm -rf "$stale" 2>/dev/null || true ;; esac
done
}
mktemp() {
[ -n "$MJ_TMP_ROOT" ] || { command mktemp "$@"; return; }
local base="${TMPDIR:-/tmp}" a rest; local -a args=()
base="${base%/}"
for a in "$@"; do
# only a name directly in TMPDIR is moved. A path that merely lies below it is somewhere
# in particular: a workspace under the root, or a repository that itself lives in /tmp,
# as every fixture does on a machine where TMPDIR is unset, and there a template beside
# a record was sent to a directory that did not exist.
case "$a" in
"$base"/*)
rest="${a#"$base"/}"; rest="${rest#/}"
case "$rest" in */*) ;; *) a="$MJ_TMP_ROOT/$rest" ;; esac ;;
esac
args+=("$a")
done
command mktemp "${args[@]+"${args[@]}"}"
}
mj_cleanup() {
mj_timing_report
rm -f "${MJ_CUR_FLAT:-}" "${MJ_POL_FLAT:-}" "${MJ_PRO_FLAT:-}" "${MJ_REC_TMP:-}" 2>/dev/null
# MJ_REC_TMP above is not under the root: it is made beside the record it becomes.
# Only the shell that made the root removes it. A subshell that sources the library again
# sets this trap for itself and keeps the root it inherited (above), so its exit would
# otherwise remove the root its parent is still writing into.
[ -n "${MJ_TMP_ROOT:-}" ] && [ "${BASH_SUBSHELL:-0}" = 0 ] && rm -rf "$MJ_TMP_ROOT" 2>/dev/null
# A lock this process is holding goes with it. Releasing here and not only at the end of
# the critical section is what makes the lock crash-safe for every exit a command has:
# mj_die, a failed `set -e` command, an interrupt from the test runner's bound.
[ -n "${MJ_HELD_LOCK:-}" ] && rmdir "${MJ_HELD_LOCK}" 2>/dev/null
return 0
}
trap mj_cleanup EXIT
# A script that sources this library makes its root here. Before this only the entry point
# asked for one, and every script that sources the library — the derive's generators, the
# site build and its checks, the cases — made its files directly in TMPDIR and left them:
# the project model's directory, the manifest's and the knowledge sources' flattenings, one
# of each per run. On 2026-10-11 TMPDIR held 65,530 entries, the most one directory holds
# on that volume, and every `mktemp -d` on the machine failed as if its parent were gone.
# The entry point still makes its root only after its dispatch, because a command it hands
# to the other program is an exec, which runs no exit trap; it sets MJ_TMP_ROOT_DEFER
# before sourcing the library to say so. A script that replaces the exit trap leaves its
# root to the next process that starts, as a KILL does. A root that cannot be made leaves
# `mktemp` as it was rather than stopping the script that sourced the library. A subshell
# makes none: it shares its parent's root, and a root of its own would be named after the
# parent's process and outlive the subshell.
if [ -z "${MJ_TMP_ROOT_DEFER:-}" ] && [ -z "$MJ_TMP_ROOT" ] && [ "${BASH_SUBSHELL:-0}" = 0 ]; then
mj_tmp_root_init 2>/dev/null || true
[ -d "${MJ_TMP_ROOT:-}" ] || MJ_TMP_ROOT=""
fi
# ---------------------------------------------------------------- mutual exclusion
# One named lock, taken by creating a directory, which is the one filesystem operation
# POSIX guarantees is atomic and which works on every filesystem this tool runs on. The
# shape is `adr propose`'s, generalised here because a second command needed it: a
# check-then-write over a shared store is not exactly-once unless the check and the write
# are inside the same critical section, and `session close` proved that by publishing two
# canonical records for one episode when two of a provider's end events arrived together.
#
# It never blocks for ever and it never refuses. A lock older than MJ_LOCK_STALE seconds
# belonged to a process that died holding it, and waiting on a dead process is a worse
# failure than proceeding without the guarantee — so the wait is bounded, a stale lock is
# broken, and the caller is told which happened through the return value rather than being
# stopped. The guard inside the section stays correct without the lock; it is only narrower.
#
# mj_lock_take PATH -> 0 took it · 1 proceeded without it (bounded wait expired)
# mj_lock_release -> releases whatever this process holds
MJ_HELD_LOCK=""
MJ_LOCK_WAIT="${MJ_LOCK_WAIT:-100}" # tenths of a second
MJ_LOCK_STALE="${MJ_LOCK_STALE:-60}" # seconds before a held lock is called abandoned
mj_lock_take() {
local lock="$1" waited=0 age
mkdir -p "$(dirname "$lock")" 2>/dev/null || true
while ! mkdir "$lock" 2>/dev/null; do
age="$(mj_file_age_secs "$lock")"
if [ -n "$age" ] && [ "$age" -gt "$MJ_LOCK_STALE" ]; then
rmdir "$lock" 2>/dev/null || true
continue
fi
waited=$((waited + 1))
if [ "$waited" -ge "$MJ_LOCK_WAIT" ]; then
mj_err "warning: $(mj_rel "$lock") has been held for $((waited / 10))s; continuing without it"
return 1
fi
sleep 0.1 2>/dev/null || sleep 1
done
MJ_HELD_LOCK="$lock"
return 0
}
mj_lock_release() {
[ -n "${MJ_HELD_LOCK:-}" ] || return 0
rmdir "$MJ_HELD_LOCK" 2>/dev/null || true
MJ_HELD_LOCK=""
return 0
}
# Seconds since a path was last modified, or nothing when it cannot be read. GNU stat first,
# then BSD; the two disagree about every flag they share.
mj_file_age_secs() {
local t
t="$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null)" || return 0
[ -n "$t" ] || return 0
printf '%s' "$(( $(date +%s) - t ))"
}
# ---------------------------------------------------------------- records
# A record is a Markdown file with computed YAML front matter and an authored body.
# Handovers and checkpoints share this shape; only the directory and the caller's
# extra front-matter lines differ.
# front matter of a record (between the first --- and the next ---), empty if malformed
# mj_change_set MODE [BASE] -> "<status> <TAB> <path> <TAB> <new path or empty>" per change,
# sorted by path: `staged` reads the index, `base` reads <ref>..worktree, anything else the
# working tree against HEAD. Untracked files count as additions, because a file git does not
# know about yet is still a change somebody is about to commit. One reader, so that every
# command that asks "what changed" gets the same answer.
mj_change_set() {
local mode="$1" base="${2:-}" tab
tab="$(printf '\t')"
case "$mode" in
staged) mj_git diff --name-status -M --cached 2>/dev/null ;;
base) mj_git diff --name-status -M "$base" 2>/dev/null
mj_git ls-files --others --exclude-standard 2>/dev/null | sed 's/^/A\t/' ;;
*) mj_git diff --name-status -M HEAD 2>/dev/null
mj_git ls-files --others --exclude-standard 2>/dev/null | sed 's/^/A\t/' ;;
esac | awk -F'\t' '{ s = substr($1, 1, 1); if (s == "R" || s == "C") print s "\t" $2 "\t" $3; else print s "\t" $2 "\t" }' \
| LC_ALL=C sort -t "$tab" -k2,2
}
# A stable identity for this working copy that names no path: the first sixteen hex digits
# of the sha256 of its absolute path. Two checkouts of one repository differ; the same
# checkout is the same across runs; and the path itself is not disclosed by a shared record.
# The repository, named without naming a disk: the remote's URL when there is one, and a
# hash of the common git directory when there is not. A shared record carries this; the
# local records keep mj_git_repo_id, which is a path and is theirs to hold.
#
# The URL is written without its credentials. A remote configured as
# https://x-access-token:<token>@host/o/r.git — which is how a CI clone and many a laptop
# are configured — used to be copied whole into every shared record, and a shared record
# is tracked and pushed: the token left the machine in a file nobody thought of as holding
# one. The identity is the repository's, and a credential is not part of which repository
# it is.
mj_repository_id() {
local remote; remote="$(mj_git config --get remote.origin.url 2>/dev/null)"
if [ -n "$remote" ]; then mj_url_public "$remote"; else printf 'local:%s' "$(mj_worktree_id)"; fi
}
# A URL as it may be written down: over HTTP the whole userinfo goes, because a token is as
# often the user name as the password; over any other scheme only a password does, because
# `ssh://git@host/...` names a login that is no secret and is part of how the remote is
# reached. The scp form (`git@host:o/r.git`) has no scheme and carries no password.
# mj_url_public <url>
mj_url_public() {
local u="$1" scheme rest authority userinfo
case "$u" in
*://*)
scheme="${u%%://*}"; rest="${u#*://}"; authority="${rest%%/*}"
case "$authority" in
*@*)
userinfo="${authority%@*}"
case "$scheme" in
http|https) rest="${authority##*@}${rest#"$authority"}" ;;
*) rest="${userinfo%%:*}@${authority##*@}${rest#"$authority"}" ;;
esac ;;
esac
printf '%s://%s' "$scheme" "$rest" ;;
*) printf '%s' "$u" ;;
esac
}
mj_worktree_id() {
if mj_sha256_tool 2>/dev/null; then printf '%s' "$MJ_ROOT" | mj_sha256_hex | cut -c1-16
else printf '%s' "$MJ_ROOT" | cksum | tr -d ' ' | cut -c1-16; fi
}
# Is this Markdown file a context document rather than an instance of the kind that lives
# beside it? A section's README sits in the same directory as its files and declares the
# context contract; a kind's discovery walks the directory and must not read it as one of
# its own. Coverage made these READMEs universal (ADR 0011), so the test is shared.
mj_is_context_doc() {
[ -f "$1" ] || return 1
[ "$(awk 'NR == 1 && $0 != "---" { exit } NR > 1 && $0 == "---" { exit }
NR > 1 && $0 == "kind: context" { print "yes"; exit }' "$1" 2>/dev/null)" = yes ]
}
mj_record_front() { awk 'NR==1&&$0!="---"{exit 2} NR>1&&$0=="---"{exit} NR>1' "$1"; }
# body of a record: everything after the second ---
mj_record_body() { awk 'c>=2{print} /^---$/{c++}' "$1"; }
# refuse a body that carries fields Majordomus computes: prose must not forge identity
mj_reject_identity() {
grep -qE '^(schema_version|created_at|task_id|repository_id|worktree|branch|head|working_tree|changed_files|bound_issue|bound_intent|bound_exemption|plan_revision|evidence_standing):' "$1"
}
# collect staging files an earlier writer was killed before it could unlink. A SIGKILL runs
# no trap, and the file it leaves sits in a tracked directory looking like repository content.
# An hour of grace, so that a sibling publishing right now is never robbed of the file it is
# about to link: the window between mktemp and ln is microseconds.
mj_sweep_record_temps() {
find "$1" -maxdepth 1 -type f -name '.tmp.??????' -mmin +60 -exec rm -f {} + 2>/dev/null || true
}
# publish content as a new file in a directory, atomically, mode 0600, never overwriting.
# mj_publish_record DIR NAME_PREFIX CONTENT_FILE -> prints the final path
#
# The staging file is created inside DIR because the hard link that publishes it cannot cross
# a filesystem, and DIR is tracked — so an abort between mktemp and the unlink below dirties a
# record section. Three things stop that: MJ_REC_TMP puts the file on the EXIT trap's list, the
# sweep above collects what a kill left on an earlier run, and .gitignore makes either
# uncommittable in the meantime. A leftover must never be able to reach a commit.
mj_publish_record() {
local dir="$1" prefix="$2" src="$3" final n=0 head
mkdir -p "$dir"
mj_sweep_record_temps "$dir"
MJ_REC_TMP="$(mktemp "$dir/.tmp.XXXXXX")"; chmod 600 "$MJ_REC_TMP"; cat "$src" > "$MJ_REC_TMP"
head="$(mj_git_head)"
while :; do
final="$dir/$(mj_now_compact)--${prefix:+$prefix--}$(mj_branch_key)--${head:0:7}--$(mj_rand16).md"
ln "$MJ_REC_TMP" "$final" 2>/dev/null && break
n=$((n + 1)); [ "$n" -lt 10 ] || { rm -f "$MJ_REC_TMP"; MJ_REC_TMP=""; return 1; }
done
rm -f "$MJ_REC_TMP"; MJ_REC_TMP=""
printf '%s\n' "$final"
}
# write the computed front matter of a record to stdout. $1 = task id or "none", $2 = profile,
# $3 = owner, remaining args are extra "key: value" lines appended verbatim.
mj_record_front_matter() {
local task_id="${1:-none}" profile="${2:-none}" owner="${3:-}"; shift 3 2>/dev/null || true
printf -- '---\nschema_version: 1\ncreated_at: %s\ntask_id: %s\nprofile: %s\nowner: "%s"\n' \
"$(mj_now)" "$task_id" "$profile" "$(printf '%s' "$owner" | sed 's/"/\\"/g')"
printf 'repository_id: %s\nworktree: %s\nbranch: %s\nhead: %s\nworking_tree: %s\nchanged_files:\n' \
"$(mj_git_repo_id)" "$MJ_ROOT" "$(mj_git_branch)" "$(mj_git_head)" "$(mj_git_dirty)"
# The work, not the exhaust. This was `git status --porcelain` verbatim, which is the
# dirty tree and not the episode's work product: running the tool dirties the tree, and a
# checkpoint written after a `scripts/derive` named every generated file as something the
# worker had done. lib/changed.sh classifies against the five declarations that already
# say what is derived; it is sourced here rather than at the top because this is on the
# record-writing path and nothing else in common.sh needs it.
[ -n "${MJ_LIB_changed:-}" ] || . "$MJ_LIB_DIR/changed.sh"
mj_changed_files_block
local extra; for extra in "$@"; do printf '%s\n' "$extra"; done
printf -- '---\n\n'
}
# ---------------------------------------------------------------- resolution
# Resolve the most relevant record in a directory for this worktree and branch.
# Tier 0: same repository, same worktree, same branch. Tier 1: same repository, same
# non-detached branch. Never repository-wide: an unrelated worktree's record must not
# become current context. Newest wins within a tier.
# mj_resolve_latest DIR [TASK_ID] -> 0 and sets MJ_RES_*, or 1 when nothing matches.
# Malformed records are skipped with a warning on stderr, never silently.
MJ_RES_PATH=""; MJ_RES_TIER=""; MJ_RES_MATCH=""; MJ_RES_HEAD=""; MJ_RES_BRANCH=""
MJ_RES_DIRTY=""; MJ_RES_CREATED=""; MJ_RES_TASK=""; MJ_RES_SKIPPED=0
mj_resolve_latest() {
local dir="$1" want_task="${2:-}" f fm flat tier key best="" best_key="" rversion
local my_id my_branch; my_id="$(mj_git_repo_id)"; my_branch="$(mj_git_branch)"
MJ_RES_PATH=""; MJ_RES_SKIPPED=0
[ -d "$dir" ] || return 1
for f in "$dir"/*.md; do
[ -f "$f" ] || continue
mj_is_context_doc "$f" && continue # a section's own contract is not a record
fm="$(mktemp "${TMPDIR:-/tmp}/mj.fm.XXXXXX")"
mj_record_front "$f" > "$fm" || { rm -f "$fm"; mj_err "warning: skipped $f: no front matter"; MJ_RES_SKIPPED=$((MJ_RES_SKIPPED+1)); continue; }
flat="$(mktemp "${TMPDIR:-/tmp}/mj.fl.XXXXXX")"
if ! mj_yaml_flatten "$fm" > "$flat" 2>/dev/null; then
rm -f "$fm" "$flat"; mj_err "warning: skipped $f: malformed front matter"; MJ_RES_SKIPPED=$((MJ_RES_SKIPPED+1)); continue; fi
# A record carries a version, a head and a time. `schema_version: 1` is what the local
# records have always said; a shared session record says `schema: <kind>/v1` instead,
# and both are versions this resolver reads (ADR 0014).
#
# The version has to be one this executable reads, not merely present. The test used to
# be "`schema:` is non-empty", which accepts `session/v2` — a record written by a newer
# Majordomus — and offers it to the next worker as the thing to resume from, under a
# contract that no longer describes its fields. A record from the future is not a record
# this version can read, and being handed one silently is worse than being handed none:
# the worker cannot tell it is wrong until it has acted on it.
rversion="$(mj_yget "$flat" schema)"
if [ -n "$rversion" ] && [ "${rversion##*/}" != v1 ]; then
rm -f "$fm" "$flat"
mj_err "warning: skipped $f: schema '$rversion', and this executable reads v1 (run: majordomus doctor)"
MJ_RES_SKIPPED=$((MJ_RES_SKIPPED+1)); continue; fi
if { [ "$(mj_yget "$flat" schema_version)" != 1 ] && [ -z "$rversion" ]; } \
|| [ -z "$(mj_yget "$flat" head)" ] || [ -z "$(mj_yget "$flat" created_at)" ]; then
rm -f "$fm" "$flat"; mj_err "warning: skipped $f: missing required fields"; MJ_RES_SKIPPED=$((MJ_RES_SKIPPED+1)); continue; fi
if [ -n "$want_task" ] && [ "$(mj_yget "$flat" task_id)" != "$want_task" ]; then rm -f "$fm" "$flat"; continue; fi
tier=""
# A shared record names the repository by its remote, a local one by its git directory;
# the same repository answers to either (ADR 0014).
if [ "$(mj_yget "$flat" repository_id)" = "$my_id" ] \
|| [ "$(mj_url_public "$(mj_yget "$flat" repository_id)")" = "$(mj_repository_id)" ]; then
# Tier 0 is "this worktree". A local record names it by path; a shared one names it by
# `worktree_id`, because an absolute path is a fact about a disk and a shared record
# carries none (ADR 0014). Either identifies the same working copy.
if { [ "$(mj_yget "$flat" worktree)" = "$MJ_ROOT" ] \
|| { [ -n "$(mj_yget "$flat" worktree_id)" ] && [ "$(mj_yget "$flat" worktree_id)" = "$(mj_worktree_id)" ]; }; } \
&& [ "$(mj_yget "$flat" branch)" = "$my_branch" ]; then tier=0
elif [ "$my_branch" != DETACHED ] && [ "$(mj_yget "$flat" branch)" = "$my_branch" ]; then tier=1; fi
fi
if [ -n "$tier" ]; then
key="$tier|$(mj_yget "$flat" created_at)|$(mj_record_rank "$f")"
if [ -z "$best" ] || [ "${key%%|*}" -lt "${best_key%%|*}" ] || { [ "${key%%|*}" = "${best_key%%|*}" ] && [ "${key#*|}" \> "${best_key#*|}" ]; }; then
best="$f"; best_key="$key"
MJ_RES_HEAD="$(mj_yget "$flat" head)"; MJ_RES_BRANCH="$(mj_yget "$flat" branch)"
MJ_RES_DIRTY="$(mj_yget "$flat" working_tree)"; MJ_RES_CREATED="$(mj_yget "$flat" created_at)"
MJ_RES_TASK="$(mj_yget "$flat" task_id)"
fi
fi
rm -f "$fm" "$flat"
done
[ -n "$best" ] || return 1
MJ_RES_PATH="$best"; MJ_RES_TIER="${best_key%%|*}"
[ "$MJ_RES_TIER" = 0 ] && MJ_RES_MATCH=same_worktree_same_branch || MJ_RES_MATCH=same_branch
return 0
}
# Position of a record in the ledger, zero-padded, or 000000 when nothing recorded it.
# created_at has second resolution, so two records written inside one second would
# otherwise resolve in an order decided by a random filename suffix. The ledger is
# append-only and written in the order the commands ran, which makes it a monotonic
# tiebreak for "which of these did this machine write last".
#
# That, and nothing more: .ai/local/state/ledger.jsonl is gitignored, machine-local and
# rotated under a retention cap, so this number is not a fact about the repository and
# **must never order anything that gets published**. mj_session_keys used it, and the
# committed order of six session records sharing a created_at second was therefore decided
# by a line number no clone could reproduce; it now breaks that tie on the record's own name
# instead. The one caller left is mj_resolve_latest, which answers "the record most
# relevant to this worktree right now" — a question about this machine, whose answer is
# never committed.
mj_record_rank() {
local base n
base="$(basename "$1")"
n="$(grep -n -F -- "$base" "$MJ_STATE_DIR/ledger.jsonl" 2>/dev/null | tail -n 1 | cut -d: -f1)"
printf '%06d' "${n:-0}"
}
# age of an ISO timestamp in whole minutes; empty when unparseable
mj_age_minutes() {
local e; e="$(mj_epoch "$1")"; [ -n "$e" ] || return 1
printf '%s' $(( ( $(mj_epoch "$(mj_now)") - e ) / 60 ))
}
# "18" -> "18m"; "1500" -> "25h". A span, not a moment: a threshold is a length of time
# and rendering it with mj_age_human produced "past the 2d ago this repository calls stale".
mj_duration_human() {
local m="$1"
if [ -z "$m" ]; then printf 'unknown'
elif [ "$m" -lt 90 ]; then printf '%sm' "$m"
elif [ "$m" -lt 2880 ]; then printf '%sh' $((m/60))
else printf '%sd' $((m/1440)); fi
}
# "18" -> "18m ago"; "1500" -> "25h ago"
mj_age_human() {
local m="$1"
if [ -z "$m" ]; then printf 'unknown'
elif [ "$m" -lt 90 ]; then printf '%sm ago' "$m"
elif [ "$m" -lt 2880 ]; then printf '%sh ago' $((m/60))
else printf '%sd ago' $((m/1440)); fi
}
# ---------------------------------------------------------------- freshness
# How far a record may still be trusted as *current*, which is a different question from
# how far its commit is from HEAD. mj_git_label answers the second: exact, advanced,
# diverged, different_context — a statement about topology. A record whose commit is an
# ancestor of HEAD is `advanced` on the day it is written and `advanced` six days later,
# and on 2026-09-05 that word carried a finished instruction into every episode for a week
# (ADR 0052). This answers the first, in five words:
#
# fresh inside session.freshness.fresh_minutes — current, act on it
# aging between the two thresholds — current, but say how old
# stale at or beyond session.freshness.stale_minutes — history, never current
# unknown no timestamp to judge; absence reported as absence, not as freshness
# invalid a timestamp that does not parse, or one in the future
#
# Sets MJ_FRESH_STATE, MJ_FRESH_MINUTES and MJ_FRESH_REASON, and prints the state. The
# three variables are the answer and the printed word is a convenience: a caller that wants
# the reason must call this as a statement, because `f="$(mj_freshness ...)"` runs it in a
# subshell and the variables die with it. Under `set -u` that mistake is not a wrong string
# but an unbound variable that kills the briefing mid-sentence, so they are initialised at
# load time below and the callers here are statements.
# Requires
# a loaded policy: the thresholds live in .ai/repo/policy.yaml and nothing here carries a
# default, for the reason mj_pol_req states — a fallback beside the reader is a second
# source of truth for the same number.
MJ_FRESH_STATE=unknown; MJ_FRESH_MINUTES=""; MJ_FRESH_REASON=""
mj_freshness() {
local ts="$1" fresh stale mins
MJ_FRESH_STATE=unknown; MJ_FRESH_MINUTES=""; MJ_FRESH_REASON=""
if [ -z "$ts" ]; then
MJ_FRESH_REASON="no timestamp"; printf 'unknown'; return 0
fi
if ! mins="$(mj_age_minutes "$ts")"; then
MJ_FRESH_STATE=invalid; MJ_FRESH_REASON="timestamp does not parse: $ts"
printf 'invalid'; return 0
fi
MJ_FRESH_MINUTES="$mins"
if [ "$mins" -lt 0 ]; then
MJ_FRESH_STATE=invalid
MJ_FRESH_REASON="timestamp is $(( -mins )) minute(s) in the future"
printf 'invalid'; return 0
fi
fresh="$(mj_pol session.freshness.fresh_minutes)"
stale="$(mj_pol session.freshness.stale_minutes)"
case "$fresh" in ''|*[!0-9]*) MJ_FRESH_REASON="policy is missing session.freshness.fresh_minutes"; printf 'unknown'; return 0 ;; esac
case "$stale" in ''|*[!0-9]*) MJ_FRESH_REASON="policy is missing session.freshness.stale_minutes"; printf 'unknown'; return 0 ;; esac
if [ "$mins" -ge "$stale" ]; then
MJ_FRESH_STATE=stale
MJ_FRESH_REASON="$(mj_age_human "$mins"), past the $(mj_duration_human "$stale") this repository calls stale"
elif [ "$mins" -ge "$fresh" ]; then
MJ_FRESH_STATE=aging
MJ_FRESH_REASON="$(mj_age_human "$mins"), past the $(mj_duration_human "$fresh") this repository calls fresh"
else
MJ_FRESH_STATE=fresh
MJ_FRESH_REASON="$(mj_age_human "$mins")"
fi
printf '%s' "$MJ_FRESH_STATE"
}
# True when a record must not be presented as the current instruction. `stale` and
# `invalid` are the two states a reader acts on differently; `unknown` is not one of them,
# because a record with no timestamp is not thereby old.
mj_freshness_is_history() {
case "$1" in stale|invalid) return 0 ;; esac
return 1
}
# One awk function, prepended to any program that reads a ledger line, so that every reader
# of a key agrees on which occurrence of it is the answer: the FIRST, which is the one in the
# envelope that `mj_ledger_append` wrote.
#
# The idiom it replaces is `sub(/^.*"event":"/, "", e)`. awk's `.*` is greedy, so that
# strips up to the LAST occurrence and returns the payload's value instead of the
# envelope's. It was in three readers, and on the provider receipts — which carried
# `"event"` twice until this was fixed — `history --validate` used it to read the event name
# as `start`, refusing a line the registry does in fact declare. A reader that takes the
# last of a duplicated key stays wrong about a line written by an older version, so the
# writer's guard is not a reason to keep it.
#
# Prepended rather than copied: three copies of one extraction is how two of them came to
# disagree with mj_history_render, which had it right all along.
MJ_LEDGER_FIELD_AWK='
function mjfield(line, key, r) {
if (match(line, "\"" key "\":\"")) { r = substr(line, RSTART + RLENGTH); sub(/".*/, "", r); return r }
if (match(line, "\"" key "\":")) { r = substr(line, RSTART + RLENGTH); sub(/[,}].*/, "", r); return r }
return ""
}
'
# Value of a flat JSON key on one ledger line: mj_json_field LINE KEY. Already the first
# occurrence — it is `match`, not a greedy `sub` — and left alone because it distinguishes an
# absent key (nothing printed) from one whose value is the empty string (an empty line),
# which mjfield's single return value cannot.
mj_json_field() {
printf '%s' "$1" | awk -v k="$2" '
{ s=$0
if (match(s, "\"" k "\":\"")) { r=substr(s, RSTART+length(k)+4); sub(/".*/,"",r); print r; exit }
if (match(s, "\"" k "\":")) { r=substr(s, RSTART+length(k)+3); sub(/[,}].*/,"",r); print r; exit } }'
}
# line numbers of ledger lines that are not well-formed events (need ts and event)
mj_ledger_bad_lines() {
[ -f "$1" ] || return 0
awk '{ if ($0 !~ /^\{/ || $0 !~ /"ts":"/ || $0 !~ /"event":"/ || $0 !~ /\}$/) printf "%s ", NR }' "$1"
}
# Does a string contain a newline? Written as a function because the obvious inline form,
# case "$v" in *"$(printf '\n')"*), can never match: command substitution strips the
# trailing newline, leaving an empty pattern that matches everything.
mj_is_multiline() { case "$1" in *"$(printf 'x\ny')"*) return 0 ;; esac
[ "$(printf '%s' "$1" | wc -l | tr -d ' ')" != 0 ]; }
# ---------------------------------------------------------------- composed web surfaces
# mj_web_composed <repository-root> [mounts|rows|trees]
#
# The published static surfaces other than the application: what scripts/site-build copies
# into the site at their mounts (ADR 0086), read from the committed web topology,
# docs/generated/web.json — every surface of kind static-directory that the published site
# carries (availability both or published-only) and that is not the app itself. The site's
# own page contracts judge the site's own pages; a composed surface's pages are judged by
# that surface's own check (`majordomus quality rustdoc` for the crate's reference). So the
# build that composes them and every check that must tell the site's pages from theirs ask
# this one selection, and nothing names a surface or a mount by hand.
#
# mounts one mount per line, as the topology writes it (no trailing slash)
# rows id, mount, artifact and producer of each, tab-separated
# trees every repository-relative directory that holds a composed surface's pages: its
# own artifact, and its mount under the artifact of every other static surface,
# which is where site-build composes it — the published build and the served one
#
# Empty output is an answer (nothing is composed). A topology that cannot be read is not: it
# fails with 12 and says so, because a scope that silently came back empty would put every
# composed page back under the site's contracts, and one that silently came back wrong would
# exempt pages nobody chose to exempt.
mj_web_composed() {
local mj_wc_web="$1/docs/generated/web.json" mj_wc_mode="${2:-mounts}"
case "$mj_wc_mode" in mounts|rows|trees) ;; *) mj_err "mj_web_composed: mounts, rows or trees, not '$mj_wc_mode'"; return 2 ;; esac
[ -f "$mj_wc_web" ] || { mj_err "$mj_wc_web is missing; the composed surfaces cannot be told from the site's own pages (regenerate: majordomus generate)"; return 12; }
command -v jq >/dev/null 2>&1 || { mj_err "jq is required to read $mj_wc_web"; return 12; }
jq -r --arg mode "$mj_wc_mode" '
[.surfaces[] | select(.kind == "static-directory"
and (.availability == "both" or .availability == "published-only")
and .id != "app")] as $c
| if $mode == "mounts" then $c[].mount
elif $mode == "rows" then $c[] | [.id, .mount, (.artifact // ""), (.producer // "")] | @tsv
else ($c[] | .artifact // empty),
([$c[].id] as $ids
| .surfaces[]
| select(.kind == "static-directory" and (.artifact // "") != "" and ((.id | IN($ids[])) | not))
| .artifact as $host | $c[] | $host + .mount)
end' "$mj_wc_web" || { mj_err "$mj_wc_web does not parse as a web topology"; return 12; }
}
# A task is on the mesh while it works (I2288). `start` claims the task's scope as an
# advisory claim of the mesh session `task-<id>` on this checkout's running server, so every
# linked runtime on every machine sees what the task works on and reports an overlap; the key is
# kept on the task record. `finish` closes the session, which ends its claims everywhere. An
# exclusive claim before building stays the worker's own act. Silent, and never a failure, when
# no server serves this checkout or its mesh does not run: the task is the repository's, the
# mesh only tells others about it.
mj_mesh_task_bin() {
mj_has jq || return 1
# shellcheck source=rust_bin.sh
. "$MJ_LIB_DIR/rust_bin.sh"
MJ_MESH_BIN="$(mj_rust_bin "$MJ_HOME")"; [ -x "$MJ_MESH_BIN" ] || return 1
local share; share="$(mj_rust_share "$MJ_ROOT")"
[ -n "$share" ] && export MAJORDOMUS_SHARE="$share"
return 0
}
# Always returns 0 (its callers run under set -e); MJ_MESH_CLAIMED says whether the scope is
# now claimed, and MJ_MESH_WHY why not when it is not.
mj_mesh_task_claim() { # <task id> <scope, one path per word> <intent> <issue>
local id="$1" scope="$2" intent="$3" issue="$4" out key
MJ_MESH_CLAIMED=0; MJ_MESH_WHY=""
mj_mesh_task_bin || { MJ_MESH_WHY="there is no executable to ask a server with (jq or the majordomus executable is missing)"; return 0; }
set -- --session "task-$id" --advisory --task "$id" --repo "$MJ_ROOT" --format json
[ -n "$intent" ] && set -- "$@" --intent "$intent"
[ -n "$issue" ] && set -- "$@" --issue "$issue"
# shellcheck disable=SC2086 # the scope is one path per word
out="$("$MJ_MESH_BIN" mesh claim $scope "$@" 2>/dev/null)" || true
key="$(printf '%s' "$out" | jq -r '.key // empty' 2>/dev/null || true)"
[ -n "$key" ] || { MJ_MESH_WHY="no shared server of this checkout answered with a running mesh"; return 0; }
printf 'mesh_claim: %s
' "$key" >> "$MJ_CUR"
MJ_MESH_CLAIMED=1
mj_info mesh "task-$id" "the scope is claimed on every linked runtime (advisory) as $key" "majordomus mesh state --full"
return 0
}
# A task is claimed when it starts (I2288). In a fresh linked worktree no server runs yet —
# no provider start event fired there, so nothing brought one up — and a claim asked of
# nobody left the task off the mesh until the pre-push `finish --check` refused it, curable
# only by finishing the task partial and starting it again. So on a repository that declares
# its mesh enabled, `start` brings this checkout's shared server up itself before it claims:
# the same `serve ensure` the provider's start event and the shell entry run, under the same
# switches (`session.ensure_server_on_start`; `MAJORDOMUS_RUNTIME=off` for one person's
# shell). `serve ensure` is runtime-lifecycle's bounded election: one server per checkout, a
# ready one left alone, nothing stopped, the wait its own `--wait`. What it starts is said on
# stderr. Returns 0 when a ready server serves the checkout, 1 with MJ_MESH_WHY otherwise.
mj_mesh_task_ensure_server() {
MJ_MESH_WHY=""
if [ "${MAJORDOMUS_RUNTIME:-}" = off ]; then
MJ_MESH_WHY="MAJORDOMUS_RUNTIME=off, so start brought no server up"; return 1
fi
if [ "$(mj_pol session.ensure_server_on_start)" = false ]; then
MJ_MESH_WHY="session.ensure_server_on_start is false, so start brought no server up"; return 1
fi
mj_mesh_task_bin || { MJ_MESH_WHY="there is no executable to bring a server up with (jq or the majordomus executable is missing)"; return 1; }
if mj_rust_stale "$MJ_HOME" "$MJ_MESH_BIN"; then
MJ_MESH_WHY="the executable is older than its sources, and no server is started from stale code (run: just build)"; return 1
fi
local line
# whether a server already serves the checkout is serve ensure's to answer: it starts
# one only when none does, and says so below ("started by this call")
mkdir -p "$MJ_STATE_DIR/mcp" 2>/dev/null || true
line="$("$MJ_MESH_BIN" serve ensure --repo "$MJ_ROOT" 2>>"$MJ_STATE_DIR/mcp/ensure.log")" || true
case "$line" in
ready\ *"(started by this call)"*) mj_err "start: $line" ;;
ready\ *) ;;
*) MJ_MESH_WHY="serve ensure did not converge: ${line:-it said nothing} (log: $(mj_rel "$MJ_STATE_DIR/mcp/ensure.log"))"; return 1 ;;
esac
return 0
}
# start's half. On an enabled mesh: ensure the server, claim, and when the claim still did
# not happen say so as a WARN finding naming why and the repair — never silence, and never a
# refused start (a worker with no server still works; finish judges it). On a repository that
# declares no enabled mesh: claim only if a server already answers, as before.
mj_mesh_task_start_claim() { # <task id> <scope, one path per word> <intent> <issue>
if ! mj_mesh_declared_enabled; then
mj_mesh_task_claim "$@"
return 0
fi
local why=""
mj_mesh_task_ensure_server || why="$MJ_MESH_WHY"
mj_mesh_task_claim "$@"
[ "$MJ_MESH_CLAIMED" = 1 ] && return 0
mj_warn mesh "task-$1" \
"the repository declares its mesh enabled and this task's scope is not claimed on it: ${why:-$MJ_MESH_WHY}; a completed finish, and the pre-push finish --check, refuse it until it is" \
"majordomus serve ensure, then majordomus check (check claims the running task's scope once a server answers)"
return 0
}
mj_mesh_task_release() { # <task id>
local id="$1"
grep -q '^mesh_claim: ' "$MJ_CUR" 2>/dev/null || return 0
mj_mesh_task_bin || return 0
"$MJ_MESH_BIN" mesh session close --session "task-$id" --repo "$MJ_ROOT" --format json >/dev/null 2>&1 || true
return 0
}
# Whether the repository declares its mesh enabled: a tracked declaration under
# .ai/repo/mesh/ whose top-level `enabled` is true. The declaration is what the server obeys
# (ADR 0059); whether a server runs right now is another question, and a task is judged
# against what the repository declared, not against which processes happen to be up.
mj_mesh_declared_enabled() {
local f
for f in $(mj_git ls-files -- '.ai/repo/mesh/*.yaml' '.ai/repo/mesh/*.yml' 2>/dev/null); do
grep -qE '^enabled:[[:space:]]*true[[:space:]]*$' "$MJ_ROOT/$f" 2>/dev/null && return 0
done
return 1
}
# A task works on the mesh (I2289, project.a-task-works-on-the-mesh). On a repository that
# declares its mesh enabled, an active task's scope must have been claimed on it — `start`
# brings the checkout's server up and claims (I2288), `check` claims again when a server
# answers, and `finish` tries again before it judges — so that no
# machine builds what another machine never saw being built. Only a completed finish is
# refused: a worker whose server never came up still leaves by partial, blocked or failed.
mj_validate_task_on_the_mesh() {
if ! mj_load_current || [ "$(mj_cur outcome)" != active ]; then
mj_doctrine_skip mesh "-" "no active task; nothing claims anything"
MJ_DOCTRINE_SKIPPED=1; return 0
fi
local id key; id="$(mj_cur id)"
if ! mj_mesh_declared_enabled; then
mj_doctrine_skip mesh "$id" "the repository declares no enabled mesh"
MJ_DOCTRINE_SKIPPED=1; return 0
fi
key="$(sed -n 's/^mesh_claim: //p' "$MJ_CUR" 2>/dev/null | head -n 1)"
if [ -n "$key" ]; then
mj_doctrine_ok mesh "$id" "the scope is claimed on the mesh as $key"
return 0
fi
if [ "${MJ_DOCTRINE_CMD:-}" = finish ] && [ -n "${MJ_FINISH_OUTCOME:-}" ] && [ "$MJ_FINISH_OUTCOME" != completed ]; then
mj_doctrine_skip mesh "$id" "the scope was never claimed on the mesh; outcome is $MJ_FINISH_OUTCOME, not completed, so it is not refused"
MJ_DOCTRINE_SKIPPED=1; return 0
fi
mj_doctrine_fail mesh "$id" \
"the repository declares its mesh enabled and this task's scope was never claimed on it: no shared server answered when it started or since, so no other machine saw what it works on" \
"majordomus serve ensure, then majordomus check (check, and finish, claim the running task's scope once a server answers)"
return 0
}