Project families
A project that is larger than one repository, or a repository that holds several: how the skill finds the project it is in, how instances are told apart, and — in later releases — how instances name each other. The series answers #450 (cross-repository rationale) in steps; this file records the decisions as each step lands. What the code cannot say is why the boundaries were drawn where they are.
The project is the nearest .keep-the-why walking up, not the working directory and not the Git toplevel
Id: 81263e8a-ee54-41e2-84c3-83befcdf0331 Type: decision Status: active Evidence: confirmed Source: maintainer design discussion, 2026-09-26/27 Revisit when: a harness starts sessions in a directory that is neither inside a project nor above several, or a layout appears that the four named ones do not cover
A project is the tree under the nearest .keep-the-why walking up from the
working directory, minus the trees under any deeper .keep-the-why. The
nearest file wins and cuts off everything above it, whether or not a Git
boundary lies in between. A directory above several projects and below
none is not a project: the agent takes the one the request names, else
asks. Four layouts are named for readers — shared context mono repo,
isolated context mono repo, multi repo, single repo — and a nested
repository is deliberately not a fifth.
Reason: before this the rule was implicit — the working directory is
the project — which works only when a session starts exactly at the
repository root. Walking up is the rule every developer already knows from
Git, it makes a session started in src/ find the right file, and it
gives an isolated-context mono repo its semantics for free: a sub-project
with its own instance is isolated from the root's by construction, without
a field saying so. The "above several, below none" case came from a real
session: a hook that looks one level down loaded the skill in a folder of
many repositories, and the only correct move was to ask which one.
Rejected alternative: the Git toplevel as the project. Rejected because
it cannot express one repository with several context/ directories, and
because a repository cloned inside another would then be swallowed by the
outer one.
Rejected alternative: an inner instance reads the enclosing instances
as well (the way Codex walks AGENTS.md from the root to the working
directory). Rejected because isolation is what the layout is for — a
sub-project that wants the root's knowledge declares it, in the family
mechanism the next steps add, rather than inheriting it by position.
Rejected alternative: a fifth layout for nested repositories. Rejected
because the walk-up rule already handles them, and a nested repository
with its own remote gets an ordinary id; naming the case would suggest a
mechanism that does not exist.
canonical is a stored locator beside id, and a sub-project's place is a root field, not part of the URL
Id: 4d77c151-4e83-4a9b-9c2a-68a04a0348f5
Type: decision
Status: active
Evidence: confirmed
Source: maintainer design discussion, 2026-09-26/27, with two rounds of external review
Revisit when: a second locator kind is needed (a project reachable by something other than a repository URL), or a host stops resolving the normalized https:// form
.keep-the-why gains canonical, the origin URL normalized (https://,
no .git, no trailing slash, SSH rewritten), written once at init and
changed only deliberately; and, for an instance below the Git toplevel,
root, its path from the toplevel. Its id appends that path as a slug,
<owner>---<repo>---<sub-path>. id may initially be derived from the
same remote; afterwards the two evolve independently.
Reason: the next steps let projects name each other, and a name that
works across machines has to be the repository's URL — the one thing every
clone knows. It is a locator, not an identity: repositories are renamed,
transferred and moved between hosts, so the value is stored and changed on
purpose, exactly as id already is (config-format.md, "Project identity
is stored explicitly"). The slug in the id is a bug fix as much as a
design: two instances in one repository share one remote and would have
shared one personal file.
Rejected alternative: put the sub-path into the URL —
https://github.com/owner/project1/sub-project2 as the sub-project's
canonical, one comparable string. First chosen, then reversed on review:
that URL is neither the repository's nor one the host answers (GitHub
returns 404), so it was a locator that did not locate, and every tool
would have had to take it apart again. (canonical, root) is the key
instead.
Rejected alternative: make the URL the identity and drop id.
Rejected because id keys every existing personal file, and because a
URL changes for reasons that have nothing to do with the project — the
argument that put id into the file in the first place.
Rejected alternative: re-derive canonical from the remote each
session. Rejected for the reason id is not re-derived: a renamed remote
would silently break every reference that names the old value, with
nothing recording what it used to be.
Entries carry a UUID as their Id, and See and Superseded by resolve to it
Id: 6cae3bbb-10ce-46d5-914b-f884da584532 Type: decision Status: active Evidence: confirmed Source: maintainer design discussion, 2026-09-26/27, with two rounds of external review Revisit when: a host stops rendering heading anchors the way the locator rule assumes, or a project reports UUID lines as a real cost in readability
Every entry gets **Id:**, a UUID version 4 made by an OS command and never
changed; See cites another entry as <locator> — <uuid> — as of <date>;
Superseded by is required on every superseded entry and names the
successor's Id, a cross-project reference, or none — <reason>. The
locator is what a person clicks, the Id is what a tool resolves: when a
heading is reworded or a file is split, the linter finds the entry by Id
and reports the stale locator (E119), and the agent repairs it. The date
is a historical hint, not a revision.
Reason: until now an entry was identified only by its heading, and the
links in this repository's own context/ are file-level for exactly that
reason — a file.md#heading link breaks silently on the two operations
the skill asks for, rewording and splitting (rule 6). The next step of the
series lets projects cite each other's entries across repositories, and a
web built on heading links would rot. A UUID is globally unique, so the Id
alone is an address in any project, any family and any export, with no
"unique within the project" clause and no disambiguation by project; and
the same OS command that already makes a project id without a remote makes
it, so nothing is composed by the agent from imagination.
Rejected alternative: a short random token, eight or twelve hex characters. First chosen for line length, dropped on review: 32 bits collide at about 1 % for ten thousand entries, and even 48 bits would have needed a per-project uniqueness rule; a random token nobody reads or types gains nothing from being short.
Rejected alternative: See as a prose convention rather than a field.
Rejected because a convention cannot be linted and cannot be turned into
edges by the dashboard; the field is what makes entry-to-entry references
mechanical.
Rejected alternative: a commit hash in the See line instead of, or
next to, the date. Rejected for now: a See wants the living entry, whose
Status may have moved to superseded since, and a hash would show the old
state for ever; the day plus the target's history is enough to recover what
was cited, and an optional fourth part would lengthen every line for a case
nobody has.
Rejected alternative: Superseded by optional, with a warning when
absent. Rejected because the spec's lifecycle already says a replaced
decision is recorded by a new entry; in the seventeen superseded entries
surveyed across this repository and the suite the successor either existed
or was an event — and none — <reason> covers the event, the way Type:
undefined — <reason> covers an unclassifiable entry.
The linter reports stale locators and missing Ids; the agent repairs and migrates
Id: 4d461bd1-ea3b-40d1-a8c5-cbb72f6ca5d4 Type: decision Status: active Evidence: confirmed Source: maintainer call, 2026-09-27 See: lint.md#the-home-files-are-checked-only-behind---setup-never-by-default — c2162bae-c634-49e6-8b39-0b3eb05a0c1f — as of 2026-09-27 Revisit when: the linter gains a second consumer that needs it to write (an editor integration that expects fixes), or the migration pass turns out to be too large for an agent session in a real project
The migration to entry ids is a pass the agent runs — generate a UUID per
entry, add Superseded by where Status is superseded — and the linter
then verifies it, E114–E121. A stale See locator is likewise a
finding the agent acts on. The linter never writes a file.
Reason: the linter is a CI tool that reads pull requests from
strangers, and "it reads and reports, it never mutates" is a trust
statement this project makes on its security page. A write mode, even a
narrow one behind a flag, would have made that sentence carry a footnote.
The agent is on every machine the skill runs on, the pass is additive (one
line per entry, no existing link changes, because links were file-level
and no See lines existed), and the linter finds what the agent missed —
so the agent checks itself against the tool instead of the tool doing the
work.
Rejected alternative: a --assign-ids write mode in the linter, one
parser for both jobs. Rejected for the trust statement above, and because
no permanent migration tool is wanted for a one-time pass.
Rejected alternative: a shipped migration script. Rejected because the
skill ships no executables on purpose — the security scanners flag a
bash in a skill, and the project's line is that the only executables are
the two optional packages.
A family is one parent and its children, and the parent's children block is the routing
Id: 14c6f3cd-a3c1-4c42-9d76-8171b214617b
Type: decision
Status: active
Evidence: confirmed
Source: maintainer design discussion, 2026-09-26/27, with two rounds of external review
Revisit when: a real family needs a member to belong to two parents, or a project asks for a relation that is neither parent nor child and cannot be expressed as a See line
A project belongs to at most one parent, declared by one parent line;
the parent lists its children in a children block, one line each with a
required one-line scope. That block is the routing: family-wide knowledge
goes to the parent, a child's subject goes to that child even when it
surfaced elsewhere, an ambiguous case is asked, and the parent is not the
place for everything no child claims. The family exists to organize
context/; it does not model dependencies.
Reason: the suite case that started this — a meta repository that
already is the central place for what concerns all packages — needs one
thing: that knowledge lives once, where it belongs, and is cited from
everywhere else. That takes a map of where things belong, kept in one
place so that a new module is one line in the parent and nothing is
maintained twice; the child's own index.md already says what it holds,
so the child repeats nothing. The scope is required because a child
without one gives the routing nothing — the agent working in the
websocket package cannot know that a REST quirk belongs to the REST
package unless something says so. One parent keeps it maintainable; a
family can nest, but nothing resolves through a grandparent.
Rejected alternative: upstream and downstream roles beside parent and
child, as the original issue sketched — the dependency direction as a
declared relation. Rejected because every family member may cite every
other with a See line, which covers the case the issue describes, and
because dependencies are already in the package metadata; a second copy
in .keep-the-why would drift.
Rejected alternative: an inner instance reads the enclosing ones by
position, so that a mono-repo sub-project sees the root's context/ with
no declaration. Rejected with the discovery rule: isolation is what the
layout is for, and the family mechanism is the explicit way back.
Rejected alternative: the scope in the child, next to its own config, with the parent holding only a list. Rejected because routing must be readable from the parent without opening — or cloning — every child, and because it would put the same information in N files instead of one.
Rejected alternative: the parent's scope as "everything no child claims". Rejected on review: it makes the parent a dumping ground; what is family-wide or clearly the parent's own goes there, the rest is asked.
Children lines are <name>: <location> — <scope>, in the block grammar every config file already uses
Id: c3ffbd28-b391-4437-8443-34b0d7f6a4e3 Type: decision Status: active Evidence: confirmed Source: maintainer call while implementing, 2026-09-27 Revisit when: a child needs more than a location and a scope (a per-child setting), or the block grammar gains a list form
A child line is a - key: value line like every other config line: the
key is a short name, the value is the location, an em-dash separator and
the scope. The design draft had - <location> — <scope> without a name.
Reason: the block parser splits a line at its first colon, and a URL
location contains one; a nameless line would have needed a second grammar
for one block, with its own duplicate and unknown-line rules. A name
costs one token, gives the parser the key it expects (a child listed twice
is the existing E004), and gives findings and prose something to say
("child widget does not name this project as its parent") instead of
repeating a URL.
Rejected alternative: a list grammar for the children block only.
Rejected because one config grammar for four block kinds is a property
the linter, the dashboard and every reader rely on.
A write into another family member needs its local working tree, found in the same repository or the sibling folder
Id: e4bc46e9-650a-4c87-a09d-e61a10196bbf Type: decision Status: active Evidence: confirmed Source: maintainer design discussion, 2026-09-26/27, with two rounds of external review Revisit when: the mapping and the read-only cache land (the next step of the series) — then "local" is what the mapping knows, not only the sibling folder; or dogfooding shows a real need to write into a project that is not checked out
The agent may write into a family member that has a local working tree —
the same mono repo, or a checkout in a sibling directory of the current
project whose .keep-the-why carries the named canonical — under that
project's own capture-confirmation, saying which project it wrote to. A
member that is not local is named and the entry is not written into the
current project instead. Nothing outside the family is written; nothing is
cloned by this step.
Reason: routing without a write path would name where an entry
belongs and then lose it, and writing it locally with a note would
recreate the redundancy the family exists to remove. A working tree is
where a person expects uncommitted work and sees it at every
git status; committing stays their action, so a multi-repo write is an
ordinary change in another checkout. The sibling-folder rule is how a
suite is laid out on a developer's machine anyway, and it needs no state
file — the mapping comes with the next step. Working tree = work; what a
cache is for, and why it is read-only, is decided there.
Rejected alternative: write the entry into the current project with a pointer, for the parent to pull later. Rejected as redundancy through the back door.
Rejected alternative: search the whole machine, or every path ever seen, for a checkout. Rejected for this step because a search without a recorded mapping guesses, and because two clones of one repository must never be told apart by whichever was opened last — the mapping step carries that rule.