Files
2026-10-24 01:50:24 +08:00

275 lines
19 KiB
Markdown

# Extending beyond a file
**What this answers:** how to change this repository's own machinery, rather than add another
document to it: a new class of artifact, a reference family, the frontmatter or the metadata
contract, the budget that governs the always-loaded file, a new gate and its CI wiring, and the
procedure for demoting a claim when a measurement disagrees with it.
**When to load this:** when the change you want is not covered by the extension points in
[CONTRIBUTING.md](../CONTRIBUTING.md), or when it changes what a green build means.
Everything here rests on one distinction. Part of this repository's contract is enforced by scripts
at the root; the rest can only be judged by a reader. A change that moves a rule from the second half
into the first is a real improvement, and a change that looks machine-checked but is not is how a
guarantee rots. Where a class is maintained by a reader, this document says so.
## Ordinary, or a discussion first
| Change | Status | Why |
|---|---|---|
| A reference, a script, a pitfall entry, a case precedent, a measurement record, a capability row | Ordinary. Follow `CONTRIBUTING.md` | Each has a registration point and a gate that notices if the registration is missing |
| A new gate script, additive and read-only | Ordinary, if it cannot fail a change that used to pass | A gate that only adds a signal is cheap to review |
| Wiring an existing gate into CI | Ordinary | The sequence is in `.github/workflows/ci.yml`, and a missing gate is a visible gap |
| Recording a downgrade of a claim | Ordinary, and expected | The repository states that a measurement which disagrees re-opens the conclusion |
| A reference family (a subdirectory with its own index) | Ordinary, with one condition stated below | Its files are not resolved by any gate, so its index is the only entry point that exists |
| A whole new artifact class (a new tree, or a new format the tooling reads) | Discussion first | It changes what a reader must check by hand, and possibly what an installed skill ships |
| Adding a key to the `metadata` frontmatter block | Ordinary | No gate resolves those paths, so nothing depends on the key existing |
| Changing what an existing `metadata` key means, or the strength-label vocabulary | Discussion first | Every document in the corpus grades against that vocabulary |
| Changing a budget threshold, or the method that measures it | Discussion first, with a measurement | A threshold moved without a measurement is a preference with a number on it |
| Adding a new failure mode for contributors | Discussion first | A gate that blocks a reasonable change is bypassed, and then it protects nothing |
## What the tooling actually reads
Five facts about the layout decide how a new artifact is discovered, and none of them is stated in
the file you are adding.
1. `check_repo.py` discovers every `skills/*/SKILL.md` up to three levels deep, and a shallower
`SKILL.md` shadows anything nested below it, which is the installer's own rule. Adding a second
skill needs no change to the gates.
2. `check_repo.py` and `check_routing.py` look at `references/*.md` and `scripts/*.py|.js`
**non-recursively**. A file in a subdirectory of either is invisible to the routing comparison.
3. `check_budget.py` counts the corpus recursively: `references/**/*.md`, plus every file under
`scripts/`. The corpus note fires at 110 files or 26,000 lines, so a family is visible to the
budget before it is visible to routing.
4. The path resolver patterns match `references/<name>.md` and `scripts/<name>.py` with no separator
anywhere in the name, so a nested path such as `references/coverage/route-inventory.md` is not
resolved by any gate. A dangling nested pointer fails nothing.
5. `check_commands.py` scans `README.md` and every `.md` under `skills/` and `docs/`, skipping
directories named `fixtures`. A command in a nested file is still validated; a command in a file
that no walker reaches is not.
The consequence is the rule for everything below: a new class needs one file that an existing gate
already walks, and that file must state honestly what is checked and what is not.
## Adding a whole new artifact class
A class is a set of files with a shared shape that a reader is expected to keep consistent: the
evidence companions, the coverage inventory and the case library are three examples of what is
already here.
1. **Decide whether it ships.** Inside `skills/exe-reverse/` is shipped to anyone who installs the
skill; the repository root is maintenance tooling. `README.md`'s structure block states the split,
and a class that ships belongs in that block with an explicit `skills/exe-reverse/...` path,
because the README's path check fails a bare relative path.
2. **Find out whether any gate walks it.** If one does, its completeness is machine-checked and the
class can rely on that. If none does, the class's own index must say so in a sentence a reader can
act on, because a machine-checked guarantee that does not exist is the failure this repository
keeps recording.
3. **Give the class an index that is discovered.** The cheapest reliable form is a top-level
`references/<class>.md` that opens with the head shape (the title, a goal line, and
`## When to load this`), registers itself in `routing.md`'s Reference index, and is named in
`routing.md`'s Sub-indexes table as the class's index, which is how the coverage inventory and the
case library are already registered.
4. **State the row contract in the index**, not in the individual files: what each entry must carry,
which document holds the claim it supports, and which grade applies.
5. **Add the class's own test only if it has a mechanical part.** A class of prose files has nothing
to test, and a test that asserts a file exists is not a guarantee about its content.
Two things to avoid. A class whose only index is named inside another document's prose is not
reachable, because nothing compares prose to a directory listing. And a class that duplicates a
registration duty that routing already owns will drift from it.
## Adding a reference family
A family is a directory of related files with an index, and it is worth the extra structure exactly
when the files are read together and would be misleading in the flat library.
1. Create `skills/exe-reverse/references/<family>/` and write `README.md` as the index: what the
family is for, when to load it, the register or table of its members, and the template a new
member follows. Copy the shape of `references/precedents/README.md`, which is both an index and a
template.
2. Register the family index in `routing.md`, in the Sub-indexes table, as
`references/<family>/README.md`, saying what the file holds.
3. If the family needs a top-level touchpoint as well (a symptom, a route), add that row to
`SKILL.md` and to the mirror in `routing.md` in the same pass; the routing gate compares those two
tables row by row and fails when one side has a row the other lacks.
4. Add each member to the family's own register at the same time you add the file, never later. The
register is the only reachability that exists for a nested file.
5. Say in the index that the members are not resolved by any gate, so a reviewer knows the index must
be read against the directory. This is a one-sentence honesty cost that makes the gap visible
instead of implied.
Members of a family are still covered by the checks that scan every markdown file under `skills/`: a
`python <script>.py` command in one of them is validated against the script's argument table, and a
cross-file section reference is resolved against the target's real headings. `check_refs.py` reads
`README.md` and `skills/` only, so the same content placed under `docs/` is not checked that way.
## Evolving the frontmatter or the metadata contract
**What is enforced.** `check_repo.py` parses the frontmatter with a real YAML parser, because
frontmatter that satisfies a pattern can still be rejected by the parser the installer uses. It then
requires `name` to equal the skill's directory name and to match lowercase alphanumerics joined by
single hyphens, at most 64 characters, and `description` to be between 40 and 1024 characters. A
description whose value contains a colon followed by a space must be quoted; an unquoted one makes
the whole skill invisible to the installer. Where PyYAML is not installed the gate prints a warning
and falls back to a colon test, so the check degrades rather than disappearing.
**What is not enforced.** The `metadata` block. `capability_registry`, `host_probe`,
`evidence_summary` and `strength_labels` are promises about paths and vocabulary, and no gate
resolves any of them. A renamed file whose path still appears there is drift nothing will report, so
a rename has to be carried by hand and reviewed. Treat the block as documentation that happens to live
inside a parsed structure.
**The description is a loading decision, not a summary.** It is the only text an agent sees when
deciding whether this skill applies to its task. Adding a route to it is a budget decision against
the 1024-character ceiling, and the routes that earn their characters are the ones a wrong answer
would otherwise be given for.
**Changing the strength-label vocabulary needs a discussion**, because every reference, every
evidence row and every case grades against `observed | inferred | unverified`. The burden of proof is
that the existing three cannot express a distinction that changes a decision a reader makes. If a new
label is genuinely needed, the change is mechanical in its extent and semantic in its risk: every
document that states a grade has to be revisited in the same pass, and the frontmatter key that names
the vocabulary has to be updated with them.
## Changing the always-loaded budget
`SKILL.md` loads in full whenever the skill activates, so the budget is measured on the whole file,
including its index tables. `check_budget.py` reports:
| Measure | Soft | Hard | Fails the gate? |
|---|---|---|---|
| `SKILL.md` lines | 460 | 560 | at the hard limit |
| `SKILL.md` tokens | 13000 | 16000 | at the hard limit |
| Index-row description length | 250 characters | 400 | never: both branches are warnings |
| Long reference with no navigable head | 100 lines, head checked in the first 40 | not applicable | never: a warning |
| Corpus size | 110 files, 26000 lines | not applicable | never: a note |
The token count comes from `tiktoken`'s `cl100k_base` encoding when it is installed, and otherwise
from `characters / 4.285`. That fallback constant is calibrated against the real measurement rather
than guessed, and it exists for a stated reason: an earlier value made the gate reach **different
verdicts on different machines**, reporting an over-budget file where the tokenizer was absent and a
compliant one where it was present. The gate's answer must not depend on what the runner happens to
have installed, which is also why CI installs the tokenizer so the real path is exercised.
**To change a threshold:** produce the measurement that justifies the new number, keep the two
measurement paths agreeing, and update the numbers and the calibration note in the docstring in the
same change, because the docstring is the only place the calibration is recorded. A threshold that
moves because a document outgrew it is not a change to the budget; it is the budget being abandoned.
**The ordinary answer instead:** move the detail into a reference and leave a pointer. Two things
stay in the always-loaded body, and both are argued in the gate's own comment: the symptom index,
because a symptom-to-file lookup must stay one hop, and the gates with their pass criteria, because
"the idea is understood" does not clear a gate.
**Verify with both modes:** `python check_budget.py` reports the measured values and the notes;
`python check_budget.py --strict` turns every warning into a failure, which is how a warn-only rule
is checked when someone chooses to hold the line.
One known wording defect, reported here rather than copied: the docstrings of `check_routing.py` and
`check_budget.py` describe "the four gates with their pass criteria", while the entry point defines
five (G1 to G5). Comment text is checked by no gate. If you touch either file, correct the wording
instead of reproducing it.
## Adding a new gate script and wiring it into CI
**The shape a gate has here.** A root-level `check_<name>.py`, runnable from the repository root with
no arguments as `check_<name>.py` under the interpreter, printing a human-readable summary and, where a caller needs
to branch on the verdict, a final `RESULT=<token>` line. Exit 0 when the tree satisfies the rule and
non-zero when it does not. `--json` is the convention for machine-readable output, and the one place
it is deliberately absent from the text stream is a JSON payload: a `RESULT=` line appended to JSON
makes the stream unparseable for the caller that asked for JSON, so there the status is a field and
the exit code carries the meaning. `check_repo.py` and `check_refs.py` take no arguments at all;
`check_routing.py` takes only `--json`; `check_budget.py` adds `--strict`; `check_commands.py` adds
`--path`, `--fix-report`, `--include-workbench` and `--quiet`.
**Rules a gate follows here:**
1. **Read-only.** A gate must not modify the tree. The only root tool that writes is
`build_scripts.py`, and it writes only under `--apply`, after an audit pass that is the default.
2. **Report the boundary of the check.** `check_commands.py` counts the invocations it could not
verify rather than skipping them silently, because "no finding" from an interface it cannot read
would be indistinguishable from coverage. A new gate that cannot see part of its surface should
say so in its own output.
3. **Say when the surface is empty.** A gate that found nothing to check has not passed; it has run
against nothing. `scan_leaks.py` has a dedicated exit code for exactly this.
4. **Prefer a narrow rule with a stated exemption over a broad rule that is wrong.** The gates here
carry their exemptions in a table with a reason per entry, because a gate that gets switched off
protects nothing.
5. **Do not restate another gate's rule without saying why.** Measured duplication exists here: the
routing gate and the budget gate both check that a reference is reachable. The second copy is
justified in a comment, because a dead reference is invisible in a way a broken path is not.
6. **Warn, do not fail, when the gate cannot judge.** Hedge detection, restatement and corpus size
are notes; index-row length and navigability are warnings; only the ceilings fail. `--strict` is
the opt-in that makes warnings fatal.
**Every gate needs a test.** A gate gets a subprocess contract test under `tests/cli/`: the flags it
accepts, the exit code per verdict, and the `RESULT=` token where it prints one. A mis-typed marker
is a harness failure rather than a warning, because the suite runs with `--strict-markers`.
**Lint and typing are free.** The CI lint job runs `mypy` and `flake8` over `*.py` at the root plus
the skill's scripts, so a new root gate is checked without a configuration change. Both tools read
their configuration from the repository root, and the job deliberately runs on an interpreter with
none of the optional dependencies installed, so a new gate must import them lazily or behind a
fallback with a stated behaviour.
**Wiring it in.** Add the step to the `test` job in `.github/workflows/ci.yml`, in the gate sequence
and before the regression suite. There is deliberately no `paths:` filter on the workflow: a filter
that watches only source files would skip the structural gates on exactly the commits they exist to
catch, which are documentation commits. A gate that can only run where a device, a debugger or a
network peer exists does not belong in CI; record that route in the measurement record instead and
let the test self-skip with a printed reason.
**Document it.** `README.md`'s repository-maintenance list names every root tool with what it
enforces, one line each. A tool that is not in that list is invisible to a contributor who never
opens the root directory.
**What needs a discussion first:** a gate that can fail a change which used to pass, a gate whose
rule is a matter of taste rather than a checkable property, and a gate that duplicates a rule another
gate already owns.
## Recording a downgrade when a measurement disagrees
The stop condition is stated in the entry point: a measurement that disagrees with a conclusion
recorded as settled re-opens the conclusion, and the measurement is not explained away. That is a
rule about behaviour; this is the procedure that makes it a change a repository can hold.
1. **Freeze the measurement first.** The command as it was run, its output, the artifact digest, the
tool and its version, and the host build. Without those pins the disagreement is an anecdote, and
it cannot raise or lower anything.
2. **Name the link that was weaker than recorded.** A chain is graded by its weakest link, so the
question is not "was the conclusion wrong" but "which step was assumed". A tool that reports
success is evidence about the tool; it was never evidence about the target.
3. **Move every document that states the claim, in one pass.** The coverage row
(`references/coverage-and-limits.md`: the direction, the strongest defensible grade, what it rests
on, and the dependency that is not shipped here), the evidence row
(`references/evidence-summary.md`), and the machine-readable row under `skills/exe-reverse/evidence/`.
A downgrade that lands in one of the three leaves the other two asserting the stronger grade, and
the stronger grade is the one a reader will find.
4. **Record the negative result.** `docs/tool-verification/` is where the pass goes, with the command
and its output, and with the scenarios the target could not exercise named. A route measured and
failed on a named host is more useful than a route nobody ran.
5. **Add a pitfall entry only if the downgrade has a mechanism.** The catalogue's value is the
mechanism that made the failure invisible, not the failure. If the earlier claim looked right for
a reason a reader could hit, that reason is an entry; if the claim was merely optimistic, it is
not. Cite the entry by number from anywhere else rather than restating it.
6. **Describe the demotion in the change.** The repository's own rule is that a corrected conclusion
is described rather than quietly edited, and that a record keeps the wrong version beside the
right one, because the retraction is the transferable part.
**Restate the grade for the whole chain**, not for the corrected step alone. A chain that ends in a
step nobody executed is `inferred` at best, and a chain whose weakest link was just replaced is
entitled to be re-graded only as far as the new weakest link.
**A downgrade is a result, not a failure.** The coverage file commits to this: a negative measurement
raises a row's honesty even while it lowers its grade, and a row with no measurement behind it is
worse than a row with a disappointing one. What a downgrade must not do is delete the earlier claim,
edit a conclusion without saying so, or leave a downstream document stating the grade that was just
withdrawn.
There is a precedent in this repository for the kind of error that forces a downgrade: the budget
gate's token fallback constant was first set to a value that made the gate reach different verdicts
on machines with and without the tokenizer, which is the shape of a claim that looks measured and is
not. Its docstring now records the calibration and the reason, which is what a downgrade should leave
behind.