Imported from Matthew-Hsu/alta-route10-controld (
AGENTS.md). Install upstream withnpx skills add Matthew-Hsu/alta-route10-controld. Copyright stays with the author.
Agent Notes
Operational notes for an AI coding agent picking up work in this repository.
Read README.md and CONTRIBUTING.md first.
This file doesn't repeat what's there, only what's specific to working here
as an agent rather than a human.
Your sandbox is not the target
This code runs on an Alta Labs Route 10 router: aarch64, BusyBox ash,
BusyBox awk, an OpenWrt-derived filesystem, iptables, uci, and a
persistent /cfg partition that survives firmware updates. Your execution
environment almost certainly has none of that.
-
sh test.shhere runs the unit suite; iptables and firewall calls are stubbed. A green run proves the logic, not the on-router behavior. -
Anything touching iptables, cron,
/etc/firewall.user, or boot persistence (rc.local,post-cfg.sh) cannot be verified from a sandbox. Say so explicitly in your PR description instead of claiming it's tested. -
The generated
watchdog.shis the exception: the suite extracts it fromsetup.sh, redirects its/cfgand/tmppaths into a sandbox and runs it against stubs, so its control flow can be tested off-device. What it does to iptables still cannot, and neither is how long anything takes: probe costs differ by an order of magnitude between a container and a router, which is how a wait loop bounded by iterations passed CI and ran six times too slow on hardware. Every bug in this project's history that CI could not see surfaced on hardware first (seeCONTRIBUTING.md's "Testing on hardware"). -
Run the suite under both awks before proposing a change:
sh test.shandAWK="busybox awk" sh test.sh. They disagree often enough that this has caught real regressions that a GNU-awk-only run missed. -
Before saying a feature works, check README.md's "Verification Status" section. If it is in the "not exercised on hardware" table, say so rather than implying the suite covers it. If you do verify one on a device, move the row out of that table and write what you ran into
docs/hardware-verification.md, under the heading for its area.
Verify before you claim
Don't describe a fix as done until you've re-read the diff and confirmed the change is actually there. This has gone wrong before: a commit message once claimed a fix that a failed edit had silently dropped, and nothing caught it because the accompanying test checked the shape of the code (grepping for the intended change) rather than its outcome. If you can't run the real behavior, say plainly what you verified and what you couldn't.
Attribution
Commits and PRs you make here may carry your tool's Co-Authored-By trailer.
This project welcomes that disclosure, see CONTRIBUTING.md. Don't strip it,
and don't apply a stricter attribution policy of your own that contradicts
what's written there.
But never put a URL in a commit message or PR body, including a session
link your harness adds automatically. Many harnesses append one; remove it.
This is a hard rule. See CONTRIBUTING.md for why. Cite commits by hash and
files by path instead.
Prose
Before writing or editing prose here, read blader/humanizer's SKILL.md and
apply it. Fetch it at the time you need it rather than copying it into this
repo, so its updates reach you without anyone maintaining a snapshot.
Prose means markdown, commit messages, PR bodies, and shell comments. Anything written for a person to read is held to the same standard wherever it lives.
Printed messages are the exception, and they are excluded as a class rather than one at a time. They share a deliberate shape, the finding on the left of a dash and its consequence on the right, held to one terminal line:
drift "No managed block in ${FW_USER} — redirects will not survive a firewall restart or reboot"
A period fragments a readout meant to be scanned, and a colon reads like a list header, so the dash is doing work there that it is not doing in a paragraph. Leave all of them, not only the few a test pins. The dash above survives this file's own rule because the line is quoted, not written.
Do not decide whose voice a line is in by reading git blame. Every file here is written with an AI assistant and edited afterwards, so a name on a commit records who reviewed a line, not who phrased it. There is no sample in this repository whose em dashes are somebody's style, and treating one as such is how a cleanup talks itself into leaving the pattern in place. For prose the baseline is none.
A comment or string that code reads is an interface, not prose. Never reword one. They are listed by what they say rather than where they sit, because line numbers drift and a stale pointer aims attention at the wrong line:
- the comment carrying
controld-boot-hook, in therc.localheredoc insetup.sh.is_our_rc_local()greps the installed hook for it, anduninstall.shdecides from that whether the hook is ours to remove - the
test.shfixture that mirrors that line, which only tests the real thing while the two match - the
── Inline benchmark ──header insetup.sh, whichtest.shuses as asedrange anchor from another file - the
── Step 8: Install cron job for weekly updates ──and── Step 9: Copy lib.sh to router for runtime use ──headers insetup.sh, the two ends of anothersedrangetest.shreads. It extracts Step 8, points its/cfgpaths at a sandbox and runs it against a stub crontab, which is the only way the installer's own cron decisions can be tested off-device. The two ends fail differently, exactly as in theREADME.mdrange below: retitling the Step 8 heading empties the range, while rewording the Step 9 one leaves it running to the end of the file, where the extracted body still contains every string a guard would think to look for. So there are two guards. One checks the range still carries itscron_remove, its gate and its crontab write; the other checks it stops before Step 9b'sUTILITY_SCRIPTS, which is the first thing past it. The second was added after retitling the Step 9 header left the whole suite green - the
#### Guided Protocol Selectionheading inREADME.mdand theOption 5 runsline that closes it. They are the two ends of asedrangetest.shreads to check the documented menu still matches the installer's. Retitling the heading empties the range, and rewording the closing line leaves it running to the end of the file - the
lib.shfunction names indocs/technical-details.md's Shared Library list.test.shscans*.sh,*.md,docs/*.mdandconfig/*.examplefor a caller of every function, so that inventory counts as one. No function depends on it alone today, which was checked by deleting the inventory and re-running the suite: the dead-function assertion still passed, because calls insidelib.shcount as references. The exposure is a future function whose only mention outsidelib.shlands there - every
# shellcheckdirective, including the trailing prose ones. The prose after the directive is editable; the directive is not - every shebang, the four inside
setup.sh's heredocs included - every user-facing message string, not only the ones a test anchors on by
text such as
uninstall.sh's "carries no redirect to port". These are UI - the links from
README.mdanddocs/to this repository's issues, written ashttps://github.com/Matthew-Hsu/alta-route10-controld/issues/<number>..github/scripts/check-issue-refs.shreads that form to fail CI once a linked issue is closed, and skips a link written any other way, sotest.shchecks that every link to this repository's issues in those files is one it sees
Every one of those is guarded, so the suite catches a breakage whether or not
anyone read this section. Each guard was confirmed by mutating the thing it
protects and watching it fail. The shebangs and the shellcheck disable
directives needed guards built for them: removing a generated script's shebang
passed the entire suite, and deleting a directive left shellcheck green,
because the main lint runs at -S warning and SC2086 is info-level. CI now
makes a second, narrow pass for that one code.
When you add an interface, add its guard in the same commit, and prove the guard works by breaking the thing it protects and watching it fail. You have added one whenever code starts reading text that reads like commentary or like a message: a new marker grepped out of a file, a new section header used as a range anchor, a new printed line a test keys on. Add it to the list above too, so a person knows without reading the assertion.
A guard firing on a change you meant to make is the guard working. Update it and say in the commit what moved. Deleting one to get green is how this project ends up back where it started, with a documented rule and nothing enforcing it.
That rule is the only thing covering an interface added later, so treat it as load-bearing. The guards above protect these ten and nothing else: add an eleventh without one and the suite stays green, both when you add it and when someone reworks the comment away months later, in a different change, with nothing to connect the breakage back to the edit that caused it. Simulating exactly that is how this section was checked. Whether a new interface is guarded is a decision someone makes, not something the tooling notices.
Two limits on how to do the work. Never sweep the tree: one file per commit,
because a 300-line prose diff cannot be read line by line, and this project
says every diff is. And prove that only comments moved, rather than promising
it: run code_only() over the file before and after and diff the two. Matching
output means no code line changed. A trailing inline comment survives that
filter, so check those by eye.
Proving the text is identical is not proving the move is correct, and on prose the two come apart. A sentence carries what it refers to: "this", "here", "that", "above", "the two". Those point at whatever preceded them, and moving the text silently re-points them, while every byte-for-byte check passes. Three bugs in one restructure came from exactly this, including a section whose first line became circular once its heading was renamed around it, and a paragraph reading "the two sets" under a sentence naming three things. So after a move, read the first sentence of what moved against what now precedes it, and read what is left behind at the origin, where a following sentence may have been leaning on the text you took.
Title-case headings stay as they are. They are the convention across the repo
and README.md's table of contents links to them.
If you cannot reach the skill, the one rule worth keeping from it: do not use an em dash as a general-purpose connector. Use the punctuation the sentence is already asking for.
Where a change to the documentation goes
README.md is for someone deciding whether to use this and then installing
it. docs/ is for how and why. A change that explains mechanism belongs in
docs/, and the README gets a sentence and a link to it. This is not a
preference someone applied once: README.md was 934 lines in 1.10.1, before
1.11.0 moved the internals out, and it is worth keeping that way.
The test is what a reader does with a paragraph. What the job does, the command
to run, that a setting survives a reboot, what turning it off costs: that is a
decision, so it stays. Why the crontab does not survive a firmware update, what
a backstop flag is for, which readout says what in which state: that is
mechanism, so it goes to docs/technical-details.md or
docs/troubleshooting.md and the README links to it.
Two things in the README are not subject to this. The Verification Status
table stays there because CONTRIBUTING.md makes it canonical and the issue
and pull request templates point at it. And the section headings are linked
from the table of contents and used as sed range anchors by test.sh, so
they are covered by the interface rule above rather than this one.
When you move a paragraph out, check that the docs file does not already say it, and that nothing was lost rather than saying so. Cite the lines that now carry it.
Keeping it true is the other half, and it has no safety net. The interface rule above catches a comment or string that code reads; a sentence describing what something does is read by people only, so nothing fails when it stops being true. The suite cannot tell, review usually cannot either, and a stale paragraph is worse than a missing one because it is trusted.
So the check is mechanical rather than remembered. Before you call a behaviour change finished, search the documentation for what the thing is called:
grep -rn 'AUTO_UPDATE\|write_env_file' ./*.md docs/
Read every hit, not the first. In this repository two sentences in the same section described the same behaviour, the code changed underneath both, one was corrected in the commit that changed it and the other was not, and the stale one then survived a review that edited the paragraph directly above it. It was found later, by accident, while reading that file for something else.
That is also the argument against saying a thing twice. One file owns a fact and the others link to it. A paragraph copied into the README because it seemed useful there is a second place to remember, and the copy is the one that goes stale, because the person changing the behaviour is looking at the file nearest the code.
The same applies to what a change makes true. This branch made a trailing
comment survive a rewrite, which turned a sentence in
docs/technical-details.md from correct to wrong and a sentence in
README.md from wrong to correct. Both needed finding. Grepping for the
behaviour you changed finds the first kind; the second kind needs you to ask
what the docs currently promise that the code could not previously deliver.
Scope discipline
One concern per commit, per CONTRIBUTING.md. If you're operating
semi-autonomously, resist folding an unrelated cleanup into the same commit
just because you noticed it while you were already in the file.
