Skip to main content
Glama

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 ─ attempt
  • The 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_ask promised 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_hypothesis refused 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.

  • result is measurement, verdict is the call, why_now is 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.py
claude mcp add --scope user sainpost -- python C:\Users\me\work\Sainpost\launch.py

launch.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  <-A2

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

That 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 instead

Each 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_path counts 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 under lines of every question opened beneath it.

  • note_trunk prints 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_open and note_search give 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 record

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

It 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 settled

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

Two 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 there
python -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 k7m

A 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

invocation — what you ran, verbatim

cause — what was actually wrong

observed — what came back, verbatim

change — what you altered

needed — what your measurement needed

verify — run this before measuring again

invalidates — do numbers taken through the broken rig still stand

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 dump

SAINPOST_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. ABANDONED is a legitimate verdict — a kill criterion firing is a result.

With arms side by side, a close names its attempt

Guessing would record one arm's number against the other.

falsifier and apparatus required before measuring

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 prompted_by at the old one. The path is the value.

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 note_open shows a node as one line. Cut by machine, that line is a summary nobody checked — and it cut a 97-character Japanese question down to "[R1]".

A write hands back the check, not the writing

note_result returns the falsifier you committed to — that is what the call is for — and the number of characters it stored. Echoing the result cost a copy of everything just measured: the median result here is 1,362 characters, the median falsifier 208, and one afternoon of writes was returning over ten thousand characters a call.

why_now is required once the project has a RESULT to name

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 BEGIN IMMEDIATE

Otherwise the rules above hold for one agent and quietly stop holding for two.

BLOCKED is unreachable from note_result, and needs a defect naming an owner

A front closed as blocked with nobody named is a lost day nobody is working on.

note_blocked touches no attempt until stopped says what the failure did to it

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. git answering is an observation.

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 note_export's file, written only when asked, and it says what committing it mid-attempt costs.

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 -q

tests/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 result or 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_defects looks 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.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables managing research-in-flight as a lab notebook with proposals, experiments, and gap signals, using filesystem storage and append-only semantics.
    1
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables 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
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables 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