Docs Merge — the Client-Feedback Loop
Turns one piece of client material (a file, or plain text) into a versioned, ruled-on
change to macstack/. Nothing skips from raw material straight into the spec — every
change passes through a delta, at least one human gate, and a logged ruling.
Open ${CLAUDE_PLUGIN_ROOT}/skills/documents/SKILL.md for the folder layout and
${CLAUDE_PLUGIN_ROOT}/skills/documents/references/doc-contracts.json for anchors,
section keys and ID regexes first — this skill assumes both and does not repeat them.
Three ways material arrives
- A file — client email, upload, a path already in this repo. Start at Step 1.
- A stray file in
client/— anything there that is not one of the six documents is incoming material, not a document. Move it toinbox/first, then Step 1. Lint rule 12.22 already refuses to let it sit there. - Text in chat — "надо убрать геолокацию", "improve X". Write it to
inbox/<date>-<slug>.mdFIRST, then Step 1 like any other file.
The third is the one people skip, and it is the one that costs most. A correction that lives only in a chat transcript has no source: six months later nobody can say who asked for it or when, and the document states something nobody can trace. Cost of writing it down: one file. Cost of not: an unexplainable document.
0. Resolve
macstack/macstack.json → ./macstack.json (legacy) → search upward to the git root.
Both present is an ERROR, not a silent choice — report both paths and stop: the
remedy is migration mode in documents, which relocates the legacy root file into the folder (or
git rms it once the moved copy is verified). Read docs.language (documents are
written in it; anchors and IDs never are) and docs.files.
1. Intake
New material → inbox/<slug>-YYYY-MM-DD.<ext>, ASCII filename, never touched again
once it lands — inbox is immutable, the only writable file in it is inbox/README.md
(the manifest). Content pasted in chat is saved to inbox/ first so the raw survives.
A source already committed in this repo at a stable path is cited by path, not
copied — a copy diverges from its original and an immutable zone can't be fixed
afterward. Add a row to inbox/README.md: | file | received | from whom | channel | supersedes | processed in |.
GATE 1 — "N unprocessed in inbox/. Taking <one>. Who sent it, what does it
supersede?" One source at a time, human in the loop.
2. Read
.pdf— Read tool with page ranges..docx— unzipword/document.xmland strip tags (read-only, prints to stdout, writes nothing); paragraphs collapse into one stream — rebuild hierarchy from the section numbering in the text, not from markup:python3 -c "import zipfile,re,sys; x=zipfile.ZipFile(sys.argv[1]).read('word/document.xml').decode('utf-8','replace'); print(re.sub(r'\s+',' ',re.sub(r'<[^>]+>',' ',x)).strip())" "path/file.docx".xlsx— REFUSED. Ask for a CSV export beside it and write NO log entry, so the file stays in the unprocessed set. Say so in the report rather than closing the journal.- Before reading anything: check size / first bytes. A not-yet-materialized iCloud file reads as empty and produces a confidently wrong delta — refuse rather than guess.
3. Delta
Write deltas/YYYY-MM-DD-<slug>.md with the anchored sections from doc-contracts.json
(howto, contradictions, additions, confirmed, absent, journal, plus
applied for the closing banner):
| Part | Content |
|---|---|
1 — Contradictions K-N |
The document says something a live case doesn't; each carries a blast-radius tag and names the cases it would rewrite. ASCII K only (U+004B) — the Cyrillic capital KA (U+041A) renders identically, greps as absent, and silently breaks every cross-reference check. |
2 — Additions N-N |
Grouped by role. |
| 3 — Confirmed | What the document already matches. |
| 4 — Absent | What the document doesn't address at all. |
The file declares itself a proposal, not an edit, and names its bar (USER-CASES.md).
It stays as history after applying — never deleted or rewritten.
3b. Bypass — Part 1 is empty (pure additions) → skip the delta AND the rulings file, apply directly, log it. Skip this branch and the ceremony gets abandoned wholesale by the third small edit.
GATE 2 — Present K-N ordered by blast radius. The OWNER rules on each. Additions merge without a ruling unless the owner objects. Nothing in Part 1 merges silently.
4. Rulings
Allocate D-ids in DECISIONS.md FIRST, in their own commit — writing each row's id,
date, title, decided by and file at allocation time — then write
decisions/YYYY-MM-DD-<slug>-rulings.md. Each entry: the ruling, "— because …",
Cost if wrong under its anchor (written AT DECISION TIME, before the outcome is
known — a retrospective cost clause is worthless), and who decided (owner | session).
Header stamps date, branch, result (e.g. "USER-CASES.md v1.9"). Tail: what is left to
the client, where this was applied.
5. Apply
Order matters: USER-CASES.md → UX-UI.md and AUTOMATION.md → TEST-CASES.md → OVERVIEW.md → macstack.json. The
last three are derived from the first, so writing the spec first means writing changes
you may have to reverse. A changed or added acceptance bullet leaves TEST-CASES.md
stale by definition — re-derive it with macstack-dev:test-cases in the same pass, or
the coverage table starts lying. USER-CASES gets a version bump in its header AND a matching journal-table
row naming the delta and the rulings file. Re-insert any stripped anchors idempotently
— a missing anchor is never a reason to rewrite the document; headings and prose stay
in docs.language, anchors never translate. No line-number pointers into source
(src/foo.ts:214) — cite a symbol name or a test title instead. When a role is
removed from roles[], strike its entire USER-CASES.md section heading as well as
its cases — the letter is retired with it and never reassigned.
GATE 3 — show the USER-CASES diff before writing. GATE 4 — show the macstack.json diff separately (a spec change is lint-gated).
6. Remainder, then 7. Close
- Unresolved →
OPEN-QUESTIONS.md§A with fresh A ids, cross-referencing the K-N. Resolved → struck with the closing decision id and date:~~A6~~ · CLOSED D14, 2026-08-24(numbers never reused). New deferred engineering → §B with BOTH the reason it was safe to defer AND the trigger that makes it unsafe. - Close: prepend the applied banner under the
appliedanchor in the delta, naming the resulting version, the rulings file, and the A ids opened — the delta is now history. DECISIONS.md rows were already written in step 4 — do not re-add them here; only fill in a row'sfilelink now if it was not yet known at allocation time.
8. Journal — one row per statement that changed
Every edit gets its own row in history/ledger.jsonl, keyed by the id of the thing
that changed, not by the document:
{"date":"2026-08-24","doc":"client/USER-CASES.md","item":"C-04","kind":"changed",
"was":"…прошлый текст…","now":"…новый…",
"why":"комментарий клиента в пакете 2026-08-25",
"source":"inbox/client-portal-spec-2026-08-24.pdf","decision":"D42","by":"claude"}
An edit without a row is a defect, and lint 12.36 says so. Document-level logging cannot replace it: the client package shows each statement with ITS history, and a row that names only the file cannot be attached to a statement.
Then one CHANGELOG.md line for what actually reached the people who use the system —
that file is read by humans, so a re-formatting does not belong in it.
Bump lifecycle.updated to today's date in the same step — the dates must never
disagree.
Material that came through the chat is cited by its inbox path like anything else, because Step 1 wrote it there. There is no second citation format.
9. Verify — and what's machine-checkable vs. judgment
Run the lint skill → report 🟢/🟡/🔴 with numbered next steps. Lint plus this loop
together cover:
Script it: inbox file with no manifest row · inbox file with no merge entry naming
it in the ledger · missing pointers · non-unique / non-ASCII / gap-numbered IDs · a cited
D that doesn't resolve in DECISIONS.md · a struck A with no closing decision id and date ·
USER-CASES header version ≠ last journal row · lifecycle.updated older than the
newest ledger row · a delta past its age budget with no applied banner.
Call it yourself: whether a contradiction is real or the client's silence · whether an addition duplicates an existing case · whether a §B trigger has already fired · whether OVERVIEW has started restating USER-CASES.
Routing
| Situation | Do |
|---|---|
No macstack/ yet |
macstack-dev:documents first |
| Delta has no contradictions | 3b bypass — apply directly, skip rulings |
| Anything headed for a client document or macstack.json | Never directly — always through this loop |
| Acceptance bullets changed | macstack-dev:test-cases in the same pass |
| After apply | macstack-dev:lint |