CLI reference
manni cite exposes five verbs, check, add,
update, remove and
history. There is no default. A bare manni cite prints
usage and exits 2, so nothing runs by accident. check can fail a build,
three verbs write to pages, and history walks the whole git history.
check, update, remove and history take paths and share the metadata
tool’s input model; add
takes one page and one source.
manni cite [options] <command> [command options] [arguments]Global options
Section titled “Global options”Global options are accepted before the subcommand.
| Option | Description |
|---|---|
-V, --version | Print the manni version and exit. |
-h, --help | Print help for the program or a subcommand. |
The citation tool. Every command on this page lives under it. cite accepts
its own -V, --version, which reports the manni version, and -h, --help.
manni cite [options] <command> [command options] [arguments]Options
Section titled “Options”| Option | Description |
|---|---|
--no-color | Disable colored output. Color applies to pretty output only, on a TTY, and never under NO_COLOR; the same rules as the metadata tool. |
cite check
Section titled “cite check”Check every citation in the given pages against its source. Each end of an
entry is hashed under the one
rule and compared with its
pin. A source end is current, skipped, or one of the five source-*
rules; a claim end is current, skipped, or one of the four claim-*
rules. The page-side rules run whether or not the sources are reachable. Those
are the claim end, a marker that names no entry, a broken anchor, and an entry
the schema refuses. Every status and rule, with its default severity, is on
the citations reference.
manni cite check [paths...] [options]Findings ride the metadata tool’s reporters and its baseline. -f github,
sarif and junit produce the same envelopes
meta validate does, under the
rule id manni:cite/<rule>. --baseline is the same ratchet with its own
file. No model and no network are involved. A clean corpus costs one hash per
citation. With sources on, every run indexes them through git ls-files
where git is available. History is read only when a pin does not hold.
A claim whose pin no longer holds has the page’s own history read as well. The
newest commit whose page still held the pin is the claim’s
baseline. The walk
reads at most 256 commits that touched the page, and nothing stores the
baseline. A claim whose words are unchanged since the baseline reads
claim-reanchored, a notice. A plain update re-pins it. One whose words
were edited reads claim-changed, and says since when.
Arguments
Section titled “Arguments”| Argument | Description |
|---|---|
[paths...] | Files, directories, or globs to check. Use - to read a page from stdin, with --as. Optional. Without paths the run covers every configured collection, or the ones --collection names. No paths and no collections is an error (exit 2). See the shared input model. |
Options
Section titled “Options”| Option | Argument | Default | Description |
|---|---|---|---|
--collection | <name> | every collection | Run over a configured collection rather than every declared one. Repeatable, one name per occurrence, never comma-separated; repeats collapse, and a name must match a declared collection exactly. Cannot be combined with positional paths, and needs a config file to select from (exit 2 for both). |
--ext | <list> | supported extensions | Comma-separated extensions used when expanding directories and globs. |
--exclude | <glob> | n/a | Glob to exclude from directory and glob walks. Repeatable. When the run reads from collections, their exclude: globs apply as well; a typed path is filtered by --exclude alone. |
--as | <format> | n/a | Force an input format (extractor name, e.g. markdown) for every input. Required when reading from stdin. |
-f, --format | <pretty|json|github|sarif|junit> | pretty | Output format. See output. An unknown value is an error (exit 2). |
-c, --config | <path> | discovered | Path to a config file. When set, the file must exist or the run exits 2. |
--no-config | n/a | off | Ignore any discovered config file. -c and --no-config set the same option, so the one written later on the command line wins. |
-q, --quiet | n/a | off | In pretty output, hide current citations and clean files. Has no effect on the machine formats. |
--allow-empty | n/a | off | Treat zero matched files as success instead of an error (exit 2). See empty input sets. |
--no-gitignore | n/a | on | Check files .gitignore covers as well. Filtering is on by default; see .gitignore. Overrides config respectGitignore:. |
--baseline | [path] | config baseline:, else .manni-cite-baseline.json | Compare findings against a recorded baseline and fail only on new ones. The value is optional. A named file that does not exist is an error (exit 2). |
--write-baseline | [path] | config baseline:, else .manni-cite-baseline.json | Record this run’s findings as the baseline, then exit 0. Wins over --baseline. |
--no-baseline | n/a | off | Ignore a baseline supplied by config baseline: for this run. |
--no-check-sources | n/a | on | Run the page-side rules only. Every source-side status is skipped, which is not a finding. For a public docs checkout that does not have the code beside it. Overrides config checkSources:. |
--root | <dir> | n/a | The directory source.file paths resolve from, relative to the working directory. It may point at another checkout. Without it: config root:, else the nearest git root above the working directory, else the working directory with a notice on stderr. See where sources resolve from. |
--repo | <name=dir> | [] | A checkout an entry’s source.repo may name, as name=dir relative to the working directory. Repeatable, one pair per occurrence, never comma-separated. It overrides config cite.repos for that name only, and works with --no-config. A pair without =, a bad name or a repeated name is an error (exit 2). |
--show-diff | n/a | off | In pretty output, print the commit subjects and the diff since the baseline under each source-changed, claim-changed and claim-reanchored row. A source’s baseline is its commit-sha, and a claim’s is the newest commit whose page held the pin. A claim with no baseline gets the page lines it covers now. Every block is capped at 60 lines, and the row says how many were left out. |
--reveal | n/a | off | In pretty output, print the decrypted path beside an encrypted source. Nothing else ever prints it; see the output rule. |
Output
Section titled “Output”pretty prints one line per file and one per citation beneath it. A citation
row is its id, then the claim end, then the source end. On a page whose
entries come from more than one place, a manifest row ends with the manifest
and the entry’s line. Both ends read in file lines, and the source is spelled
exactly as the entry spelled it:
✗ docs/limits.md ↕ fetch-timeout :9 changed since 3f9c2a1, 2 commits lib/limits.ts:2 current docs-citations.yaml:6 Check it still matches the source, then run manni cite update --accept --only fetch-timeout. manni:cite claim-changed: The sentence was edited since it was pinned. Guide: https://hawkeyexl.github.io/manni/cite/fix/#claim-changed ✓ max-files :12 current lib/limits.ts:1 current ℹ retries :14 moved -> :16 lib/limits.ts:3 current Run manni cite update. manni:cite claim-moved: The pinned page text sits at a different line. Guide: https://hawkeyexl.github.io/manni/cite/fix/#claim-moved ✗ page-size :21 current lib/limits.ts:5 changed since 3f9c2a1, 1 commit Check the sentence against the source, then run manni cite update --accept --only page-size. manni:cite source-changed: The cited lines are not what they were. Guide: https://hawkeyexl.github.io/manni/cite/fix/#source-changed ℹ page-order :26 reanchored since 5d1e0b7 -> :26-28 lib/limits.ts:7 current Run manni cite update. manni:cite claim-reanchored: The pinned words are unchanged, and only the layout around them moved. Guide: https://hawkeyexl.github.io/manni/cite/fix/#claim-reanchored ✓ timeouts marker :30 current ~AQm4…:8-12 current ↕ host-scope marker :44 -> :41 moved lib/scope.ts:9 current Run manni cite update to move it. manni:cite marker-misplaced: A marker line sits inside a paragraph and splits it. Guide: https://hawkeyexl.github.io/manni/cite/fix/#marker-misplaced Run manni cite update. manni:cite claim-moved: The pinned page text sits at a different line. Guide: https://hawkeyexl.github.io/manni/cite/fix/#claim-moved ✓ lib/limits.ts current
1 file checked, 0 passed, 1 failed, 6 findings (2 warnings) (3 notices)A source in another checkout carries its repo in front, as
sdk:src/client.ts:2, in this format and every other.
Under a row with a finding, the first line says what to do. Two dim lines follow: the rule with what it means, and a Guide link to its section of the fix page. A finding that is not about an end prints its message there instead, followed by the same two lines.
When one manifest keeps every entry on a page, its path follows the page on the file’s line, and no row repeats it:
✓ docs/limits.md docs-citations.yaml ✓ fetch-timeout :6 current lib/limits.ts:2 current
1 file checked, 1 passed, 0 failed, 0 findingsA marker’s claim column says where the marker is. When the marker is
misplaced it says where it belongs too, as marker :44 -> :41, and the
status that follows is the claim’s.
A changed or reanchored claim carries its baseline, as the source column does.
A reanchored claim that stores its own lines names the run it moves onto, and
one a marker anchors reads marker :14 reanchored since 3f9c2a1. Four more
forms the claim column takes, the last on a record only:
| Claim column | When |
|---|---|
:9 changed since 3f9c2a1, uncommitted | The working tree holds the change. It never reads 0 commits. |
:9 changed (history unavailable; fetch-depth: 0) | A shallow checkout, or the 256-commit cap. |
:9 changed | No git, or no commit held the pin. |
#/pains/0 current | A claim that is a metadata value on a record. Its status reads moved -> #/pains/1, moved-ambiguous (#/goals/0, #/pains/1), changed or missing. |
Three run notices go to stderr, once per run. The third is the source end’s own, for a source whose history ends before the pin held:
manni: git is not available here, so citations are checked without history: no never-true, no reanchored claims, no diffs, no commit subjects.manni: the page history ends before the pin held; use fetch-depth: 0 to tell reanchored claims from changed onesmanni: the source history ends before the pin held; use fetch-depth: 0 to tell never-true from changedWhen a finding is reported and the strategy: section cannot be read, one
more notice says the report leaves out whom the pages serve. Strategy’s own
message follows it. The exit code is the check’s own:
manni: personas and journeys left out of the report. manni.config.yaml: strategy does not carry "paths". Name a collection under collections:.Under --show-diff, a changed or reanchored claim row gains the subjects of
the commits that touched the page since the baseline. A unified diff of the
claim’s own lines follows them. The labels name the page at the baseline and
the page now, in file lines:
↕ fetch-timeout :9 changed since 3f9c2a1, 2 commits lib/limits.ts:2 current Check it still matches the source, then run manni cite update --accept --only fetch-timeout. manni:cite claim-changed: The sentence was edited since it was pinned. Guide: https://hawkeyexl.github.io/manni/cite/fix/#claim-changed docs(limits): the timeout is thirty seconds docs: rewrap limits.md --- docs/limits.md@3f9c2a1:9 +++ docs/limits.md:9 -The fetch timeout is 10 seconds. +The fetch timeout is 30 seconds.The rows read down the page, by the line each one anchors to. That is the
marker’s line where a marker anchors the claim, and the claim’s first file line
otherwise. The order of the entries in the frontmatter or the manifest does not
show. json keeps that order instead, and so do the findings.
✓ is a citation with no finding, ↕ a warning, ℹ a notice and ✗ an
error. · is a bare pin whose source was skipped, or a citation whose every
finding the baseline forgave. A skipped source beside a claim end still reads
✓. The last row
is a bare pin: no id and no claim end, so both columns are empty. A file is
✗ when it holds an error-severity finding, ⚠ when its worst finding is a
warning, and ℹ when it holds notices only. The summary counts notices apart
from warnings, as in 3 findings (1 warning) (1 notice).
When a collection holds type: persona or type: journey records, a block
comes before the summary line. It names the records that the pages with
findings name in personas: and journeys::
The pages with findings serve 2 personas and 2 journeys. devin Devin, Platform / CI Engineer 3 errors on 2 pages maya Maya, Documentation Engineer 2 errors and 1 warning on 2 pages d5 Gate citations in CI without blocking on prose 3 errors on 2 pages m2 Tighten the standard (draft) 1 warning on 1 pageThe heading names only the types present, as in serve 2 journeys. Personas
come first, then journeys. Within each, rows run by errors, then warnings, then
notices, then findings, all descending, then by id. A journey whose lifecycle
is draft or archived comes after every live one, and says so after its
title. Columns pad to the widest id and title, and a record with no title
leaves its cell blank. The block has no colour, and prints under -q too. A
baselined finding counts in no row, and a run with none left prints no block.
json prints { summary, impact, pages }. summary has the metadata tool’s shape
(files, passed, failed, errors, and warnings, notices, gitignoreSkipped,
baseline when non-zero). impact is the pretty block’s roll-up, in the same
order, and is omitted when it is empty. Each page carries its file, its format, its
citations, its findings and its notices, the run notices it raised. An
impact entry looks like this:
{ "type": "persona", "id": "devin", "title": "Devin, Platform / CI Engineer", "findings": 3, "errors": 3, "warnings": 0, "notices": 0, "pages": ["docs/src/content/docs/cite/ci/index.mdx", "docs/src/content/docs/cite/reference/cli.mdx"]}| Field | Type | Meaning |
|---|---|---|
type | "persona" or "journey" | Which page field named it. |
id | string | The record’s id, as the record spells it. |
title | string, optional | The record’s title. Absent when the record has none. |
lifecycle | string, optional | A journey record’s lifecycle, when it has one. |
findings | integer | Reported findings on the pages that name it. |
errors, warnings, notices | integer | findings, split by severity. |
pages | string list | Those pages, spelled as pages[].file spells them, so the two join. |
A citation looks like this:
{ "id": "fetch-timeout", "origin": { "kind": "manifest", "file": "docs-citations.yaml", "line": 6 }, "anchor": "claim", "claim": { "lines": "3", "fileLines": "9", "status": "changed", "commitSha": "3f9c2a1e…", "historyAvailable": true }, "source": { "src": "lib/limits.ts:2", "status": "current", "commitSha": "3f9c2a1e…", "historyAvailable": true }}A claim end carries two of the members the source end has. commitSha is the
baseline, as the full hash, and historyAvailable is false where the walk
hit a shallow boundary or its cap. On a source end commitSha is the entry’s
recorded value. A source end adds baselineSha, the full hash its verdict was
read against, whenever its history found one. A source end whose entry names a
checkout adds repo, and its src carries the same prefix. A claim status may read reanchored, and
a reanchored claim with its own lines adds newLines, as a moved one does.
The diff, the commit subjects and the page text are in no machine format.
origin.kind is frontmatter or manifest. anchor is claim, marker or
null. claim.lines is the entry’s body lines and fileLines is where they
are now, and claim itself is null for a bare pin. A moved end adds
newLines, a moved source end newSrc too, and an ambiguous one
candidates. A moved source that changed file names the new path in
newSrc, encrypted where the entry’s own source is. A changed source end
adds newLines when the in-range search found its span. On a record, a pointer claim carries pointer in place of lines, and a
moved one adds newPointer. Its candidates are pointers, and its status
may read missing. The citation’s at is the file line of the value now:
{ "id": "unknown-schema", "origin": { "kind": "manifest", "file": "records/personas/theo.citations.yaml", "line": 13 }, "anchor": "claim", "at": 8, "claim": { "status": "moved", "pointer": "/pains/1", "newPointer": "/pains/0" }, "source": { "src": "research:notes/2026-09-12-contributor.md:4", "repo": "research", "status": "current", "commitSha": "9b50d4c7…" }}A citation a marker anchors adds a marker object. It carries
the marker’s line. When the marker sits inside a paragraph it also carries
a misplaced object, with the paragraph’s unit and the to line the
marker takes:
{ "id": "host-scope", "origin": { "kind": "frontmatter", "file": "docs/limits.md", "line": 9 }, "anchor": "marker", "marker": { "line": 44, "misplaced": { "unit": "41-47", "to": 41 } }, "claim": { "fileLines": "45-47", "newLines": "42-47", "status": "moved" }, "source": { "src": "lib/scope.ts:9", "status": "current" }}A marker claim’s fileLines is the span that held, and its newLines is the
span the pin will cover once the marker moves. Both are file lines: a
marker-anchored claim has no body lines of its own.
Each finding has rule,
ruleId, severity, message,
line, id, src and index, the entry’s position in citations. A
finding on a moved source adds newSrc, and one that sits on a manifest adds
file. The resolved path
of a ciphertext, the diff and the commit subjects are never in it.
The last key of every finding is guidance, the rule behind it. It holds
the id manni:cite, the rule, the rule’s description, and guide,
the link to the rule’s section of the fix page:
"guidance": { "id": "manni:cite", "rule": "source-changed", "description": "The cited lines are not what they were.", "guide": "https://hawkeyexl.github.io/manni/cite/fix/#source-changed"}github prints one annotation per finding, ::warning for a warning and
::notice for a notice:
::error file=docs/limits.md,line=9,title=manni%3Acite/source-changed::fetch-timeout (lib/limits.ts:2): changed since 3f9c2a1, 1 commit. Check the sentence against the source, then run manni cite update --accept --only fetch-timeout.%0Amanni:cite source-changed: The cited lines are not what they were.%0AGuide: https://hawkeyexl.github.io/manni/cite/fix/#source-changed::warning file=docs/limits.md,line=9,title=manni%3Acite/claim-changed::fetch-timeout: the claim at line 9 has changed since 3f9c2a1, 2 commits. Check it still matches the source, then run manni cite update --accept --only fetch-timeout.%0Amanni:cite claim-changed: The sentence was edited since it was pinned.%0AGuide: https://hawkeyexl.github.io/manni/cite/fix/#claim-changed::notice file=docs/limits.md,line=15,title=manni%3Acite/claim-reanchored::retries: the claim at lines 15-17 was reanchored since 3f9c2a1. Its words are unchanged. Run manni cite update.%0Amanni:cite claim-reanchored: The pinned words are unchanged, and only the layout around them moved.%0AGuide: https://hawkeyexl.github.io/manni/cite/fix/#claim-reanchored::warning file=docs/limits.md,line=44,title=manni%3Acite/marker-misplaced::host-scope: the marker at line 44 splits the paragraph at lines 41-47. Its place is above line 41. Run manni cite update to move it.%0Amanni:cite marker-misplaced: A marker line sits inside a paragraph and splits it.%0AGuide: https://hawkeyexl.github.io/manni/cite/fix/#marker-misplacedThe message ends with what to do. The rule line and the Guide link follow it
after %0A, GitHub’s escaped line break.
On a page that names persona or journey records, the message gains one more
sentence before %0A. Each value prints as id (title), or as the bare id
when the record has no title:
::error file=docs/src/content/docs/cite/ci/index.mdx,line=87,title=manni%3Acite/source-changed::summary-counts-apart (src/cite/reporters/pretty.ts:439-448): changed since b9712a2, 1 commit. Check the sentence against the source, then run manni cite update --accept --only summary-counts-apart. Serves devin (Devin, Platform / CI Engineer) and d5 (Gate citations in CI without blocking on prose).%0Amanni:cite source-changed: The cited lines are not what they were.%0AGuide: https://hawkeyexl.github.io/manni/cite/fix/#source-changedA value that names no record is left out. With no record, the message is unchanged.
sarif builds on the metadata tool’s renderer, so the envelope, the rule id
manni:cite/<rule> and the partialFingerprints the baseline matches are
the same. Each rule is described in plain words, in shortDescription and
fullDescription, and its helpUri links the rule’s section of the
fix page. A result’s message is the one the github
annotation carries, with the guidance lines joined by a line break. The same
guidance is in properties.guidance. So a result on a page that names records
carries the Serves sentence too, before the guidance lines. A warning is level
warning and a notice is level note. An excerpt of the rules and one result:
"informationUri": "https://hawkeyexl.github.io/manni/cite/","rules": [ { "id": "manni:cite/source-changed", "shortDescription": { "text": "The cited lines are not what they were." }, "fullDescription": { "text": "The cited lines are not what they were." }, "helpUri": "https://hawkeyexl.github.io/manni/cite/fix/#source-changed" }]{ "ruleId": "manni:cite/source-changed", "level": "error", "message": { "text": "fetch-timeout (lib/limits.ts:2): changed since 3f9c2a1, 1 commit. Check the sentence against the source, then run manni cite update --accept --only fetch-timeout.\nmanni:cite source-changed: The cited lines are not what they were.\nGuide: https://hawkeyexl.github.io/manni/cite/fix/#source-changed" }, "locations": [ { "physicalLocation": { "artifactLocation": { "uri": "docs/limits.md" }, "region": { "startLine": 9 } } } ], "partialFingerprints": { "docmetaViolation/v1": "22c8ce68bee49224" }, "properties": { "guidance": { "id": "manni:cite", "rule": "source-changed", "description": "The cited lines are not what they were.", "guide": "https://hawkeyexl.github.io/manni/cite/fix/#source-changed" } }}junit prints one testcase per page under the classname manni.cite, and a
failure per error-severity finding. The failure’s type is the rule id, and
its message is the annotation’s, followed by the line. An entry a manifest owns
names that file instead. Warnings and notices are never failures:
<?xml version="1.0" encoding="UTF-8"?><testsuites name="manni" tests="1" failures="1" errors="0"> <testsuite name="manni" tests="1" failures="1" errors="0"> <testcase name="docs/limits.md" classname="manni.cite"> <failure type="manni:cite/source-changed" message="fetch-timeout (lib/limits.ts:2): changed since 3f9c2a1, 1 commit. Check the sentence against the source, then run manni cite update --accept --only fetch-timeout. (line 9)"/> </testcase> </testsuite></testsuites>The failure’s message leaves out the annotation’s guidance lines. It keeps the
Serves sentence, before the line:
<failure type="manni:cite/source-changed" message="fetch-timeout (lib/limits.ts:2): changed since 3f9c2a1, 1 commit. Check the sentence against the source, then run manni cite update --accept --only fetch-timeout. Serves maya (Maya, Documentation Engineer) and m5 (Pin a claim and catch it going stale). (line 9)"/>Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 | No file holds an error-severity finding the baseline does not already record. A warning and a notice never move the exit code. So a run of source-moved or marker-misplaced citations exits 0, and so does one of claim-reanchored citations at its default. |
1 | At least one file holds an unbaselined error-severity finding: a source-changed, source-never-true, source-missing or source-moved-ambiguous end, or a marker-orphan, marker-invalid, anchor-invalid, entry-invalid or quote-drift finding. Those are the rules whose default severity is error, and severity: can change the set. |
2 | Operational or usage error. No inputs and no config, no files matched, an unknown --format or --as, or stdin without --as. Also a missing -c file, a missing --baseline file, or a --root that does not exist. Also a cited repo directory that does not exist, a malformed --repo, or a config key the tool does not know. So is a Markdown or MDX page whose opening frontmatter fence has no close, because its citations can’t be read. |
Only an error moves the exit code. So a job that fails on claim-changed at
error fails on fewer claims than it did, and never on more. A rewrap or a
hoisted marker reads claim-reanchored instead.
Baselines
Section titled “Baselines”A baseline records the findings that exist today so that a run fails only on
findings that are new. It is the metadata tool’s
ratchet, with its own default file,
.manni-cite-baseline.json. So a cite --write-baseline can never erase the
metadata backlog, and the reverse cannot happen either.
manni cite check --write-baseline # record today's findings, then exit 0manni cite check --baseline # from now on, fail only on new onesThe fingerprint is stable across a move and across a line shift. Its
subject is the entry’s id, or its source.integrity when it has none, and
neither changes when update rewrites lines: 2 to lines: 4. It changes
only when the source pin is re-minted, which is the act that resolves the
finding. The claim’s pin is never the subject, so accepting a changed claim
does not reopen a baselined finding. A --baseline file that does not exist
is exit 2, with the remedy named: record one with --write-baseline, or drop
the flag.
Where sources resolve from
Section titled “Where sources resolve from”A source.file is a path relative to one root, and the root is settled
once per run. It is --root if given, relative to the working directory. Else
it is config root:, relative to the config file. Else it is the nearest git
root above the working directory. Else it is the working directory itself,
with a notice on stderr. Sources are then resolved through tracked files
only, from git ls-files, under a realpath containment check. So a symlink
out of the root is missing rather than read. Without git, a walk that
follows no symlinks stands in.
The root may be another checkout. That is the two-checkout layout: the pages
live in the docs repository, so SARIF locations resolve, and the sources are
reached through --root ../code. See Public docs, private
code.
An entry with source.repo resolves in that checkout instead, under the same
two rules. Config cite.repos maps each name to a directory, relative to the
config file, and --repo name=dir maps one for a run, relative to the working
directory. --repo wins for its name. A run opens only the checkouts its
entries name, and a missing one stops it (exit 2). An entry naming a repo
nobody declared is source-missing. See
more than one checkout.
Examples
Section titled “Examples”manni cite check docs/ # walk a directorymanni cite check # every configured collectionmanni cite check --collection guides # one of themmanni cite check "docs/**/*.md" -f github # CI annotationsmanni cite check -f sarif > cite.sarif # code scanningmanni cite check --show-diff docs/limits.md # see what changed, and since whenmanni cite check --baseline # fail only on new findingsmanni cite check --no-check-sources docs/ # public docs repo: page-side rules onlyMANNI_ENCRYPTION_KEY=$SECRET manni cite check --root ../code --reveal docs/manni cite check --repo sdk=../../oss/sdk # a local layout that differs from cite.reposcat page.md | manni cite check - --as markdown # read from stdincite add
Section titled “cite add”Mint a citation for a source range and write it to a page. add reads both
ends, hashes each under the rule, and records HEAD as source.commit-sha.
The page’s own lines are the claim, so nothing is copied into the entry. It
writes the entry to the page’s frontmatter, or to the
sidecar manifest when a
collection declares one. Where the entry goes follows the config, not a flag.
It refuses to write a citation it can already see is broken. That is a line
past the end of the page, a line in the frontmatter, or a range longer than
5,000 lines. It is also a block
that does not reproduce the range, or an id the page already uses. A claim
whose lines hold no text is refused too, a blank or a fence line. So is a
duplicate of an entry the page already has for those lines and that source.
manni cite add <page>[:L|:L1-L2] <src> [options]Arguments
Section titled “Arguments”| Argument | Description |
|---|---|
<page>[:L|:L1-L2] or <record>#<pointer> | The page to cite from, relative to the working directory, with the claim. page:L or page:L1-L2 names body lines as an editor numbers them. Only a trailing :L or :L1-L2 is read as lines, so a path that carries a colon keeps it. record#/pointer names a value on an audience, persona or journey record, such as theo.md#/pains/0. The text after the first #/ is the pointer, and it is read only when the file before it is a record. On any other page the whole argument is a path. Without lines or a pointer, the entry is a bare pin. - reads the page from stdin and writes the rewritten page to stdout, with the report on stderr; that needs --as, and -:L carries lines. |
<src> | The source: path, path:L or path:L1-L2, relative to the root. Or repo:path with the same line forms, for a checkout in cite.repos or --repo. Or an encrypted ~source with the same line forms. A bare path pins the whole file. L2 before L1, a range longer than 5,000 lines and a line past the end of the file are refused (exit 2). So is a path that no checkout tracks, and an encrypted source that the current key can’t decrypt. A plain path the root lacks is looked up in each declared repo. When one repo tracks it the entry gets that repo, and when several do add refuses. |
Options
Section titled “Options”| Option | Argument | Default | Description |
|---|---|---|---|
--id | <id> | n/a | A kebab-case name for the entry, unique within the page. What a marker names, and what update --only and every finding are reported under. Required with --marker; optional otherwise, including on a bare pin. An id another entry uses is refused. |
--marker | n/a | off | Write a cite <id> marker instead of storing claim lines, and pin the text it anchors. A marker that already names the id needs no flag, and stays as it is. When a marker already anchors that text, the id joins its list instead, and no line is written. The marker joined is the nearest one. That is the marker on the text’s own line when there is one, else the last marker line above it. The id goes last, so nothing another citation wrote is reordered. A join adds no line, so no claim below it moves. When the nearest marker holds 25 ids, or names an entry of the other kind, quote or plain, a new marker line is written instead. A written marker goes directly above the paragraph or fenced block that holds the page lines, below any markers already there. It takes the indentation of the first line of that paragraph or block, so a marker in a list item stays in the item. In markdown and MDX, above a list item that follows another, it is indented to the item’s text, which keeps the list whole. In asciidoc it stays at column 0, where a comment must start. The lines must all sit in that one paragraph or block, of at most 5,000 lines. A heading, a section title, an adornment and a lone tag are each a block of their own. Above a line that follows one, the marker goes below it. Entries whose claim lines sit below a written marker have them moved down in the same write. The marker travels with its sentence. Needs the page lines and --id. |
--quote | n/a | off | The page lines are a fenced block, fences included, that reproduces the cited lines. The block is pinned whole, and its contents are compared with the source, so a quoted snippet that drifts is quote-drift even while the pin holds. Needs the page lines, and a format with a fence the tool can locate: markdown, mdx or asciidoc. |
--encrypt | n/a | off | Write source.file encrypted, with an hmac-sha256- pin, so the page carries neither the path nor a verifier for the line. An available encryption key, a top-level encryptionKey: or MANNI_ENCRYPTION_KEY, encrypts every add without the flag. With no key, on a terminal, add offers to generate one and write it to manni.config.yaml. Off a terminal, or on a no, it refuses (exit 2) and writes nothing. |
--no-commit-sha | n/a | on | Do not record HEAD as the entry’s commit-sha. Without it, a later check can still say the lines changed, but not since when, nor whether the pin was ever true. It cannot follow the source into another file either, nor name the span a changed range now covers. Off a git checkout no commit is recorded either way. |
--dry-run | n/a | off | Print the unified diff of the page and write nothing. The report line goes to stderr. |
--as | <format> | n/a | Force the page’s format. Required with -. |
--root | <dir> | n/a | The directory <src> resolves from, exactly as on check. |
--repo | <name=dir> | [] | A checkout <src> may name with a repo: prefix, or add may find a plain path in, as name=dir. Repeatable, exactly as on check. |
-c, --config | <path> | discovered | Path to a config file, read for root, repos, the encryption key, and the citations manifest each collection declares. |
--no-config | n/a | off | Ignore any discovered config file. The entry goes to frontmatter. |
A page argument with no lines writes a bare pin: a source block and
nothing anchoring it. That is source-of-truth at line granularity: this page
rests on these lines, say so when they change. --id is allowed on a bare
pin, so update --only can name it. --marker and --quote are not, because
each needs page lines.
A marker can come first
Section titled “A marker can come first”A page can name a claim before anything cites it. Write <!-- cite retries -->
above the sentence, and check reports marker-orphan until an entry has that
id. Then add with that --id and a line the marker anchors adopts the marker.
The entry pins all the text the marker anchors, and no line of the body changes.
--marker makes no difference here, because the page has already said how this
id is anchored. Claim lines beside a marker are anchor-invalid, so a marker’s
entry is the only one add can write.
A line outside what the marker anchors is refused, and so is a pointer. The
refusal names the marker’s line and the lines it anchors. With several markers
naming the id, the first is the one that counts, as it is on check. A bare pin
asks for no claim, so it writes a source-only entry beside the marker as usual.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 | The citation was written, or printed under --dry-run. The report line says what was added, where, and which line each end is pinned at. |
2 | A refusal. The page or the source cannot be read, or a --root does not exist, or a range is invalid or out of range. Or the page lines are in the frontmatter, or they hold no text, or no block reproduces the range. Or no checkout tracks the source, or several repos track a plain path, or a repo: prefix names no declared repo. Or the id is taken, or a marker naming the id anchors other text. Or the entry duplicates one the page has, or the flags do not compose on this page’s format. Or --encrypt needs a key and none is available or created. Nothing is written. |
There is no exit 1. add either writes a citation it has verified, or it
does not write.
add does not move a marker it did not write, so it refuses two writes a
misplaced marker would make wrong. A misplaced marker anywhere else on the
page does not stop it. Neither does one naming the id being added. add adopts
that marker where it sits, and update can move it once it names an entry.
| Invocation | stderr |
|---|---|
--marker into a paragraph a misplaced marker splits | manni: docs/limits.md:46 is in the paragraph at lines 41-47, which the marker at line 44 splits. Run manni cite update first. |
| Claim lines whose range holds a misplaced marker’s line | manni: docs/limits.md:42-46 holds the marker at line 44, which splits its paragraph. Run manni cite update first. |
Examples
Section titled “Examples”manni cite add docs/limits.md lib/limits.ts # a bare pin over the whole filemanni cite add docs/limits.md:9 lib/limits.ts:2 --id fetch-timeoutmanni cite add docs/limits.md:30 lib/limits.ts:8-12 --id timeouts --markermanni cite add docs/limits.md:30 lib/limits.ts:9 --id backoff --marker # joins the marker theremanni cite add docs/limits.md:30 lib/limits.ts:5 --id retries # adopts the marker that names retriesmanni cite add docs/limits.md:14-18 lib/limits.ts:1-3 --quote # the block that reproduces itmanni cite add docs/limits.md:9 lib/limits.ts:2 --dry-run # preview the diffmanni cite add docs/personas/theo.md#/pains/0 notes/2026-09-12.md:3-4 --id terse-messages # a value on a recordMANNI_ENCRYPTION_KEY=$KEY manni cite add docs/limits.md:9 lib/limits.ts:2 --root ../codemanni cite add docs/limits.md:17 sdk:src/client.ts:2 --id retries # a source in the sdk checkoutcat page.md | manni cite add -:9 lib/limits.ts:2 --as markdown > out.mdThe report line names both pins so a reviewer can read them off the terminal. A pin is abbreviated to eight hex digits, a commit to seven, and the first pinned source line is quoted beside the source:
docs/limits.md: added fetch-timeout to frontmatter (claim at line 9, sha256-c41f09aa…; source lib/limits.ts:2 "export const FETCH_TIMEOUT_MS = 10_000;", sha256-78af1d33…, 3f9c2a1)docs/limits.md: added a bare pin to frontmatter (source lib/limits.ts, sha256-aebba92f…, 3f9c2a1)docs/limits.md: added timeouts to frontmatter; marker at line 30, claim pinned at lines 31-34 (sha256-c41f09aa…; source lib/limits.ts:8-12 "export const TIMEOUTS = {", sha256-78af1d33…, 3f9c2a1)docs/limits.md: added backoff to frontmatter; joined the marker at line 30, claim pinned at lines 31-34 (sha256-c41f09aa…; source lib/limits.ts:9 "const BACKOFF = 2;", sha256-78af1d33…, 3f9c2a1)docs/limits.md: added retries to frontmatter; adopted the marker at line 30, claim pinned at lines 31-34 (sha256-c41f09aa…; source lib/limits.ts:5 "export const RETRIES = 3;", sha256-e9f5bdf9…, 3f9c2a1)docs/limits.md: added an entry to frontmatter (claim lines 14-18, a block that reproduces lib/limits.ts:1-3)records/personas/theo.md: added terse-messages to records/personas/theo.citations.yaml:3 (claim at /pains/0, sha256-c1ac46fc…; source research:notes/2026-09-12-contributor.md:3 "- The error named the field and said nothing about the fix.", sha256-13745edd…, 9b50d4c)The line says adopted the marker when a marker already named the id. It says
joined the marker when the id went into a marker already there, and marker
when a marker line was written.
The quoted line is what the range caught, so a mis-typed range shows up at write time. Its whitespace is collapsed and it is cut to sixty characters. A whole-file pin quotes no line, and neither does an encrypted source, which no output spells in the open.
The claim’s line in that first message is a file line, and it is where the sentence sits after the write. On a page whose entry went to the frontmatter, the new entry pushes the body down. So the number is larger than the one you typed, and the stored body line did not move.
One notice goes to stderr while the entry is still written. It fires when the claim’s text appears somewhere else on the page. A later move of that sentence could not be told from its copies. It names up to three other locations, then counts the rest:
manni: fetch-timeout: the claim's text also appears at lines 40-41, so a move would be ambiguous. Use --marker, or pin more lines.Every refusal names what is wrong and writes nothing:
manni: docs/limits.md:40 is past the end of the page (32 lines).manni: Invalid range "lib/limits.ts:1-5001": it spans 5001 lines, more than 5000.manni: docs/limits.md:2 is in the frontmatter. A claim is body text.manni: docs/limits.md:14 is blank.manni: docs/limits.md:14 is a fence line, not claim text.manni: docs/limits.md:14-18 holds no claim text.manni: --marker needs the page lines to anchor: docs/limits.md:L.manni: --marker needs --id: the marker names the entry.manni: docs/limits.md:30-40 runs past the paragraph at lines 31-34. A marker anchors one paragraph.manni: docs/limits.md:31 is in a paragraph at lines 31-5032 that spans 5002 lines, more than 5000. A marker anchors the whole paragraph.manni: docs/limits.md:31 is inside the claim of fetch-timeout (lines 28-34). A marker there would change its pin.manni: docs/limits.md:30 is a marker line. A marker there would change its pin.manni: --quote needs the block's lines: docs/limits.md:L1-L2.manni: docs/limits.md:9 is not a fenced block, so it cannot be a quote.manni: The block at docs/limits.md:14-18 does not reproduce lib/limits.ts:4-6.manni: docs/limits.md already has an entry fetch-timeout.manni: docs/limits.md already has a marker retries at line 30, which anchors lines 31-34. Cite a line there, or move the marker.manni: docs/limits.md already has a marker retries at line 30, which anchors nothing. Move it above the text it cites.manni: The marker at line 30 also names timeouts, and a quote entry cannot share a marker with a non-quote entry. Give the quote its own marker.manni: docs/limits.md already has an entry for lines 9-10 and lib/limits.ts:2 (fetch-timeout).manni: docs/limits.md already has a bare pin for lib/limits.ts (/citations/0).On a record, the frontmatter refusal names the value’s pointer, and a pointer has refusals of its own:
manni: records/personas/theo.md:8 is in the frontmatter. Cite the value there as records/personas/theo.md#/pains/0.manni: records/personas/theo.md:1 is in the frontmatter, and starts no value. Cite a value as records/personas/theo.md#/<key>.manni: records/personas/theo.md#/pains/9 names no value in the page's metadata.manni: records/personas/theo.md#/citations/0 is a citation, not a claim.manni: records/personas/theo.md#/owner holds an encrypted value. A claim's pin is public, so cite a plain value.manni: records/personas/theo.md#/pains is empty.manni: Invalid pointer "/pains/0~2": write ~ as ~0 and / as ~1.manni: --marker anchors body text, so it takes page lines, not a pointer.manni: records/personas/theo.md already has an entry for /pains/0 and research:notes/2026-09-12-contributor.md:3 (terse-messages).On a page that is not a record, the whole argument is a path, so
cite add page.md#/title … reads manni: File not found: "page.md#/title".
A duplicate is named by the existing entry’s id, or by /citations/N when it
has none. The source is compared as the entry spells it, so an encrypted
source compares as its ciphertext. --quote is the exception to the no-text
rule, because a quote is fences and content by design.
cite update
Section titled “cite update”Rewrite moved citations in place; with --accept, re-mint changed ones.
update works on both ends. A moved source end is re-minted over the
range it moved to, and its source.file follows the pin into another file.
A moved claim end has its claim.lines spliced. A reanchored claim end is
re-pinned, and its lines are rewritten where it has lines, both without
--accept. The splice is textual, so
the rest of the frontmatter, its comments and quoting included, is untouched.
An entry in a sidecar manifest is spliced there, and each manifest is written
once per run. An encrypted source stays encrypted. update needs the sources,
so --no-check-sources and config checkSources: false are refused (exit 2).
manni cite update [paths...] [options]Arguments
Section titled “Arguments”| Argument | Description |
|---|---|
[paths...] | Files, directories, or globs to update. Use - to read a page from stdin, with --as. Optional. Without paths the run covers every configured collection, or the ones --collection names. |
Options
Section titled “Options”| Option | Argument | Default | Description |
|---|---|---|---|
--collection | <name> | every collection | Run over a configured collection, exactly as on check. Repeatable, one name per occurrence, never comma-separated. |
--ext | <list> | supported extensions | Comma-separated extensions used when expanding directories and globs. |
--exclude | <glob> | n/a | Glob to exclude from directory and glob walks. Repeatable. When the run reads from collections, their exclude: globs apply as well. |
--as | <format> | n/a | Force an input format for every input. Required when reading from stdin. |
-c, --config | <path> | discovered | Path to a config file. |
--no-config | n/a | off | Ignore any discovered config file. |
--allow-empty | n/a | off | Treat zero matched files as success instead of an error (exit 2). |
--no-gitignore | n/a | on | Update files .gitignore covers as well. Overrides config respectGitignore:. |
--no-check-sources | n/a | on | Refused. update needs the sources to know where an entry moved to. |
--root | <dir> | n/a | The directory source.file paths resolve from, exactly as on check. |
--repo | <name=dir> | [] | A checkout an entry’s source.repo may name, exactly as on check. update never changes an entry’s repo. |
--accept | n/a | off | Re-mint every changed and never-true end at HEAD: a new integrity, and a new commit-sha where the source had one. For a source the report prints the old and the new pin. For a claim it prints the text it re-pinned, so the acceptance is visible in the log as well as in the diff. A claim whose target line shares no sentence with the claim at its baseline is refused, reported, and exits 1, unless --only names it. |
--recommit | n/a | off | Re-record commit-sha to the commit where the pinned lines arrived on the history of HEAD. It acts on entries whose recorded commit does not contain the lines they pin, or is outside that history. Only that one field changes: file, lines and integrity are left as they are. It reads only entries whose source is current, and re-records only where HEAD holds the pinned lines. A commit that holds them at other line numbers counts as holding them, so a pin whose lines drifted is left alone. In a full clone, a commit git cannot read is outside the history, so it is re-recorded. A shallow clone cannot tell, so it leaves such a commit alone and says so once on stderr. Needs git (exit 2 without it). |
--only | <id> | n/a | Limit the rewrite to entries with this id. Repeatable. An entry without an id cannot be named, so give a bare pin an --id when you want to reach it this way. A marker’s move still shifts the claim lines below it, whichever entries own them. |
--dry-run | n/a | off | Print the diffs and the report; write nothing. |
-f, --format | <pretty|json> | pretty | Output format. json prints the run as { pages, rewritten, skipped, manifests, exitCode }, where manifests lists each rewritten manifest’s file, diff and written, and appears only when a manifest changed. Each page carries its file, its rewritten list, its refused list, its skipped findings, its diff, and whether it was written. A rewrite has id, index, line, end, reason, status, from and to. end is claim, source or marker; reason is moved, accepted, re-anchored, shifted or recommitted. Read the typed pairs rather than from and to: fromLines and toLines carry lines, fromPin and toPin carry pins, and fromCommit and toCommit carry commits. A re-anchored claim adds lines, the span that held, and newLines, the span now pinned. A claim re-pinned because its words held reads status: "reanchored", and carries commitSha, the baseline. An accepted claim adds at and text, and an accepted source adds src, plus commitSha when the entry records one. A re-committed source reads reason: "recommitted" with status: "current", and carries src, fromCommit, toCommit and because. because is not-contained when the recorded commit did not hold the pinned lines, and not-in-history when it held them outside the history of HEAD. A claim a blanket --accept refused sits on the page’s refused list, beside rewritten, with reason: "replaced". One --only named instead reads status: "replaced" on an ordinary accepted rewrite. An unknown value is an error (exit 2). |
--recommit is tidying, not repair. check derives each source’s baseline
from git, so a recorded commit that a squash merge dropped changes no verdict.
The commit it records is the oldest in the newest unbroken run of commits that
hold the lines. After a squash merge that is the squash commit, so a second
run rewrites nothing.
What is rewritten, and what is not
Section titled “What is rewritten, and what is not”| End and status | Without --accept | With --accept |
|---|---|---|
source-moved, same file | Re-minted over the new range: source.lines rewritten to it, and source.commit-sha set to HEAD where the entry already has one. Where the entry records a commit and git cannot give one, nothing is written and the finding says so. | The same. |
source-moved, new file | source.file and source.lines rewritten, and a whole-file pin has only its file. source.commit-sha is left alone, because the pin was minted at that commit and still holds. | The same. |
source-changed, source-never-true | Skipped, and reported. | source.integrity re-minted, and source.commit-sha set to HEAD where the entry already has one. Where the finding named a span, the re-mint covers that span and source.lines is rewritten to it. |
source-moved-ambiguous | Skipped. The tool will not guess which copy. Widen the range and re-add. | The same. |
source-missing | Skipped. A path that is gone cannot be rewritten. add is the remedy. | The same. |
claim-moved | claim.lines rewritten to the new body lines. A pointer claim has claim.pointer rewritten to the new pointer. | The same. |
claim-reanchored | claim.integrity re-minted over the text the anchor covers now. An entry with claim lines has them rewritten to the run its words sit on. | The same. |
claim-changed | Skipped, and reported. | claim.integrity re-minted over the paragraph or fenced block now at the claim’s first line. A claim sharing no sentence with its baseline is refused. A pointer claim is re-pinned over the value now at its pointer, under the same guard. |
claim-moved-ambiguous | Skipped. The same text sits in more than one place. | The same. |
claim-missing | Skipped. The pinned value is gone from the record, and remove is the remedy. | The same. |
current | Left alone. | Left alone. |
marker-misplaced, the claim current or moved | The marker moves to where cite add --marker writes it, and claim.integrity is re-pinned over the paragraph it now anchors. | The same. |
marker-misplaced, the claim changed | Skipped. The marker stays, and both findings are reported. | The marker moves, and the claim is re-pinned over the paragraph. |
marker-misplaced on a quote: true entry | The marker moves above the block it anchors. The block’s pin does not change, so nothing is re-pinned. | The same. |
A marker moves without a flag, because the pin proves the text is intact. A
moved comment line changes no rendered text either, except to rejoin a
paragraph. Run --dry-run first on a repository that has never been updated.
Every claim-lines entry the move pushed along has its claim.lines rewritten
in the same write, so no claim-moved is left for the next run. The page is
written once, with every movable marker relocated. A stacked run can split:
one marker moves while the next stays, and the page then carries a smaller
misplaced run until update --accept settles it.
Four markers stay put whatever --accept says, and each is reported as
skipped:
| Case | Message |
|---|---|
The claim holds nowhere, and --accept was not given | robots: the marker at line 70 stays, because its claim changed since it was pinned. update --accept moves it and re-pins. |
| A claim-lines entry whose range holds the marker’s line, or reaches the line it would move to | timeouts: the marker at line 80 stays, because moving it would change the claim of retries (lines 77-81). |
| An orphan, repeated or invalid marker, which names no entry to check | the marker at line 43 stays, because it names no entry update can check. |
An anchor-invalid entry, which has claim lines and a marker both | fetch-timeout: the marker at line 43 stays, because the entry also has claim lines. Keep one. |
A marker outside --only is not reported, as an entry outside --only is
not. One thing crosses it. A marker that moves pushes every claim-lines
entry below it along, and those lines are rewritten whether or not --only
names the entry. The page has already moved under them. An entry whose
pinned text moved elsewhere is left for a run that names it.
update --accept re-pins the unit at the claim’s first line. That is the
paragraph there, the fenced block there, the lines inside a fenced block
there, or the table rows there. A line whose first non-blank character is |
is a table row, so a table indented inside a list item counts.
A paragraph that gained a sentence gained it as part of the same statement, so a claim over a paragraph grows with it. A table row is a statement of its own. A claim keeps the number of rows it was minted over. One recorded line re-pins one row, and five re-pin five. When the table no longer reaches that far, the claim is skipped rather than guessed at.
Lines inside a fenced block work the way table rows do. A claim minted over three of them re-pins three, at the line it starts on, and never widens to the block around them. A claim on the fence’s opening line still re-pins the whole block. When the claim’s lines reach the closing fence, or run past it, the claim is skipped.
It skips a claim it cannot re-pin, and names the reason beside the finding:
| The claim’s first line | Message |
|---|---|
| Is blank | Not re-pinned: the line is blank. |
| Holds lines that reach out of the fenced block | Not re-pinned: the claim's lines are not all inside one fenced block. |
| Starts no paragraph | Not re-pinned: the line does not start a paragraph. |
| Is outside the page body | Not re-pinned: the line is outside the page body. |
| Opens fewer rows than the claim covers | Not re-pinned: the table no longer holds every row the claim covers. |
Opens no fenced block, under quote: true | Not re-pinned: quote: true, and the line no longer opens a fenced block. |
| Opens a unit over 5,000 lines | Not re-pinned: the paragraph spans 6000 lines, more than 5000. |
| Holds a cite marker | Not re-pinned: that line now holds a cite marker, not claim text. |
| Opens nothing but table rules | Not re-pinned: that line is a table rule, not claim text. |
| Holds text the page repeats | Not re-pinned: the text there also appears at line 16, so a pin cannot identify it. Re-run with --only dup to accept it anyway. |
The last three ask what the line holds, not what the claim said. --only
naming the entry bypasses them and re-pins the line as it reads.
A sentence that was reworded and moved has left that line. --accept then
re-pins whatever now starts there, so read the printed text. If it is not the
sentence, cite add the sentence again.
Two guards bound an accepted claim. The first refuses a line that was replaced rather than edited. It reads the claim at its baseline against what the stored first line holds now, under two tests. Either one refuses.
The sentence test splits both texts into sentences, with whitespace collapsed and case dropped. An empty intersection refuses the claim.
The overlap test counts words. It takes the distinct words of each text, case dropped, and divides the size of their intersection by the baseline’s own count. A line holding under half the claim’s words is refused, and the report prints the share. Two rows of one table end alike often enough to share a sentence while saying other things, which is the drift this catches.
Either refusal is reported and exits 1. So a blanket --accept blesses an
edit to a paragraph, and hands a replaced line back to cite add. With no
baseline available, --accept behaves as it did before. The second guard is
the report’s. A re-pin over a unit wider than the stored lines still happens,
and the line names both spans:
docs/limits.md: fetch-timeout claim at line 9 skipped: that line now holds different text than the claim at 3f9c2a1. Re-add it with cite add.docs/limits.md: show-diff claim at line 17 skipped: that line now holds text sharing 31% of the claim's words at 3f9c2a1. Re-add it with cite add.docs/limits.md: fetch-timeout claim lines 9 -> 9-12 re-pinned (changed; now "The fetch timeout is 30 seconds.")A refused claim counts toward the run’s skipped total. The quoted text covers the re-pinned lines, capped at 200 characters with an ellipsis.
--only naming the entry bypasses the first guard. Naming an id is the
judgement the guard asks for. A reviewer who has read the claim and its source
re-pins a reworded sentence without a remove and an add. Each id in --only
names its own entry, so each bypasses. The line says replaced rather than
changed, which is how the log tells a bypass from an edit:
docs/limits.md: fetch-timeout claim at line 9 re-pinned (replaced; now "Retries are capped at three.")A pointer claim on a record meets the word test alone, because a value is most often one sentence and any edit changes it. Its lines name the pointer, and the log prints the value now pinned:
records/personas/theo.md: terse-messages claim at #/pains/1 re-pinned (changed; now "Error messages are terse and name no fix.")A value that keeps under half the pinned value’s words is skipped with
that value now holds text sharing <n>% of the claim's words.
How far the first guard reaches
Section titled “How far the first guard reaches”The guard refuses a claim whose line shares no sentence with the claim at its
baseline, or under half its words. It needs a baseline to judge at all. A pin
update minted has none until a commit holds it, so an accept run straight
after an update is unguarded. --dry-run shows every claim a run would re-pin, with its text,
before anything is written.
An anchoring or entry finding (marker-orphan, anchor-invalid, an invalid
entry) is never rewritten, and is reported as skipped.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 | Everything the run could rewrite was rewritten, or previewed under --dry-run, and nothing was skipped with an error-severity finding. |
1 | Something was skipped with an error-severity finding: a source-changed end without --accept, a source-moved-ambiguous one, a source-missing one, or an anchoring or entry rule at error. Also a claim a blanket --accept refused, whose line was replaced rather than edited; --only naming it accepts it instead. A skipped warning or notice, such as claim-changed or marker-misplaced without --accept, leaves the exit code at 0. Work is left undone, which is what fill’s exit 1 means too. |
2 | Operational or usage error, including --no-check-sources, checkSources: false, an unknown --format, and everything check exits 2 for. Also a write that failed after another file in the run was already written. Every file the run wrote is restored first, pages and manifests alike, and the message names them. manni: docs-citations.yaml could not be written (EACCES). docs/limits.md was restored. A file that could not be restored is named too, and the message says it holds an intermediate state. |
Examples
Section titled “Examples”manni cite update docs/ # rewrite moved entriesmanni cite update --collection guides # one configured collectionmanni cite update --dry-run docs/ # see the diffs firstmanni cite update --accept --only fetch-timeout docs/limits.mdmanni cite update --accept --dry-run -f json docs/ # what would be re-minted, as dataThe report names each rewrite, the end it was on, and the reason:
docs/limits.md: retries claim line 14 -> 16 (moved)docs/limits.md: fetch-timeout source lib/limits.ts:2 -> lib/config/limits.ts:4 (moved)docs/limits.md: page-size source lib/limits.ts:5 re-pinned at 9b1e04c (changed; sha256-11aa… -> sha256-22bb…)docs/limits.md: retry-cap source lib/limits.ts:20-32 -> lib/limits.ts:20-35 re-pinned at 9b1e04c (changed; sha256-33cc… -> sha256-44dd…)docs/limits.md: fetch-timeout claim at line 9 re-pinned (changed; now "The fetch timeout is 30 seconds.")docs/limits.md: timeouts claim at marker line 30 re-pinned (changed; now "Timeouts are per request.")docs/limits.md: host-scope marker line 44 -> 41 (misplaced)docs/limits.md: host-scope claim re-pinned over lines 42-47 (moved; was lines 45-47, lines 42-44 newly pinned)docs/limits.md: page-order claim lines 41-42 -> 44-45 (shifted by a marker)7 citations rewritten in 1 file, 0 skippedOn a record, a moved pointer claim is rewritten, and a missing one is skipped with its remedy:
records/personas/theo.md: terse-messages claim #/pains/1 -> #/pains/0 (moved)records/personas/theo.md: unknown-schema ↕ skipped: unknown-schema: the value pinned at #/pains/0 is gone. Run manni cite remove --only unknown-schema. If the claim still stands, cite add it at its pointer now.1 citation rewritten in 1 file, 1 skippedA claim whose words held names the baseline they held against, and a claim-lines entry names the run it moved onto:
docs/limits.md: retries claim at lines 15-17 re-pinned (reanchored; words unchanged since 3f9c2a1)docs/limits.md: page-size claim lines 21 -> 21-23 re-pinned (reanchored; words unchanged since 5d1e0b7)The summary counts citations, so an entry whose marker moved and whose claim was re-pinned counts once. A re-pin over the paragraph can cover lines the old pin did not. The report names them, so the widening is in the log as well as in the diff. A pin that only lost marker lines says that instead:
docs/limits.md: host-list claim re-pinned over lines 59-61 (moved; was lines 58-61, which held a marker line)A claim a marker anchors reads at the marker’s line, as check reports it.
One that stores its own lines reads at the claim. Under -f json the at
field of an accepted claim is the first file line of the claim’s text, whichever
anchors it.
cite remove
Section titled “cite remove”Remove the citations you name, markers and all. The entry goes out of the
page’s frontmatter, or out of the
sidecar manifest that owns the
page’s citations, wherever add put it. The id goes out of every marker
naming it, and a marker that loses its last id loses its whole line. The
claims below each deleted marker line move up with the text they pin. That is
the inverse of the shift add --marker performs, and it happens in the same
write, so a removal leaves no finding of its own behind.
--only is required. remove never takes every citation off a page.
manni cite remove [paths...] --only <id> [options]Arguments
Section titled “Arguments”| Argument | Description |
|---|---|
[paths...] | Files, directories, or globs to remove from. Use - to read a page from stdin, with --as; the rewritten page goes to stdout and the report to stderr. Optional. Without paths the run covers every configured collection, or the ones --collection names. |
Options
Section titled “Options”| Option | Argument | Default | Description |
|---|---|---|---|
--only | <id> | none, required | The entry to remove, named by its id, or by its pointer /citations/N when it has none. Repeatable, one id per occurrence, never comma-separated. A run with no --only is a refusal (exit 2). |
--collection | <name> | every collection | Run over a configured collection, exactly as on check. Repeatable, one name per occurrence, never comma-separated. |
--ext | <list> | supported extensions | Comma-separated extensions used when expanding directories and globs. |
--exclude | <glob> | n/a | Glob to exclude from directory and glob walks. Repeatable. When the run reads from collections, their exclude: globs apply as well. |
--as | <format> | n/a | Force an input format for every input. Required when reading from stdin. |
-c, --config | <path> | discovered | Path to a config file, read for the collections and the manifest each one declares. |
--no-config | n/a | off | Ignore any discovered config file. Entries are then the page’s own frontmatter. |
--allow-empty | n/a | off | Treat zero matched files as success instead of an error (exit 2). |
--no-gitignore | n/a | on | Remove from files .gitignore covers as well. Overrides config respectGitignore:. |
--dry-run | n/a | off | Print the diffs and the report; write nothing. |
-f, --format | <pretty|json> | pretty | Output format. json prints the run as { pages, removed, manifests }, where removed is the total and manifests lists each rewritten manifest’s file, diff and written. Each page carries its file, its removed list, its diff, and whether it was written. A removal has id, index, origin and markerLines; a marker that named no entry carries id and markerLines alone. An unknown value is an error (exit 2). |
What goes, and what stays
Section titled “What goes, and what stays”| What | What happens to it |
|---|---|
| The entry | Removed from the page’s frontmatter, or from the manifest that owns the page’s citations. An empty list takes the citations key with it, so no page keeps a key with nothing under it. |
| Its markers | The id goes out of the list of every marker naming it. A marker that named nothing else is deleted, the whole line. A marker that keeps an id keeps its line. |
| The claims below | Their claim.lines move up one line per deleted marker above them, in the same write. A marker that only lost a word moves nothing. |
| Every other entry | Untouched, comments and quoting included: the entries that stay are re-emitted as they were written. |
| An id with a marker and no entry | The marker alone is removed, which is how a marker-orphan is cleared. |
| A broken entry | Removed like any other. remove reads the entries as they are written, so an entry the schema refuses is still reachable by its id or its pointer. |
Each page and each manifest is written once per run, and nothing is written
until every --only has been found. So a run either removes what you named
or it leaves the tree alone.
An id names one entry per page, so it does the same thing over a corpus as
over one page. A pointer names a position instead, so
--only /citations/0 over a collection removes the first entry of every page
that has one. Prefer an id where the entries have one.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 | Everything you named was removed, or previewed under --dry-run. The report names each removal and the markers the id came out of. |
2 | A refusal. No --only, or an id and a pointer that name nothing, or a pointer past the last entry of a page. Or a marker sharing its line with text, or a marker line inside another entry’s claim. Plus everything check exits 2 for, including no inputs and no config, and a missing -c file. Nothing is written. |
There is no exit 1. Like add, remove either does what you named or it
does not write.
Examples
Section titled “Examples”manni cite remove docs/limits.md --only fetch-timeoutmanni cite remove docs/ --only fetch-timeout --only retries --dry-runmanni cite remove --collection site --only /citations/3 -f jsonmanni cite remove docs/ --only fetch-timeout --ext md,mdx --exclude "**/drafts/**"cat page.md | manni cite remove - --as markdown --only fetch-timeout > out.mdThe report names each removal, where the entry was kept, and the markers that went with it:
docs/limits.md: removed fetch-timeout from frontmatter, and its marker at line 30docs/limits.md: removed /citations/3 from site.metadata.yaml:412docs/limits.md: removed the marker fetch-timeout at line 30, which named no entry1 citation removed from 1 fileUnder --dry-run the footer says what the run would have done, and the diffs
come first. Every refusal names what is wrong and writes nothing:
manni: remove needs --only <id>; it never removes every citation on a page.manni: docs/limits.md has no entry or marker fetch-timeout.manni: none of 12 pages has an entry or marker fetch-timeout.manni: "/citations/9" is past the last entry of docs/limits.md (4 entries).manni: the marker fetch-timeout at docs/limits.md:12 shares its line with text; remove it by hand.manni: docs/limits.md:30 is inside the claim of retries (lines 29-33). Removing the marker there would change its pin.cite history
Section titled “cite history”Walk the git history and report how long each citation’s source pin stood
stale, and which releases shipped it. A stale window opens at the first
commit where check would have reported source-changed, source-missing or
source-never-true. It closes at the first commit where the pin holds again,
or where the entry or its page is gone. A moved pin is supported somewhere, so
it opens no window.
manni cite history [paths...] [options]Each first-parent commit of HEAD is read as it stood. Its own
manni.config.yaml decides where the entries live, and its tree supplies the
pages, the manifests and the sources. The working tree is never read. The
classification at each commit is the one check makes, with the move search,
the cross-file follow and the derived baseline. A change to the claim end, or
to commit-sha alone, is no event.
There is no cosmetic filter. A whitespace or comment change that breaks the
hash opens a window like any other. So a window is an upper bound on how long
the page was wrong. The output says “stale”, never “wrong”, and
check --show-diff is where a person decides whether a change mattered.
history is a report, not a gate. It exits 0 whatever it finds, so a
scheduled job can keep its output. manni check does not run it.
history walks one repository. An entry with source.repo is left out, and
one notice on stderr names each one left out:
manni: cite history walks one repository, so 2 citations that name a repo are left out: retries, timeout.Arguments
Section titled “Arguments”| Argument | Description |
|---|---|
[paths...] | Files, directories, or globs whose citations are walked. They are matched in the working tree, and each page’s past is read from git by path. Optional. Without paths the run covers every configured collection, or the ones --collection names. - is refused (exit 2), because a piped page has no history. |
Options
Section titled “Options”| Option | Argument | Default | Description |
|---|---|---|---|
--collection | <name> | every collection | Walk a configured collection, exactly as on check. Repeatable, one name per occurrence, never comma-separated. Cannot be combined with positional paths (exit 2). |
--ext | <list> | supported extensions | Comma-separated extensions used when expanding directories and globs. |
--exclude | <glob> | n/a | Glob to exclude from directory and glob walks. Repeatable. When the run reads from collections, their exclude: globs apply as well. |
--as | <format> | n/a | Force an input format for every page. |
-c, --config | <path> | discovered | Path to a config file. Its cite.severity sets each window’s severity. Its baseline: is not read, because a baselined pin is still stale. |
--no-config | n/a | off | Ignore any discovered config file. Entries are then each page’s own frontmatter. |
--allow-empty | n/a | off | Treat zero matched files as success instead of an error (exit 2). |
--no-gitignore | n/a | on | Walk pages .gitignore covers as well. Overrides config respectGitignore:. |
--root | <dir> | n/a | The directory source.file paths resolve from, as on check. It must be inside a git repository (exit 2 otherwise). |
--newer-than | <duration> | n/a | Walk only the commits within this span of HEAD’s committer time: 30m, 24h, 90d or 12w. A window already open at the first commit read is marked bounded. The span counts back from HEAD, not from the clock, so a report reproduces from its commit. That differs from docevals run --newer-than, which counts from now. |
-f, --format | <pretty|json> | pretty | Output format. See output. github, sarif and junit carry findings, and a window is not a finding, so each is an error (exit 2). |
Output
Section titled “Output”pretty prints the pages in path order, one row per window and a dim line
under it. A row reads severity, id, source, duration and releases. The
severity is the rule’s, resolved through cite.severity, and colored as
check colors it. The dim line names the opening and closing commits, the
rule and the cause. A rule set off drops its windows.
docs/src/content/docs/cite/set-up/index.mdx error pages-from-collections src/cite/core/config.ts:308-313 stale 45 minutes v2.3.9 541b63e..bf8f986 source-changed, closed by a re-pin error collection-paths-config-relative src/cite/core/config.ts:308-313 stale 45 minutes v2.3.9 541b63e..bf8f986 source-changed, closed by a re-pin
2 stale windows on 2 citations in 1 page, none open. 1.5 stale pin-hours.1 release shipped a stale pin: v2.3.9.Walked 193 commits, 2026-09-11 to 2026-10-21.| Part | What it says |
|---|---|
| Duration | Minutes under an hour, hours under two days, days after that. An open window reads stale 47 days, open, and runs to HEAD’s committer time. |
| Releases | Up to three tags by name. More read as a range, such as v4.2.0 through v4.6.0 (5 releases). No release reads -. |
| Cause | closed by a re-pin, closed when the source was restored, closed when the entry was removed, or still open at HEAD. |
bounded | The window was already open at the first commit the walk read. Its true start is earlier and unknown. |
formerly | The page was renamed while the window was open. The window is reported under the page’s current path, with the old path beside it. |
A release is a tag HEAD reaches. It shipped a window when its commit
contains the opening commit and not the closing one. A tag on a merged side
branch counts, and a prerelease tag on a branch never merged does not.
A run with no windows prints two lines:
No stale windows on 52 citations in 1 page.Walked 193 commits, 2026-09-11 to 2026-10-21.json prints { summary, pages }. A page with no windows has an empty
windows list.
{ "summary": { "head": "10779bd0…", "walked": { "commits": 193, "from": "2026-09-11T02:35:25Z", "to": "2026-10-21T16:49:00Z" }, "files": 1, "citations": 32, "windows": 2, "open": 0, "staleSeconds": 5350, "bySeverity": { "notice": 0, "warning": 0, "error": 2 }, "releases": ["v2.3.9"], "skipped": { "encrypted": 0 } }, "pages": [ { "file": "docs/src/content/docs/cite/set-up/index.mdx", "windows": [ { "id": "pages-from-collections", "source": "src/cite/core/config.ts:308-313", "rule": "source-changed", "severity": "error", "opened": { "commit": "541b63e5c…", "date": "2026-09-17T20:19:46Z", "subject": "fix(collections): honour --collection with stdin beside it (#61)", "bounded": false }, "closed": { "commit": "bf8f98690…", "date": "2026-09-17T21:04:21Z", "subject": "docs(cite): re-pin the citations that #60 and #61 moved (#75)", "by": "re-pinned" }, "seconds": 2675, "releases": ["v2.3.9"], "formerly": [] } ] } ]}| Field | Values |
|---|---|
rule | source-changed, source-missing or source-never-true. |
severity | notice, warning or error, through cite.severity. |
closed | An object, or null while the window is open. |
closed.by | re-pinned, source-restored or removed. |
opened.bounded | true when the window was already open at the first commit read. That commit is the history’s start, the --newer-than bound, or the first commit after an old config or citation manifest that does not load. |
seconds | Whole seconds, committer time to committer time. |
releases | Tag names, oldest first. |
formerly | The page’s earlier paths while the window was open, oldest first. |
source | The entry’s source when the window opened, spelled as the entry spelled it. An encrypted source stays a ciphertext. |
id | The entry’s id, or its pointer /citations/N when it has none. |
Two notices go to stderr, each said once, and neither changes the exit code:
manni: history before 3f559dc is unread: its config or citation manifests do not load under this version. Windows open there are marked bounded.manni: 2 encrypted citations were skipped: no encryption key. Set MANNI_ENCRYPTION_KEY or encryptionKey: to include them.The first names the first commit read after one whose config or citation manifests today’s loader refuses. The second counts encrypted citations read without the key.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 | The walk finished, whatever it found, open windows included. Also --allow-empty with nothing matched. |
2 | The walk could not run. The messages are below. |
manni: cite history reads git, and /tmp/not-a-repo is not inside a git repository.manni: cite history reads git, and /tmp/new-repo has no commits yet.manni: this clone is shallow, so cite history would start at its oldest commit. Fetch the full history with fetch-depth: 0, or git fetch --unshallow.manni: cite history reads each page's past from git, and stdin has none. Name the page's path instead of -.manni: No files to read. Pass paths/globs, or declare a collection under `collections:` in manni.config.yaml.manni: --collection selects a configured collection; it cannot be combined with paths.manni: Unknown --format "sarif". Use pretty or json.manni: --newer-than must be a duration such as 30m, 24h, 7d or 2w, got "soon"There is no exit 1. history measures, and check gates.
Examples
Section titled “Examples”manni cite history docs/src/content/docs/cite/set-up/index.mdxmanni cite historymanni cite history --collection site -f json | jq '.summary.staleSeconds'manni cite history --newer-than 90dmanni cite history --collection site --ext md,mdx --exclude "**/proposals/**" \ --as mdx -c manni.config.yaml --no-gitignore --allow-empty --root . \ --newer-than 12w -f jsonThe stale-window journey runs it from a scheduled job to the number.