Sainpost
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Sainpostwhere does my current investigation stand, and what have I ruled out?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Sainpost
サインポスト — Japanese for a signpost. Read it as Saint Post if you like. Look closer and there is an AI in it.
A shared lab notebook for long investigations, exposed as an MCP server. One database, every project on the machine.
What it is for
A long investigation rarely fails by diverging. It fails when an unverified intermediate gets promoted to a fact, and every later step is reasoned correctly on top of it. The footing drops out and nothing reports an error. You then re-derive hypotheses you already killed, because you do not know that you are re-deriving them.
Filing does not fix that. A searchable reference answers "is this known?", which you only ask when you already suspect. Humans fixed it with two things a reference cannot do, and both are built in here:
A commitment made before the data. note_hypothesis will not record an
attempt without its falsifier and an apparatus check. note_result has
nothing to attach a number to unless that pre-registration happened. There
is no update path anywhere — the record is append-only.
A position you can see. note_trunk prints where you are: the current
question, the chain of reformulations that led to it, one line per attempt
underneath. Being lost is the loss of position, so the trunk is small on
purpose and meant to be re-read at every checkpoint. Bodies come from
note_open.
Related MCP server: project-memory-mcp
The shape of the record
project ─ question ─ attemptThe position does not move while an attempt is open. Open attempts are the position — one question, with every arm open under it — and the trunk lists only the current question's attempts, so moving it made the falsifier somebody had committed to vanish from the view they re-read at every checkpoint. It fired when another project filed a question, and equally when you superseded your own question mid-measurement. The second case came back unnoticed: this paragraph said it was fixed, no test held it, and the later change that moves the pointer off a closed question moved it off a superseded one with work still open under it. The position is now read from the open work itself, and a test holds it. A question asked in the meantime waits, and becomes current when the last open attempt closes — which
note_askpromised for a long time before anything did it. Which question that is, is recorded when it is asked: reconstructing it later needs "asked in the meantime" from the row, and both candidates are wrong. The numbering says only which was written first, so a tree laid out in advance sends the position to the highest number instead of where anyone was working; and these timestamps are whole seconds, so by the clock a question asked in the same second as the attempt it interrupted was never in the meantime at all.Work can be opened under a question that is not the current one (
note_hypothesis(question="Q3")), and the position follows it there. The pointer used to move only to the question most recently asked, so a tree laid out before any work began left every requirement but the last unreachable: the only order that worked was to open one question and measure it before writing the next, which is the opposite of laying the argument out first. Refused while work is open under another question — that is what one front means — and nothing moves the position without opening work there, so it never points at a question nobody is on.The position never rests on a closed question while another is open. It is read past one, onto the most recently opened. It used to move only at the instant a question closed, so a pointer already resting on a closed one stayed there for good: this repository's own project sat on answered Q7 with Q5 open, and
note_hypothesisrefused it everything, telling it to ask a new question in order to reach an open one.A question can be superseded by another question. Reformulating the question is usually the actual finding, so it is a node, not a rename — its attempts stay readable instead of dangling under a dead question.
A question can also stand under another: what its answer will rest on. That is the opposite relation, and until it existed the only one available said the earlier question had been wrong.
Attempts link by
prompted_by: which prior result made you try this. Not a taxonomic parent; you never know the parent while you are lost. You always know which number you had just read.resultis measurement,verdictis the call,why_nowis interpretation of n=1 and is labelled as such wherever it is rendered. The narrative of a path is exactly where interpretation gets promoted to measurement, so the three never share a field.
Install
claude mcp add --scope user sainpost -- python /home/you/work/Sainpost/launch.pyclaude mcp add --scope user sainpost -- python C:\Users\me\work\Sainpost\launch.pylaunch.py builds .venv/ from requirements.txt on first use (one
dependency, mcp) and then serves stdio from inside it. Nothing else to
start.
It takes the platform from os.name, so the same command is the whole
install on either. Two things differ underneath it: the venv keeps its
interpreter in Scripts/ rather than bin/, and Windows has no exec.
os.execv() there spawns a replacement and exits the process the MCP
client launched, so the client sees the server it is holding disappear
mid-handshake and reads it as a crash — on Windows the launcher stays alive
as a thin parent instead, handing the child its own stdio handles.
The database lives at ~/.local/share/sainpost/research.db, and at
%LOCALAPPDATA%\sainpost\research.db on Windows;
SAINPOST_DB moves it. It is plain SQLite — if this server is ever
abandoned, the notebook still opens.
Windows deliberately ignores XDG_DATA_HOME, which git-bash, msys and
WSL-interop shells all export. The notebook is machine-wide — that is the
whole reason it lives outside any repository — so a path that depended on
which shell happened to start the server would quietly give the machine a
second notebook, and the two are not distinguishable from inside: each
looks like a complete record that is simply missing the other's work.
It runs in WAL mode, which is what lets two agents share it, and that has a
trap worth knowing: recent writes live in research.db-wal, so cp research.db elsewhere on a live notebook yields a file that opens cleanly
and contains nothing. Three sessions of records were lost here to exactly
that. Store.close() checkpoints, so a notebook nobody is attached to is
whole in the one file — and backup.py is always safe, because it goes
through sqlite's own backup API rather than the filesystem.
An older notebook is brought to the current shape by the first session that
opens it, and every other session opening it in the same instant waits and
finds it done. Almost all of that is adding columns. The exceptions are
constraints SQLite cannot relax in place, and those tables are rebuilt in
SQLite's own order — the new table built beside the old, then renamed into
place. The first rebuild here renamed the old table out of the way first,
and SQLite rewrites every reference to a renamed table: defect_notes came
out pointing at defects_was, dropped a moment later, and any note on a
defect after that would have failed with no such table. A notebook in that
state is repaired on open.
A pointer for every agent on the machine belongs in ~/.claude/CLAUDE.md:
name note_projects() as the entry point (it is the only tool that needs no
arguments, and every other one wants a project name you do not have yet) and
point at this file. Nothing else — it is loaded into every session on the
machine, including all the ones that will never open a notebook.
Seeing the whole path
note_trunk answers where am I and stays small by showing one question.
That is the wrong shape for the other thing an agent arrives wanting: what
has already been ruled out here. note_path(project) is that view — the
digest first, because a FALSIFIED row is the most valuable kind in this
record and is worth nothing unread:
PROJECT t3-sweep
1 BLOCKED, 1 CONFIRMED, 3 FALSIFIED; 1 question(s) answered (1 not endorsed by the user)
the question moved: Q1* -> Q2 -> Q3 (* answered)
CLOSED OFF -- carried to a verdict already. Do not re-derive these.
A2 FALSIFIED frontage() takes a cost argument we are not passing
-> signature is (pad, repair=False); dir(escape) == ['frontage']
A4 FALSIFIED MAX_DETOUR_MM is defined somewhere we can read
-> named once in a docstring, defined in no scope
STANDING -- what anything you do next will be resting on
A1 the via cost never reaches the joined-span computation
-> spent entirely on `c > 0`; N=6 at every cost
NOT SETTLED
A3 BLOCKED --repair raises the joined count above 6/8
D1 -> corridor-team
THE PATH
Q1 why is the count identical at every via cost [answered, not endorsed by the user]
A1 CONFIRMED the via cost never reaches the computation
ANSWER (not endorsed by the user) the sweep is not measuring anything
Q2 can the board team wire the cost through themselves [became Q3]
A2 FALSIFIED frontage() takes a cost argument <-A1
A3 BLOCKED --repair raises the count <-A2The verdict that closed each line comes with the number that closed it —
"MAX_DETOUR_MM is defined somewhere" on its own does not tell anyone it is
not. BLOCKED, INCONCLUSIVE and OPEN are kept out of closed off:
none of them means ruled out, and filing them there would retire live work.
Still one line per node; bodies come from note_open.
Three cold agents in a row rebuilt this by hand, calling note_trunk once
per question after guessing the labels.
Every node carries its own line, written by whoever wrote the body. A
question, a hypothesis, a result, an answer, a note: if the body is longer
than 96 characters, headline= is required, and that is the line every view
but note_open shows. Shorter bodies need none — they are already their own
line, and a field that asks for a second copy of a one-sentence question is
how why_now came to hold paragraphs explaining that it did not apply.
note_result(project, <2,000 characters of measurement>, "CONFIRMED",
headline="23件中20件を説明、残り3件は記録の欠落")The line used to be the body with a machine's cut in it — a summary nobody wrote and nobody checked, which is the one kind of claim this record refuses everywhere else. It also cut badly. Backing up to the last space assumes there is one near the cut; a Japanese line has none, so a 97-character question came back as its four-character tag, and a tree of them said nothing at all. Rows written before headlines existed are still cut to fit, and no headline is invented for them: there is no edit path here, and a summary written now by somebody who was not there is exactly what the field exists to prevent.
A trail too big for one reply becomes the map of itself. note_path
renders the whole thing while the whole thing is under 6,000 bytes, and
otherwise renders a page: where the work is, the open questions, what was
worked most recently — one line each, with what came of the attempts under
them as a count — the rest counted, and the calls that open any of it.
PROJECT pcbrouter /home/you/work/PcbRouter
6 ABANDONED, 6 BLOCKED, 116 CONFIRMED, 126 FALSIFIED, 21 INCONCLUSIVE
the whole trail is 27,813 bytes; this is the map of it
NOW Q38 4層に残った配線のうち何本が… 55 CONFIRMED, 58 FALSIFIED, 5 BLOCKED
OPEN Q11 配置そのものを機械が決められるか… (nothing tried)
RECENT Q40 難しさの違う盤を並べて… [answered, not endorsed by the user] 1 CONFIRMED
... and 29 more question(s), by label
READ note_path('pcbrouter', question='Q<n>') one branch, closer in
note_trunk('pcbrouter') where the work is now, with the falsifiers
note_open('pcbrouter', 'A12,Q7') any node whole
note_search('<what you are about to try>') across every projectThat is 3,150 bytes for a project whose trail is 172,697 — the same move the record itself makes, a line that says what is there with the body one call below it, applied to the project as well as the node. A branch too big for one reply does it again, and names its attempts by label and verdict so there is something left to open.
The ceiling underneath it. Claude Code hands the model at most 25,000 tokens of an MCP result and warns past 10,000; over the limit the text goes to a file and the model gets a path — the whole trail, present as a filename. A branch is budgeted at 28,000 bytes, counted rather than estimated. Bytes, because a Japanese line is three bytes and roughly one token per character, so a budget in characters passes three times too much of it through.
Three things keep a rendered trail inside it, and none of them is silence:
Each fact once. The digest says what every attempt found and names the question it sits under; the tree is the shape — one line per question with the verdicts under it as a count. The hypothesis used to print in both, so 275 attempts went through twice and came to 170 KB.
The map is never cut, the list is. A project has far fewer questions than attempts, and a reader who cannot see a branch cannot ask for it either. What the budget cannot fit is the digest's older rows — the branch being worked first, then newest — and
note_path(question="Q7")reads any branch whole.What is left out is said, in rows and in the call that opens them.
NOT SHOWN: 115 standing; 132 closed off.
note_path(question='Q<n>') opens one branch whole; note_open gives any label its body.Measured across this machine's twelve projects: every one now renders between 118 bytes and 4.8 KB, where the largest was 172,697 bytes — and the trail that used to be 2.3× the client's hard limit is a page of about a thousand tokens.
One line per node is a size rule, not only a style one, and it reaches
the question's own body: that was the last kind note_path printed whole,
unnoticed while questions were a sentence long and fatal the day one carried
its materials and its checklist into a tree view. Cutting a line to fit is
its own trap: backing up to the last space assumes there is one near the cut,
and a Japanese line has none, so a 97-character question came back as its
four-character tag, and a tree of them said nothing at all. It was written
here two lines below the example while the code printed whole answers, and on
the first project to accumulate real history that reached 65,177 bytes
against a 64 KiB tool-result ceiling — 359 bytes of headroom, one answer from
going over, with the client truncating rather than refusing, so a reader
would have got a path stopping mid-answer and nothing saying so. Answers and
postscripts were 94% of it. They are first lines now, the footer says how
many characters were left out, and note_open gives any of them whole.
The agent that reported it had checked whether an earlier fix had landed by
reading the SQLite file directly rather than calling note_path — pulling
every answer back to read one header line cost more than the check was worth.
A tool documented as the first thing to read when picking up work you did not
start, routed around by the one agent using it.
The same rule reaches what a read of prior art hands back: already_tried
and in_flight give one line of each result, which is where "somebody
carried this to FALSIFIED, and here is what fell over" lives. Four of them
arrived whole on a single pre-registration — 7,000 characters of numbers
nobody had asked for, with six more questions still to register.
And it reaches what a write hands back. A question carries its
materials and what it has to settle, and a paper's ran to 900 characters;
note_hypothesis and note_request each echoed it whole, so six calls in
one afternoon returned that body six times to the agent that had just
written it. Where a view names the question a node hangs off, it is one
line, and note_open on the question gives the rest.
Looking at it yourself
Every view above is sized for a model's context. The person the agents are working for arrives with the same two questions — where is the work, and what has already been ruled out — and reads the answer faster as a picture:
python -m sainpost.view # serve it at http://127.0.0.1:8765/
python -m sainpost.view --out tree.html # one self-contained file insteadEach project is drawn as its tree: questions under what they serve, attempts
under their question, coloured by verdict. A replacement stands beside the
question it replaced with a dashed arrow from the old one; prompted_by is
drawn on the right, bold for whatever is selected. Clicking a node gives its
whole body, with why_now marked as interpretation and the result as
measurement, the way note_open labels them. Clicking the project gives the
digest: where the work is, what is closed off, what is standing, who is
blocked on whom. A project with more than 60 attempts opens with them folded
under their questions, except on the branch being worked.
It reads only, and the same code the tools run. The notebook is opened
through Store.read_only, so SQLite itself refuses every write, and what is
drawn is trail() and open() — a second reading of the record written for
the page would be a second record that could disagree with the first. A
notebook no server has opened since the schema last grew is missing columns
those reads name, and a reader cannot add them; it says so and names them,
rather than failing inside trail(). Restarting the MCP server grows it.
Served, each reload reads the notebook again, so a page left open beside a working agent shows what it wrote. The server answers only on 127.0.0.1 and only to a request addressed to it by its own name: the page is every project on the machine, including other people's work in flight. The written file carries the same, so it is not a thing to publish.
What was learned after the answer
An answered question used to be a dead end: it could not be superseded and could not be answered again. That is right for the answer and wrong for the question — six months on, an answer can turn out to have been partial, and the only move was to open an unrelated new question that pointed at nothing.
note_postscript adds what was learned later. The answer is never
touched:
Q6 Does keeping the notebook's dump in git cost more than it is worth? [answered, ...]
ANSWER (not endorsed by the user) Keep it in git; back the record up to Drive as well. ...
LATER (2026-09-07) The per-backup figure above is a floor, not an
estimate. Every measurement behind it used synthetic attempts
whose fields were the same string repeated hundreds of times ...Both are kept, each with its own date, and both print wherever the answer prints. Rewriting the original to look like what you know now would destroy the one thing that made it worth recording — what was believed at the time, and on what evidence — which is the same failure this record exists to stop, wearing its own clothes.
An answer can accrue more than one over years; each is written once and never edited, like everything else here. It is refused on an open question: there is no answer to add to yet.
On an attempt (A3) it says what the result turned out to be worth.
Not for a finding that merely went out of date — that is a new attempt with
prompted_by pointing back, and the old measurement was never wrong, it
measured a world identified by its commit. This is for the measurement that
was invalid and nobody noticed: a sweep run against a broken rig produces
a plausible number, and written down it is indistinguishable from a sound
one. Nothing could mark it.
The verdict is not revisable and a note does not revise it. It is bound to a
falsifier written before the data — "the count did not move" either
happened or it did not. What the note adds is that the number should not be
believed, and why. shown_by names the attempt that demonstrated it; without
one the note renders as INTERPRETATION, the same way why_now does, because
a claim that a measurement was invalid is itself a claim and it is not free.
A1 INCONCLUSIVE ...
-> Sweep over 46 parameters ... 0 type mismatches ...
LATER (2026-09-07, shown by A2) The sweep behind this result was
measuring nothing ... the 0-of-46 figure should not be quoted
as a measurement of anything.On a request or a defect (R3, D2) it says what the decision or the
fix turned out to rest on. A decision's reason and a fix's cause carry
claims, and the first one to need this was a count: a request accepted
because "16 of 89 near constraints are affected", whose check was fixed the
same afternoon and found 5 — two of the eleven that vanished refuted
outright by copper the router had already laid on that board. The
correction existed, in a commit message and a CLAUDE.md. The request still
said 16, as decided, to everyone arriving through the notebook, because
note_postscript refused the label.
The decision stands, the way a verdict does. Only on something closed — an
undecided request or an unresolved defect has nothing to add to, and a live
defect already has note_query. These labels are notebook-wide and the two
sides are different projects, so the note records which side wrote it,
and only those two sides may: a third project leaving notes on somebody
else's decision would be writing their record for them. shown_by is an
attempt of the writer's own project and prints with the project name, since
A9 on a notebook-wide label names nothing. note_open takes R labels
too; a decided request otherwise left every view but
note_defects(closed=True).
A question that was answered can still be replaced
Answered is not the same state as superseded, and one refusal message used to block both by naming the wrong one. A settled question is exactly the kind whose frame later turns out to be wrong: the measurements under it stay valid and stop meaning what they were taken to mean.
note_ask(..., supersedes="Q1") now works on an answered question. Q1 stays
answered, its answer and its attempts are untouched, and the two are linked
both ways; new measurement goes under the successor. There are three
different things here and each has its own path — the world changing (a new
attempt, with a commit that says which world), the frame changing
(supersession), and the measurement having been invalid (an attempt note).
Only the last two were missing.
Whose answer it is
An answer is written by an agent. Mostly that is all it needs to be: the answer to why is the count identical at every via cost is what the measurements under it say, and the agent that took them writes it down. Some questions are not the agent's to settle — what a paper claims, what is in scope — and the record could not tell the two apart.
A paper's notebook is where that cost something. The agent laying out the
argument asked what does this paper claim, answered it with a claim of its
own — held to what a working check reads — and opened eighteen questions
under it. The author never accepted it, and later turned it down in so many
words; two postscripts recorded that. The answer stayed [answered], and its
own line went on reading as settled, ahead of the notes, to anyone who did
not open them. When the drafts kept drifting back to that claim, the
project's own investigation of why named this as one of four routes:
sessions resuming the work read note_projects and note_path first, as
the machine's instructions say to, and read the claim back as the author's.
That is the project's reading of its own history, one case; it is also the
request this answers.
So an answer says whose it is, on its own line, in every view:
Q1 what does this paper claim [answered, then REJECTED by the user]
Q2 what must be shown for it [answered, then WITHDRAWN]
Q3 what the author's brief claims [answered, endorsed by the user]
Q4 why the drafts kept drifting [answered, not endorsed by the user]Not endorsed by the user is what every answer an agent writes is, and it asks nothing of the writer: it is the true state of an answer nobody has put to the user. The tally at the top of
note_pathcounts each kind.Endorsed by the user takes their own words, verbatim, and where they said them —
note_answer(..., user_said="「1にしよう。」 (chat, 2026-09-30)")when they decided it,note_postscript(label, text, answer_is="ENDORSED", user_said=...)when they accept it later. The user is the person the work is for. A message from an agent — the one writing, or the one that delegated to it — is not theirs, and a paraphrase is the agent's reading of them, recorded as them.Rejected by the user (
answer_is="REJECTED", with their words) and withdrawn (answer_is="WITHDRAWN", the writer's own finding that it no longer stands, with no words of the user's) both mean it no longer stands.
Endorsing and rejecting need the user's words for the reason a note on an
attempt with nothing measured behind it renders as INTERPRETATION: a claim
that the user holds something is itself a claim, and it is not free.
Withdrawing needs none — saying an answer no longer stands takes nothing from
anyone that they were entitled to rest on.
Nothing written is touched. The answer stays as it was written, with its date. Each mark is a postscript with its own date; the latest one is where the answer stands, and every one of them prints. It is a field rather than a sentence in the note because the code has to act on it, and the two postscripts on that paper's claim said it no longer stood in sentences. Nothing reads a postscript's text for words like replaced: a row written before these fields existed reads as not endorsed, which is what it is, and adding the mark is one more note.
When an answer no longer stands:
Its question's line says so wherever a question is one line — the map, the tree, and the
underlines of every question opened beneath it.note_trunkprints where it stands, with the user's words, before the answer, and the answer as one line with the call that gives it whole. Printed whole, it came to the front ahead of the notes that said it no longer stood.The CONFIRMED attempts opened under that question leave STANDING for NO LONGER STANDING. Their verdicts are bound to falsifiers written before the data and do not change, but STANDING is read as what to rest on, and on that paper two of the hypotheses in it were the rejected claim itself. Questions opened below it keep their own answers' standing, each marked on its own: on that paper some of the requirements under the claim went with it and some did not, and only the people working it can say which.
A citation of the question, or of an attempt under it, warns, whatever its role — it was learned later, like any note. That paper's requirements cited the claim's own attempt as support five times.
note_openandnote_searchgive where it stands before the answer.
An answered question that was later replaced (note_ask(supersedes=)) says
so on its line as well — [answered, not endorsed by the user, then replaced by Q5]. It used to say [answered] and nothing more.
An argument is a tree
A claim is supported by what it requires, and each requirement by what shows it. Written as a paper, that was sixteen questions: what does this claim, then five requirements under it, then two under each of those — which material shows this and which section says it.
The record had no way to say it. supersedes means the earlier question was
wrong, so using it for a requirement would be a lie; leaving it off made each
requirement a question related to nothing, and the digest then announced them
as "the question moved: Q1 → Q2 → … → Q8" — a project read as one long
reformulation, its earlier questions as abandoned framings, over questions
that had superseded nothing. Nothing stopped the root being answered while a
requirement under it was still open, either. The agent writing that paper
stopped before opening its second question and asked for the shape, because
labels are cited and a flat sixteen cannot be rearranged afterwards.
note_ask(project, "what does the claim require", parent="Q1") THE PATH
Q1 what does this paper claim, in one sentence [answered, not endorsed by the user]
ANSWER (not endorsed by the user) structure decides what an agent can handle, not perception
Q2 the claim must explain the failures too [answered, not endorsed by the user]
A3 CONFIRMED every failure is a place nothing was written down
Q4 which material shows that [became Q7]
Q7 which material shows it, in the board's own recordThe parent cannot be answered while anything under it is open, at any
depth — the same rule as an open attempt, one level up: an answer written
over a requirement still in flight is a summary, not a conclusion. A
replacement stands where the replaced question stood, so reformulating a
requirement does not move it out from under what required it; supersedes
and parent therefore cannot be passed together. Nothing opens under a
question that is already settled: its answer is written, and a gate added
afterwards is one that never held.
note_trunk prints the line of questions this one serves and the ones
waiting under it, note_path indents the tree, and the digest now names only
the reformulations that happened.
Standing on another project's record
A result or an answer can cite one:
note_result(project, "20 of 23 events explained", "CONFIRMED",
cites="de10nano-4ch-adc/A4, de10nano-4ch-adc/Q4")Prose could already name one, and that is the problem. A name in a sentence is not checked and does not change when what it names does. A paper's claim rested on about twenty attempts in a board's notebook; one of them had since been annotated — the number it reported had come off a rig that was double-counting — and the citing text went on reading as sound. That is this record's own failure, an unverified intermediate promoted to a fact, crossing a project boundary where nothing was watching for it. The same draft had already cited "119" from an article where the registered measurement was 113, and found out only by reading the whole attempt.
So a citation is a row, and what it names is read at the moment the citing record is read — never stored, never cached:
A1 CONFIRMED structure decides what an agent can handle
-> 20 of 23 events explained
RESTS ON de10nano-4ch-adc/A4: learned later about it (2026-09-15):
the 113 came off a rig that double-counted courtyardsIt warns when the cited attempt is open, BLOCKED, FALSIFIED, INCONCLUSIVE or ABANDONED (none of those is a standing result), when the cited question is still open or was replaced, when anything has been learned about it since, and when a reading inside it was reported false.
The role says which warnings are true of a citation. supports is the
default and leans on the row as a result. refutes: leans on a rival
explanation having been killed — a FALSIFIED row is the most valuable kind
in this record, and an argument that stands on one is standing on exactly
that — so there FALSIFIED is the sound verdict and CONFIRMED, OPEN or
INCONCLUSIVE is the warning. Filed as a recording instead, it would stop
warning and also stop saying that anything was leaning on it.
records: says you are citing what happened, not leaning on it. A paper
citing nine attempts as the events it describes — several FALSIFIED, cited
because they were falsified — got six warnings back, five of them "not a
standing result", and the note that mattered (the number behind one of them
had turned out to be an artefact) was a line among them. Prefixing a
citation with records: drops the verdict warnings for that one and keeps
every warning about what was learned later, which is true either way. The
default is the loud one: you have to say you are only recording. A citation that names no row
is refused: it reads as a reference and cannot be followed. Citations travel
with the record, and one whose target this notebook does not hold is carried
and said, rather than dropped or shown as checked.
The loop
note_trunk(project) where am I
note_hypothesis(...) pre-register. Returns hypotheses like this one
from EVERY project — settled ones with their
verdicts, and any another agent has open now
... do the actual work ...
note_result(...) the numbers, and a verdict
note_ask(..., supersedes=) when the question itself was wrong
note_answer(project, ...) when it is settledThe cross-project search is not a separate tool anyone has to remember: the only route to recording passes through it. It answers two questions at once, because they have different consequences:
already_tried— attempts somebody carried to a verdict. A FALSIFIED row here is days you do not spend.in_flight— attempts another agent has open right now, with their falsifier and apparatus. Reported, never refused: two rigs measuring one claim is a replication and worth having; the same measurement twice by accident is waste, and only the caller can tell which.
More than one agent
The notebook is shared by every project on the machine, so two agents writing at once is the ordinary case rather than an exotic one.
Handing over. The handover document is the trunk. note_projects shows
which projects have an attempt still open; note_trunk on one of them shows
the position; note_open gives the body. An open attempt prints its
falsifier, its apparatus and how long it has been open — those three are
the handover, and they disappear once it closes, because a closed attempt
hands over its result instead. The receiving agent may finish someone
else's attempt with note_result, or close it ABANDONED if it cannot
reproduce the apparatus named. It may also open an arm of its own beside it
under the same question — but not move on to another question while it is
open, and the question cannot be answered until every arm under it has closed,
so an inherited attempt cannot be quietly left behind.
Arms of one question, side by side. A question often has competing
answers worth measuring at once — the solver is faster vectorised on the
CPU, the solver is faster on the GPU — and a project's agent may hand each
to a helper and run them in parallel. Each is pre-registered on its own, with
its own falsifier, and each is closed by label; with more than one open,
note_result and note_blocked refuse to guess which one a number belongs
to. The rule that is left is the one that was always doing the work: the
front is one question. Attempts open under two questions at once are still
refused, because that is two positions and the trunk shows one. It is one
front per notebook: two machines working one project each keep their own,
and the trunk lists the other's open work apart, as open_on_other_notebooks
(see the next section).
QUESTION Q1 which device makes the solver faster at N=1e6
A1 OPEN the solver is faster vectorised on the CPU
falsifier: CPU median is not below 0.8x the baseline
A2 OPEN the solver is faster on the GPU
falsifier: GPU median is not below 0.8x the baselineTwo arms are compared in the end, and they can only be compared if they were
measured the same way. So each new arm gets back the arms already open beside
it — their measure, falsifier and apparatus — at the moment it is
pre-registered, before any number exists rather than after both do. And
prompted_by cannot name a sibling: it points at a result that was read, and
an arm in flight has none. The old rule enforced that by itself, and a test
said so, predicting that relaxing it would let an in-flight attempt become an
edge unnoticed; the guard is written out now.
One thing the record cannot see: every attempt reads its commit from the
project's repo. An arm whose code lives in another checkout — a git
worktree, which is how parallel helpers usually get one each — is recorded
against that checkout's commit, not its own. note_hypothesis says so
whenever an arm opens beside another.
This used to be one open attempt per project, and parallel agents took a project each. That split one question's arms into records that could not see each other, which is the failure the notebook exists to stop.
Working in different projects. What connects them is note_hypothesis,
which searches every project on every write, and in_flight in particular:
it exists because two agents can otherwise pre-register the same hypothesis a
minute apart and neither will ever learn it. The other attempt has no
verdict, so prior art cannot mention it, and note_search is the one tool
nobody is obliged to call.
One writer at a time, at the file. Every guard in the store is a read
that authorises the write after it. Those pairs run inside a single
BEGIN IMMEDIATE, with WAL and a busy timeout, so a second agent waits and
then reads what the first one actually wrote. Without that, two agents
opening arms at the same instant are handed the same label, and the rules
above hold for one agent and quietly stop holding for two — which is this
server's own failure mode, arriving through the server.
tests/test_two_agents.py fails against a deferred transaction.
The same project on another machine
The notebook is per machine, so a project worked on from two of them had
two records that could not see each other — and each looked complete, simply
missing the other's work. note_export writes one project's record into its
own repository, and note_import merges it on the other side:
note_export("PcbRouter") # -> <repo>/.sainpost/PcbRouter.json
... commit, push; pull on the other machine ...
note_import("/home/you/work/PcbRouter") # every .sainpost/*.json therepython -m sainpost.share export PcbRouter [path]
python -m sainpost.share import [--dry-run] <file-or-repo>Look before you import. note_import(path, dry_run=True) runs the same
merge inside a transaction and rolls it back, so what it reports is what the
import does rather than a forecast of it: which project the file lands in,
what it adds and closes, what it leaves behind, and what it would be refused
for. An agent that asked for this had read the code and opened the database
read-only to work that out by hand, because the only other way to learn what
an import does was to do it — and an import cannot be taken back.
Import before writing anything to that project, export when you stop. Both machines write, in turn, so an import is a merge of two append-only records — and it can be one only because nothing here is edited. A row exists or it does not; the only changes to one that exists are one-time transitions (an attempt closing, a question answered or superseded, a request decided, a defect queried, replied to or closed), each NULL-to-value, once. An import adds the rows this notebook lacks and applies the transitions it has not seen, and overwrites nothing.
Labels are never renumbered — and a number is not unique. Labels are
cited inside other records — "A22 showed", "R3 was accepted on" — so a
renumbered row silently changes what every sentence pointing at it means. For
a while that was kept by refusing: a question or attempt was matched by its
label, the same label with different contents meant both machines had added
to the project without importing first, and nothing was written. That made
the ordinary way of working — two machines at once, on one investigation — a
refusal every time, and it left the record that was found here: PcbRouter
written on one machine and pcbrouter on the other, 19 and 40 questions of
one investigation, both numbered from Q1, and nothing that could join them.
So a question or attempt is now what requests and defects had become a
fortnight earlier (below): its number and what it was made with — ident,
a hash of its text and the moment it was made — and it carries the name of
the notebook that wrote it (origin). A notebook names itself the first time
it is opened: three characters, never changed, since they end up in labels
agents copy into text. A number held twice prints bare for the first holder
here and after the writer's name for the other:
Q20 is it the frontage written in this notebook
Q20@k7m is it the via stack written in notebook k7mA notebook's own rows print bare in it, so the two machines print the pair
the other way round — and each agent's text, which cites its own work bare,
reads right where it was written. The next number is past every holder, so a
number is only ever shared by rows made before the two notebooks saw each
other. note_open on text written elsewhere names the row a bare label in it
meant, the same way as for requests and defects (below).
What is still refused, with nothing written: a row closed differently on
each side; one notebook left open under two questions (one front per
notebook, above); and two projects under one name that are not one project.
That last used to be caught at Q1. With a Q1 able to arrive as Q1@k7m, it is
caught at the card: a project is made once and carried, so the same project
has the same card on every machine, to the second and to the word, and a
number both use under two different cards is refused as two investigations.
Two projects that are one investigation are merged by being told so.
note_merge(into, absorbed) moves every question and attempt of one into the
other and keeps every number. A number both held prints bare for the row
written in this notebook — or else the one into held — and after its
notebook's name for the other. The old name is read as the new one from then
on, by every tool and by the import of a file the other machine is still
writing under it. The merge travels: note_export(into) names what it
absorbed, and the import on the other machine makes the same merge there,
where that machine's own rows print bare. It is refused where it cannot hold:
a row carried in before files said which notebook wrote it (import the
current export of the machine that wrote it first — rows are matched by what
they are, and their notebook is recorded); a number one notebook used in
both, which that notebook's name cannot tell apart; and one notebook with
work open under a question in each. It is not undone. It says the two were
always one investigation.
A project is matched by its exact name, or by rename=. The first real
import met the case exact names leave: PcbRouter, written on one machine,
arriving where pcbrouter already recorded the same checkout. They were two
investigations of one repository — Q1 through Q14 were different questions on
each side — so naming one as the destination of the other could only have
been refused at Q1; making them one project would have meant renumbering
questions and attempts, which nothing then could avoid. Two projects on one
repository are allowed, and were already in the notebook. What was wrong was
that it happened without a word, so the import (and its dry run) says when a
project new here arrives beside one that records the same repository or
differs only in case — and now also when a side of its requests or defects
would become a blank card beside such a project.
The second case was different in kind. Trace-from-photos had filed its
requests to cadbridge, a blank card its own notebook made by naming it; the
notebook they had to reach knew the tool as CadBridge, with eight questions
and twelve attempts. Matched by exact name, every one of those requests would
have gone to a second, empty project. note_import(path, rename={"cadbridge": "CadBridge"}) reads the name as the project here — for the file's own card
and for every request and defect naming it as a side. It joins nothing the
checks above would refuse: a card made separately is still refused where the
two use one number, so what it can join is a card with nothing to renumber. The reading is recorded, because the other machine goes on
writing its own name, and one import that forgot to say so would file that
machine's next requests under a blank card for good — nothing here is moved
afterwards. So it is refused for a name that is already a project here, and
for one an earlier import was told means something else.
The case matters on disk too. PcbRouter.json and pcbrouter.json sit side
by side in a Linux checkout, and a Windows or macOS one cannot hold both — git
checks one out over the other, and the loser is a whole record gone from the
machine it was carried to. note_export refuses to write beside a file whose
name differs only in case, and asks for path=; the import reads the project
from inside the file, so the file's name is free. The one exception is the
file of a project merged into this one: the merged record is its successor,
and goes over it under its name.
note_export enforces the order from its side: it will not overwrite a file
that carries records this notebook does not have yet, because that file is
the other machine's work in transit, and replacing it would take that work
back out and guarantee the next write here collides with it.
R and D labels are notebook-wide, and each machine numbers its own, so two machines that each file one request have collided. For a while a request or defect whose number was taken here was left behind. The pair of files that ended that carried 32 requests and 13 defects, and the dry run was going to drop 42 of them — nearly every decision and fix the tool's repository most needed — while their text, which did come across, cited them: "R6 のカメラ読み返しで毎枚確認" eleven times, about trace-from-photos' own R6, into a notebook where R6 is a board request.
So a request or defect is its number and what it was made with — a hash
of its creation fields (ident), the same on every machine, and blind to the
names of its two sides, since rename= can read those differently. A record
whose number is held here by another comes across anyway, under the same
number, and prints the hash beside it:
R6 footprints を禁止する rule area… [PcbRouter -> PcbRouter]
R6@1cc456 screenshot の応答に、camera を… [trace-from-photos -> CadBridge]The record that held a number here first keeps printing it bare, so nothing already here is ever relabelled, and a new number is never one already taken. A qualified label is the same on every machine that holds the record; every tool that takes a label takes one.
A bare label means whatever the notebook it was written in gave that
number, so what a bare R6 inside carried text names is worked out where the
text is read, and never by rewriting it. The first file carried also said
"D4: プランが「0 avoid」と言った…" about its own D4 — and in that same file
D4 is a diode's pad (D4.1) and a MOSFET's drain (D4(67.31,49.53)), and
R1 a resistor on the gate. A substitution would have corrupted designators
as surely as it fixed citations. Instead the notebook records which writing
arrived from where (carried: a row's making, and each one-time transition
— an attempt opened on one machine and closed on the other is the ordinary
handover), and note_open on text written elsewhere names, beside it, the
record each bare label meant when that is not the one it names here:
"labels in text from another notebook": {
"R6": "R6@1cc456 screenshot の応答に… [trace-from-photos -> CadBridge] (in: apparatus, result)"
}A reader who follows the bare label anyway lands on this notebook's R6, and
its body lists the other records under that number. Pad references like
D4.1 are skipped; no label is ever written that way. It is still string
matching for a reader to judge, and it says so.
It is worked out where the text is read, not as each file arrives, because the cited record can travel in a different file: trace-from-photos' D4 and R19 cite R16, which only cadbridge's file carries. Resolved per file, the answer depended on which file was read first. The import that brings a record says so for the text an earlier import carried, and the dry run of a repository's files is now one merge rolled back at the end, so the second file's report already sees what the first one added. With records from more than one other notebook under one number, the record cannot tell which of them wrote a text, and the body says so rather than choosing.
A defect whose stopped measurement
belongs to a project this notebook does not hold says where that attempt
lives instead of claiming nothing was stopped, and a note citing an attempt
that is not here is left behind rather than printed as INTERPRETATION.
The position is a pointer each machine moves for itself. The file's is taken only when this notebook has nothing the file lacks. The repository path is not carried at all — it names nothing on the other machine; a project new to this notebook takes the repository the file was found in.
The file is UTF-8 with LF endings and is a pure function of the record: no
timestamp, no host, no local path, so an export that learned nothing leaves
it byte-identical. It is the project's whole record, including requests
and defects exchanged with other projects, so committing it to a public
repository publishes all of that. sainpost-project/1 carried every
reference as a number, which was identity only while numbers were unique in
a project. /2 carries each question and attempt with its ident and its
origin, and every reference between them — a parent, a replacement, the
question an attempt is under, what prompted it — as an ident. A /1 file
is still read, by upgrading it in memory; what it cannot say, which notebook
wrote each row, stays unknown until a /2 file of the same rows says it. A
server older than /2 refuses a /2 file rather than misreading it, so the
other machine is updated before it imports one. What the notebook records
about imports (which file, which writing, which name was read as which)
stays in this notebook; it names files on this disk.
It is the one thing the notebook writes into a repository it observes, and only when asked. The cost is said at export time: committed while an attempt is open, the file moves HEAD, and that attempt closes against a different commit than it opened on — which the record reports as the code having moved, on code that did not. Commit it after the attempt closes.
Which build was actually running
apparatus asks for "the check that the rig is sound: versions, flags,
which build is actually running". When the rig is code, prose cannot answer
the last one and a commit id can. Two failures follow from not recording it,
and neither reports an error:
The code moves under a measurement. You pre-register at one commit, measure, close at another. The number then reads as a fact about the current code and nothing disagrees. Both halves look completely normal.
A number is taken while the working tree differs from every commit. It cannot be repeated, so it is not a measurement — but it looks exactly like one once written down.
Start a project with a repository and every node records the commit it was created against, and whether the working tree differed from it:
note_start("ddr", "escape routing", repo="/home/you/work/router")The notebook reads and writes nothing. No commit, no ref, no push,
nothing under .git at all — every call carries --no-optional-locks, so
not even the index's stat cache is refreshed, and
test_nothing_under_git_is_written_at_all fingerprints every file under
.git before and after to prove it. A second test walks this module's AST
and fails if any git subcommand outside {rev-parse, status} appears.
That guard exists because the first version of this feature did the opposite. It built a tree, wrote a commit, moved a ref and pushed it, so that a dirty tree could be made reproducible. Every fault it produced came from that one decision: a working tree pushed off the machine before anything said so, refs left in repositories the notebook did not own, and — because a dirty tree got a fresh timestamped commit at each capture — "the code moved under this measurement" firing on byte-identical code, destroying with its own noise the single signal the mechanism existed to give. A notebook that modifies what it observes is not a notebook.
The two kinds of difference are reported separately, because they mean opposite things:
"measured_against": {
"commit": "8f2a51c66146",
"untracked files, in no commit": "__pycache__/"
}A modified tracked file means the code the number came out of is in no
commit and cannot be returned to. Untracked files are listed by name and
not judged — running any Python program creates __pycache__/, and an alarm
that fires on that is an alarm that gets ignored, while an untracked file can
equally be the experiment. Conflating them made the warning fire on every
Python investigation with a false claim, and one agent passed that claim to
another team inside a defect, telling them they could not reproduce something
they could.
Neither is a task. Committing on your behalf would make the sentence false
without making the number reproducible by anyone else — and a receiving agent
reported the earlier wording as an alarm with no path to ever being satisfied,
since the notebook never commits and its own instructions say to commit only
when asked. .gitignore is respected, because git's own status respects it.
A project started without repo= is not second-class; it simply records no
commit.
A stale repo path can be re-pointed, and only a stale one. A path is not
a claim about the world at a moment — which is what everything else here is,
and why none of it can be edited. It is a pointer, and pointers move: the
trial records that found half the faults in this server name commits that
lived in a scratch directory due to be cleaned up. note_start on an
existing project with a repo= re-points it if the recorded path no longer
resolves, and moves nothing else — a path that still exists is refused, and
the description is a claim and stays exactly as written. That narrowness is
the whole guard against this becoming a general edit path.
When the apparatus breaks and the fix is not yours
An agent implements something, another agent goes to measure with it, and a
trivial bug stops the run. Nothing was measured, so nothing was learned
about the hypothesis — but the four verdicts all say something about it.
ABANDONED is documented as a result about the budget;
a TypeError says nothing about the budget. There was no honest way to
close that attempt, and holding it open pins your project's only front to
somebody else's schedule.
BLOCKED is that verdict, and it is reachable only through note_blocked
with stopped=true, which frees the front and files the defect in one call. The two cannot
diverge: an attempt closed blocked with nobody named is a lost day nobody is
working on, and a defect with no stopped measurement behind it is the
wishlist this record has no room for.
The split is the feature.
the blocked party writes | the owner of the apparatus writes | |
|
| |
|
| |
|
| |
| ||
cannot write | the cause, or the fix — there is no field |
The reporter measured a symptom, not a cause; if it had measured the cause
this would not be somebody else's bug. A cause asserted without a
measurement, handed to the agent that owns the code as its starting point,
is an unverified intermediate promoted to a fact — this record's own failure
mode, crossing an agent boundary. So there is no parameter for one, and
test_the_reporter_of_a_defect_is_never_offered_a_field_for_the_fix asserts
that against the advertised schema, because a client fills in what it is
asked for.
Most of what the owner needs is inherited from the attempt and never
restated: which rig (apparatus), what was being measured (measure), and
why anyone cares (hypothesis).
invalidates is a closed vocabulary — NOTHING,
EVERY_MEASUREMENT_ON_THIS_RIG — rather than prose, because it is written on
every fix and prose written on every fix stops being read. It asks about
other measurements, never the blocked one: that produced no number, so a
value naming it could not mean anything. It is what stops
a plausible number taken through a broken rig from quietly staying in the
record as a fact.
The round trip, without a comment thread. NOT_ACTIONABLE comes back
saying what was missing, and the reporter files a new defect with
supersedes= — a new node with an edge back, the same shape a re-run of a
falsified attempt takes. Nothing is edited here either. NOT_REPRODUCIBLE
requires verify too: it passing there and failing here means the two rigs
differ, and that difference is itself an apparatus finding.
note_query asks the other side one question without closing anything, and
note_reply answers it — either direction. The owner needs it when the
crash is one line but nothing says what the thing was supposed to do; the
reporter needs it because needed is a one-shot at filing time and cannot
carry a question only the owner can answer. Exactly one exchange: a second
means the report itself was wrong, and re-filing already covers that. Every
other lever here is a resolution, which left an agent choosing between
NOT_ACTIONABLE — untrue, when the defect is actionable and yours — and
silence.
It shipped owner-to-reporter only, on the strength of one agent needing that direction. The very next agent was the reporter, read the tool's description, found its own situation named in it, and the door pointed the wrong way.
attempt="none" is the reporter saying no measurement of theirs is
involved — the bug is in a tool they are using, not measuring with. The
description had promised exactly that and, with arms open side by side, it
could not be said: omitting the label asked which arm does this close, and
naming one either closed a measurement the tool had not stopped or filed the
bug as a false reading inside a measurement it had nothing to do with. An
agent with two arms open filed nothing at all, and the bug it was trying to
report — the digest calling eight unrelated questions one reformulation —
waited for a door to be built. The refusal now names the way out where it is
hit.
Asking for something, as opposed to reporting something broken
note_request is for what you want a tool to do, from the project that
uses it. Nothing is broken, so it is not a defect and needs no measurement of
anyone's to be stopped. What keeps it from being a wish is because: a
defect proves it happened by pasting what came back, and a request has no
output to paste, so the cost of not having the thing carries the weight.
"Writing one at a time is tedious" is a preference; "Q1 through Q13 went
in as thirteen separate calls" is an observation, and it can be checked.
Naming the shape you want is allowed here, unlike on a defect. You know your own need. What stays forbidden everywhere is telling an owner how to fix something that broke — that is a claim about code you do not own, and it has no field anywhere in this record. Those two were lumped together for a long time under "no wishlists", and only one of them deserved it.
note_decide closes it ACCEPTED or DECLINED — not the defect vocabulary,
because a request is not broken and cannot be fixed; it is wanted or it is
not. The reason is required either way: declined with none reads as nobody
looked, and accepted with none loses what was actually agreed to, which is
rarely the whole of what was asked. Requests appear beside defects in the
owner's trunk, because what an agent needs is one answer to what is on my
plate, not two lists it has to remember to check.
The first one came from another project an hour after this existed, and was
accepted for note_ask and refused for note_hypothesis: an attempt commits
to a falsifier before the data, and writing several in one call is writing
several commitments nobody has had to stand behind separately.
A tool that worked and told you something false belongs here too, not in
note_request. Nothing has to have crashed and nothing of yours has to have
stopped: a tool that reports a position which is not the position has done
the worse thing, which is hand back a plausible answer. That is the failure
this record is built on, one level down — and for a while it had no home,
because the only defect path was framed around something breaking. Three
reports arrived as requests for exactly that reason.
A false reading inside a measurement that went through stops nothing,
and for a while nothing could say so. note_blocked closed whatever attempt
was open as BLOCKED, not measured. The first false reading to arrive
(R29) was a seam inspection reading a 0.1 mm step as a 138° crease, filed
after seven predictions and a control had all been measured — so the call
that reported it recorded that measurement as one that never happened, for
good, and its numbers could only go back in as a note rendered as
interpretation. Calling note_result first was the one order that kept
them, and nothing said so.
So note_blocked asks, whenever there is an attempt to touch: stopped,
refused until it is answered, because only the reporter knows whether a
number came out and either default is wrong for somebody. stopped=true
closes it BLOCKED, as before. stopped=false files the defect against a
reading inside the attempt and touches nothing else — an open attempt stays
open for note_result, a closed one keeps its verdict — so the two calls
can come in either order. The link has its own column, reading_in;
attempt_id still means only the measurement a defect stopped. It is
printed wherever the verdict is — the trunk, note_path, note_open, and
beside the falsifier in what note_result returns — because a verdict can
rest on the reading: that first one had fired a pre-registered falsifier.
A bug in a tool you are only using is the same thing and needs no
measurement of your own to be stopped. That requirement shut out exactly the
reporter who most needs to be heard: a second agent recording an
investigation here had thirteen questions, no attempts, and therefore no way
to report anything about the notebook it was using. What keeps this list from
becoming a wishlist was never that rule — it is invocation and observed
demanding verbatim text, and "it would be nice if" cannot be written into
"what came back, verbatim". Where the reporter was is read from the record
rather than typed.
Filing against a project nobody has started yet gives it a blank card,
and its own team fills that in with note_start when they arrive. Requiring
the owner to exist first made a reporter run note_start for another team:
writing a description of an investigation it knew nothing about, which is
precisely the unverified intermediate this record refuses everywhere else.
Adopting a blank card is not an edit — it is the first time that project's
own team has spoken.
note_defects() is the machine-wide list of measurements currently stopped
on somebody. It cannot rot into a backlog: a row exists only while a
specific pre-registered measurement is stopped by it. Both directions also
appear in the trunk — why your front is empty, and whose front you are the
reason for:
PROJECT ddr
QUESTION Q1 why do U2's four byte lanes jam
A1 BLOCKED via price is too high to escape to another layer
-> not measured -- the apparatus failed, see D1
YOU ARE BLOCKED ON (note_open for the body):
D1 TypeError: 'NoneType' object is not subscriptable [router-impl]Backing it up
The record lives outside this repository, so it is the one thing here with
no second copy. backup.py makes one:
python backup.py # snapshot, send it off this disk, commit
python backup.py restore # rebuild the database from the dumpSAINPOST_DRIVE says where the copy goes — either a directory some sync
client already watches, or an rclone remote like gdrive:Sainpost:
export SAINPOST_DRIVE=gdrive:Sainpost[Environment]::SetEnvironmentVariable(
'SAINPOST_DRIVE', 'C:\Users\me\OneDrive\Sainpost', 'User')The server runs it after every call that wrote. Run by hand, it was not
run: for three weeks every copy at the destination was a test fixture — the
suite's backup tests inherited the real SAINPOST_DRIVE and sent their
notebook there — and the real one had no copy off its disk. So whether a call
wrote (the connection's total_changes, not a list of writing tools) is what
starts backup.py --no-commit in the background: one run at a time per
server, writes during it make one more, and a lock keeps two servers from
running it at once. At the destination the new file lands beside the old and
replaces it in one step, so a run stopped partway leaves the copy that was
there. SAINPOST_AUTOBACKUP=0 turns it off; the tests do, and take the
destination away.
A OneDrive or Dropbox folder is the directory case, and on Windows it starts with a drive letter — so the two forms both contain a colon, and the whole difference between them is that a drive letter is one character long. Deciding on the colon alone told a Windows user that their own backup folder was an rclone remote, and rclone is not installed: an error about a program they had no reason to have, for a directory that was sitting right there.
Two files go, and neither is a cp of a live notebook. research.db is
a consistent snapshot through sqlite's own backup API — it restores by
copying and is the thing itself. research.sql is the same record as text —
readable, greppable, salvageable by hand if the binary is ever damaged, and
the one that diffs if it is in git. Together they are under 150 KB, so
choosing between them buys nothing.
Never cp the live .db yourself: WAL keeps recent writes in
research.db-wal, so the copy opens cleanly and contains nothing. Three
sessions of records were lost here to exactly that, and
test_the_database_copy_goes_through_sqlites_backup_api leaves a notebook
open and mid-write to prove the backup does not repeat it.
The send happens on every run, including one where nothing changed: nothing to commit is not nothing to send, and a destination that was only just configured, or that the last run could not reach, is exactly the case that must not be skipped. An unreachable destination never costs the backup — the dump is already written locally.
It writes notebook/research.sql — a SQL dump, not the .db file. The
record is append-only, so its dump is append-only too: each backup adds
lines and changes none. The dump contains no timestamp and no hostname, so a
run that learned nothing produces no commit and no new version.
It is written as UTF-8 with LF endings whichever machine writes it. The
locale is no more part of the database than the hostname is: Python's default
would have given the same record CRLF on Windows, and on a Japanese Windows
the console codepage is cp932, which cannot encode an accented word — a
notebook that had recorded one would have ended the backup with a
UnicodeEncodeError rather than a file.
notebook/ is gitignored. The notebook is machine-wide: it carries every
project's investigation on this host, and once a second agent starts using
it, that includes other people's work in flight. That is a scope decision,
not a size one — the measured cost of keeping it in git was single-digit MB
over years.
What that gives up is the ability to say which backup introduced a
corruption, so the destination keeps it instead: the copy being replaced is
moved under history/<date>/ rather than overwritten, and a run that changed
nothing files nothing. Drive's own file versions expire; these do not.
The snapshot is taken through sqlite's own backup API, so it cannot catch a half-written transaction and cannot block an agent that is writing one. Restore refuses to land on an existing notebook — there is no update path anywhere in this record, and that has to hold for the backup too.
The repository must be private. The dump is every investigation on the
machine, and a public push is not a mistake anyone gets to take back. The
script asks gh before pushing and refuses on a public remote; it also
refuses when it cannot tell, which --push-anyway overrides. Nothing
overrides a confirmed-public remote.
Rules the server enforces, and why
Rule | Why |
Open attempts all sit under one question; several may run side by side under it | Two open fronts is how position is lost. Arms of one question are one front. |
With arms side by side, a close names its attempt | Guessing would record one arm's number against the other. |
| Most lost days are a stale or misconfigured rig producing plausible numbers. Both fields are written while you are still clear-headed. |
Attempts close once, never edited | If the finding changed, open a new attempt and point |
A question cannot be answered with work open under it — an attempt, or a question opened under it at any depth | An answer that skips an unclosed front is a summary, not a conclusion. |
An answer is the user's only with their own words, verbatim; every other answer shows as not endorsed by the user | An agent's answer read back as the author's claim moved a paper away from what its author had said, one resumed session at a time. That the user holds something is a claim, and it is not free. |
A note can say an answer no longer stands; what was confirmed under it then leaves STANDING | Said in a sentence, the correction sat under an answer whose own line still read as settled, and the next agent cited the answer again. |
A citation names a row or is refused, and is read as the cited record stands now; the role decides which verdict warns, never whether a later note does | |
A body longer than one line carries a headline, written with it | Every view but |
A write hands back the check, not the writing |
|
| Not "from the second attempt on", which is what the field said: an arm opened beside the first attempt is a second attempt with nothing measured, and the agent in that position wrote the paragraph the rule exists to prevent. A client fills in what it is asked for, so the advertised rule is the rule. |
Guard and write share one | Otherwise the rules above hold for one agent and quietly stop holding for two. |
| A front closed as blocked with nobody named is a lost day nobody is working on. |
| Assumed, it closed a finished measurement as unmeasured, for good, because one reading in it was false. Only the reporter knows whether a number came out. |
A request or defect is its number and what it was made with; the first to hold a number here prints it bare | Each machine numbers its own, so numbers collide as a matter of course. Left behind, 42 of 45 records in one pair of files would not have arrived; renumbered, every sentence citing them would change meaning. |
Carried text is never rewritten; what its bare labels name is shown beside it | A bare label means what the writer's notebook gave that number. In a circuit record R1 or D4 is as often a part, so a substitution corrupts designators as surely as it fixes citations. |
The code state is read, never typed | An agent asserting which commit it measured is a claim. |
The notebook writes nothing to the repository it observes | A record that modifies its subject is not a record. Every fault the capturing version produced came from that one decision. The one exception is |
A defect's reporter has no field for the cause | It measured a symptom. A cause asserted without a measurement, acted on by someone else, is this record's failure mode crossing an agent boundary. |
A FALSIFIED row is the most valuable kind in here. It is what stops the
next session spending days on something that looks like it obviously should
work.
Tests
.venv/bin/python -m pytest tests -qtests/test_code_state.py runs against real git repositories — a capture
that works against a mock is not evidence about anything, since the whole
mechanism is git's behaviour. tests/test_defects.py guards the split between what the blocked party may
write and what only the owner may. tests/test_two_agents.py runs two
connections at a barrier; it fails if the
guards are read outside the write lock. tests/test_the_door.py drives
launch.py over real stdio JSON-RPC and
checks the JSON schema each tool advertises. It exists because every tool in
this server once shipped broken while 41 in-process tests passed: calling a
decorated function directly skips schema validation, so a wrapper that
flattened the signature to (*a, **kw) was invisible from inside. Those
three tests fail against that version. A suite that never opens the door
does not test the door.
Deliberately not built
Free-form observations. Side findings go in
resultor in the question's answer. A general notes field becomes the dumping ground that the trunk then has to filter, and the trunk's whole value is being small. A defect is not an exception to this: it has no body and no comment thread, only named fields, each refused when blank. One vague field is how a bug report arrives unactionable — which is the same failure as one vague word in a conversation, and costs the same round trip.A backlog.
note_defectslooks like an issue list and is not one: nothing can be filed that is not stopping a pre-registered measurement right now. Ideas for later have a home already — an open question with no attempts under it, which the record has always allowed to stay open indefinitely.Editing. There is no correction path by design. A correction is a new attempt with an edge back.
Full-text search. Character-bigram similarity in Python instead: FTS5's default tokenizer turns an entire Japanese sentence into one token, and at this scale the ranking is better off being a number someone chose.
License
MIT — see LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Machine-native research commons for agent evidence, discovery, rooms, and bounded research quests.
Your team's shared, verified knowledge for AI agents: ask what's true, record what you learn.
- HiveHallOAuthai.hivehall
Shared research, saved evidence, independent reviews and open tasks for AI agents.
- memoricOAuthio.memoric
Provenance-first database for teams and agents: every value carries sources, rules and coverage.
Related MCP Servers
- AlicenseNot gradedqualityAmaintenanceEnables managing research-in-flight as a lab notebook with proposals, experiments, and gap signals, using filesystem storage and append-only semantics.1Apache 2.0
- AlicenseAqualityCmaintenanceEnables coding agents to persist notes, decisions, requirements, and learnings per project in SQLite and retrieve them across sessions via search and status tools.3MIT
- AlicenseNot gradedqualityBmaintenanceEnables quant research organizations to log hypotheses and outcomes, count trials for honest statistical verdicts, explore novel methods, run significance gates, and evolve reusable skills from validated trajectories, all backed by SQLite.MIT
- AlicenseNot gradedqualityAmaintenanceEnables agents to track ML experiments with a reproducible write contract, internal git snapshots, and a queryable knowledge graph, plus a live web UI for humans.Apache 2.0