feat!: docs/ sorted into tutorials, how-to, reference, explanation plus unshipped docs/dev/; explanation pages and developer guide moved, human docs English (#187)
The seven explanation pages move to docs/explanation/ without a stub, DEVELOPMENT.md becomes docs/dev/development.md (translated to English), dist export leaves docs/dev/ out, docs verify rejects a file outside the five areas, and AGENTS.md states that the stack's human documentation is English. Required migration 9.0.0-docs-diataxis rewrites the old path in an instance's own kb/CONVENTIONS.md. Files changed: - .wikitool-kb.json - AGENTS.md - CHANGES.md - CLAUDE.md - DEVELOPMENT.md - INSTALL.md - README.md - VERSION - docs/dev/development.md - docs/explanation/README.md - docs/explanation/knowledge-and-commitment.md - docs/explanation/language-boundaries.md - docs/explanation/model-and-effort-selection.md - docs/explanation/ownership-and-templates.md - docs/explanation/pipeline-rationale.md - docs/explanation/version-model.md - docs/explanation/why-gates-are-code.md - docs/how-to/README.md - docs/knowledge-and-commitment.md - docs/language-boundaries.md - docs/model-and-effort-selection.md - docs/ownership-and-templates.md - docs/pipeline-rationale.md - docs/reference/README.md - docs/tutorials/README.md - docs/version-model.md - docs/why-gates-are-code.md - instructions/CONTRACT.md - instructions/dev/dev-setup.md - instructions/dev/doc-pull-through.md - instructions/dev/stack-close/SKILL.md - instructions/dev/stack-mode.md - instructions/gtd-weekly-review/SKILL.md - instructions/ingest-queue.md - instructions/migrations/6.0.0-type-guidance-split.md - instructions/migrations/9.0.0-docs-diataxis.md - instructions/upgrade-instance.md - kb/CONVENTIONS.md - kb/CONVENTIONS.md.template - tools/CONTRACT.md - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/task_cmd.py - tools/chemenu/tasks/protocol.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_git_publish.py - tools/chemenu/tests/test_toc.py - types/type-guidance.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
1 parent
0e15972ee7
commit
d66a3f65f4
43 files changed
+756
-324
No files matched your search
@@ -0,0 +1,18 @@
|
||||
# Explanation
|
||||
|
||||
Why the stack is built the way it is. These pages are background for humans and agents alike:
|
||||
read by link or on request, never as instruction, and they carry no normative sentence - a rule
|
||||
lives in `AGENTS.md` or a `CONTRACT.md`, and a page here only gives its reasons.
|
||||
|
||||
- [pipeline-rationale.md](pipeline-rationale.md) - why the pipeline has four stages
|
||||
- [ownership-and-templates.md](ownership-and-templates.md) - why a `.template` split exists, and
|
||||
why an instance comes only from a release
|
||||
- [language-boundaries.md](language-boundaries.md) - why the control plane is English everywhere
|
||||
and the KB language is a value
|
||||
- [why-gates-are-code.md](why-gates-are-code.md) - why the gates are code rather than instruction
|
||||
- [version-model.md](version-model.md) - why a version number answers a compatibility question
|
||||
and a migration question separately
|
||||
- [knowledge-and-commitment.md](knowledge-and-commitment.md) - why commitments live in a task
|
||||
tracker rather than in `kb/`
|
||||
- [model-and-effort-selection.md](model-and-effort-selection.md) - which model and effort suit
|
||||
which kind of session, for the harness that has to choose
|
||||
@@ -0,0 +1,147 @@
|
||||
# Why knowledge and commitments are two layers
|
||||
|
||||
Chemenu compiles knowledge into `kb/`, and it also tracks what its operator has committed to do.
|
||||
Those look like one subject - both are "things about my projects" - and the stack deliberately
|
||||
keeps them apart: `kb/gtd/` holds one page per initiative, an external task tracker holds the
|
||||
open items, and the only thing that crosses between them is a name. This page is about why that
|
||||
line was drawn there. The rules that follow from it live in [kb/CONTRACT.md](../../kb/CONTRACT.md)
|
||||
and the `review`, `new` and `task new` records in [tools/CONTRACT.md](../../tools/CONTRACT.md).
|
||||
|
||||
<!-- wikitool:toc -->
|
||||
## Contents
|
||||
|
||||
- [Different half-lives want different machinery](#different-half-lives-want-different-machinery)
|
||||
- [Pattern 4: separate ownership, no synchronization](#pattern-4-separate-ownership-no-synchronization)
|
||||
- [The join happens at read time, and stores nothing](#the-join-happens-at-read-time-and-stores-nothing)
|
||||
- [One name, carrying the duties of an identifier](#one-name-carrying-the-duties-of-an-identifier)
|
||||
- [Status has exactly one home](#status-has-exactly-one-home)
|
||||
- [A finished initiative is a state, not a location](#a-finished-initiative-is-a-state-not-a-location)
|
||||
- [Which tracker is a decision the stack does not make](#which-tracker-is-a-decision-the-stack-does-not-make)
|
||||
<!-- /wikitool:toc -->
|
||||
|
||||
## Different half-lives want different machinery
|
||||
|
||||
`kb/` is a compiler for durable things, and every mechanism in it assumes durability: `raw/` is
|
||||
immutable, a claim has to trace back to a source, a page's title is its identity, the indexes are
|
||||
generated, and a large change stops at a gate so a human can look at it. All of that is the right
|
||||
amount of ceremony for something that will still be true next year.
|
||||
|
||||
A next action is the opposite kind of fact. It is unsourced - nobody cites a reason for "call the
|
||||
plumber". It changes several times a week. It is state, not knowledge: the interesting thing
|
||||
about it is whether it is still open. And it is only correct *now*.
|
||||
|
||||
Running both through one layer does not produce a richer wiki; it produces a worse one. Every
|
||||
task-shaped page carries `provenance: general` because there is no source to bind it to, which
|
||||
drains that field of meaning for the pages where it matters. `kb/log.md` fills with "task
|
||||
checked off" entries until the audit trail of what the *wiki* learned is unreadable. Lint findings
|
||||
about orphans and stale claims start firing on pages that are supposed to be short-lived. And a
|
||||
weekly pass over the task list trips the Mass-Update Gate every single time, which is how a gate
|
||||
stops being read and starts being cleared reflexively.
|
||||
|
||||
The GTD method this borrows from draws the same line for its own reasons: of its horizons, `kb/`
|
||||
covers the two slowest - project support material and reference - and nothing faster.
|
||||
|
||||
## Pattern 4: separate ownership, no synchronization
|
||||
|
||||
Four arrangements were on the table, and three of them fail in ways worth naming.
|
||||
|
||||
**One layer** is the case above. **Export** - the wiki writes a task list the tracker imports -
|
||||
means a checkbox ticked in the tracker is a tick in a view, while the truth sits in a file the
|
||||
operator was not editing; the two disagree immediately and silently. **Bidirectional sync** works,
|
||||
at the cost of an id mapping to maintain, a conflict-resolution rule to design, and a deletion
|
||||
semantics to decide - all of it machinery whose only job is to repair a split nobody needed.
|
||||
|
||||
What is left is **separate ownership with no sync at all**: the tracker owns the tasks, `kb/` owns
|
||||
the project memory, and the single point of contact is the project's name. Nothing is mirrored,
|
||||
so nothing can drift out of mirror.
|
||||
|
||||
## The join happens at read time, and stores nothing
|
||||
|
||||
Because there is no shared state, the connection between the two sides has to be made when
|
||||
somebody actually asks - which is what `wikitool review` does: it reads both sides, matches them on
|
||||
the case-normalized project name, prints what it found, and saves nothing. Not a cache, not a
|
||||
mapping file, not even a `reports/` artifact.
|
||||
|
||||
That is the same posture `search` takes, and for the same reason: anything it wrote down would be
|
||||
a third copy of a state the two sides already hold, stale the moment either side moved, and the
|
||||
first thing to distrust in a report. A read-time join can be wrong about the present, but it
|
||||
cannot be wrong about the past, because it does not remember one.
|
||||
|
||||
## One name, carrying the duties of an identifier
|
||||
|
||||
Reducing the coupling to a name is cheap, and it is not free. A name that joins two systems is an
|
||||
identifier, whether or not anything enforces it, so the design had to pick up an identifier's
|
||||
obligations explicitly: uniqueness is checked before a project is created rather than discovered
|
||||
later; a rename is a deliberate, infrequent operation that touches both sides in one pass; and
|
||||
nothing tries to re-match automatically behind the operator's back.
|
||||
|
||||
The last one is what makes the review's *both-directional* report matter. A tracker project with
|
||||
no page and a page with no tracker project are reported separately, as two findings. They are
|
||||
usually the two halves of one rename - and reporting them separately is exactly what turns a
|
||||
silent decoupling into a visible event, at the cost of the review occasionally saying the same
|
||||
thing twice.
|
||||
|
||||
## Status has exactly one home
|
||||
|
||||
The sharpest consequence of the split is a rule that feels like a restriction: a `kb/` page never
|
||||
summarizes its own task list. No "3 open items", no "next: call the supplier".
|
||||
|
||||
Two places claiming to know the current status is the failure mode the whole arrangement exists
|
||||
to avoid, and a summary is a copy with a slower clock. The page says what an initiative *is* -
|
||||
its goal, its participants, its durable state, why it is worth doing. The tracker says what is
|
||||
open right now. Anyone wanting the second reads the tracker, or runs the review.
|
||||
|
||||
This pays for itself somewhere unexpected: with the page carrying no task state, an agent has no
|
||||
reason to read the task list at all outside the weekly review. That is what keeps the command
|
||||
surface as small as it is - two read commands and three write commands - rather than growing a
|
||||
full CRUD tree over somebody's todo list. The second creation command exists because a single
|
||||
name is not always the whole story: a source can carry a piece of durable knowledge and a
|
||||
commitment to follow up on it at the same time - a complaint arriving by email is both something
|
||||
to file and something to chase - and the tracker-side half of that needs its own write path
|
||||
alongside `new project`'s pairing of a page with a tracker project. `task new` creates only the
|
||||
tracker item, never a page; a source that also carries knowledge gets that knowledge filed
|
||||
through the ordinary page-creation commands, as a separate step. The two are never one
|
||||
transaction the way `new project`'s tracker-then-page order is within a single command - they are
|
||||
two independent writes a skill sequences, tracker first, so a failure creating the item leaves no
|
||||
page and no promoted source material behind it. That holds for an ordinary source; a source large
|
||||
or broad enough to run through the large-tree procedure instead promotes ahead of its own
|
||||
per-unit commitment decision, because that procedure hands its units through a workshop directory
|
||||
that needs them already promoted to address them at all - the same raw-file-without-page state
|
||||
the ordinary case avoids becomes, there, the expected condition for as long as the run takes. And
|
||||
a failure on the knowledge side afterwards is exactly the ordinary "a source without a page" state
|
||||
`lint` already reports.
|
||||
|
||||
The write surface stops at *creating* an item and *marking one done* - it never moves a reminder
|
||||
and never deletes anything. `task close` sets exactly the field the tracker's own "done" checkbox
|
||||
sets, nothing more: reversible, and it leaves a record in the tracker rather than removing the
|
||||
item's trace. A command that deleted would take the same shortcut through somebody's task list
|
||||
that the whole split above exists to avoid - a write this stack cannot undo, made on behalf of a
|
||||
tracker it does not own. `task list` is the one addition on the read side, and it changes nothing
|
||||
about the join itself: it exists only because closing an item needs the tracker's own id for it,
|
||||
and that id was never worth exposing before there was a write that consumed it.
|
||||
|
||||
## A finished initiative is a state, not a location
|
||||
|
||||
Archiving moves nothing. A completed initiative's page stays where it is and changes its `state:`
|
||||
value, because the moment an initiative finishes is the moment its page is *most* valuable -
|
||||
what was decided, what it cost, who was involved - and filing it away is how that gets lost.
|
||||
|
||||
The state field carries the distinction the review actually needs, which is not "open vs. done"
|
||||
but "does silence here mean something is wrong". An initiative that is deliberately paused looks
|
||||
identical, from the outside, to one that quietly stalled; only the operator knows which. Without a
|
||||
value for "paused on purpose", the review reports the same untouched initiatives every week, and
|
||||
a report that is mostly noise stops being read by the third week - which would cost more than the
|
||||
findings are worth.
|
||||
|
||||
## Which tracker is a decision the stack does not make
|
||||
|
||||
The tracker is reached through a provider layer, and no instruction anywhere names which one it
|
||||
is. An instruction that said "open Super Productivity" would bake one instance's tool choice into
|
||||
the shared stack, and the next instance - a different context, a different employer, a different
|
||||
set of constraints - would have to edit prose to change a setting.
|
||||
|
||||
So the provider lives in configuration (`.wikitool-tasks.json`), the adapters live behind one
|
||||
protocol, and a capability the provider lacks surfaces as an ordinary tool error rather than as a
|
||||
paragraph of instruction explaining what this particular tracker cannot do. A provider that
|
||||
cannot create a project, for instance, stops and asks the operator to do it - the same posture the
|
||||
gates take, and for the same reason: better a visible stop than an invented workaround.
|
||||
@@ -0,0 +1,163 @@
|
||||
# Language Boundaries
|
||||
|
||||
Two languages run through this repo at once. `kb/` is written in whatever language the instance
|
||||
chose - German here, and the value lives in `kb/CONVENTIONS.md`'s `language:`. Everything that
|
||||
tells an agent what to do - [AGENTS.md](../../AGENTS.md), every `CONTRACT.md`, everything under
|
||||
`instructions/` - is written in English, in every instance, whatever the first value says. So
|
||||
is the stack's own documentation for humans, for a different reason (§ "The stack's human
|
||||
documentation").
|
||||
|
||||
The rule itself is in [AGENTS.md § File naming](../../AGENTS.md#file-naming). This page holds the
|
||||
part that is not a rule: why the line runs where it does, why the English half is not a setting,
|
||||
and which argument for it turned out to be wrong.
|
||||
|
||||
<!-- wikitool:toc -->
|
||||
## Contents
|
||||
|
||||
- [The axis is the reader, not the owner](#the-axis-is-the-reader-not-the-owner)
|
||||
- [Why the control plane's language is English](#why-the-control-planes-language-is-english)
|
||||
- [Why it is not a parameter](#why-it-is-not-a-parameter)
|
||||
- [What the KB language still decides](#what-the-kb-language-still-decides)
|
||||
- [Where the line runs around a page type](#where-the-line-runs-around-a-page-type)
|
||||
- [The stack's human documentation](#the-stacks-human-documentation)
|
||||
- [What would put this back on the table](#what-would-put-this-back-on-the-table)
|
||||
<!-- /wikitool:toc -->
|
||||
|
||||
## The axis is the reader, not the owner
|
||||
|
||||
For a long time the two halves could be told apart by asking who owned the file, and the answer
|
||||
came out right every time: the stack owns `AGENTS.md` and the contracts, which are English; the
|
||||
instance owns its pages and the templates that shape them, which are in the KB language. The
|
||||
ownership boundary is a real and load-bearing thing - [ownership-and-templates.md](ownership-and-templates.md)
|
||||
is about what it buys - so it was easy to read the language split as one of its consequences.
|
||||
|
||||
It is not. The case that separates them is a page type an instance adds for itself. `types/`
|
||||
takes a new type without a code change, so an instance can write one; that file is instance-owned
|
||||
from the first line to the last, ships nowhere, and is nobody's to overwrite. Its authoring
|
||||
guidance is still instruction addressed to an agent, and reads exactly like the guidance in the
|
||||
four types the stack ships. Ownership says "yours"; the audience has not moved at all.
|
||||
|
||||
So the question a line answers is not *whose file is this* but *who reads this line*, which is
|
||||
the same cut [kb/CONTRACT.md](../../kb/CONTRACT.md#language-and-identifiers) already makes inside a
|
||||
single page between prose and identifiers - applied one level up, to the halves of a document.
|
||||
Ownership decides who may change a sentence. The reader decides what language it is in. The two
|
||||
questions were answered together for as long as they happened to agree.
|
||||
|
||||
## Why the control plane's language is English
|
||||
|
||||
Not because English is better for the purpose, and not to be neutral: this instance's operator
|
||||
reads German, and the pages are German for that reason.
|
||||
|
||||
- **The control plane is almost entirely about identifiers, and the identifiers are English.**
|
||||
`base_dir`, `provenance: sourced`, `--confirm`, exit 42, `root: kb`. A sentence in another
|
||||
language explaining when to set `page_ref_fields` is already half English by the time it
|
||||
reaches the verb, and the prose/identifier boundary inside it becomes something a reader has to
|
||||
work out line by line.
|
||||
- **It quotes a body of material that is English and stays English.** The harness documentation
|
||||
it has to agree with, the vendored skill-authoring sources under `commonplace/`, the tool's own
|
||||
`--help`. A contract that translates their vocabulary makes its own claims harder to check
|
||||
against them, not easier.
|
||||
- **One language keeps instances comparable.** Two instances running the same stack version hold
|
||||
the same control plane byte for byte, so a question about one is answerable from the other -
|
||||
and anything an instance changes locally shows up as a difference in content rather than in
|
||||
language.
|
||||
|
||||
## Why it is not a parameter
|
||||
|
||||
The natural next move, once `kb/CONVENTIONS.md` holds `language:`, is a second value beside it -
|
||||
`control_plane_language:` - defaulting to English and settable by an instance that would rather
|
||||
read its contracts in its own language. That option is deliberately not taken.
|
||||
|
||||
- **The knob's cost is paid by every file; its benefit lands on the few a human reads.** Every
|
||||
rule about writing an instruction would have to name which of the two languages it means, every
|
||||
example would need a note saying which one it is in, and every review of an instruction would
|
||||
start by establishing which language it should have been in. The stack has one mechanism for
|
||||
that class of problem - one rule, one place (AGENTS.md invariant 8) - and a second language
|
||||
value forks it everywhere at once.
|
||||
- **The document the knob is for is read by an agent.** An instruction, a contract, a type-spec's
|
||||
guidance half: the reader is a model, and a model reads the English fine. What the *operator*
|
||||
reads is unaffected by any of this - see the section below.
|
||||
- **Today's local document is tomorrow's upstream candidate.** An instruction an instance wrote
|
||||
for itself is the most likely thing it ever contributes back. Written in the KB language it
|
||||
would have to be translated first, and the translation would have to re-derive the
|
||||
prose/identifier boundary that the original author had in their head and did not write down.
|
||||
- **Nothing would check it.** There is no mechanical test for what language a paragraph is in -
|
||||
a stop-word scan flags the quoted vocabulary the rule deliberately keeps and misses a cleanly
|
||||
translated paragraph. A setting nothing enforces produces drift that is visible only to whoever
|
||||
next opens the file.
|
||||
|
||||
## What the KB language still decides
|
||||
|
||||
Making the control plane English does not make the instance's language an implementation detail.
|
||||
`kb/CONVENTIONS.md`'s `language:` decides two things, and both are the ones an operator actually
|
||||
experiences:
|
||||
|
||||
- **Page text.** Every page under `kb/`, and inside the page type-specs exactly the parts that
|
||||
become page text - each one's `## Template` block, its `layout:` titles, and the subtype
|
||||
templates `types/<name>.<value>.md` beside it, which are page text from the first line to the
|
||||
last.
|
||||
- **What an agent says.** An agent speaks the KB language, whatever the file it just read was
|
||||
written in. An instruction that models a sentence for the operator writes that model in
|
||||
English, like the rest of the control plane, and the agent delivers it in the instance's
|
||||
language.
|
||||
|
||||
So an operator who reads no English gets German pages and German answers from an agent reading
|
||||
English instructions. The English is what the machinery is written in, not what it says back -
|
||||
with one exception, the stack's documentation for humans (§ "The stack's human documentation").
|
||||
|
||||
## Where the line runs around a page type
|
||||
|
||||
A page type's contract is where the two languages meet most closely, and it is worth knowing
|
||||
which part is which before editing any of it. Its authoring guidance addresses an agent and is
|
||||
English; its `## Template` block, `layout:` titles and subtype templates become the literal
|
||||
headings of pages and follow the KB language; its field names and enum values are identifiers and are translated in
|
||||
neither direction.
|
||||
|
||||
The language line did not move when the *file* line did. A `root: kb` type-spec may now put its
|
||||
authoring guidance in a separate, stack-owned `types/<name>.guidance.md` rather than carrying it
|
||||
beside the template, but that split was made for ownership reasons - so an upgrade can improve
|
||||
the guidance without overwriting what the instance chose - and it leaves this page's argument
|
||||
untouched: each part is still written in the language its own reader needs, and a type-spec that
|
||||
declares no `guidance:` keeps both halves in one file with exactly the same rule applying inside
|
||||
it. [types/type-spec.md § Who owns a type-spec](../../types/type-spec.md#who-owns-a-type-spec) has
|
||||
the split as a table, and [ownership-and-templates.md](ownership-and-templates.md) § "Where the
|
||||
file boundary used to strain" has what it cost to keep two audiences in one file for as long as
|
||||
it did.
|
||||
|
||||
## The stack's human documentation
|
||||
|
||||
`README.md`, `INSTALL*.md`, `EVALS.md` and everything under `docs/` are read by humans, and the
|
||||
reader axis alone would put them in the operator's language. They are English anyway, and the
|
||||
reason is not the reader but the fact that there is no single one. This documentation belongs to
|
||||
the stack, not to an instance: it ships identically to every instance, whatever each one's
|
||||
`language:` says, so there is no instance value it could follow - only a translation per language
|
||||
to maintain beside it, each one a second copy that drifts. And a growing part of it is generated
|
||||
from the code (the command reference from `cli_contract` records, for one), whose source is
|
||||
English control-plane material; a translated frame around English generated text is the
|
||||
half-done version of both.
|
||||
|
||||
The KB language keeps deciding what an operator reads *of their own*: pages and answers. What
|
||||
the stack tells every operator about itself is the same text everywhere. `CHANGES.md` sits
|
||||
outside the question - it is a log of what was true at a time, not documentation, and its older
|
||||
entries are in the language they were written in.
|
||||
|
||||
## What would put this back on the table
|
||||
|
||||
A `docs/` page goes stale when the reasoning stops holding rather than when the code changes, so
|
||||
it is worth naming what that would look like here. Two things would:
|
||||
|
||||
- **A human starts reading the control plane directly and routinely** - not an operator checking
|
||||
a rule now and then, which is the case today, but a workflow where people rather than agents
|
||||
are the primary readers of `instructions/`. The second argument above is the one that fails
|
||||
first, and it is the load-bearing one.
|
||||
- **The identifiers stop being English.** If the tool's own vocabulary were ever localized, the
|
||||
first argument would invert: the prose would then be the only English left in a file that is
|
||||
otherwise not, which is the situation this page argues against.
|
||||
|
||||
Neither is close. Both are cheaper to notice here than to rediscover in an argument about a
|
||||
single file.
|
||||
|
||||
The human documentation has a trigger of its own: **translations the stack maintains itself,
|
||||
with a check that holds each one to its English source.** Without such a check a translation is
|
||||
exactly the drifting second copy the section above argues against; with one, the argument for a
|
||||
single language weakens to cost alone.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Choosing a Claude Code model and effort level
|
||||
|
||||
Claude Code exposes three choices this repo has an opinion on: which model a session itself
|
||||
runs as, what model a spawned subagent gets, and which `/code-review` effort level to pick.
|
||||
None of them are enforced anywhere - the gates in [instructions/gates.md](../../instructions/gates.md)
|
||||
are code precisely because a model cannot be talked out of them
|
||||
([why-gates-are-code.md](why-gates-are-code.md) makes that argument for gates; this page applies
|
||||
the same axis to who is holding the keyboard). What follows is a reference for making that choice
|
||||
well, not a rule anything checks.
|
||||
|
||||
The axis worth tracking is not how important a task feels, but **what would catch a mistake in
|
||||
it**. Work behind `pytest`, `docs verify`, `instructions verify` or CI surfaces a bad call within
|
||||
one more round. Work behind nothing but a session reading prose does not surface at all - it
|
||||
ships, and stays until someone happens to notice. That asymmetry, not task size, is what the
|
||||
phase guide below is built on.
|
||||
|
||||
<!-- wikitool:toc -->
|
||||
## Contents
|
||||
|
||||
- [Phase guide](#phase-guide)
|
||||
- [Model per session, not per phase](#model-per-session-not-per-phase)
|
||||
- [Subagent models](#subagent-models)
|
||||
- [`/code-review` effort](#code-review-effort)
|
||||
- [When it's unclear](#when-its-unclear)
|
||||
- [Scope](#scope)
|
||||
<!-- /wikitool:toc -->
|
||||
|
||||
## Phase guide
|
||||
|
||||
<!-- dist:strip-start -->
|
||||
This repo's own stack-development work splits the axis into three phases, one skill each -
|
||||
`stack-dev` (design), `stack-build` (build) and `stack-close` (closing) - handed over through
|
||||
states in the issue tracker rather than inside one session:
|
||||
|
||||
<!-- dist:strip-end -->
|
||||
| Phase / task | What would catch a mistake | Suggested model | Effort |
|
||||
|---|---|---|---|
|
||||
| `wiki-status`, simple `wiki-query` lookups | the answer is re-checkable against the corpus | Sonnet | default |
|
||||
| `wiki-lint` | `lint` itself is the check | Sonnet | default |
|
||||
| `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | `lint` and `docs verify`, partly | Sonnet | high |
|
||||
| Stack dev: design, the version part, a boundary-crossing judgment | nothing mechanical | Opus | high |
|
||||
| Stack dev: code, tests, mechanical doc sync, waiting for CI | `pytest`, `docs verify`, `instructions verify`, CI | Opus (open - see below) | high; medium for a small change |
|
||||
| Stack dev: closing an issue, `docs/` staleness, changelog prose | nothing, by construction | Opus | high |
|
||||
|
||||
The two unchecked rows are short - minutes, not hours - so keeping them on the strongest model
|
||||
costs little and protects the only work that fails silently. The checked middle row is where the
|
||||
tokens are, which makes it the tempting one to run cheaper. It is also the row with the least
|
||||
settled answer: a build phase of this stack reads a lot of the tree, and a smaller context window
|
||||
does not hold it - it runs into compaction, which costs more than the cheaper model saves. Sonnet
|
||||
at high effort remains possible, but only as a session of its own (below).
|
||||
|
||||
**Effort is the cheaper lever than the model.** A reduced effort level is what gives up
|
||||
multi-file consistency first, so `high` is a reasonable floor for anything touching more than one
|
||||
file or a contract; `default` or `medium` suits a small mechanical change with a test behind it.
|
||||
|
||||
## Model per session, not per phase
|
||||
|
||||
A session cannot switch its own model - that is the user's `/model` - and it should not be asked
|
||||
to mid-flow either. Two reasons:
|
||||
|
||||
- **A switch throws away the prompt cache.** A cache entry belongs to the model that wrote it,
|
||||
so a new model starts the session's whole history from cold. The same holds for effort:
|
||||
changing it always invalidates the cached message history - by far the largest part of a long
|
||||
session - and, on some models, the tool and system prefix too (Anthropic's prompt-caching
|
||||
documentation lists effort and the thinking configuration among what invalidates the cache). A
|
||||
switch from one effort to another costs the same re-read of the whole session as a switch of
|
||||
model.
|
||||
- **An offered switch is rarely taken.** A sentence in the output at the moment a phase changes
|
||||
is easy to read past - for the agent writing it and for the user reading it - and the session
|
||||
just carries on in whatever it started as.
|
||||
|
||||
So the choice is made once, when a session starts, and phases that want different models or
|
||||
effort levels are separated by a session boundary instead: `/clear`, then the next phase in a
|
||||
session started the right way. That is only cheap if the next phase does not depend on the
|
||||
previous session's context - which is why the handover has to live somewhere outside the session
|
||||
(an issue body, a page) and be kept current at fixed points, not reconstructed at the end.
|
||||
<!-- dist:strip-start -->
|
||||
In this repo the handover points are a ready issue body (design → build) and a green CI run with
|
||||
the body updated (build → closing); the two later skills can only be started by the user's slash
|
||||
command, so each phase change is a real stop at which that choice is made.
|
||||
<!-- dist:strip-end -->
|
||||
|
||||
## Subagent models
|
||||
|
||||
The `Agent` tool's `model:` parameter (`haiku`, `sonnet`, `opus`, `fable`) is a per-subagent
|
||||
choice a session *can* make on its own:
|
||||
|
||||
- Read-only search/lookup (an `Explore` agent, or a `general-purpose` agent doing pure
|
||||
retrieval): `haiku` - no judgment is being delegated, only retrieval.
|
||||
- A subagent that writes pages, reviews code, or decides something: leave `model:` off so it
|
||||
inherits the parent session's model.
|
||||
- A fork (`subagent_type: "fork"`) always inherits the parent's model; a `model:` override on a
|
||||
fork is ignored.
|
||||
|
||||
## `/code-review` effort
|
||||
|
||||
- A routine diff: `low` or `medium` - fewer, high-confidence findings are enough.
|
||||
- Gate code, the compiler, or a change about to ship in a version bump: `high` and up - broader
|
||||
coverage is worth it when the blast radius of a missed bug is a safety gate.
|
||||
- `ultra` is user-triggered and billed separately - worth recommending, not assuming.
|
||||
|
||||
## When it's unclear
|
||||
|
||||
- A task spans both a mechanical step and a judgment call: weigh it by the judgment call, not the
|
||||
mechanical one - the tooling carries the mechanical part regardless of which model supervises.
|
||||
- No row fits cleanly: Sonnet at high effort is a safer default than the most capable model at
|
||||
the highest effort. Under-provisioning where a check exists costs one worse answer once;
|
||||
reflexively over-provisioning is a standing cost every session pays.
|
||||
- Not sure whether a phase is checked: treat it as unchecked - a needless Opus phase costs money
|
||||
once, an unchecked Sonnet phase can ship something nobody looks at again.
|
||||
- The phase changed and the session runs on a model or effort the table would not pick: keep
|
||||
working, and cut the session at the next handover rather than switching mid-flow - never block a
|
||||
publish or an issue close on a choice the session cannot make itself.
|
||||
|
||||
## Scope
|
||||
|
||||
Specific to Claude Code: the model names, the `/code-review` dial and the `Agent` tool's `model:`
|
||||
override have no equivalent in this repo's other supported harnesses (Codex CLI, GitHub Copilot
|
||||
CLI, Mistral Vibe). Does not set the classifier model behind Claude Code's own `auto` permission
|
||||
mode - that is a harness internal, not a per-task choice this repo controls.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Ownership and Templates
|
||||
|
||||
Chemenu ships two kinds of files side by side, and at a glance they look the same: both are
|
||||
plain markdown, both sit in the repo root or under `kb/`, both get read at session start. But a
|
||||
stack upgrade treats them completely differently. Some - [AGENTS.md](../../AGENTS.md),
|
||||
[kb/CONTRACT.md](../../kb/CONTRACT.md), the per-stage contracts - are identical in every instance
|
||||
that runs this stack and are the next release's to replace (with one caveat about local edits,
|
||||
below). Others - `USER.md`,
|
||||
`SOUL.md`, `kb/CONVENTIONS.md`, `ENVIRONMENT.md` - describe one particular instance, and
|
||||
overwriting them would silently erase a choice someone made on purpose.
|
||||
|
||||
<!-- wikitool:toc -->
|
||||
## Contents
|
||||
|
||||
- [Two different kinds of truth](#two-different-kinds-of-truth)
|
||||
- [Why silent overwrite is the failure being designed against](#why-silent-overwrite-is-the-failure-being-designed-against)
|
||||
- [Why the boundary is a predicate rather than a list](#why-the-boundary-is-a-predicate-rather-than-a-list)
|
||||
- [Why a `.template`, not just an absent file](#why-a-template-not-just-an-absent-file)
|
||||
- [Where the file boundary used to strain](#where-the-file-boundary-used-to-strain)
|
||||
- [The consequence in practice](#the-consequence-in-practice)
|
||||
- [Why an instance comes only from a release](#why-an-instance-comes-only-from-a-release)
|
||||
<!-- /wikitool:toc -->
|
||||
|
||||
## Two different kinds of truth
|
||||
|
||||
The stack-owned files describe how the tool works. `kb/CONTRACT.md` opens by saying it holds
|
||||
what `tools/wikitool` enforces or what follows mechanically from how it operates - see
|
||||
[kb/CONTRACT.md](../../kb/CONTRACT.md), lines 10-13. That kind of statement doesn't vary by
|
||||
instance: the compiler behaves the same way regardless of who is running it, so the sentence
|
||||
describing that behavior can be copied byte-for-byte into every checkout without becoming
|
||||
wrong anywhere.
|
||||
|
||||
The instance-owned files describe a choice: which language pages are written in, what tone the
|
||||
agent takes, who the operator is, which git remote is authoritative, which MCP servers are
|
||||
reachable. None of that follows from the tool's mechanics - two instances of the identical
|
||||
stack can answer all of these differently and both be correct. [AGENTS.md § Personalization](../../AGENTS.md#personalization)
|
||||
frames the split the same way for `kb/CONTRACT.md` versus `kb/CONVENTIONS.md`: "the split is by
|
||||
who may change the sentence, not by what it is about." A rule about page structure could in
|
||||
principle have been written per-instance too, but then every instance answering "not German" to
|
||||
setup would be hand-editing a file the stack also ships, and the next release update would
|
||||
hand the stack's file back to it, discarding the customization.
|
||||
|
||||
## Why silent overwrite is the failure being designed against
|
||||
|
||||
A stack update is meant to be a routine, low-risk operation: pull the latest release, get
|
||||
whatever fixes and features shipped since the last one. That only stays low-risk if the update
|
||||
knows which files it's allowed to touch. If `USER.md` or `kb/CONVENTIONS.md` were treated the
|
||||
same as `AGENTS.md` - shipped and periodically re-copied - an upgrade would quietly replace a
|
||||
description of *this* operator, in *this* language, with whatever placeholder or default the
|
||||
stack maintainers wrote. The damage wouldn't be loud: nothing crashes, the files still parse,
|
||||
the agent just starts acting on the wrong premises until someone notices the voice or the
|
||||
language changed.
|
||||
|
||||
Keeping the boundary at the file level, rather than trying to merge changes within a shared
|
||||
file, means an upgrade never has to guess which lines are "stack" and which are "instance" -
|
||||
the file itself already answers that.
|
||||
|
||||
## Why the boundary is a predicate rather than a list
|
||||
|
||||
For a while the boundary was written down as a list of paths - once in `dist_cmd.py`, once in
|
||||
the merge procedure a private instance was told to run by hand, and once in the check that
|
||||
procedure ended with. Three copies of one fact, which is the shape [AGENTS.md](../../AGENTS.md)
|
||||
invariant 8 exists to forbid, and they drifted exactly as predicted: the hand-run procedure was
|
||||
still naming three paths after the collection contracts had moved to the instance's side of the
|
||||
line, so it discarded upstream changes to files it had never heard of, while its own final check
|
||||
excluded the same three paths and therefore reported success.
|
||||
|
||||
`chemenu/ownership.py` replaced the lists with one question - is this path, under a content
|
||||
stage, the stack's or the instance's? - answered by shape rather than by enumeration:
|
||||
`<stage>/CONTRACT.md`, and anything ending `.template`. Every consumer asks it - at the time,
|
||||
`dist export` and a `wikitool upstream merge` that took the hand-run procedure's place; since
|
||||
that path was removed, the export alone - so no two of them can disagree, and a machinery file
|
||||
added under a content stage tomorrow is recognised without any of them being edited. The deeper point is not the
|
||||
deduplication: a list has to be maintained by whoever remembers it exists, and the failure mode
|
||||
when nobody does is silence, because a path the list has never heard of simply looks like
|
||||
content.
|
||||
|
||||
## Why a `.template`, not just an absent file
|
||||
|
||||
The mechanism for instance-owned content is a `.template` file the distribution ships instead
|
||||
of the real one - `USER.md.template`, `SOUL.md.template`, `kb/CONVENTIONS.md.template`,
|
||||
`ENVIRONMENT.md.template`. An alternative would have been to ship nothing at all and let a
|
||||
brand-new instance start from a blank page. The template exists because a blank page doesn't
|
||||
tell [instructions/setup-instance.md](../../instructions/setup-instance.md) what shape the answer
|
||||
should take, and it gives nothing for a validator to check afterward.
|
||||
|
||||
A template carries a placeholder value - a sentinel - in the fields that need a real answer.
|
||||
Setup interviews the operator and replaces the sentinel with what they actually said. That
|
||||
gives `doctor` a mechanical way to tell "personalized" from "not yet": a file that still
|
||||
contains the sentinel hasn't been through setup, regardless of whether the file exists. That's
|
||||
also why `ENVIRONMENT.md` only warrants a WARN rather than a FAIL when absent - see
|
||||
[AGENTS.md § Environment](../../AGENTS.md#environment) - while a missing or unfilled
|
||||
`USER.md`/`SOUL.md`/`kb/CONVENTIONS.md` is a harder failure: `ENVIRONMENT.md` describes one
|
||||
checkout among possibly several and is gitignored for that reason, so its absence is a normal
|
||||
state rather than a sign setup was skipped.
|
||||
|
||||
## Where the file boundary used to strain
|
||||
|
||||
"The file itself already answers that" held for every file above except one shape: a `root: kb`
|
||||
type-spec used to carry two audiences inside one file.
|
||||
|
||||
Its authoring guidance - when to use this type, what each frontmatter field means, how to cite -
|
||||
was instruction to an agent. It read like the stack's own prose because it *was* the stack's own
|
||||
prose: a later release that learned something about writing entity pages would want to improve it
|
||||
everywhere. Its `## Template` block and its `layout:` titles were the opposite: they became the
|
||||
literal headings of pages this instance writes, in the language this instance chose, and no
|
||||
release had any business touching them.
|
||||
|
||||
The same file is where the language question comes apart from the ownership one, and for the same
|
||||
reason: ownership decides who may change a line, its reader decides what language it is in -
|
||||
which is why a `root: kb` type-spec still keeps English prose around a template block written in
|
||||
its own language. [language-boundaries.md](language-boundaries.md) has that argument; this page is
|
||||
about ownership alone.
|
||||
|
||||
Ownership is per file, so a file carrying both audiences had to give both halves to whoever owned
|
||||
it. The template half was correct that way. The guidance half paid for it: an instance that
|
||||
adopted its type-specs at setup never received an improvement to the guidance again, because
|
||||
`dist upgrade` wrote the `.template` beside the adopted file and never the file itself. Nothing
|
||||
broke, and nothing reported it - the instance simply kept reading the guidance it was handed the
|
||||
day it was created.
|
||||
|
||||
That was not an argument against the per-file boundary; the boundary is what makes an upgrade
|
||||
safe at all, and merging inside a shared file is the failure the whole section above is about. It
|
||||
was an argument that this particular file was cut in the wrong place - so it was cut again. A
|
||||
`root: kb` type-spec may now declare `guidance:`, a repo-relative path to a second,
|
||||
stack-owned file (`types/<name>.guidance.md`) holding exactly the half that used to be stranded:
|
||||
when to use the type, when not to, and mechanism-level advice that holds for every instance. That
|
||||
file ships verbatim and upgrades like any other machinery file, whether or not the type-spec that
|
||||
links it has ever been adopted. `types/type-spec.md` §§ "Who owns a type-spec" and "Anatomy of a
|
||||
type" hold the current shape; `tools/wikitool types describe <name>` composes the type-spec and its
|
||||
guidance into one answer, so an agent asking for a type's contract never needs to know it comes from more than one
|
||||
file. An instance that adopted its type-specs before this split existed takes it as an *offered*
|
||||
migration rather than something an upgrade applies on its own - the same reasoning as any other
|
||||
instance-owned file in the middle category below, spelled out for this one case because it is the
|
||||
case that motivated the category existing at all.
|
||||
|
||||
A type-spec that declares no `guidance:` - one an instance writes entirely for itself - is
|
||||
unaffected: it is still described from its own body alone, the way every type-spec worked before
|
||||
`guidance:` existed. The split is optional exactly where there is no stack-owned improvement to
|
||||
receive.
|
||||
|
||||
## The consequence in practice
|
||||
|
||||
An upgrade sorts every shipped path into three categories, not two - and the third one only
|
||||
becomes visible once an upgrade is a command rather than a hand-run copy:
|
||||
|
||||
- **Verbatim files** - `AGENTS.md`, `kb/CONTRACT.md`, the per-stage contracts, everything under
|
||||
`tools/`, `types/` and `instructions/` - are the release's to replace. This is where a `root:
|
||||
kb` type-spec's optional `types/<name>.guidance.md` sits: verbatim, even though the type-spec
|
||||
it documents (below) is not.
|
||||
- **`.template`-sourced files** - `USER.md`, `SOUL.md`, `kb/CONVENTIONS.md`, each
|
||||
`kb/<name>/COLLECTION.md`, `ENVIRONMENT.md`, the `root: kb` type-specs and their subtype
|
||||
templates `types/<name>.<value>.md` - are never written by an upgrade at all. A subtype
|
||||
template is the guidance split run the other way: the file boundary cut once more, this time
|
||||
to give page material a file of its own, and page material belongs to the instance, so it
|
||||
lands here rather than among the verbatim files - and because it is its own file, a release
|
||||
can ship a new one without touching an adopted type-spec. The distribution ships only the `.template` beside them, so the filled file
|
||||
is out of reach by construction rather than by a rule someone has to remember. The same
|
||||
property has a second face on the way in: when a release ships a `.template` for a type or
|
||||
collection the instance does not have *yet*, the upgrade writes the template and stops - it
|
||||
cannot write the filled file without deciding the instance's own language and wording for it.
|
||||
Adoption is therefore an act the instance performs, and where the stack *requires* that type
|
||||
(the `source` idiom, and `project` since 7.0.0) an upgrade that skips it leaves a tree
|
||||
`docs verify` refuses. That is the ownership boundary working rather than a gap in it, but it
|
||||
is the one shape in which "the upgrade never writes this file" turns into work somebody has to
|
||||
do; `instructions/upgrade-instance.md` carries the step.
|
||||
- **Seeded-once files** - `.wikitool-kb.json`, `CHANGES.md`, `kb/log.md`, `raw/.gitkeep` - are
|
||||
written into a *new* instance by `dist export` and belong to the instance from then on. They
|
||||
are the awkward category: they sit in the release stamp's file list like any other shipped
|
||||
file, so an upgrade has to exclude them deliberately (`chemenu.ownership.is_export_stub` and
|
||||
`is_upgrade_preserved`). An upgrade that re-seeded them would reset the record of which
|
||||
migrations ran, or erase the changelog the instance wrote for itself.
|
||||
|
||||
The first category carries a caveat that the word "verbatim" hides. It says who *decides* the
|
||||
content, not that overwriting is always safe: an instance can still have edited a verbatim file
|
||||
- a patched `tools/`, a locally adjusted instruction - and an upgrade assuming otherwise would
|
||||
destroy that silently. Avoiding that assumption is the whole reason `dist export` records a
|
||||
sha256 per shipped file in `.wikitool-release.json`. `wikitool dist upgrade` compares every
|
||||
candidate path against the digest recorded when it was installed, overwrites only what still
|
||||
matches, and refuses rather than overwrite what does not.
|
||||
|
||||
So the practical rule is narrower than "overwrite the verbatim files, leave the rest alone":
|
||||
overwrite the verbatim files *this instance has not touched*, never write the other two
|
||||
categories, and make a locally changed file a decision someone takes deliberately instead of
|
||||
one an upgrade takes for them. The template-sourced files were filled in once, by a person, for
|
||||
a reason, and nothing about a newer release of the stack's mechanics gives it standing to
|
||||
override that.
|
||||
|
||||
The same rule runs in the other direction, for a file a release stops shipping, and there it has
|
||||
a deadline the overwrite side does not. The stamp an upgrade writes is the new release's, and the
|
||||
path is no longer in it - so the next upgrade cannot tell that file from one the instance wrote
|
||||
itself, and an instance-owned file is exactly what an upgrade must never touch. Whatever happens
|
||||
to a retired file therefore happens in the run that sees it go, or not at all. An unchanged one is
|
||||
deleted as silently as an unchanged one is overwritten; a changed one is a local change like any
|
||||
other and needs the same deliberate answer, where keeping it makes it the instance's own for
|
||||
good. Leaving the decision optional, as it once was, did not defer it: it made it, the wrong way,
|
||||
and left orphans behind - harmless under `tools/`, where nothing discovers modules by listing a
|
||||
directory, and not harmless under `instructions/` or `types/`, where an orphaned skill keeps
|
||||
being published and an orphaned type-spec keeps being a type.
|
||||
|
||||
## Why an instance comes only from a release
|
||||
|
||||
Everything above depends on one file every instance carries: the `.wikitool-release.json` its
|
||||
release wrote. It is the base `dist upgrade` classifies against, the marker that turns telemetry
|
||||
off for someone who never asked for it, and the record of which stack version the instance runs.
|
||||
An instance that starts anywhere else starts without that base, and every later step has to
|
||||
reconstruct the boundary by other means.
|
||||
|
||||
For a while there were four ways in: a release, a `dist export` from a checkout of the origin
|
||||
repository, a clone of that repository, and a private clone that kept the origin as a git
|
||||
`upstream` and took stack updates by merging. The last two never had the stamp, so they needed
|
||||
the boundary a second way. The clone took the origin's demo corpus, demo persona and development
|
||||
skills with it and had to be emptied by hand, and the instruction for doing so neither said what
|
||||
had to survive nor fitted into the iteration budget. The merge path needed its own
|
||||
ownership-aware command, `upstream merge`, which shipped two data-destroying bugs before it was
|
||||
right, and still left a checkout with two remotes and no stamp. None of the four was in use when
|
||||
they were cut down to one in 8.0.0.
|
||||
|
||||
What is left is a single shape. A release is an export packed as a tarball, installed into an
|
||||
empty folder - or an empty clone of the instance's own repository - by a script attached to the
|
||||
same release. `dist export` remains, as the tool that builds a release and tests what one would
|
||||
ship, not as a way to install. A clone of the origin repository remains too, as the place the
|
||||
stack is developed, and is never an instance.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Why the pipeline has four stages
|
||||
|
||||
Chemenu could, in principle, be one directory: drop a file in, ask a question, get an answer
|
||||
computed fresh each time. It isn't built that way. The pipeline in
|
||||
[AGENTS.md](../../AGENTS.md#routing) - `raw/` -> `[types/ + tools/]` -> `kb/` -> `reports/`, with
|
||||
`work/` alongside rather than inside it - separates *material* from *meaning* from
|
||||
*byproduct*, and each seam exists because collapsing it costs something specific.
|
||||
|
||||
## Why raw material stays untouched
|
||||
|
||||
[raw/CONTRACT.md](../../raw/CONTRACT.md) keeps a source exactly as it arrived. The reasoning is
|
||||
simple once stated: the moment someone "cleans up" or reformats a source on the way in, the
|
||||
thing later claims get checked against is no longer the thing that was actually said. An
|
||||
immutable `raw/` means a citation always resolves to the original, not to somebody's tidied
|
||||
memory of it. It also draws a trust boundary in one place instead of scattering it - everything
|
||||
past `raw/` can be treated as reviewed, because nothing upstream of it silently already was.
|
||||
`incoming/` sits entirely on the near side of that boundary: a file waiting there is
|
||||
not yet reviewed and not yet a citation target, so its being gitignored and readable by an
|
||||
ingest session does not weaken anything - the boundary is the promotion into `raw/` itself, not
|
||||
the moment a human happened to drop a file somewhere.
|
||||
|
||||
Once a document can arrive from *outside* - the MCP server's optional `submit` tool - "a human
|
||||
happened to drop a file somewhere" stops describing how everything reaches `incoming/`, so the
|
||||
near side of the boundary gets a stage of its own rather than a second meaning. `mcp-upload/`
|
||||
holds what nobody has looked at yet; `incoming/` holds what someone has. Two arrows, two
|
||||
different things being granted: `upload accept` grants *trust* (a human read the material and
|
||||
took responsibility for it), `raw accept` grants *immutability* (it becomes a citation target
|
||||
and stops being editable). Collapsing them would have meant one of the two lying - either an
|
||||
unreviewed stranger's file sitting in the same directory a human's own drop does, or the
|
||||
promotion into `raw/` quietly doubling as the review step it cannot perform.
|
||||
|
||||
## Why extraction happens once, through a schema
|
||||
|
||||
[types/type-spec.md](../../types/type-spec.md) is what stands between a raw file and a `kb/` page:
|
||||
a type-spec defines what a conforming instance of a page looks like, and the compiler
|
||||
(`tools/wikitool`) applies it. The alternative - every query re-reading and re-interpreting the
|
||||
source on demand - would mean paying the cost of understanding the material every single time,
|
||||
and getting a slightly different answer each time depending on how the question was phrased.
|
||||
Extracting once, against a fixed schema, turns "re-read and re-guess" into "look up what was
|
||||
already compiled." That is the "never re-derive, always compile" principle from
|
||||
[AGENTS.md](../../AGENTS.md): understanding a source is expensive and worth doing exactly once,
|
||||
after which it becomes a cheap, stable lookup.
|
||||
|
||||
## Why a `kb/` page has to stand on its own
|
||||
|
||||
[kb/CONTRACT.md](../../kb/CONTRACT.md) sets the bar for the compiled layer: a page should answer a
|
||||
future question without sending the reader back to the source it came from. That's the payoff
|
||||
of compiling in the first place - if every answer still bottomed out in "go re-read the raw
|
||||
file," the `kb/` layer would just be a pointer with extra steps, and the cost of extraction
|
||||
would have bought nothing. A page that stands alone is what makes the corpus fast and
|
||||
consistent to query: the work of understanding is already sitting there, done.
|
||||
|
||||
## Why `reports/` doesn't need to be maintained
|
||||
|
||||
[reports/CONTRACT.md](../../reports/CONTRACT.md) treats most of what lands in `reports/` -
|
||||
lint output, telemetry traces - as disposable. The structural content of a lint report can be
|
||||
recomputed from the tree at any commit, so keeping an old copy around would just be a second
|
||||
version of something the tool can already answer on demand, and a second copy is exactly the
|
||||
kind of thing that quietly goes stale. Treating it as derived output rather than a fourth thing
|
||||
to maintain means there is nothing there to fall out of sync - regenerating it is cheaper than
|
||||
reconciling it. The one part that genuinely can't be recomputed - the judgment a pass produced -
|
||||
is carried out into `kb/` or `kb/log.md` before the report itself is discarded, which is the
|
||||
distinction between what's recomputable and what isn't.
|
||||
|
||||
## Where `work/` fits
|
||||
|
||||
[work/CONTRACT.md](../../work/CONTRACT.md) describes a workshop, not a fifth pipeline stage: a
|
||||
place for the notes, extracts and open decisions of a task that spans more than one session, on
|
||||
its way toward becoming a `kb/` page. It sits beside the raw -> kb -> reports flow rather than
|
||||
inside it - closer in spirit to a desk than to a conveyor belt.
|
||||
|
||||
## The shape this produces
|
||||
|
||||
Four stages, each answering a different question: `raw/` - what was actually said; `types/` +
|
||||
`tools/` - how to turn that into structured understanding; `kb/` - what is now known;
|
||||
`reports/` - what a pass over the corpus noticed in passing. Keeping them separate is what lets
|
||||
each one be trusted for what it is, instead of every layer having to double as all four at
|
||||
once.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Why the stack version splits compatibility from migration
|
||||
|
||||
A stack version number looks like it answers one question. It actually answers two, and the two
|
||||
are independent of each other.
|
||||
|
||||
<!-- wikitool:toc -->
|
||||
## Contents
|
||||
|
||||
- [Two questions, not one](#two-questions-not-one)
|
||||
- [Why "kb/ untouched" is not proof of anything](#why-kb-untouched-is-not-proof-of-anything)
|
||||
- [Reading compatibility off the leftmost non-zero component](#reading-compatibility-off-the-leftmost-non-zero-component)
|
||||
- [Downgrade is half the promise](#downgrade-is-half-the-promise)
|
||||
- [A promise made to a machine, not only to a person](#a-promise-made-to-a-machine-not-only-to-a-person)
|
||||
- [The 2.0.0 story](#the-200-story)
|
||||
- [Why a number is only spent by a release](#why-a-number-is-only-spent-by-a-release)
|
||||
- [Where the procedure lives](#where-the-procedure-lives)
|
||||
<!-- /wikitool:toc -->
|
||||
|
||||
## Two questions, not one
|
||||
|
||||
The first question is whether the new version is a drop-in replacement for the old one - whether
|
||||
an existing instance can install it, and can also go back, without anyone doing hand-work. That
|
||||
is what a version number *is*: a promise. The second question is whether the existing corpus in
|
||||
`kb/` needs to change shape to keep working under the new version. These sound like the same
|
||||
question, because most of the time a change that breaks compatibility also happens to touch
|
||||
content, and most of the time a change that leaves content untouched also happens to be
|
||||
compatible. The correlation is real; it just is not a law. `instructions/dev/version-parts.md`
|
||||
carries the actual test for telling them apart and the steps that follow from it - this page is
|
||||
about why the split exists at all.
|
||||
|
||||
## Why "kb/ untouched" is not proof of anything
|
||||
|
||||
The tempting shortcut is: if no page in `kb/` had to change, the bump can't be that serious. This
|
||||
is exactly backwards for a class of changes that live entirely outside the corpus - a renamed
|
||||
release artefact, a Python import path, an environment variable, the URL an instance's own
|
||||
updater points at. None of those touch a single page. All of them can strand an existing
|
||||
instance just as thoroughly as a rewritten type-spec would. The corpus is the part of the stack
|
||||
that looks at itself; the compatibility question is about everything an instance depends on to
|
||||
keep functioning, most of which the corpus never sees.
|
||||
|
||||
## Reading compatibility off the leftmost non-zero component
|
||||
|
||||
Semantic versioning gives every component a job, but only one of them is where an existing
|
||||
instance's tooling actually looks to decide "is this safe." On a `2.x` stack that is MAJOR; on a
|
||||
still-pre-1.0 `0.x` stack, by the same convention, it's MINOR - the leftmost slot that isn't
|
||||
pinned to zero is the one an automated updater treats as the compatibility boundary. Bump
|
||||
anything to its left, or bump that slot itself, and the promise changes. Everything to the right
|
||||
of it can move as freely as the project likes without touching that promise. This is why the
|
||||
question "is it boundary-crossing" always resolves to one specific digit, not to a feeling about
|
||||
how big the change is.
|
||||
|
||||
## Downgrade is half the promise
|
||||
|
||||
It's natural to test compatibility by only asking "does the upgrade work." The other half -
|
||||
"can an instance that upgraded put the old version back and land where it started" - carries
|
||||
equal weight, and it's the half that's easy to forget because forward motion is what everyone is
|
||||
testing for anyway. A state file the old version can no longer parse, a generated index in a new
|
||||
shape, a stamp file that got renamed: none of these have to break the upgrade to break the
|
||||
downgrade. An instance that can go forward but not back has already lost the property a
|
||||
compatible version number is supposed to guarantee.
|
||||
|
||||
## A promise made to a machine, not only to a person
|
||||
|
||||
A human reading a changelog can absorb "this technically isn't compatible but it's fine, just
|
||||
update those two things by hand." An instance's own update mechanism cannot. It reads a version
|
||||
number, decides whether to pull the new release, and has no channel for nuance - which is exactly
|
||||
why the update path itself is one of the sharpest ways to cross the boundary invisibly: if the
|
||||
new version moves where updates come from, the very channel that would have told an instance to
|
||||
adjust is the channel that just broke. The version number isn't documentation aimed at a reader;
|
||||
it's an input consumed by code that has no other way to ask.
|
||||
|
||||
## The 2.0.0 story
|
||||
|
||||
This isn't hypothetical for this stack. The rebranding that produced Chemenu renamed the repo,
|
||||
the release artefact, and the Python package - and left every page in `kb/` untouched. The first
|
||||
instinct was a MINOR bump, on the reasoning that nothing in the corpus needed migrating. That
|
||||
reasoning was correct on its own terms and answered the wrong question. Three things broke
|
||||
underneath it: every existing instance's `update_url` pointed at a repo path that no longer
|
||||
existed and, because it's a machine-written file, couldn't be hand-repaired; the release artefact
|
||||
name changed, breaking every download script and pin against it; and the import name changed,
|
||||
breaking anything importing the package from outside the shipped tree. The corpus had nothing to
|
||||
say about any of this, because none of it lived in the corpus.
|
||||
|
||||
What caught the mistake was a person looking at the diff and asking whether it really was a
|
||||
drop-in replacement, not a validator. No check in `docs verify` or anywhere else confirms that a
|
||||
version part was chosen correctly - it only confirms that a boundary-crossing bump documents
|
||||
what it breaks. The 2.0.0 entry in `CHANGES.md` carries the corrected reasoning in full, and the
|
||||
version bump that shipped it was `--major --no-migration`: boundary-crossing and untouched
|
||||
corpus, at the same time, which is precisely the combination the two-question split exists to
|
||||
make visible.
|
||||
|
||||
## Why a number is only spent by a release
|
||||
|
||||
Everything above is about what a version number *promises*. A separate question turned out to
|
||||
matter just as much in practice: how many numbers get handed out along the way to making one
|
||||
release. For a while the answer was "one per bump," and that turned out to be the wrong grain
|
||||
entirely.
|
||||
|
||||
Two mechanisms decide when a number gets minted, and they answer different questions. CI's
|
||||
version gate asks a *commit*-level one: has this tree changed since the last push, and if so
|
||||
has `VERSION` moved with it. A release asks something else entirely: is this a state worth
|
||||
handing to someone, under a number they will pin against. Tying the second to the first - every
|
||||
`VERSION` move firing the release workflow - answers the gate correctly and the release question
|
||||
by accident, because it treats every bump as if it were about to ship when most bumps are steps
|
||||
toward a release that has not happened yet.
|
||||
|
||||
The failure mode is not phantom numbers; every one of those releases was real, tagged and
|
||||
downloadable. It is that "real" stopped meaning anything. On 2026-09-03 this repository cut four
|
||||
releases in six hours - `4.3.0` through `4.3.3` - for one continuous arc of work, two of them for
|
||||
prose changes alone. Someone tracking the feed saw four upgrades and had no way to tell which, if
|
||||
any, was a moment worth stopping for. A release is a promise addressed to a consumer, and a
|
||||
promise made four times an afternoon is not a smaller promise, it is a less legible one.
|
||||
|
||||
The fix is not to slow the gate down - it still wants `VERSION` to move every time, and it still
|
||||
gets that. It is to stop treating every movement as a number worth publishing. Between two
|
||||
releases the stack now carries one running candidate, escalating through `-beta.N` as bumps
|
||||
accumulate, and only `version release` spends the number for real by fixing it and closing its
|
||||
changelog entry. A number is proposed by a bump and spent by a release; conflating the two was
|
||||
the actual defect, not the arithmetic of any single bump.
|
||||
|
||||
This is also why a candidate never gets to a distributed instance. The promise a released version
|
||||
makes - "install this, and it is exactly what its number says" - has no equivalent for something
|
||||
still being decided during a single dev checkout's session. `release.yml`'s only job with respect
|
||||
to this is refusing to act on a suffixed `VERSION` at all: not because a beta is unsafe, but
|
||||
because there is nothing yet to promise.
|
||||
|
||||
## Where the procedure lives
|
||||
|
||||
The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the
|
||||
steps for a boundary-crossing bump - the `--breaking` lines, the migration document or
|
||||
`--no-migration` reason, talking to the user before bumping - are one procedure, kept at one
|
||||
place: `instructions/dev/version-parts.md`.
|
||||
|
||||
Named as a plain path rather than linked, because it is not here to link to. `dist export`
|
||||
prunes `instructions/dev/` wholesale, so that file exists only in the origin repo - the place
|
||||
where a version is bumped at all. An instance reads this page to understand what a version
|
||||
number promises it; it never runs the procedure.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Why gates are code
|
||||
|
||||
Chemenu has five hard limits - the Mass-Update Gate, the Publish-Remote Gate, the Upload Review
|
||||
Gate, the Guideline Push Gate, and the Iteration Budget Gate - and all five live inside
|
||||
`tools/wikitool`, not in a
|
||||
paragraph of instructions an agent reads and follows. The rules themselves, and what to do when
|
||||
one trips, are in [AGENTS.md § Gates](../../AGENTS.md#gates) and
|
||||
[instructions/gates.md](../../instructions/gates.md). This page is only about the design choice
|
||||
underneath them: why code, and why these mechanisms in particular.
|
||||
|
||||
<!-- wikitool:toc -->
|
||||
## Contents
|
||||
|
||||
- [A suggestion an agent can talk itself past](#a-suggestion-an-agent-can-talk-itself-past)
|
||||
- [Why different mechanisms, not one](#why-different-mechanisms-not-one)
|
||||
- [Exit 42 is a posture, and it outgrew the gates](#exit-42-is-a-posture-and-it-outgrew-the-gates)
|
||||
- [A gate in code still has to be reachable](#a-gate-in-code-still-has-to-be-reachable)
|
||||
- [Numbers that come from measurement, not intuition](#numbers-that-come-from-measurement-not-intuition)
|
||||
<!-- /wikitool:toc -->
|
||||
|
||||
## A suggestion an agent can talk itself past
|
||||
|
||||
An instruction like "don't publish too much at once" or "don't loop forever" lives in the same
|
||||
place as every other piece of guidance a session is holding - alongside the task, the user's
|
||||
last message, and whatever context made the moment feel urgent. Under pressure, or with a
|
||||
plausible-sounding reason ("this batch is different, it's mechanical"), that guidance can be
|
||||
reasoned around without anyone deciding to break a rule. Nothing enforces it; it just competes
|
||||
for attention with everything else in the context window, and sometimes loses.
|
||||
|
||||
A check compiled into the tool doesn't have that problem, because it isn't part of the
|
||||
conversation at all. It runs before the command dispatches, regardless of how convincing the
|
||||
case for skipping it seemed a moment earlier. The difference isn't that code is smarter than a
|
||||
well-written instruction - it's that code doesn't get talked into anything.
|
||||
|
||||
## Why different mechanisms, not one
|
||||
|
||||
The five gates ask four different kinds of question, and each one's shape follows from what kind
|
||||
of question it is.
|
||||
|
||||
The Mass-Update Gate asks *is this change too large to publish unreviewed* - a judgment that
|
||||
varies changeset by changeset, so it clears with a `--confirm` token tied to the specific
|
||||
output the user just read. Approval is scoped to that one publish.
|
||||
|
||||
The Publish-Remote Gate asks something underneath that: *is this even the right repository*.
|
||||
That's not a per-push judgment, it's a standing property of the checkout - true or false for
|
||||
every publish that checkout will ever attempt, not just this one. A confirm token would let an
|
||||
agent clear it once and then treat the answer as settled, which is exactly backwards for a
|
||||
question whose answer shouldn't move at all mid-session. The only way past it is the user
|
||||
editing `.wikitool-remotes.json` directly, outside the gate's own flow.
|
||||
|
||||
The Upload Review Gate asks the Mass-Update Gate's own question - *is this change right?* - at
|
||||
the opposite end of its size range: one file from a stranger instead of a changeset from the
|
||||
session's own work. That similarity is exactly why it reuses the same shape (a `--confirm` token
|
||||
digesting the thing being approved) rather than inventing a fourth one: the two gates differ in
|
||||
*who* produced the change and *how much* of it there is, not in what kind of question either one
|
||||
is answering, so nothing about the mechanism needed to change - only the boundary it sits behind
|
||||
did, since the submission lives in a quarantine the ordinary pipeline never reads at all rather
|
||||
than in the working tree `publish` is about to commit.
|
||||
|
||||
The Guideline Push Gate asks the same question once more, at the boundary facing outwards: *is
|
||||
this content right for these repositories, now?* `export guidelines --push` writes a generated
|
||||
file straight onto another repository's branch, with no review on the receiving side, and a
|
||||
public target publishes it the moment it lands. That is a per-run judgment - the guidelines
|
||||
change, the set of opted-in repositories changes, a branch moves - so it takes the token shape,
|
||||
digesting exactly what would be written where. It deliberately does not take the Publish-Remote
|
||||
Gate's shape, although it too pushes to a remote: which repositories are targets is already a
|
||||
standing, committed declaration - the capture manifests under `raw/` - and asking for the same
|
||||
URLs in an allowlist as well would be a second declaration of one fact. What is left to ask is
|
||||
the per-run question, and that is the one a token answers.
|
||||
|
||||
The Iteration Budget Gate asks a fourth kind of question - not "is this instance correct" but
|
||||
"has this session stopped making progress." That's read from the shape of the call history
|
||||
itself (call count, repeated identical calls), not from anything about the content of any one
|
||||
call.
|
||||
|
||||
## Exit 42 is a posture, and it outgrew the gates
|
||||
|
||||
Those five are the named gates, and they are not the only thing that exits 42 any more. When the
|
||||
task-tracker provider layer arrived, it brought a case that looks like a gate from the outside and
|
||||
is not one: a provider whose API cannot create a project (Super Productivity's local REST API
|
||||
reads projects but does not write them) raises `HumanInterventionRequired`, and the command prints
|
||||
what a human has to do and exits 42.
|
||||
|
||||
Reusing the code was deliberate, and so was not calling it another gate. What the named gates share
|
||||
is a *refusal*: the operation was possible and the tool declined to perform it unreviewed. This is
|
||||
the opposite situation - the operation is not possible at all, and no token could make it
|
||||
possible. What the two have in common is only what the exit code actually communicates: **stop,
|
||||
show this to a human, do not improvise a way around it.** That sentence is the whole meaning of
|
||||
42 here, and it is worth more as a shared convention than as a number reserved for one mechanism.
|
||||
|
||||
The alternative was worse in a specific way. A provider that cannot do something could have been
|
||||
described in the instruction layer instead - "if you are on this tracker, create the project by
|
||||
hand first" - which is exactly the prose-shaped rule this page argues against, with the added cost
|
||||
that every instruction would then have to know which provider an instance runs. The capability
|
||||
gap belongs where the capability is, and reaches the session as an exit code rather than as a
|
||||
paragraph it has to remember to apply.
|
||||
|
||||
The preflight is the second such case, and the one closest to this page's own argument. A machine
|
||||
without Python or ripgrep is not something the tool can fix, and an install that met that gap
|
||||
with only a setup instruction to go on showed what follows: the agent worked around each missing
|
||||
piece - another environment, a hand-made configuration - and kept going. So `tools/preflight.sh` (and its PowerShell twin, `tools/preflight.ps1`)
|
||||
exits 42 with the command a human has to run, and the launcher in front of every `wikitool` call
|
||||
exits 42 until the preflight has passed. "Check first, stop, let the user act" lives in two places
|
||||
a session cannot read past, not in a step it can skip.
|
||||
|
||||
## A gate in code still has to be reachable
|
||||
|
||||
Code beats prose for the reason above, but on its own it buys less than it looks like: a check
|
||||
that runs on every call is only as good as the thing it counts under. The Iteration Budget Gate
|
||||
scopes its counter to a session, and "session" was approximated by the parent process id whenever
|
||||
nothing set an explicit one. On a harness that runs every tool call in a freshly initialised
|
||||
shell, that approximation hands out a new session per call - so a traced run of thirty-three calls
|
||||
arrived as twenty-one sessions of one to three calls each, the ceiling of sixty was never
|
||||
approached, and the loop-breaker's window never held three calls at once to compare. The gate ran
|
||||
on every one of those calls, exactly as written, and refused nothing.
|
||||
|
||||
That failure has no symptom of its own. A gate that fires announces that it exists; a gate that
|
||||
*cannot* fire looks identical to a gate nobody happened to need - the same clean runs, the same
|
||||
silence - and what finally told the two apart was reading a trace for an unrelated reason. So
|
||||
there is a third property to keep alongside living in code and carrying measured numbers: each
|
||||
gate has to leave evidence that it can still fire. The ones that clear by token or by a
|
||||
deliberate edit have it by construction, because clearing one is a visible event in somebody's
|
||||
terminal. The budget gate, whose ordinary outcome is silence, is the one that had to be given
|
||||
it - which is why its session id now carries where it came from, into both the trace and
|
||||
`budget status`, so a session's own record answers the question instead of an investigation
|
||||
having to.
|
||||
|
||||
## Numbers that come from measurement, not intuition
|
||||
|
||||
The iteration ceiling didn't start where it sits now. It used to run 15-25, borrowed from a
|
||||
general rule of thumb, until four real ingest runs measured 24, 26, 29 and 30 calls apiece -
|
||||
every one of them an ordinary workflow doing nothing wrong, and every one of them at or past
|
||||
where the old ceiling would have refused it. A limit that the normal case keeps tripping stops
|
||||
functioning as a limit; it becomes background noise a session learns to route `--override-budget`
|
||||
around as a matter of course, and the whole point of a hard-coded check is that it isn't supposed
|
||||
to feel routine.
|
||||
|
||||
That's the deeper reason these numbers live in a tool rather than in prose: prose is read once
|
||||
and remembered loosely, but a threshold enforced every call is tested by every call, and a
|
||||
threshold that fails its own test gets noticed and re-measured rather than quietly ignored.
|
||||
|
||||
The suite's coverage floor is the same argument run forwards instead of backwards. The ceiling
|
||||
above was wrong first and measured afterwards; the floor was withheld on purpose until the number
|
||||
existed - measured, then watched across 38 runs while the code grew by a quarter, and only then
|
||||
written down as 85 against an observed 87.0%. The two points of daylight are the same
|
||||
consideration as the ceiling's headroom: a limit the ordinary case keeps tripping stops being a
|
||||
limit. A coverage floor set at the measured number goes red on the next thin command wrapper,
|
||||
which is not a regression, and a threshold that goes red for a non-reason gets lowered rather
|
||||
than earned - the failure mode above, reached from the other direction.
|
||||
Reference in new issue
Block a user