As implemented. Every field listed here is both written by something and read by something. A field that loses one of those is removed, not kept "for later".
Conventions:
- YAML for anything a person edits. JSON Lines for anything only Majordomus appends to.
- The YAML is a subset, and a subset is a narrowing: no flow maps, no folding, no
anchors. A file this repository accepts must therefore also parse as YAML for
everyone else, because
share/is vendored into repositories whose tools are not ours. Three constructs read as intended to our flatteners and are syntax errors to every other parser: an unquoted:inside a plain scalar, a plain scalar opening on an indicator character (`,%,@), and nested brackets inside a flow sequence. Quote the scalar — both engines strip matching surrounding quotes, so the value does not move.test/cases/128_the_subset_is_a_subset.shasks an independent parser and fails when a canonical file leaves the language. - Unknown keys are errors at every level.
- Identity fields (
repository_id,branch,head,working_tree,changed_files) are always computed from git at write time. A worker or a person supplying them is an error, not an override. - Timestamps are UTC ISO 8601 with seconds:
2026-09-03T19:30:12Z.
.ai/manifest.yaml
The section registry of the repository's AI layer. Discovery reads this file and never
walks .ai/; a file under repo/ that no section covers is not context and carries no
authority. init writes it from the skeleton; the format version is what an executable
checks before reading anything else, and one it does not read is refused with the reason.
schema: ai-repository/v1
repo:
path: repo # tracked canonical context
local:
path: local # this checkout's own; ignored by git
tracked: false
implicit_context: false
sections: # relative to .ai/; each one resolves to a path the tool reads
policy: repo/policy.yaml
profiles: repo/profiles
rules: repo/rules
prompts: repo/prompts
skills: repo/skills
workflows: repo/workflows
knowledge: repo/knowledge
adrs: repo/adrs
project: repo/project
Every key is required except that project may resolve to a directory that does not
exist, which is a repository with no plan. Unknown keys are errors (share/allow/manifest.txt).
doctor fails when a section the manifest names is absent, when local/ is not ignored
or carries a tracked file, or when pre-.ai project data still sits under .majordomus/.
.ai/repo/policy.yaml
The one canonical, provider-neutral policy.
version: 1 # schema version; only 1 is valid
context:
always_loaded_budget_lines: 150 # hard cap on the always-loaded projection
builder_budget_lines: 300 # hard cap on what `majordomus context` prints
recent_decisions: 5 # decisions offered to a worker, newest first
max_list_items: 20 # cap on any list inside the assembled context
strategy: minimum-sufficient # documentation of intent; projected into instructions
transcript_is_state: false # projected as a rule; never true
profiles:
default: implementation # must name a file in profiles/
checkpoint_interval_default: 15m # profiles may override
verification:
required_for_changes: true # finish requires --verify-command when files changed
finish_requires: # the contract; order is display order
- scope_respected
- verification_ran
- state_updated
- no_open_blockers
- note_present
checkpoint:
max_body_lines: 40 # a body over this is refused, not truncated
retention_max_files: 500 # doctor reports when exceeded
handover:
required_sections: [Objective, Current State, Next Action]
retention_max_files: 200 # doctor reports when exceeded
ledger:
retention_max_lines: 5000
cache:
flatten_max_entries: 2000 # parsed YAML files kept under .ai/local/cache/flat/; 0 is no cache
benchmark:
samples: 10 # warm samples per target in `majordomus bench`
warmup: 2 # unsampled runs before them
regression: # fractions over the baseline that `bench --check` refuses
p50: 0.5
p95: 0.5
p99: 0.6
budget: # doctor and watch report their own wall time against these (WARN, never the exit code)
doctor_ms: 20000 # the skeleton's values, measured on a freshly adopted repository
watch_ms: 20000 # re-derive with `majordomus bench` once your catalogue has grown
enforcement: # what doctor reconciles; each must be wired
- name: doctor-on-commit
path: majordomus # on PATH, repo-relative, absolute, or named in the hook line
args: [doctor]
wired_by: git-hook:pre-commit
- name: finish-on-push
path: majordomus
args: [finish, --check]
wired_by: git-hook:pre-push
projections:
- provider: claude-code
target: CLAUDE.md
mode: region # file (default) | region
always_loaded: true # exactly one projection may be always_loaded
- provider: codex
target: AGENTS.md
- provider: gemini
target: GEMINI.md
- provider: generic
target: docs/AI_INSTRUCTIONS.md
mode: file generates the whole target and owns every byte of it. mode: region
generates only the text between two markers and owns nothing else in the file:
Whatever the repository already wrote. Majordomus never reads or rewrites this.
<!-- majordomus:begin 7b88abe0f22a -->
... generated from the policy ...
<!-- majordomus:end -->
The hash in the begin marker names the policy the region came from; it is not part of
the hashed content, so re-running update after an unrelated policy edit is not a hand
edit. update replaces an existing region in place and appends one to a file that has
none. Markers that are unclosed, out of order, or repeated are refused (15), never
guessed at. fingerprints.yaml records mode alongside the hash, and for a region the
hash covers the region — an edit outside the markers is the repository's business and is
never reported as drift.
wired_by values: git-hook:<name> (resolved through core.hooksPath or
.git/hooks/), ci:<path> (a file that must exist and contain the invocation),
git-config:<key> (a key the clone's own git configuration must set to a value naming the tool),
toolchain:<file> (the pinned Rust toolchain the file names is installed on this machine, so
scripts/derive and prs repair --apply can build),
manual (documented, not verified; doctor lists it as unverified, never as wired).
The hook line must not swallow the exit code (|| true, || exit 0).
.ai/repo/scope.yaml
The repository scope: what a worker reads and what it never reads. Named by the
manifest's scope section; a manifest naming none means the distribution's default
applies (share/skeleton/ai/repo/scope.yaml). The schema is
share/schemas/majordomus/scope/scope.v1.schema.json; the shell tool's allow-list is generated from it.
SCOPE.md explains the judgement.
version: 1
in: # pathspecs anchored at the root; a trailing / is the directory and everything beneath
- .ai/repo/**
- src/**
out: # wins over in; every key optional
paths: ['.git/', '.ai/local/', '**/target/']
binary: true # a NUL byte in the first 8 KiB
max_bytes: 1048576
archive: { names: [...] } # written as a block mapping; the subset has no flow maps
image: { names: [...] }
video: { names: [...] }
pdf: { names: [...] }
database_dump: { names: [...] }
generated: { paths: [...], names: [...] }
secret: { paths: [...], names: [...] }
fixtures: { paths: [...], names: [...], max_bytes: 65536 }
names are matched against the file name alone and may not carry a slash; a pattern
with a leading slash, a :( prefix or a .. segment is refused with the key named.
.ai/repo/profiles/<name>.yaml
Five independent axes. Nothing here names a vendor model.
name: debugging
description: reproduce, isolate, fix, and prove a defect fixed
capability: strong # fast | standard | strong | strongest — projections map this
effort: high # low | medium | high | xhigh | max; omit to inherit the default
effort_escalation: # optional; projected as guidance, not enforced
after_blocked_attempts: 2
to: xhigh
verbosity: concise # terse | concise | detailed
presentation: engineering # machine | engineering | summary
context: # toggles; projections turn them into loading guidance
task: true
current_state: true
decisions: true
relevant_files: true
failing_output: true
recent_history_depth: 50 # commits; 0 disables
architecture_notes: false
knowledge: true # knowledge pointers: what the task's issue references
knowledge_max_items: 8 # at most this many; capped by context.max_list_items
verification:
verify_command_required: true
regression_test_required: true
decision_record_required: false
checkpoint_interval: 15m
output_contract: # fields a completion note must carry for this profile
- outcome
- changes
- verification
- blockers
- next_action
The four shipped profiles:
routine | implementation | debugging | deep-work | |
|---|---|---|---|---|
| capability | fast | standard | strong | strongest |
| effort | low | medium | high | high, escalates to xhigh |
| verbosity | terse | concise | concise | detailed |
| presentation | machine | engineering | engineering | engineering |
| context | task, state | + decisions, files, knowledge 8 | + failing output, history 50 | + architecture, history 200, knowledge 12 |
| verify command | if files changed | required | required | required |
| regression test | no | no | required | no |
| decision record | no | no | no | required |
| checkpoint | 30m | 15m | 15m | 30m |
.ai/repo/rules/**/*.md — rule objects
A rule is a Markdown file with YAML front matter. The baseline the tool ships lives in
share/standard/majordomus/ and is vendored into the repository under
.ai/repo/rules/vendor/majordomus/ (a manifest.yaml naming every file with its hash,
and rules/*.md); the repository's own rules live under .ai/repo/rules/project/. See
DOCTRINE.md for the model and .ai/repo/rules/README.md for the format
as a repository reads it.
---
id: majordomus.scope-integrity # identity, with version; never the file name
version: 1
kind: rule
title: Scope integrity
description: A task touches only the paths it claimed; work found elsewhere is not accepted as done.
statement: Work outside the paths a task claimed is not accepted as that task's work.
status: active # active | deprecated
class: blocking # blocking | advisory — nothing else parses
depends_on: [majordomus.one-worker-one-scope@1] # exact id@version references, or []
tags: [scope, verification]
x-majordomus: # present only on a rule the tool enforces
validator: scope # runs mj_validate_scope; a name with no function is a failure
category: scope # the finding category a violation is reported under
enforced_by: [check, finish, watch]
policy_key: scope_respected # finish doctrines only: the name used in verification.finish_requires
exit_code: 10 # from the existing contract; a rule invents no code
claims: [scope-enforcement, scoped-task] # ids in docs/CLAIMS.yaml
tests: [test/cases/04_start_check.sh]
---
# Rationale
...
# Required behaviour
...
# Failure behaviour
...
# Verification
...| field | required | meaning |
|---|---|---|
id, version | yes | identity; a project rule may not use the majordomus. namespace. The URL slug on the site is the id without its namespace |
kind | yes | rule |
title, description, statement | yes | one line each, rendered verbatim |
status | yes | active or deprecated; a deprecated rule is not in the effective set and may not be depended on |
class | yes | blocking stops the command; advisory reports and lets it pass. Any other value fails the whole set closed |
depends_on | yes | exact id@version references; a missing one or a cycle fails the set |
tags | no | free labels; principle marks the ten principles |
x-majordomus.validator | with the block | the suffix of mj_validate_<validator>; the function must exist in lib/ |
x-majordomus.category | with the block | the finding category, so output stays one vocabulary |
x-majordomus.enforced_by | with the block | commands that dispatch it; each must exist and call mj_doctrine_dispatch |
x-majordomus.exit_code | with the block | 0 for advisory, otherwise a code from the exit-code contract |
x-majordomus.policy_key | no | present only on doctrines a repository can select in verification.finish_requires |
x-majordomus.claims | no | claim ids this doctrine backs; each must exist in docs/CLAIMS.yaml |
x-majordomus.tests | with the block | the cases that prove the behaviour; each file must exist |
The allowed keys are share/allow/rule.txt; any other key is an error. majordomus doctor
verifies every one of those constraints against the source, that the vendored package
matches its manifest, and additionally that no mj_validate_* function exists which no
rule declares.
.ai/**/README.md — context documents
A context document is a Markdown file under the .ai/ tree (minus local/ and
rules/vendor/) whose front matter declares the contract below. The manifest's
context.documents names the file names that must carry it wherever they appear in the
tree; today that is README.md. See CONTEXT.md for the model.
---
schema: context/v1
id: ai.repo.rules # identity; ^[a-z][a-z0-9-]*(\.[a-z0-9-]+)*$ ; unique across the tree; survives a move
kind: context
title: Repository rules
description: One sentence.
status: active # active | deprecated (discovered and listed, never applied)
scope: subtree # directory | subtree | explicit
paths: [] # explicit only: repo-relative directories inside the tree
providers: ["*"] # "*" or provider names from the policy's projections
audience: [human, agent] # who it addresses; resolve --audience filters
composition: extend # extend | replace | final
order: 100 # integer; ties within one depth are broken by path
supersedes: [] # replace only: ids of ancestor-chain documents, none of them final
tracks: [lib/rules.sh] # git pathspecs this document describes
children: # subtree only: what the directories below owe
require_contract: true # true | false; the default where nothing declares it is true
exempt: [] # subtrees this layer carries but does not author
---| field | required | meaning |
|---|---|---|
schema | yes | context/v1; a newer value is refused, never guessed at |
id | yes | identity, unique across the tree; the file name is a convention |
kind | yes | context; another kind is another kind of file |
title, description | yes | one line each |
status | yes | active or deprecated |
scope | yes | directory, subtree or explicit |
paths | with explicit | repository-relative directories inside the tree the document applies to |
providers | yes | "*" or names of projections in the policy; an unknown name fails validation |
audience | no | human, agent; a filter, never a permission |
composition | yes | extend, replace or final |
order | yes | an integer; less is earlier within one depth |
supersedes | with replace | ids in the ancestor chain this document stands in for; a final ancestor cannot be named |
tracks | no | pathspecs whose change names this document for review |
children.require_contract | no | subtree only: whether every directory below this one owes a context document. Absent everywhere above a directory means true. A descendant may raise false to true; lowering an inherited true is illegal-override |
children.exempt | no | subtree only: repository-relative directories inside this document's own scope that owe no context document, and nor does anything below them — a subtree the layer carries but does not author, such as an installed package whose integrity is its own manifest's business. Naming a directory outside the document's scope, or its own directory, is illegal-override; a directory that does not exist is broken-reference |
The allowed keys are share/allow/context.txt; any other key is an error. There are no
defaults: a required key that is missing is invalid-front-matter, not a silent value.
majordomus context validate checks every constraint over the whole tree, and
majordomus doctor dispatches the same check through majordomus.context-integrity.
.ai/repo/sessions/<stamp>--<id>--<branch>--<head>--<digest>.md
One closed execution episode, written by majordomus session close and by nothing else. A
shared object of the layer: discovered by the source class session, projected to the
index, majordomus://session/<id>, the object routes, the graph and the site, with no list
of sessions kept anywhere (ADR 0014).
---
schema: session/v1 # the contract; a version this executable does not read is refused
kind: session
session_id: s-20260906035523-7b6c # identity, allocated at open, never reused
created_at: 2026-09-06T03:55:24Z # when the record was written: the close
started_at: 2026-09-06T03:55:23Z
closed_at: 2026-09-06T03:55:24Z
outcome: closed # closed | interrupted
title: "Session s-… on feature/x" # one line for a listing; the task's title when it had one
task_id: none # the task the episode belonged to, or none
profile: none
worker: "claude-code/opus-5" # what did the work, as it identified itself
repository_id: git@github.com:… # the remote; `local:<hash>` when there is none
worktree_id: e2d0ee3a34301bd5 # the working copy, named without naming a path
branch: feature/session-records
start_head: 4658856… # the commit the episode started from
head: 4658856… # the commit it ended at
start_working_tree: dirty # clean | dirty, at open
working_tree: dirty # and at close
commits: [] # between the two heads, oldest first
changed_files: [] # repository-relative, as git reported them
tasks: [] # every list below is read from the ledger's events
issues: [] # for this episode: nothing here is authored
milestones: []
checkpoints: []
handovers: []
decisions: []
questions: []
evidence: []
---
The body: a summary of the work, given on standard input at close.| Key | Required | Meaning |
|---|---|---|
schema | yes | session/v1 |
kind | yes | session |
session_id | yes | identity; the file name is a convenience |
started_at, closed_at | yes | RFC 3339, UTC |
outcome | yes | closed or interrupted |
title, task_id, profile, worker | no | what the episode was and what worked it |
repository_id, worktree_id | no | where it ran, without naming a path |
branch, start_head, head | no | from git |
start_working_tree, working_tree | no | clean or dirty |
commits, changed_files | no | from git |
tasks, issues, milestones, checkpoints, handovers, decisions, questions, evidence | no | from the ledger's events for this episode |
Unknown keys are errors, and no value may be an absolute path: a shared record carries what
the repository can prove, never a fact about the machine that ran it. There is no field for
a conversation, which is project.never-store-transcripts enforced by the contract rather
than by a habit. The open episode's own state file is a different object with its own schema
(session), and it stays in the checkout-local half.
.ai/repo/rules/vendor/majordomus/manifest.yaml
The package manifest: every rule file the vendored baseline holds, its identity and the
hash of the file. Written by init and by majordomus rules vendor update, never by
hand; doctor fails when a listed file is absent, differs from its hash, declares another
identity, or when a file under rules/ is not listed.
vendor: majordomus
package: majordomus-standard-rules
version: 1
format: ai-rules/v1
source_revision: 0.1.0 # the executable version the package was taken from
rules:
- id: majordomus.scope-integrity
version: 1
file: rules/scope-integrity.v1.md
sha256: 6b737b27...
The same manifest describes the package the distribution ships in
share/standard/majordomus/; rules vendor status compares the two and reports a newer
distribution package without applying it.
.ai/repo/project/project.yaml
The canonical project model's root. Present only in a repository that plans through
milestones and issues; the model is opt-in and doctor skips it where it is absent.
schema_version: 1
name: Prismatic Majordomus
repository: korczis/prismatic-majordomus # owner/name; the projection target
default_branch: master
Nothing derived is stored here. There is no active_milestone field: the active milestone
is the lowest-ordered one that is ACTIVE, or failing that the lowest-ordered one that is
not finished, computed on every read.
.ai/repo/project/milestones/<ID>.yaml
One executable specification of an outcome. The filename is the id; a record whose id
disagrees with its filename is refused rather than reconciled.
id: M000
title: Milestone and DAG driven development
slug: milestone-dag-driven-development
order: 0 # integer; decides which milestone is active
priority: p0 # p0 | p1 | p2 | p3
problem: "What is wrong today." # single-line scalars; the YAML subset has no folding
outcome: "What is true when it ends."
current_state: "Where it stands."
desired_state: "Where it is going."
scope: [...] # what this outcome covers
non_scope: [...] # what it deliberately does not
acceptance_criteria: [...] # what would make the outcome true
validation: [...] # commands that demonstrate it
evidence_required: [...] # tokens gating milestone acceptance
risks: [...]
cancelled: false # optional
evidence: # appended; each entry covers one required token
- covers: suite
type: test # test | build | ci | artifact | manual
command: "bash test/run.sh"
result: "every case passed"
artifact: "…" # optional: a path, URL or hash
commit: 60f83e3… # written by the tool
recorded_at: 2026-09-04T03:14:00Z # written by the tool
created_at: 2026-09-04
updated_at: 2026-09-04
There is no issues: list. An issue names its milestone and the relation is read in that
one direction, so the two records cannot disagree about which issues belong to the outcome.
There is no status: field; see below.
.ai/repo/project/issues/<ID>.yaml
One bounded execution contract, carrying enough for a worker with no conversation history.
id: I0007
milestone: M000
title: Enforce the canonical model as doctrine
slug: model-doctrine
priority: p1
profile: implementation # which execution profile suits this work
parallel_safe: true # false forces serialisation regardless of the graph
objective: "What this issue produces."
why: "Why it exists."
current_state: "…"
desired_state: "…"
scope: [...] # the paths this issue may touch
non_scope: [...]
depends_on: [I0005] # issue ids; the edges of the DAG
acceptance_criteria: [...] # required; an issue without one is a placeholder
validation: [...] # required; the commands that demonstrate it
evidence_required: [...] # tokens that gate completion
risk: "…"
owner: alice # optional
completion: "…" # optional one-line completion report
# lifecycle markers — written only by `majordomus plan`, never by hand
started_at: 2026-09-04T03:00:00Z
verified_at: 2026-09-04T03:20:00Z
completed_at: 2026-09-04T03:31:00Z
cancelled: false
evidence: # same shape as a milestone's
- covers: doctrine_test
type: test
command: "bash test/run.sh 44_model_doctrine"
result: "1 passed, 0 failed"
commit: aa90a8b…
recorded_at: 2026-09-04T03:30:11Z
There is no status field on either record. BLOCKED, READY, ACTIVE, VERIFY,
DONE and CANCELLED are derived by lib/project.awk from the lifecycle markers, the
evidence coverage, and the state of the issue's dependencies; a milestone's status is
derived from its issues and its own evidence. Writing status: into either file is an
unknown key and fails majordomus plan validate and majordomus doctor.
Unknown keys are checked against share/allow/project.txt, share/allow/milestone.txt and
share/allow/issue.txt, the same mechanism the policy and the profiles use.
.ai/local/state/current.yaml
The one active task. Absent when nothing is active.
id: t-20260903-193012-a4f1 # t-<utc compact>-<4 hex>
task: fix OAuth callback
profile: debugging
owner: alice # free-form string
scope: # normalised at start; no trailing slashes
- lib/auth
started_at: 2026-09-03T19:30:12Z
checkpoint_at: 2026-09-03T20:02:41Z # updated by check --checkpoint and by finish
outcome: active # active | completed | partial | blocked | no_match | failed | handed_over
# computed from git at start; refreshed at finish; never authored
repository_id: /abs/path/.git # git rev-parse --git-common-dir
worktree: /abs/path # the checkout this task belongs to
branch: main
head: 3f2a9c1e... # full SHA
working_tree: dirty # clean | dirty
.ai/local/state/ is never tracked, so this record stays with the checkout that wrote it. A copy that reaches another
worktree on the same branch reads it and must not be held to a scope it never claimed, so
worktree says which checkout the task belongs to. check, finish --check and watch
report a record from another checkout and enforce nothing from it; finish refuses to write
to it at all. A record written before this field existed has no opinion and is treated as
local, so upgrading does not turn an existing installation red.
outcome and checkpoint_at are the only fields a command changes after start.
active refuses a new start; every other outcome lets start archive the record to
state/archive/<id>.yaml and begin a new task.
.ai/local/state/telemetry/<task-id>.yaml — a telemetry record
What a provider reported about the execution of one task. Majordomus invokes no model, so it measures none of this itself: every number in the record was handed to it, and the record keeps who handed it.
This section is ahead of its writer, and says so. The contract is defined
(share/schemas/majordomus/telemetry/telemetry.v1.schema.json, kind telemetry in
share/kinds.yaml, allow-list share/allow/telemetry.txt) and a record that breaks it is
refused wherever a telemetry document is validated, but nothing writes one yet. The
writer is the usage receiver of issue I3288 (every-sessions-usage-is-observed), which takes
a provider's usage from its official seam (ADR 0213, offers.usage in share/providers.yaml)
rather than from a command a worker hands numbers to; I0302, which planned that command,
was cancelled for it. Until I3288 lands, the directory does not exist and nothing reads it. It is the one format in this document described before something produces it,
because every later statement about what a task consumed rests on this shape and it had to
be fixed first.
schema: telemetry/v1
kind: telemetry
task_id: t-20261012201705-7bf9 # the task `majordomus start` allocated
session_id: s-20261012201704-3c1e # optional; the episode the facts were reported in
recorded_at: 2026-10-12T21:04:10Z # when the record was written, never the provider's clock
facts:
input_uncached_tokens:
value: 1840
reporter: claude-code
cache_write_tokens:
value: 52210
reporter: claude-code
cache_read_tokens:
value: 903377
reporter: claude-code
output_tokens:
value: 21504
reporter: claude-code
reasoning_tokens:
not_reported: "the harness reports reasoning inside output_tokens, not apart"
reporter: claude-code
requests:
value: 61
reporter: claude-code
tool_calls:
value: 0 # a measurement: the reporter said zero
reporter: claude-code
cost_microusd:
not_reported: "no price table was reported for this session"
reporter: claude-code
Three rules make the record what it is, and the schema enforces each of them:
- Every fact names its reporter.
reporteris the party the value came from — a provider by its id inshare/providers.yaml, or another named source. A fact without one is refused. For an absent fact it is the party that would have reported it and did not. - Absence is a value. A fact is
valueornot_reported, exactly one of them, andnot_reportedcarries the reason. All eight facts are required in every record: a record that leaves one out is refused, because an omitted key is what a reader takes for zero. A provider that reports nothing produces a record in which every fact isnot_reported. - Nothing derived is stored. There is no total and no ratio here; those are arithmetic
over the facts, done by the reader.
cost_microusdis what the reporter says the work cost, in millionths of a US dollar — a harness's projection or a bill, as the reporter states it — and is never computed from the other facts.
The fact names are the provider-neutral ones majordomus economics already normalises
recorded runs onto (apps/majordomus-cli/src/economics/usage.rs): input that touched no
cache, input written to the cache, input read from it, and output with reasoning included.
A provider whose own field means something else — one input figure with the cached part
inside it — is mapped by whatever hands the facts in, and what it cannot separate is
not_reported, not a guess.
What version 1 does not carry: the model or models that answered, per-request detail, wall time, and anything a session said or read. None of them is estimated to fill the gap.
Unknown keys are an error at every level, as in every other Majordomus YAML file. These files are not tracked: the numbers belong to one checkout's work.
.ai/local/state/decisions.md
Append-only, dated, one entry per decision. Human-authored. Read by decision list,
by context (which prints as many entries as context.recent_decisions allows, for this
task or for the repository depending on the profile), by search, and by a prompt asset
that uses {{DECISIONS}}. check reads it only to report entries that no task or head can
be attributed to. Projections reference it by path and never inline it.
Append-only. decision add writes one entry with Task and Head computed; the
remaining fields come from its options. --why is required, because a decision with no
recorded reason cannot be reviewed later, only re-argued. Absent optional fields are
written as - rather than omitted, so every entry has the same shape.
An entry is never edited or deleted. --supersedes records that a later decision replaced
an earlier one, and refuses text that matches no recorded decision.
## 2026-09-03 — token refresh uses the existing session store
Task: t-20260903193012-a4f1
Head: 9b1e2d4f8c3a5e7b1d0f2a4c6e8b0d3f5a7c9e1b
Why: one source of truth for expiry; a second cache would mean two expiry clocks
Rejected: a separate refresh cache
Evidence: lib/auth/session_store.rb#expiry
Supersedes: -
check, doctor and watch report an entry missing Task, Head or Why as a
warning: the file is hand-editable by design, and an entry nothing can attribute is a
decision that no gate will ever find. Text inside an HTML comment is the file's own
template and is not an entry.
.ai/local/state/open-questions.md
Things blocked on a human. finish --outcome completed refuses while any entry for the
current task is unresolved.
Written by question add and rewritten in place by question resolve. Resolving edits the
line because an index of what is still open must not accumulate; the append-only record of
every opening and resolution, with its answer, is the ledger.
- [unresolved] t-20260903193012-a4f1 — token refresh window: 15 or 60 minutes? (2026-09-03)
- [resolved 2026-09-02] t-20260902120000-b3d1 — keep the legacy callback path — yes, until Q4
Exactly two line shapes are valid:
- [unresolved] <task id> — <question> (<YYYY-MM-DD>)
- [resolved <YYYY-MM-DD>] <task id> — <question> — <answer>
— separates the fields, so a question may not contain it. Any other - [ line is a
failure in check, doctor and watch, not a warning: this file is a gate on
acceptance, and a gate that cannot read an entry is a gate that can be bypassed by
mistyping one.
.ai/local/state/handovers/<file>.md
Filename: <utc-compact>--<branch-key>--<short-head>--<16 hex>.md, e.g.
20260903T201455Z--main--9b1e2d4--c0ffee1234567890.md. branch-key is the branch
with any character outside [A-Za-z0-9._-] replaced by -, or DETACHED.
---
schema_version: 1
created_at: 2026-09-03T20:14:55Z
task_id: t-20260903-193012-a4f1
profile: debugging
owner: "alice"
repository_id: /abs/path/.git
worktree: /abs/path
branch: main
head: 9b1e2d4f...
working_tree: clean
changed_files:
- lib/auth/oauth.rb
- test/auth/oauth_test.rb
---
# Objective
Fix the OAuth callback dropping state on redirect.
# Current State
Root cause found: state param stripped by the proxy rewrite. Fix in oauth.rb applied,
regression test added, suite green.
# Next Action
Decide the refresh window (open question) and then finish.
# Decisions
See decisions.md 2026-09-03.
# Verification
make test — exit 0, 41s, at head 9b1e2d4.
Front matter is written by Majordomus. A body containing a line that looks like a
front-matter key is rejected. Mode 0600. Created atomically. Never staged.
.ai/local/state/checkpoints/<file>.md
Filename and front matter are exactly a handover's; only the directory and the body rules
differ. Written by checkpoint, mode 0600, created atomically, never staged.
---
schema_version: 1
created_at: 2026-09-03T19:45:00Z
task_id: t-20260903193012-a4f1
profile: debugging
owner: "alice"
repository_id: /abs/path/.git
worktree: /abs/path
branch: main
head: 9b1e2d4f8c3a5e7b1d0f2a4c6e8b0d3f5a7c9e1b
working_tree: dirty
changed_files:
- lib/auth/oauth.rb
---
OAuth state mismatch reproduced with test/fixtures/callback.json.
The cause is in callback normalisation, not in the comparison.
Next: regression test before touching the implementation.
The body is free text, not sections, and is capped at checkpoint.max_body_lines lines. A
body over the cap is refused, not truncated, with the suggestion to write a handover
instead: the cap is what keeps a checkpoint short enough to quote whole into the next
briefing rather than becoming a second kind of report.
A body containing any identity field is refused, as for a handover. An empty body is
allowed and writes no file — it updates checkpoint_at only, which is what
check --checkpoint does.
Read by checkpoint --show and --list, and by context, all through the same resolver
as handovers: same worktree and branch, else same branch, else nothing.
.ai/local/state/sessions-open/<provider session>.yaml
One open execution episode. One per provider session, not one per worktree: two windows
of the same provider open on one checkout are two workers, and folding them into one record
stamps one worker's ledger lines with the other's id and lets either window's end event
close both. The file is keyed by the provider session that opened it; an episode nobody
named is keyed hand, and there is at most one of those, because a provider that sends no
session identity is indistinguishable from a person at a terminal.
session start refuses while your episode is open rather than replacing it; a different
provider session gets an episode of its own. session close is the only writer that removes
one, and it removes its own.
.ai/local/state/session-current.yaml is a relative symlink into that store: the pointer to
the episode of this checkout, aimed at the episode opened here last, and re-aimed by a close
at the one episode still open when exactly one is. It is what a process that cannot name a
provider session resolves through — continuity.state reads that path, and every reader of
it (a sed, the YAML flattener, a whole-document reader in Rust) follows the link without
knowing it did. A symlink and not a copy: two accounts of one open episode is the drift this
record exists to avoid.
Which episode is a given process's is decided in mj_session_here_file (lib/common.sh):
the provider session named on the command line, else the provider session this process is
running inside when an episode with that key is open here (MAJORDOMUS_PROVIDER_SESSION, or
the variable the lifecycle adapter in lib/capture.sh declares — CLAUDE_CODE_SESSION_ID
for Claude Code), else the pointer.
session_id: s-20260904153733-fc51
started_at: 2026-09-04T15:37:33Z
owner: "alice"
worker: "some-provider/some-model" # optional; recorded only when supplied
provider: "claude-code" # optional; only a provider hook can supply it
provider_session: "cc-1" # optional; the key this episode is filed under
# computed from git; never authored
repository_id: /abs/path/.git
worktree: /abs/path
branch: master
start_head: 9b1e2d4f8c3a5e7b1d0f2a4c6e8b0d3f5a7c9e1b
start_working_tree: dirty
session_id is s- followed by the compact UTC timestamp with its T and Z removed
and four hex characters, which is the shape a task id already has. worker is a free-form
string and is the only field a person or a worker supplies beyond owner; nothing
validates it, and nothing infers it when it is absent — an unrecorded worker stays
unrecorded rather than becoming a plausible guess.
Unknown keys are an error, as in every other Majordomus YAML file.
These files are not tracked. The other state files are, because something outside the checkout reads them: a task record carries the scope claim other worktrees compare against, the question store is scoped to a branch by version control, and the append-only records have to travel. An open session carries none of that, so committing one would only make every checkout on the branch inherit an episode it did not open. Closed sessions are durable records and are tracked.
The defence stays in place regardless: a session record from another worktree is reported and is not treated as this checkout's open session, exactly as a foreign task record is.
.ai/local/session-contexts/<stamp>--<session-id>.md
The bounded working context of one episode, written by session start and appended to by
session close. Its contract is majordomus.session-context/v1
(share/schemas/majordomus/session-context/); the kind is declared in share/kinds.yaml
like every other, and no source class discovers it.
---
schema: session-context/v1
kind: session-context
session_id: s-20260906035523-7b6c
opened_at: 2026-09-06T03:55:23Z
opened_by: hook # hook | hand
provider: claude-code # only when a provider's event opened it
provider_session: "abc-123" # the provider's own identity, as it sent it
branch: master
head: 9b1e2d4f8c3a5e7b1d0f2a4c6e8b0d3f5a7c9e1b
task_id: none
profile: none
worker: "some-provider/some-model" # optional; recorded only when supplied
---
# Working context of session s-20260906035523-7b6c
## Context at open
<the context builder's output, verbatim>
## Notes
<the worker's own account of the work>
## Close # appended by `session close`
- closed_at: 2026-09-06T05:12:04Z
- outcome: closed
- head: 9b1e2d4f…
- record: .ai/repo/sessions/20260906T051204Z--s-…--master--9b1e2d4--c0ffee1234567890.md
opened_by is hook exactly when the open named the provider that delivered the event,
which only something running inside that provider's hook can do; that is what makes it a
fact rather than a claim. provider_session is the string that ties the episode to the
prompt archive, whose records carry the same one.
The document is appended to, never rewritten: the front matter describes the open, and the close adds a section, so whatever a worker typed between the two events survives. It is not tracked, and unlike the other local state it is not tracked for a second reason as well: it is a snapshot of a projection, so re-resolving it later produces a different document and no surface can reproduce it (ADR 0015).
It is never a transcript. The derived half is the builder's output and the authored half
summarises the work; a front-matter key naming a message list, a completion or a model's
reply is refused by the majordomus.session-lifecycle doctrine, which is how
project.never-store-transcripts is kept mechanically here rather than by memory.
.ai/repo/sessions/<file>.md
The immutable record of a closed session. Filename:
<utc-compact>--<session-id>--<branch-key>--<short-head>--<16 hex>.md, e.g.
20260904T171402Z--s-20260904153733-fc51--master--3c9ba2f--c0ffee1234567890.md.
The grammar is the handover's with the session id inserted after the timestamp, and it
carries the same four properties. The leading UTC timestamp makes lexicographic order
chronological order. branch-key is branch-safe by construction. The short head says
which history the record was written against. The sixteen random hex characters plus an
atomic hard-link publish make it collision-safe: a name already in use is retried, never
overwritten. Nothing reads filesystem modification time, which does not survive a clone
and is not the time the record asserts.
---
schema_version: 1
created_at: 2026-09-04T17:14:02Z
task_id: t-20260904153733-fc51
profile: deep-work
owner: "alice"
repository_id: /abs/path/.git
worktree: /abs/path
branch: master
head: 3c9ba2f1d0e5a7b9c3f5e7a9b1d3f5e7a9b1d3f5
working_tree: dirty
changed_files:
- lib/session.sh
session_id: s-20260904153733-fc51
started_at: 2026-09-04T15:37:33Z
closed_at: 2026-09-04T17:14:02Z
outcome: closed
worker: "some-provider/some-model"
start_head: 9b1e2d4f8c3a5e7b1d0f2a4c6e8b0d3f5a7c9e1b
start_working_tree: dirty
commits:
- 2c4dc6f
- 3c9ba2f
tasks:
- t-20260904153733-fc51
issues:
- I0801
milestones:
- M003
checkpoints:
- .ai/local/state/checkpoints/20260904T161122Z--master--2c4dc6f--a1b2c3d4e5f60718.md
handovers: []
decisions:
- "2026-09-04 — The session envelope is derived from the ledger, not accumulated"
questions: []
evidence:
- I0801:boundary_rewritten
---
Optional. Free text, written by whoever closed the session, or absent.
created_at, head and working_tree describe the close, so the record reads back
through the same resolver and the same divergence label as a handover: head is the
commit the session ended at, and the label compares it with the current one. start_head
and start_working_tree describe the open.
commits is the list of commits between start_head and head, shortest form. When the
start commit is not an ancestor of the end commit — a rebase during the episode — the
value is the single entry diverged rather than a list computed across a history that no
longer connects.
The reference lists carry the identity each kind actually has, and nothing invents one:
| Field | Identity used | Why that one |
|---|---|---|
tasks | task id | It exists and is stable. |
issues, milestones | canonical id | Same. |
checkpoints, handovers | repository-relative path | The files are immutable, so the path is the identity. |
decisions | the dated title | decisions.md has no id field; the dated title is the heading it already uses, and it is what the ledger records. |
questions | the question text | open-questions.md has no id field either; the text is what its line format keys on. |
evidence | <issue>:<token> | The pair an evidence record is attached to. |
Two of those are weaker identities than the rest, and that is recorded rather than papered over: a decision or a question is referenced by text, so editing that text breaks the reference, and session validation reports it as a dangling reference rather than silently resolving to nothing.
The lists are derived, not accumulated. They are computed at close time by reading
ledger.jsonl, and by nothing else. No other command writes to the session record;
checkpoint, decision, question and plan are unchanged and know nothing about
sessions.
The alternative — every command appending its reference to the open session file as it runs — was rejected twice over: it puts a write on the hot path of commands that currently only append one ledger line, and it creates a second mutable store of facts the ledger already holds, which is the thing this record exists to avoid being.
Selection is by the session stamp on each line, not by a time range. Every ledger line
carries a session field naming the episode that wrote it, alongside the head and
branch it already carried; all three are computed, none is authored. A time range was
tried first and was wrong: the ledger is one file per repository, and the first real run
of it collected another worker's tasks, checkpoints and handovers, because nothing in a
timestamp tells two concurrent workers apart.
A line carrying no session belongs to no episode, and no envelope claims it. That is the
correct answer rather than a gap — sessions are optional, and work done outside one is
attributed to nobody instead of to whoever happened to have a session open nearby.
The cost is that the ledger becomes load-bearing for a second purpose. It is append-only,
written only by Majordomus, and already validated by majordomus.ledger-integrity, which is what
makes it a safe thing to derive from. Ledger line order is preserved, so two events
written inside the same second need no tiebreak at all.
Front matter is written by Majordomus. A body containing a line that looks like a
front-matter key is rejected, as for a handover. Mode 0600. Created atomically. Never
overwritten, never edited after the fact: a closed session is superseded by later
information, not corrected.
.ai/repo/prompts/<name>.md
A reusable framing, versioned with the repository. Front matter is authored, not computed — these are not records of work.
---
name: debug
description: frame a defect so that the fix is proven, not asserted
profile: debugging
---
Task {{TASK_ID}} on branch {{BRANCH}} at {{HEAD}}: {{TASK}}
Open questions that block acceptance:
{{OPEN_QUESTIONS}}| Key | Required | Meaning |
|---|---|---|
name | yes | must equal the filename without .md |
description | yes | non-empty; shown by prompt list |
profile | no | the profile this framing suits; documentation only, nothing selects on it |
Unknown keys are errors, as everywhere else.
Tokens are a closed set. Inline, substituted anywhere in a line: {{TASK}},
{{TASK_ID}}, {{PROFILE}}, {{SCOPE}}, {{OWNER}}, {{BRANCH}}, {{HEAD}},
{{WORKING_TREE}}, {{REPOSITORY}}, {{NOW}}. Block, valid only alone on a line:
{{OPEN_QUESTIONS}}, {{DECISIONS}}, {{CHECKPOINT}}, {{HANDOVER}}, {{CONTEXT}}.
Any other {{...}}, or a block token used inline, is an error. There is no templating
language: no conditionals, no loops, no includes, no shell. A prompt that silently renders
{{TSAK}} as literal text is worse than one that refuses. doctor and watch validate
every asset for the same reason.
An asset whose body contains {{CONTEXT}} is excluded from context --prompt rather than
rendered, and the exclusion is named: the result would be the same context nested inside
itself, which the budget then pays for twice.
.ai/repo/skills/<id>/SKILL.md
A skill: a provider-neutral procedure for one bounded kind of work. Front matter is
authored, validated against share/schemas/majordomus/skill/skill.v1.schema.json (the allow-list
share/allow/skill.txt is generated from it); the body is the procedure. The skill's
identity is the id, which must equal the directory name, and its MCP URI is
majordomus://skill/<id>. Discovery is the source class skill in
.ai/repo/knowledge/sources.yaml; nothing else registers a skill.
---
schema: skill/v1
id: repo-review
version: 1
title: Repository review
description: An evidence-driven review of the actual state of the repository before work is recommended or done.
status: active
tags: [review, evidence]
related: []
inputs:
- a checkout of the repository with git history available
outputs:
- findings, each with severity, evidence, location and the smallest correct fix
---
# Purpose
...
# Procedure
...
# Output
...| Key | Required | Meaning |
|---|---|---|
schema | yes | skill/v1 |
id | yes | ^[a-z][a-z0-9-]*$; must equal the directory name |
version | yes | an integer of at least 1 |
title | yes | non-empty |
description | yes | one sentence; shown by every listing |
status | yes | draft, active or deprecated |
tags | no | ids, same pattern as id |
related | no | ids of other skills; every one must exist |
inputs, outputs | no | one string per item; what the worker needs and what the procedure leaves |
Unknown keys are errors. The body carries non-empty level-one sections # Purpose,
# Procedure and # Output; majordomus skills check refuses a skill without them.
examples/*.md beside the file are optional documents, each opening with a level-one
heading. See CLI.md for majordomus skills.
.ai/repo/adrs/<NNNN>-<slug>.md
An architecture decision: what was decided, why, and what it costs. Front matter is
authored or proposed by the tool, validated against share/schemas/majordomus/adr/adr.v1.schema.json (the
allow-list share/allow/adr.txt is generated from it); the body is the narrative. The
identity is adr-NNNN, allocated once, never reused, and it fixes the file-name prefix, so
a retitle moves the slug and never the number. Discovery is the source class adr in
.ai/repo/knowledge/sources.yaml; nothing else registers a decision.
---
schema: adr/v1
id: adr-0004
kind: adr
title: One canonical declaration, composed modules, derived projections
status: accepted
date: 2026-09-05
tags: [architecture, capabilities]
supersedes: [adr-0002]
related:
- rule:majordomus.capability-registry
- claim:capability-registry
- file:apps/majordomus-cli/src/capability
- test:test/cases/91_canonical_architecture.sh
provenance:
origin: extracted
derived_from:
- decision:t-2026-09-05-a
- file:docs/CAPABILITIES.md
---
## Context
...
## Decision
...
## Consequences
...| Key | Required | Meaning |
|---|---|---|
schema | yes | adr/v1 |
id | yes | ^adr-[0-9]{4}$; the number equals the file-name prefix |
kind | yes | adr |
title | yes | one line, the decision rather than the topic |
status | yes | proposed, accepted, superseded or rejected |
date | yes | YYYY-MM-DD, the day it reached that status |
tags | no | ids, same pattern as elsewhere |
supersedes | no | decisions this one stands in for; each must exist and name this one back |
superseded_by | no | present exactly when the status is superseded |
related | no | what the decision put in force: rule:<id>, claim:<id>, file:<path>, test:<path>. Each is validated, and the knowledge graph turns it into an edge — declares, supports, references, tested_by — so the reverse direction is a query, never a second edge to maintain |
provenance.origin | no | authored (a person wrote it) or extracted (adr propose derived it) |
provenance.derived_from | no | typed references: decision:, session:, commit:, issue:, file:, test: |
Unknown keys are errors. The body carries non-empty level-two sections ## Context,
## Decision and ## Consequences; ## Alternatives rejected is optional.
propose never writes accepted. It writes status: proposed and has no flag that
says otherwise, because acceptance is the person's act: a tool that can write accepted
can turn its own inference into repository truth, and a reader months later cannot tell
which. An extracted record must name what it was derived from — a candidate without
evidence is an assertion — and is refused if it claims accepted. It may be superseded
without ever having been accepted, because that is something a later record did to it
rather than a decision it claims for itself.
Supersession is reciprocal. A chain walkable from one end only is not a chain:
supersedes on one record requires superseded_by on the other, and adr check reports
either half missing. Identities are allocated under a lock over the section directory, so
two worktrees proposing at the same moment get two numbers rather than one number twice.
See CLI.md for majordomus adr.
.ai/repo/deployments/<id>.yaml
One deployment of this repository's executable, and the only authoritative statement of
it. The container image definition (deploy/Dockerfile), its ignore file
(.dockerignore) and the files of the provider it names (deploy/<provider>/) are
generated from this object; each carries a provenance header naming
this file and the command that regenerates it, and majordomus generate --check fails
when one is edited by hand. Contract:
share/schemas/majordomus/deployment/deployment.v1.schema.json
(deployment/v1); keys are closed by share/allow/deployment.txt, generated from it.
schema: deployment/v1
kind: deployment
id: majordomus # identity, [a-z][a-z0-9-]*
title: ... # one line, for a listing
description: ... # one line: what it serves and to whom
status: declared # declared | active | retired
application: majordomus # the application's name, for a listing
build:
package: majordomus-cli # the Cargo package built
binary: majordomus # the binary target the image runs
profile: release # the Cargo profile
inputs: [apps/majordomus-cli, share, .ai, Cargo.toml, Cargo.lock]
site: site/public # what the canonical site pipeline writes; absent = no site
listen:
port: 8080 # >= 1024: the process runs as a non-root user
interface: all # loopback | all — `all` is stated intent, not a suppressed warning
health:
liveness: /api/v1/live # must be a route a capability registers
readiness: /api/v1/ready # likewise
grace_seconds: 2
interval_seconds: 15
timeout_seconds: 2
resources:
cpu_kind: shared # shared | performance
cpus: 1
memory_mb: 256 # a hypothesis until a measured run under it passes
machines:
count: 1 # the deployed inventory is asserted against this
min_running: 0 # 0 with autostop: the cheap profile
autostart: true
autostop: true
region: fra # required; no generated artifact reads it now
budgets: # each written by the run that measured it; none guessed
image_bytes: ...
binary_bytes: ...
build_context_bytes: ...
cold_start_ms: ...
resident_memory_mb: ...
blocking_check_ms: ...
request_p99_ms: ...
provider: # optional: the provider that hosts it (ADR 0219)
name: gigalixir # only a provider the contract knows; another is refused
app: prismatic-majordomus # the application at the provider, ^[a-z][a-z0-9-]{2,62}$
stack: gigalixir-24 # the provider's stack
url: https://prismatic-majordomus.gigalixirapp.com # always https://
A provider is optional, and only a known one. Absent, nobody hosts the deployment: the
GHCR image built from deploy/Dockerfile is published and run by nobody. Named, it is one
the contract knows — today gigalixir — and majordomus generate deployment renders every
file it reads under deploy/<provider>/: for Gigalixir Procfile (the executable the slug
carries at .majordomus/bin/<binary>, started with serve --discovery filesystem on the
listen interface and the provider's PORT, falling back to listen.port) and the inline
buildpack's bin/detect, bin/compile and bin/release, each opening with #!/bin/sh and
carrying the provenance header on the next line. The buildpack itself, pinned by commit, is
a constant of the renderer (render::buildpack) rather than a file, because a buildpack
list cannot carry a header. A provider beside listen.interface: loopback, or a URL that is
not https://, is refused. Fly.io was named here once, never ran, and stays no target
(2026-10-12).
What a provider receives is assembled, never the repository. majordomus deploy slug --deployment <id> --commit <sha> --executable <path> --out <dir> writes, into a directory
outside the checkout that is absent or empty, the object's build.inputs read from git at
that commit except the package the executable is compiled from, the provider's generated
files of that commit at the slug's root (each held to what the object renders, the hooks
mode 755), .buildpacks holding the pinned buildpack line, the executable at
.majordomus/bin/<binary>, and .majordomus/SOURCE.json naming the deployment, the commit,
the executable's SHA-256 and the provider. .github/workflows/provider.yml (model
.ai/repo/ci/provider.yaml) runs it after master's validate concludes success, with an
executable built at that commit, pushes the slug as one fast-forward commit on the
provider's own history, and waits until provider.url + health.liveness answers that
commit.
The port is stated once. The process, the image and the provider's Procfile all read this one field; a literal port anywhere else is the drift this kind exists to prevent. The same holds for the health routes and the build inputs.
No credential belongs here. The token that authorises a deployment comes from outside the repository and is never written into the object, into a generated file, into an image layer or into a log.
The schema decides shape; majordomus deploy doctor decides sense — a health route no
capability registers, a package or binary the workspace does not contain, a build input
that does not resolve, a min_running above count, a provider URL that is not https, a
provider beside a loopback interface — and each refusal names the file, the
key, the value found and the correction.
The image is built in stages that a cache can carry. deploy/Dockerfile is rendered with
four builder stages before the runtime (BUILDER_STAGES in src/deploy/render.rs):
toolchain (the Rust image at the exact channel of rust-toolchain.toml — the generator
refuses a repository without one rather than pin a second version), recipe (the manifest
and lockfile with the package's own version masked, so a release bump reaches the same
recipe), deps (every dependency compiled against stub sources, as layer content) and
build (the real sources; the only stage that reads the commit). A cache mount would keep
compiled dependencies only on the machine that built them; a layer travels with a registry
cache export, so a fresh runner compiles only the package. .github/workflows/docker-image.yml
builds the image, runs it and checks that /api/v1/live names the commit, and publishes
ghcr.io/korczis/prismatic-majordomus:sha-<commit> only after that commit's validate run on
master succeeded; it alone writes the shared :buildcache ref.
A release image compiles nothing: .github/workflows/release.yml downloads the release's
published Linux musl archives, holds each to SHA256SUMS, to its provenance attestation and to
the tagged commit, and builds with MAJORDOMUS_BINARY_FROM=release and the release-binary
build context, so the image published as :<tag> runs the bytes the archives ship, for every
platform they ship.
$ docker buildx build -f deploy/Dockerfile \
--cache-from type=registry,ref=ghcr.io/korczis/prismatic-majordomus:buildcache \
--build-arg MAJORDOMUS_BUILD_COMMIT="$(git rev-parse HEAD)" -t majordomus:dev --load .
.ai/repo/workspaces/<id>.yaml
One external workspace this repository is authorised to read: a body of content held by
another vendor — a ChatGPT Project, and later a Claude Project or its equivalent — over
which the operator is already authenticated. A workspace is not a provider: a provider
is a tool that works in this repository (ADR 0024, share/providers.yaml), and a
workspace is the arrow pointing the other way. Both are named after the same vendors, which
is why the distinction is written down rather than inferred. Contract:
share/schemas/majordomus/workspace/workspace.v1.schema.json
(workspace/v1); keys are closed by share/allow/workspace.txt, generated from it.
schema: workspace/v1
kind: workspace
id: chatgpt-majordomus # identity, [a-z][a-z0-9-]*; stable across a rename upstream
title: ... # one line, for a listing
description: ... # one line: what it holds and why this repository reads it
status: declared # declared | active | retired
vendor:
id: chatgpt # [a-z][a-z0-9-]*
name: ChatGPT
origins: # the only origins an adapter may observe; scheme and host
- https://chatgpt.com
upstream:
id: g-p-... # the vendor's own identifier, carried opaquely
kind: project # the vendor's word for this shape of container
url: https://... # where a person opens it; nothing resolves it
authorisation:
asserted_by: ... # who asserts it
asserted_at: 2026-09-09
statement: "..." # what is authorised, in a sentence a person would defend
write: false # false until a separate decision says otherwise
access:
browser_profile: default # a label the operator's configuration resolves, outside
# this repository — never a path, a variable or a secret
transports: [browser] # official | browser | private_http, in preference order
capabilities: # expectation, not permission
- workspace.read
- conversations.read
- messages.read
- instructions.read
- files.read
The content is not here and never will be. A workspace's conversations, messages and
attachments are synced into .ai/local/workspaces/<id>/, which is this checkout's state
and never a source (ADR 0005, ADR 0025). Nothing under it is indexed, generated from,
published or carried by a branch. A piece of it becomes the repository's own statement only
when a person promotes it into .ai/repo/, deliberately, one piece at a time.
No credential belongs here either. access.browser_profile is constrained by the
schema to ^[a-z][a-z0-9-]*$ so that a filesystem path, an environment variable or a token
cannot be written where a label belongs; what the label resolves to lives outside the
repository. test/cases/93_workspace_kind.sh reads every declaration and refuses one that
carries a credential-shaped key or value.
The declaration bounds the adapter before anything is fetched. vendor.origins is the
scope of observation rather than a hint — traffic to anything not named there is never
recorded, not filtered afterwards. authorisation.statement is a person's assertion in
their own words and the adapter may not exceed it. capabilities is an expectation: one
the vendor withdrew is a diagnostic, and an adapter that cannot provide one reports it
unavailable rather than returning an empty success.
.ai/repo/knowledge/sources.yaml
The repository's declared knowledge sources: which tracked files are knowledge, in which
class. Discovery goes through the version-control index with the pathspec given here,
never through a filesystem walk, so an untracked file, build output or a vendored tree is
never a source and two machines report the same list in the same order. The tool ships
the operational classes (the records it writes under the state directory) in
share/knowledge-sources.yaml, with paths relative to that directory; a class's scope
(shared or operational) is decided by which of the two files declared it.
version: 1
sources:
- id: policy # the class, unique; also the order sources are reported in
kind: policy # the node kind every source in this class produces
discovery: vcs # the tracked files matching the pathspec
pathspec: ':(glob).ai/repo/policy.yaml'
required: true # a class that discovers nothing is a finding, not a silence
Every vcs pathspec carries the :(glob) prefix so that * never crosses a directory
separator and no two classes claim one file. Read back with majordomus knowledge sources.
.ai/repo/knowledge/candidates/<id>.md — a candidate knowledge record
What the tool derived about the repository at an episode boundary and nobody has reviewed
yet. Written by majordomus knowledge derive — from the close of every episode, from the
compaction event, or by hand — through a temporary file in the same directory renamed over
the id-named file, never staged. Validated against
share/schemas/majordomus/knowledge/knowledge.v1.schema.json (the allow-list
share/allow/knowledge.txt is generated from it), the same schema a curated note under
curated/ is validated against: a candidate and a verified record are one kind, one status
apart. Discovery is the source class candidates in .ai/repo/knowledge/sources.yaml. The
identity is id, which equals the file name and is derived from the evidence: the episode
id followed by a digest of the canonical evidence string, so the same fact always lands in
the same file and two worktrees never write one name for different content.
---
schema: knowledge/v1
id: s-20260912101500-7f1a-3b9c2e4d1a05
kind: knowledge
class: convention
title: "Tabs are refused in the parser"
description: "Recorded as a decision under task t-20260912101512-b7c2 in episode s-20260912101500-7f1a."
status: candidate
epistemics: decided
date: 2026-09-12
tags:
- derived
- decision
provenance:
origin: extracted
derived_from:
- session:s-20260912101500-7f1a
- decision:t-20260912101512-b7c2
- commit:3f2a9c1e4b7d8a05c1119f2b6e0d7a3c8e5f1b42
relations:
- type: relates_to
target: file:.ai/repo/rules/project/yaml-has-one-encoding.md
---
# Tabs are refused in the parser
Recorded as a decision under task t-20260912101512-b7c2 in episode s-20260912101500-7f1a.| Key | Required | Meaning |
|---|---|---|
schema | yes | knowledge/v1 |
id | yes | ^[a-z][a-z0-9-]*$; equals the file name; for a derived record, <episode-id>-<digest> |
kind | yes | knowledge |
class | yes | fact, convention, constraint, memory or lesson; the deriver writes convention for a decision, fact for a resolved question or a verified completion, lesson for a blocked, failed or unmatched task; promotion is where a person changes it |
title | yes | the assertion, one line, double-quoted with \ and " escaped |
description | yes | one line saying where the evidence came from |
status | yes | candidate under candidates/; verified only under curated/; superseded when a person rejected it |
epistemics | yes | decided for a decision, observed for a resolved question or a passed verification, inferred for a task outcome |
date | yes | YYYY-MM-DD, the day of the evidence line — deterministic, never the clock; the review age is measured from the ledger and git instead |
tags | no | derived and the evidence kind: decision, question or task |
provenance.origin | yes for extracted | extracted for a derived record; authored for a curated note a person wrote |
provenance.derived_from | yes for extracted | typed references: session:<episode>, then decision:<task-id> or task:<task-id>, then commit:<sha> for each commit of the episode that touched a rule or a decision record; a decision recorded outside a task names the session alone |
relations | no | relates_to file:<path> for each rule or decision record those commits touched; present only when non-empty |
superseded_by | when superseded | the record that replaces it, when reject --by named one; otherwise the body carries # Rejected and the reason |
Unknown keys are errors. The body is the title as a level-one heading and the description; a
promoted record gains # Evidence with what the person supplied, a rejected one # Rejected
with the reason.
The deriver never writes verified. It writes status: candidate and nothing else;
majordomus knowledge promote with evidence on standard input moves the record to curated/
and sets verified, and a person editing the file is the other act. A record under
candidates/ claiming verified is refused by check, doctor and majordomus knowledge check.
A body is an assertion, never a transcript. The title, the description and the body are
subject to the check that keeps project.never-store-transcripts; a line naming a message
list, a completion or a model's reply is a blocking failure, and the deriver refuses to write
a record whose evidence carries one rather than write it for the check to refuse.
Every reference resolves. A session in the sessions store or the ledger, a task or
decision in the ledger or the state directory, a commit in git, a file or test in the tree,
an issue in the project section, a knowledge record in either directory, a rule or a
decision record in the layer. decision:none and task:none are refused. The graph compiler
treats ledger and git targets as external and never reports them as dangling; the integrity
validator is what resolves them.
.ai/local/state/ledger.jsonl
Append-only. Written only by Majordomus. One JSON object per line. Retention-capped;
doctor reports when over cap, and history --rotate moves the oldest lines to
ledger.<utc>.jsonl.archived (never deletes).
One line is one history event: what happened, when, for which task, at which head, and
in which session. majordomus history reads them back.
Common envelope:
{"ts":"2026-09-03T19:30:12Z","event":"task.started","head":"3f2a9c1e...","branch":"main","by":"majordomus/0.1.0","session":"s-20260903193010-a4f1","task_id":"t-20260903193012-b7c2"}
session is present when a session is open in this worktree and absent otherwise. Like
head and branch it is computed, never authored. It is what a closed session's
reference lists are selected by, and it exists because the alternative does not work: the
ledger is one file per repository, so a selection by time range cannot tell two concurrent
workers apart, and the first real run of one claimed another worker's records. A line with
no session belongs to no episode, which is the honest answer for work done outside one.
ts, event, head, branch, by and session are the envelope keys, and a
payload may not use one of them as a field name. JSON permits a duplicated key and says
nothing about which of the two wins, so such a line means one thing to a reader that takes
the first and another to a reader that takes the last. mj_ledger_append refuses the
collision as an internal error, naming the field; a payload that wants to record something
of the same shape names it apart, which is why the provider receipts carry
provider_event and not event. Every reader takes the first occurrence of a key —
the envelope's — through the one extraction MJ_LEDGER_FIELD_AWK declares, so a line
written by an older version is still read as its writer meant it.
The event vocabulary is closed. share/events.yaml declares every name the ledger
accepts, what writes it, and the payload keys it must carry; mj_ledger_append refuses
an unregistered name, history --event refuses to filter on one, and
history --validate reports a stored line whose name no reader recognises. Adding an
event means adding an entry there — the same rule that makes an unknown policy key an
error.
Events and their extra fields:
| event | extra fields |
|---|---|
task.started | profile, scope[], owner |
task.checkpoint | checkpoint_path when a body was written; absent when only checkpoint_at moved; task_id when a task was open, absent otherwise — a checkpoint is the episode's record and an episode need not have a task (ADR 0052) |
task.evidence | task, covers (the obligation token), kind, inputs_hash (over the obligation's declared inputs), and command, artifact, result when given |
task.finished | outcome, contract (object of doctrine id → pass/fail/skipped), verify (command, exit, seconds) or null, checkpoints (count) |
task.refused | outcome (the one claimed), unmet (count of failed contract lines), refused[] (the doctrines among them), contract (object of doctrine id → pass/fail/skipped); one line per refused finish, none for finish --check |
task.handed_over | handover_path, closed (true with --close); task_id when a task was open, absent otherwise |
decision.recorded | decision (the entry's title) |
question.opened | question |
question.resolved | question, answer |
session.started | owner, worker when one was supplied |
session.closed | outcome, session_path |
session.recovered | session_id (the episode recovered, which is never the one the envelope's own session stamp names), reason (the evidence that decided it — the last sign of life and its source for a stranded episode, the count of folded records for a duplicate), session_path |
provider.event.received | provider, provider_event (start/end/compact), provider_session when the provider named one. Written as the first act of every lifecycle adapter, before any guard decides what to do about the event |
provider.event.failed | provider, provider_event, reason. The other half of the receipt: the event arrived and the work it should have done did not complete. A receipt with neither a resulting record nor one of these beside it is itself a finding |
observation.recorded | task_id (none outside a task), kind, subject, statement, evidence (a list of typed references, possibly empty). Written by knowledge observe; the episode boundary derives a candidate from it (ADR 0118) |
knowledge.derived | episode, written, unchanged, paths; skipped when a line was refused. Written by every non-dry-run derivation that resolved an episode, even when it wrote nothing; episode is explicit because the envelope's session stamp is present only while the episode is open |
knowledge.promoted | id, path (the curated record's repository-relative path). A person promoted a candidate to verified with evidence |
knowledge.rejected | id, reason; superseded_by when --by named the record that replaces it. A person rejected a candidate; it is superseded in place |
ledger.rotated | archived (lines moved), kept, archive (path) |
session.archived | archived (records moved), kept (the cap), archive (the directory, repository-relative). Written only when majordomus session archive moved something; no record is deleted |
prompts.reconciled | records (prompt records re-examined), linked, unlinked. Every record in the archive is re-matched to the episode it belongs to, from evidence that still exists; a record whose evidence is gone is counted as unlinked rather than guessed at |
task.gate | task, gate, exit, inputs_hash (over the union of the paths of the CI classes that select the gate, so a change to any of them makes the run stale rather than merely old). A gate that has never emitted this event is reported as queued, never as passing |
prompts.pruned | records (prompt records touched), pruned (bodies replaced with a tombstone), bytes_left. A record is never deleted: what is taken is the body, and the record left behind still says when the prompt happened, in which episode, and the digest and length of what was removed |
projections.updated | policy_sha256, targets (count) |
use_cases.ran | ran, failed (counts; the evidence under .ai/local/evidence/use-cases/ carries the steps) |
plan_start | issue |
plan_verify | issue |
plan_evidence | issue, covers (the requirement it satisfies), type |
plan_done | issue |
layout.migrated | from, to, backup (the copy of local state made before it moved, or empty) |
rules.vendored | package (the revision of the package now vendored) |
adr.proposed | adr (the identity written), title; never written for an acceptance, which is a person's edit to the file |
doctor, check (without --checkpoint), watch, context, history, search,
prompt, knowledge check and knowledge candidates write nothing, the ledger included.
Every line must be a JSON object carrying ts and event. A line that is not is a
failure in history --validate, check, doctor and watch: a ledger the tool
cannot parse is a ledger that cannot be used as evidence. The reader skips such a line
rather than crashing, so a corrupted ledger is still readable while it is being reported.
Because created_at has second resolution, two records written inside one second would
resolve in an order decided by their random filename suffixes. The ledger is append-only
and written in the order the commands ran, which makes it the tiebreak the resolver uses —
the one portable monotonic ordering available without sub-second timestamps.
history --rotate moves all but the newest ledger.retention_max_lines lines into
ledger.<utc-compact>.jsonl.archived and appends a ledger.rotated event. It never
deletes and refuses to overwrite an existing archive.
session archive is the same promise for the sessions section: it moves the oldest closed
records beyond session.retention_max_files into the section's archive/ directory and
appends a session.archived event. It runs only when asked and only when the policy's
session.archive is true, it never deletes, and it refuses before the first move when a
record of the same name is already archived.
.ai/local/benchmarks/runs/<run-id>.json — a benchmark run
Written by majordomus bench, one file per run, plus latest.json (the newest run) and
history.jsonl (one compact line per run). Local evidence under the ignored half of the
layer; never a baseline.
{"schema":"majordomus/benchmark-result/v1",
"run_id":"b-20260905T031200Z-9f1c","recorded_at":"2026-09-05T03:12:00Z",
"repository":{"commit":"<40 hex>","branch":"main","dirty":false},
"environment":{"os":"Darwin","arch":"arm64","bash":"5.3.15","clock":"epochrealtime"},
"profile":{"samples":10,"warmup":2,"mode":"both"},
"results":[{"command":"doctor","mode":"warm","class":"read-only","scenario":"fixture not-wired",
"status":"ok","samples":10,"min_ms":2601,"p50_ms":2640,"p90_ms":2760,"p95_ms":2790,
"p99_ms":2790,"max_ms":2790,"mean_ms":2655.2,"stddev_ms":58.1}]}
status is ok, setup-failed, or exit-<code> when the command exited with a code its
scenario does not accept. Percentiles are nearest-rank over the sorted samples. clock
names the source of the milliseconds (epochrealtime or seconds).
.ai/local/cache/flat/<parser>/<slot> — a parsed YAML file
What the shell tool's YAML reader printed for one file, kept so that the next command on the
same bytes reads it instead of parsing it again. <parser> is the first 16 hex digits of the
SHA-256 of the parser itself, so an entry is only ever read by the parser that wrote it.
<slot> is where an entry is looked for — the file's length and sixteen of its characters as
numbers, all computed without starting a process — and never what it is trusted on: hashing
the file would cost the one process a cached read is there to save. One entry is one file:
mjflat 1 <source length> <flat length>
<the source, byte for byte><the flat lines, byte for byte>
An entry is served only when the header adds up to the file's own length and the source it
holds is exactly the file asked about, compared in full; anything else is a miss, which
parses the file again and writes the entry back. Two files that land in one slot evict each
other and neither is ever served the other's lines. A parse that failed is never kept. An
entry is written beside the others and renamed into place, so a reader never sees half of
one. After each new entry the directory holds at most the policy's
cache.flatten_max_entries, the oldest written removed first; without the key, or with 0,
there is no cache. Nothing reads this directory but the reader that wrote it; deleting it
costs one parse per file. With MJ_TIMING=1, yaml_flatten still counts every file asked
for and flatten_cache_hit counts the ones served from here.
baseline.json under .ai/repo/benchmarks/ — the accepted baseline
The same document with schema majordomus/benchmark-baseline/v1, written only by
majordomus bench --write-baseline on a clean tree (or with --force), tracked and
reviewed like any other change. bench --check compares a fresh run's p50, p95 and p99 per
target and mode against it under the policy's benchmark.regression fractions; a baseline
with another schema is reported as not comparable, never as a number.
The timing report
With MJ_TIMING=1 set, a command reports on stderr where its time went: each phase it
declares, with the milliseconds it took and how often it ran, then each work counter (a
YAML parse, a git call) summed. stdout stays the command's own. The text form is for a person:
TIMING clock=epochrealtime total=2640 ms
phase 2178 ms 1 x ctxd:cross_check
count 9 git
Under --json the same data is one line of JSON, so a check can compare one run's
breakdown with another's:
{"timing":{"clock":"epochrealtime","total_ms":2640,
"phases":[{"name":"ctxd:cross_check","ms":2178,"calls":1}],
"counters":[{"name":"git","count":9}]}}
Both forms are read from one aggregation in one order (phases by time, counters by count, a
tie broken by name), so neither carries a phase or a counter the other lacks; case 886 holds
that. clock names the source of the milliseconds (epochrealtime, or seconds where the
shell has no EPOCHREALTIME), and total_ms runs from the first line of the command to
the report.
Projection stamp
Every generated target describes itself; there is no provenance file beside it, so a fresh clone carries the same evidence as the checkout that generated it.
A file-mode target begins:
<!-- generated by `majordomus update` from .ai/repo/policy.yaml (policy 8e1d2c3b4a5f, content 4b2f9a01c7d3e6f8) — do not edit; edit the policy and regenerate -->
policy is the hash of the policy and every profile, concatenated, at generation time.
content is the hash of everything below the stamp line. A region-mode target carries
the same two values in its begin marker:
<!-- majordomus:begin 8e1d2c3b4a5f 4b2f9a01c7d3e6f8 -->
...generated region...
<!-- majordomus:end -->
and content then covers the region body only, never the host document around it.
doctor fails on any target whose content differs from the hash its stamp names
(hand-edited) and on a target with no stamp at all (not written by update). watch
reports policy drift when a stamp names a policy hash that is no longer the policy on
disk, and update refuses to overwrite a target whose content matches neither its stamp
nor the new output.
Generated artifact header
The provider bootstraps carry the stamp above, which is majordomus update's. Every other
generated artifact — everything majordomus generate writes — carries a provenance header
in the form its encoding allows, and the three lines say the same thing in every one:
<!-- GENERATED FILE — DO NOT EDIT DIRECTLY
Source: <what it was derived from>; regenerate with `majordomus generate`
Generator: majordomus-cli <version> --># GENERATED FILE — DO NOT EDIT DIRECTLY
# Source: <what it was derived from>; regenerate with `majordomus generate`
# Generator: majordomus-cli <version>
A JSON document carries them as members instead, schema first when it has a contract:
{
"schema": "majordomus/capability-registry/v1",
"generated": "GENERATED FILE — DO NOT EDIT DIRECTLY; source: …; regenerate with `majordomus generate`",
"generator": "majordomus-cli 0.1.0"
}
and a document whose own specification fixes its member names — the OpenAPI document is
the only one — carries x-majordomus-generated and x-majordomus-generator instead. The
line-oriented text artifacts (share/allow/*.txt) take the # form; every reader of one
skips comment lines.
No header carries a timestamp, an absolute path or a fingerprint that moves with an
unrelated edit. The index of every artifact, with the document each projects, its encoding,
its contract, its source, its size and its hash, is docs/generated/artifacts.json (schema
majordomus/generated-artifacts/v1), and it is itself generated; the contracts of the
generated documents are share/schemas/generated/*.schema.json, each pinned to its
document by the const of the document's schema member. The rule is
project.generated-artifacts-are-typed@1.
Worktree coordination
There is no sidecar registry. start and check --overlap read git worktree list
and each worktree's own .ai/local/state/current.yaml. Git is the authority; nothing
can rot.
Completion note (when no handover is written)
finish accepts --note <file>. For completed it needs the handover's required
sections; for partial/blocked a # Next Action; for no_match/failed a
# Reason. The profile's output_contract fields may lead as a YAML block; finish records
the note but does not validate that block. On success the note is copied to
state/completed/<id>.md.
---
outcome: completed
changes: 2
verification: make test — exit 0
blockers: []
next_action: none
---
# Objective
...
# Current State
...
# Next Action
none