Skip to content

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.

Terminal window
manni cite [options] <command> [command options] [arguments]

Global options are accepted before the subcommand.

OptionDescription
-V, --versionPrint the manni version and exit.
-h, --helpPrint 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.

Terminal window
manni cite [options] <command> [command options] [arguments]
OptionDescription
--no-colorDisable colored output. Color applies to pretty output only, on a TTY, and never under NO_COLOR; the same rules as the metadata tool.

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.

Terminal window
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.

ArgumentDescription
[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.
OptionArgumentDefaultDescription
--collection<name>every collectionRun 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 extensionsComma-separated extensions used when expanding directories and globs.
--exclude<glob>n/aGlob 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/aForce an input format (extractor name, e.g. markdown) for every input. Required when reading from stdin.
-f, --format<pretty|json|github|sarif|junit>prettyOutput format. See output. An unknown value is an error (exit 2).
-c, --config<path>discoveredPath to a config file. When set, the file must exist or the run exits 2.
--no-confign/aoffIgnore any discovered config file. -c and --no-config set the same option, so the one written later on the command line wins.
-q, --quietn/aoffIn pretty output, hide current citations and clean files. Has no effect on the machine formats.
--allow-emptyn/aoffTreat zero matched files as success instead of an error (exit 2). See empty input sets.
--no-gitignoren/aonCheck files .gitignore covers as well. Filtering is on by default; see .gitignore. Overrides config respectGitignore:.
--baseline[path]config baseline:, else .manni-cite-baseline.jsonCompare 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.jsonRecord this run’s findings as the baseline, then exit 0. Wins over --baseline.
--no-baselinen/aoffIgnore a baseline supplied by config baseline: for this run.
--no-check-sourcesn/aonRun 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/aThe 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-diffn/aoffIn 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.
--revealn/aoffIn pretty output, print the decrypted path beside an encrypted source. Nothing else ever prints it; see the output rule.

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 findings

A 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 columnWhen
:9 changed since 3f9c2a1, uncommittedThe 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 changedNo git, or no commit held the pin.
#/pains/0 currentA 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 ones
manni: the source history ends before the pin held; use fetch-depth: 0 to tell never-true from changed

When 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 page

The 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"]
}
FieldTypeMeaning
type"persona" or "journey"Which page field named it.
idstringThe record’s id, as the record spells it.
titlestring, optionalThe record’s title. Absent when the record has none.
lifecyclestring, optionalA journey record’s lifecycle, when it has one.
findingsintegerReported findings on the pages that name it.
errors, warnings, noticesintegerfindings, split by severity.
pagesstring listThose 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-misplaced

The 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-changed

A 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)"/>
CodeMeaning
0No 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.
1At 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.
2Operational 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.

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.

Terminal window
manni cite check --write-baseline # record today's findings, then exit 0
manni cite check --baseline # from now on, fail only on new ones

The 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.

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.

Terminal window
manni cite check docs/ # walk a directory
manni cite check # every configured collection
manni cite check --collection guides # one of them
manni cite check "docs/**/*.md" -f github # CI annotations
manni cite check -f sarif > cite.sarif # code scanning
manni cite check --show-diff docs/limits.md # see what changed, and since when
manni cite check --baseline # fail only on new findings
manni cite check --no-check-sources docs/ # public docs repo: page-side rules only
MANNI_ENCRYPTION_KEY=$SECRET manni cite check --root ../code --reveal docs/
manni cite check --repo sdk=../../oss/sdk # a local layout that differs from cite.repos
cat page.md | manni cite check - --as markdown # read from stdin

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.

Terminal window
manni cite add <page>[:L|:L1-L2] <src> [options]
ArgumentDescription
<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.
OptionArgumentDefaultDescription
--id<id>n/aA 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.
--markern/aoffWrite 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.
--quoten/aoffThe 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.
--encryptn/aoffWrite 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-shan/aonDo 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-runn/aoffPrint the unified diff of the page and write nothing. The report line goes to stderr.
--as<format>n/aForce the page’s format. Required with -.
--root<dir>n/aThe 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>discoveredPath to a config file, read for root, repos, the encryption key, and the citations manifest each collection declares.
--no-confign/aoffIgnore 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 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.

CodeMeaning
0The citation was written, or printed under --dry-run. The report line says what was added, where, and which line each end is pinned at.
2A 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.

Invocationstderr
--marker into a paragraph a misplaced marker splitsmanni: 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 linemanni: docs/limits.md:42-46 holds the marker at line 44, which splits its paragraph. Run manni cite update first.
Terminal window
manni cite add docs/limits.md lib/limits.ts # a bare pin over the whole file
manni cite add docs/limits.md:9 lib/limits.ts:2 --id fetch-timeout
manni cite add docs/limits.md:30 lib/limits.ts:8-12 --id timeouts --marker
manni cite add docs/limits.md:30 lib/limits.ts:9 --id backoff --marker # joins the marker there
manni cite add docs/limits.md:30 lib/limits.ts:5 --id retries # adopts the marker that names retries
manni cite add docs/limits.md:14-18 lib/limits.ts:1-3 --quote # the block that reproduces it
manni cite add docs/limits.md:9 lib/limits.ts:2 --dry-run # preview the diff
manni cite add docs/personas/theo.md#/pains/0 notes/2026-09-12.md:3-4 --id terse-messages # a value on a record
MANNI_ENCRYPTION_KEY=$KEY manni cite add docs/limits.md:9 lib/limits.ts:2 --root ../code
manni cite add docs/limits.md:17 sdk:src/client.ts:2 --id retries # a source in the sdk checkout
cat page.md | manni cite add -:9 lib/limits.ts:2 --as markdown > out.md

The 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.

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).

Terminal window
manni cite update [paths...] [options]
ArgumentDescription
[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.
OptionArgumentDefaultDescription
--collection<name>every collectionRun over a configured collection, exactly as on check. Repeatable, one name per occurrence, never comma-separated.
--ext<list>supported extensionsComma-separated extensions used when expanding directories and globs.
--exclude<glob>n/aGlob to exclude from directory and glob walks. Repeatable. When the run reads from collections, their exclude: globs apply as well.
--as<format>n/aForce an input format for every input. Required when reading from stdin.
-c, --config<path>discoveredPath to a config file.
--no-confign/aoffIgnore any discovered config file.
--allow-emptyn/aoffTreat zero matched files as success instead of an error (exit 2).
--no-gitignoren/aonUpdate files .gitignore covers as well. Overrides config respectGitignore:.
--no-check-sourcesn/aonRefused. update needs the sources to know where an entry moved to.
--root<dir>n/aThe 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.
--acceptn/aoffRe-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.
--recommitn/aoffRe-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/aLimit 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-runn/aoffPrint the diffs and the report; write nothing.
-f, --format<pretty|json>prettyOutput 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.

End and statusWithout --acceptWith --accept
source-moved, same fileRe-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 filesource.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-trueSkipped, 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-ambiguousSkipped. The tool will not guess which copy. Widen the range and re-add.The same.
source-missingSkipped. A path that is gone cannot be rewritten. add is the remedy.The same.
claim-movedclaim.lines rewritten to the new body lines. A pointer claim has claim.pointer rewritten to the new pointer.The same.
claim-reanchoredclaim.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-changedSkipped, 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-ambiguousSkipped. The same text sits in more than one place.The same.
claim-missingSkipped. The pinned value is gone from the record, and remove is the remedy.The same.
currentLeft alone.Left alone.
marker-misplaced, the claim current or movedThe 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 changedSkipped. 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 entryThe 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:

CaseMessage
The claim holds nowhere, and --accept was not givenrobots: 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 totimeouts: 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 checkthe marker at line 43 stays, because it names no entry update can check.
An anchor-invalid entry, which has claim lines and a marker bothfetch-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 lineMessage
Is blankNot re-pinned: the line is blank.
Holds lines that reach out of the fenced blockNot re-pinned: the claim's lines are not all inside one fenced block.
Starts no paragraphNot re-pinned: the line does not start a paragraph.
Is outside the page bodyNot re-pinned: the line is outside the page body.
Opens fewer rows than the claim coversNot re-pinned: the table no longer holds every row the claim covers.
Opens no fenced block, under quote: trueNot re-pinned: quote: true, and the line no longer opens a fenced block.
Opens a unit over 5,000 linesNot re-pinned: the paragraph spans 6000 lines, more than 5000.
Holds a cite markerNot re-pinned: that line now holds a cite marker, not claim text.
Opens nothing but table rulesNot re-pinned: that line is a table rule, not claim text.
Holds text the page repeatsNot 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.

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.

CodeMeaning
0Everything the run could rewrite was rewritten, or previewed under --dry-run, and nothing was skipped with an error-severity finding.
1Something 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.
2Operational 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.
Terminal window
manni cite update docs/ # rewrite moved entries
manni cite update --collection guides # one configured collection
manni cite update --dry-run docs/ # see the diffs first
manni cite update --accept --only fetch-timeout docs/limits.md
manni cite update --accept --dry-run -f json docs/ # what would be re-minted, as data

The 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 skipped

On 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 skipped

A 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.

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.

Terminal window
manni cite remove [paths...] --only <id> [options]
ArgumentDescription
[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.
OptionArgumentDefaultDescription
--only<id>none, requiredThe 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 collectionRun over a configured collection, exactly as on check. Repeatable, one name per occurrence, never comma-separated.
--ext<list>supported extensionsComma-separated extensions used when expanding directories and globs.
--exclude<glob>n/aGlob to exclude from directory and glob walks. Repeatable. When the run reads from collections, their exclude: globs apply as well.
--as<format>n/aForce an input format for every input. Required when reading from stdin.
-c, --config<path>discoveredPath to a config file, read for the collections and the manifest each one declares.
--no-confign/aoffIgnore any discovered config file. Entries are then the page’s own frontmatter.
--allow-emptyn/aoffTreat zero matched files as success instead of an error (exit 2).
--no-gitignoren/aonRemove from files .gitignore covers as well. Overrides config respectGitignore:.
--dry-runn/aoffPrint the diffs and the report; write nothing.
-f, --format<pretty|json>prettyOutput 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).
WhatWhat happens to it
The entryRemoved 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 markersThe 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 belowTheir 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 entryUntouched, comments and quoting included: the entries that stay are re-emitted as they were written.
An id with a marker and no entryThe marker alone is removed, which is how a marker-orphan is cleared.
A broken entryRemoved 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.

CodeMeaning
0Everything you named was removed, or previewed under --dry-run. The report names each removal and the markers the id came out of.
2A 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.

Terminal window
manni cite remove docs/limits.md --only fetch-timeout
manni cite remove docs/ --only fetch-timeout --only retries --dry-run
manni cite remove --collection site --only /citations/3 -f json
manni cite remove docs/ --only fetch-timeout --ext md,mdx --exclude "**/drafts/**"
cat page.md | manni cite remove - --as markdown --only fetch-timeout > out.md

The 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 30
docs/limits.md: removed /citations/3 from site.metadata.yaml:412
docs/limits.md: removed the marker fetch-timeout at line 30, which named no entry
1 citation removed from 1 file

Under --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.

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.

Terminal 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.
ArgumentDescription
[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.
OptionArgumentDefaultDescription
--collection<name>every collectionWalk 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 extensionsComma-separated extensions used when expanding directories and globs.
--exclude<glob>n/aGlob to exclude from directory and glob walks. Repeatable. When the run reads from collections, their exclude: globs apply as well.
--as<format>n/aForce an input format for every page.
-c, --config<path>discoveredPath 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-confign/aoffIgnore any discovered config file. Entries are then each page’s own frontmatter.
--allow-emptyn/aoffTreat zero matched files as success instead of an error (exit 2).
--no-gitignoren/aonWalk pages .gitignore covers as well. Overrides config respectGitignore:.
--root<dir>n/aThe directory source.file paths resolve from, as on check. It must be inside a git repository (exit 2 otherwise).
--newer-than<duration>n/aWalk 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>prettyOutput format. See output. github, sarif and junit carry findings, and a window is not a finding, so each is an error (exit 2).

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.
PartWhat it says
DurationMinutes 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.
ReleasesUp to three tags by name. More read as a range, such as v4.2.0 through v4.6.0 (5 releases). No release reads -.
Causeclosed by a re-pin, closed when the source was restored, closed when the entry was removed, or still open at HEAD.
boundedThe window was already open at the first commit the walk read. Its true start is earlier and unknown.
formerlyThe 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": []
}
]
}
]
}
FieldValues
rulesource-changed, source-missing or source-never-true.
severitynotice, warning or error, through cite.severity.
closedAn object, or null while the window is open.
closed.byre-pinned, source-restored or removed.
opened.boundedtrue 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.
secondsWhole seconds, committer time to committer time.
releasesTag names, oldest first.
formerlyThe page’s earlier paths while the window was open, oldest first.
sourceThe entry’s source when the window opened, spelled as the entry spelled it. An encrypted source stays a ciphertext.
idThe 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.

CodeMeaning
0The walk finished, whatever it found, open windows included. Also --allow-empty with nothing matched.
2The 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.

Terminal window
manni cite history docs/src/content/docs/cite/set-up/index.mdx
manni cite history
manni cite history --collection site -f json | jq '.summary.staleSeconds'
manni cite history --newer-than 90d
manni cite history --collection site --ext md,mdx --exclude "**/proposals/**" \
--as mdx -c manni.config.yaml --no-gitignore --allow-empty --root . \
--newer-than 12w -f json

The stale-window journey runs it from a scheduled job to the number.