@twin-digital/design-process
v0.11.0
Published
Validator, projection, backlog, and fold tools for the twin-digital incremental design process.
Readme
@twin-digital/design-process
Tooling for the twin-digital incremental design process: the merge-gate validator, the
projection, the opaque-id generator, the cross-increment backlog, and the fold resolver and
diff. The process itself is defined by the documents shipped from the
plan-opus repository under docs/.
Usage
Every command takes --root <dir> (default .) naming the repository root.
design-process check [--base <ref>] [--static-only] [--json]
design-process show <product> [--at <increment> | --at-ref <gitref>] [--scope <component>] [--commentary] [--json]
design-process id <r|d|q|f|run> [--count <n>] [--json]
design-process where <product> [--at <increment> | --at-ref <gitref>] [--next] [--json]
design-process diff <product> (--from <increment> | --from-ref <gitref>)
[--to <increment> | --to-ref <gitref>] [--json]
design-process conflicts <product> [--against <increment> | --against-ref <gitref>] [--json]
design-process increment [product] [--pr <url>]
design-process land <product>
design-process backlog add <product> [--title <text>] [--tag <tag>]... [--file <path>] [--json]
design-process backlog list [--product <id>] [--tag <tag>]... [--json]
design-process backlog search <query> [--product <id>] [--tag <tag>]... [--json]
design-process backlog show <id> [--json]
design-process backlog update <id> [--title <text>] [--tag <tag>]... [--add-tag <tag>]...
[--remove-tag <tag>]... [--file <path>] [--product <id>] [--json]
design-process backlog delete <id>... [--json]
design-process backlog send <increment-dir> [--item <id>]... [--product <id>] [--tag <tag>]... [--json]Output streams and --json
A command's main output — the data it exists to produce — goes to stdout, and stdout carries
nothing else. The progress and warning lines around it go to stderr: an unresolvable base ref, a
passing check's note, the tally of a failing one, and an empty result's note. So
design-process check > findings.txt leaves a file holding the findings and nothing more.
The seven commands with a machine-readable main output take --json and render it as JSON:
check and conflicts emit the findings array, show the folded projection as data, id the
ids, where the increment it names, diff the delta, and each backlog subcommand its items.
increment is a full-screen session whose output is the screen and land is a step sequence
written for a person; neither has a structured value behind it, so neither takes the flag.
Fold versions
Wherever a command takes a fold version it takes two parameters, not one: the bare parameter
names an increment, and its -ref counterpart names a git ref — --at and --at-ref, --from
and --from-ref, --to and --to-ref, --against and --against-ref. Giving both members of
a pair is an error.
An increment parameter takes the number with or without padding: --at 9, --at 09, and
--at 009 are the same increment. A ref parameter takes anything git resolves — --at-ref main,
--at-ref origin/main, --at-ref 3f2a1c0 — and folds at the product's latest published
increment there.
Draft increments
An increment being worked lives at products/<product>/increments/wip-<NNN>-<slug>/ on its own
branch — wip-, a three-digit ordinal, then a slug. The ordinal orders the drafts one tree holds
and claims no published number; landing renames the directory into the next published number
before the merge, and main never holds one.
The tooling reads a draft increment as it is worked, so an author sees what is wrong before landing rather than after. Which reader sees one:
| reader | draft increments |
| --------------------------- | ------------------------------------------------------------------------------ |
| check | read — every per-increment rule applies to a draft as to a published increment |
| show with no fold version | folded, after every published increment, in ordinal order |
| show --at / --at-ref | excluded — an asked-for version names published state |
| where, diff | excluded |
| conflicts | read on the draft side; the head it compares against is published state |
A directory that begins wip- and does not match the grammar is not a draft increment: it draws
the increment-dir-name finding worded for the wip form, and its sources are read by nothing.
wip-1-slug, wip-abc, wip-001, wip-001-, and wip-0001-slug each miss it.
One tree holds more than one draft increment only when they are stacked, and then the ordinals
are their relative order — wip-001 lands before wip-002. Ordinals need not be dense: when an
ancestor lands, the dependent keeps the ordinal it has. Two drafts that are not ancestor and
dependent are not supported in one tree; work in a fresh worktree instead. Nothing in a checkout
distinguishes that case from a legal stack, so the tooling reports only what it can see — two
drafts sharing an ordinal, which carry no relative order.
check
Applies every design rule in force to the repository tree. The output has two severities: a
finding (✖) gates any merge and sets the non-zero exit; a report (▲) informs and
gates nothing repo-wide. Each names the product it concerns where one does. The one report the
check emits today is the staleness model's — a published in-force foundation resting on a
retired fact — and it is enforced by that product's own landing sequence rather than by the
check's exit. The rule groups:
- Tree-state rules — pool identity and
$refresolution, schema validation of every structured file against the pool schema itsversionnames, id format and uniqueness, noproposeddecisions, no open questions, citations resolve (never to a question; a draft entry citing an already-retired fact is a finding, a published one the staleness report), model bindings resolve, preset adoption rules (each hop resolves, is arequirement-preset, and pins a published version; the closure is acyclic, pins one version per preset, and holds no requirement id declared twice in force; a draft applies no retired preset at a new version), dense increment numbering, implementation-record naming and claim scope, and record coverage completeness — every requirement and ruled decision in force at the record's target, the preset closure's requirements included; deferred decisions are excluded, and no coverage entry may name one. - Component and term rules (the
requirements@3dialect) — component parents resolve and the graph is acyclic; a component or term retires only when nothing in force still references it, unlesssuperseded_byresolves the references; everyscope:resolves to a live component,superseded_bychains included; a requirement-preset declares no components and scopes none of its own requirements; one increment declares a preset, component, or term once; term slugs are unique across the product's closure, adopted terms included. Statement budgets bind the new dialects only: a statement over 60 words, or awhen/then/otherwiseclause over 25, is a finding — commentary is unbudgeted. Heuristic usage detection reports rather than gates: re-parenting a component names the claims whose reach moved, redefining a term names the foundations using it, an orphan term is noted. - Evidence-bar rules — the repo-wide
facts/andevidence/pools. A pool file is aversion:mapping carrying afacts:orruns:sequence; anything else under those directories — a probe's fixtures and inputs — is artifact material and contributes no entries. Checked: file shape against the/design-process/facts@N/runs@Nschema the wrapper's own version names (@1entries keep kebab ids and enum retirement reasons;@2entries carry generatedf-/run-ids and free-text reasons), the backing's source floor, verbatim quotes at in-repo sources (an off-repo url — one carrying a scheme — is not read),run:sources resolving to a live run on a tested fact,superseded_byresolution across both dialects, and id uniqueness across the shared fact/run namespace. A pool file that does not parse as YAML is itself a finding, never a silent drop. A run's recordedoutputmust exist in the tree whether or not a fact cites the run yet. - Draft-increment rules — a
wip-<NNN>-<slug>directory always draws theincrement-dir-namefinding, sochecknever exits 0 while a draft increment is in flight; the landing rename clears it and nothing else does. Two drafts sharing an ordinal drawdraft-ordinal-unique. Every other per-increment rule reads a draft as it reads a published increment. Three readings stay on published numbers: the density gate (a wip ordinal neither fills a gap nor makes one), record coverage completeness (a record folds at its numeric target with drafts excluded, so it lands cleanly in a tree holding one), and the change rules. - Change rules — compared against
--base(defaultorigin/main, thenmain): published increments are immutable, shipped implementation records are immutable, pool versions bound by a published increment are immutable (directly or through a bound schema's$ref), and a new implementation record targets the newest published increment. A merged fact or run is frozen: the one edit is marking it retired, with its reason and its replacement — to say something different, write a new entry. Retiring a cited fact is never refused, but the change must carry a backlog item per citing product, naming the retired fact; the gate reads the backlog branch for those items.
Findings cite the requirement or decision id each rule enforces, e.g.:
✖ [published-immutable] products/demo/increments/001/decisions.yaml: edited in a published increment (r-caao9k3z)show
Renders the folded, effective state of a product at an increment as markdown: requirements
(local, and adopted through the preset closure — each preset listed once, with the presets an
indirectly reached one came through), decisions ordered by because: topology with statuses,
pins, and their ordered cases: (deferred entries counted beside the rulings), the components
and terms the fold declares, model bindings, coverage joined from the implementations/ pool
with uncovered and attestation-only counts (deferred decisions excluded, the summary naming how
many), and what the increment changed.
--scope <component> shows only claims whose reach touches that component's subtree; an
unscoped claim applies to the whole product and always shows. --commentary includes the
non-normative commentary beside each statement — and is refused with --at or --at-ref, since
a published projection is what implementers build from and never carries it. The --json data
omits commentary under the same rule.
With no fold version asked for it renders the tree as it stands, draft increments folded after
the published ones in ordinal order: their foundations appear, their supersessions close what
they name, and the coverage summary counts their claims. A draft shows under its directory name,
holding no number until it lands — # demo @ wip-003-third-thing. Naming a version with --at
or --at-ref projects published state and leaves drafts out.
id
Generates opaque ids — the ratified prefix plus 8 random lowercase base36 characters — unique
against every id mentioned under products/, facts/, and evidence/. The kinds are r
(requirement), d (decision), q (question), f (fact), and run. Backlog ids (b-) are
minted by backlog add, not here.
where
Prints the product's latest published increment, zero-padded to three digits and nothing else, so
it drops into a shell substitution. It reads the working tree by default, --at-ref another git
ref. --next prints the number a landing would claim instead — 001 for a product with no
increments yet. Without --next, a product that has published nothing there exits non-zero.
$ design-process where increment-process
011
$ design-process where increment-process --at-ref origin/main --next
012diff
Reports what changed between two versions of a product's fold: the foundations added, the
requirements amended, the decisions superseded, and the entries retired. One of --from or
--from-ref is required; the later fold defaults to the working tree, so --from 010 alone
answers "what has this branch changed since 010". Empty sections are omitted; --json emits the
same delta as structured data.
# increment-process: 009 → 011
## added (22)
- r-hbihi1xh (010) [requirement] — future work has a home outside any increment
...
## superseded (1)
- d-aaaaaaaa (011) by d-ccccccccconflicts
Checks a draft's rulings against the fold at head before it lands: an id the head already
declares, and an amends:, supersedes:, or retires: aimed at an entry the head has already
closed. The head defaults to origin/main, then main; name another with --against-ref, or an
increment with --against. It covers every increment directory carrying no published number — a
draft increment at its wip directory among them — and every directory numbered above the head.
Overlap the tooling cannot see —
two drafts ruling the same choice under different ids — is the owner's scan of open drafts.
Findings print in check's shape and exit non-zero.
increment
A full-screen session over a draft's pull request: the owner rules every entry it carries and
publishes without leaving it. What it renders is the authored surface
/design-process/ratify-screen@4 in the planning repository's surfaces/ pool.
--pr <url> names the pull request to work. Given one, the session resolves it to its head
branch: where the current tree is already on that branch it works in place, and otherwise it
fetches the branch, runs in a temporary git worktree made from it, and removes the worktree when
the session ends. Given no --pr, it takes the branch the working directory is on and works the
pull request whose head is that branch — and where the branch has none, it pushes the branch and
opens one, since a branch carrying a draft and no pull request is a draft nobody has posted yet.
The product argument is optional: the draft the pull request's diff names carries it. Where the diff carries several drafts a selection screen comes first, and naming a product narrows it.
Four things it refuses instead, saying which: the branch is the repository's default branch; the tree holds no draft increment; the tree has uncommitted changes; and the head branch lives on a fork the local clone cannot push to.
The session opens on every draft the pull request carries, whatever statuses that draft's entries hold: a companion increment whose every decision is already delegated is paged through the same way as one nothing has ruled.
It holds two entry lists, one open at a time, and the rule between the header and the body names the open one and how many entries it holds. The decisions list carries every decision the draft holds, in whatever status, and every question still open — a draft is worked over several sittings, and a list holding only what is still open would hide the rulings the owner is deciding against. The requirements list carries the requirements the draft declares and its model bindings; an entry there takes a note and no ruling, since the process gives a requirement and a model binding no status to leave. A requirement's pane carries its rationale and its verification steps below its statement, so the whole of what the owner ratifies is in one place.
Each list ends with the foundations the draft retires in the source that list reads. A
retirement names a published foundation and nothing else, so the row shows that foundation's title
over its id with retired where a ruling stands, and its pane carries that foundation's own
statement, recovered from the fold at head, with the retirement's reason in the metadata. It takes
a note and no ruling, as a requirement does.
In either list an entry is marked where it closes another foundation — what it supersedes or amends — and marked differently where a later entry of the same draft closes it, so what a draft closes is legible without opening any of them.
A header spans the top naming the product, the increment directory, the branch, and the pull request, then the draft's changed inputs the list does not hold — schemas, surfaces, facts, evidence, and drafts, counted from the branch's merge-base with the head — and how many review threads are unresolved. It holds two rows whatever it carries, so the body below it sits at the same offset in every draft and on every entry.
The detail pane marks its edge — ⌄ at the bottom, ⌃ at the top — while it is holding content
back, and a page moves by the pane's own height and stops at the content's first and last row, so
every row of an entry's detail is reachable from within the session.
| key | what it does |
| ------------------- | -------------------------------------------------------------------------------- |
| j / k, arrows | move through the list; the pane follows |
| page up / down | page the detail pane, stopping at the content's first and last row |
| a t g r d | rule the selected decision accepted, tolerated, delegated, rejected, or deferred |
| enter | answer the selected question, then f/r/d for the route it takes |
| n | leave a note on the selected entry; it settles nothing and gates nothing |
| b | the bulk action: set every still-unruled decision to one status |
| tab | swap between the decisions list and the requirements list |
| w | write the staged rulings, commit, and push |
| l | land, offered once nothing is proposed and no question is open |
| q, ctrl-c | leave; a session abandoned before a write leaves the tree untouched |
A submit does not end the sitting: it returns the owner to the list it was made from with the staged set cleared of what it wrote, and the session ends when the owner ends it or a landing completes. The exit code is what the sitting was, not what a submit within it did.
A rejection is refused without the owner's reason. An answer routed to a requirement or a decision writes a placeholder entry into the draft carrying its generated id and the answer as its text, for the owner to state; a fact-routed answer closes the question and writes nothing. A deferred decision is settled for the purpose of landing: it is not proposed, and it does not hold the landing closed.
Cited ids — a because:, what an entry supersedes — are resolved against the product's entries
and the repo-wide facts pool and shown as the title with the id beside it; one that resolves to
nothing is shown as the id alone, since a dangling citation is a merge-gate finding rather than
the session's to report.
What a submit writes
Every field the session writes from text the owner typed — a rejection's reason, a routed
answer, the placeholder statement it writes — is stored as a YAML block scalar whatever its
length, so a colon, a leading dash, a #, or a quote in the owner's prose cannot break the file.
The write is an edit to the spans it ruled: every other byte stays where it was found, the
surrounding entries' scalar styles included, so the pull request's diff shows what the sitting
did.
The commit body names each status the set took and how many entries took it —
3 accepted, 1 rejected — counting answered questions as their own clause, and a sitting that
changed nothing writes no commit. After committing, the submit pushes the branch.
A push the remote refuses is not the end of it: the session fetches the branch's tip, unwinds its own unpushed commit, reapplies the sitting's rulings to the tip by entry id, and pushes again. An entry whose status differs at the tip is left as the tip has it and reported, its ruling leaving the stage; a commit the remote has accepted is never rewritten. A second refusal is reported and the sitting continues — the work sits committed on the local branch, and the next submit pushes the pending commit even when it stages nothing new, so nothing the owner entered is lost to a rejected push.
A submit carrying only notes writes no commit and posts its review against the branch as the remote already has it.
A submit carrying notes posts exactly one COMMENT review to the pull request, after the commit
and the push. Each note is a comment against the lines of the entry it concerns; a note the
diff does not reach goes into the review's body naming the entry. No submit approves — approving
stays the landing's own step, because an approval posted earlier is dismissed by the next push.
The review is the owner's, so it is what the owner's token is spent on: a GitHub personal access token typed at the terminal the first time a session posts something, with the input not echoed, held in memory for the rest of that session and nowhere else. Opening the pull request, pushing, and the merge use the credentials the environment already holds. A refused review — a missing token included — loses nothing: the refusal is shown in the session and the notes stay staged, posted by the next submit; a landing that completes with notes still unposted prints them, so they can be saved.
land
The landing sequence, non-interactive, for an agent or a script with no session to land from. The interactive command runs the same sequence. It refuses before any step runs while the draft carries a proposed decision or an open question, naming each.
The steps run in order and stop at the first that fails, reporting what to fix:
- apply — write any rulings the session staged
- conflicts — the
conflictscheck against the head,origin/mainthenmain - rename —
git mvthe wip directory into the number the head yields - check — the full design check, as the merge gate runs it
- commit — commit the landing on the branch
- push — push the branch
- open — open the pull request where the branch has none
- approve — approve the pull request as the owner
- auto-merge — set the pull request to merge once the gate is green
An increment is published by merging, and main admits a change only through a pull request, so
the landing opens one where the branch has none: after the push, since the remote must carry the
branch first, and before the approval. Where the branch already has a pull request the open step
is a no-op and says so; the landing never opens a second one. The title is
plan(<product>): land increment <NNN> [<NNN>] and the body one line —
Publishes increment <NNN> of <product>.
The approval follows the open, and both follow the push, because a push after an approval
dismisses it. The approving token is typed in when the sequence reaches the approval, with the
input not echoed, and is held in memory for that one request: nothing writes it to a file, to the
environment, or to a command line, and a second landing asks again. Push, open, and auto-merge
use the credentials the environment already holds. Where no token is given, the increment still
publishes and the approval and auto-merge report skipped.
The merge method comes from the repository, not from a default: the auto-merge step reads
allow_merge_commit, allow_squash_merge, and allow_rebase_merge and sets the one enabled,
preferring a merge commit, then a squash, then a rebase where more than one is. Where the
repository enables none, or cannot be asked, the pull request is reported approved and awaiting
a manual merge rather than set with a guessed method — as it is where the repository refuses
auto-merge outright.
backlog
Future work captured outside any increment. Items live on an orphan backlog branch, one file
per item at <product>/<id>.md, and every write commits to that branch and pushes it to the
remote — using git plumbing, so the working tree and the checked-out branch are untouched.
--no-push commits locally; --offline skips the fetch that refreshes the local view;
--remote <name> names the remote (default origin).
An item is free markdown. Its first heading is the title, and optional YAML frontmatter carries
tags and nothing else. add takes the body from stdin or --file (- for stdin); give
--title to head an untitled body, or begin the body with a heading and omit it.
list and search print one item per line — id, product, title, tags — with the product always
present. search matches ids, titles, and bodies case-insensitively. --tag repeats and
requires every tag named. --json emits id, product, title, and tags as structured data.
show prints one item's markdown, frontmatter stripped.
update revises an item in place, keeping its id: --title replaces the first heading,
--file replaces the body, --tag replaces the tag set outright while --add-tag and
--remove-tag adjust it, and --product moves the item to another product.
delete drops one or more items by id; an id the backlog does not hold fails the whole command
and writes nothing.
send copies the selected items into <increment-dir>/drafts/backlog/<id>.md and deletes them
from the branch in the same action, printing <id>\t<path> per item. The target is a
repo-relative products/<product>/increments/<name> — a draft increment's wip directory as
readily as a numbered increment. Select with --item (repeatable), --product, or --tag; at least one is
required.
$ design-process backlog add increment-process --title "index the backlog by tag" --tag tooling
b-sqdqsq1l
$ design-process backlog list
b-sqdqsq1l increment-process index the backlog by tag tooling
$ design-process backlog send products/increment-process/increments/wip-001-fold-cache --tag tooling
b-sqdqsq1l products/increment-process/increments/wip-001-fold-cache/drafts/backlog/b-sqdqsq1l.mdConcurrent writes
Two people writing the backlog at once race for the branch. The loser's push is rejected; the tool puts the local branch back, refetches the tip, re-applies the change over what the winner left, and pushes again — so neither item is lost and no manual recovery is needed. Four attempts are made by default. When they all fail the command exits non-zero, saying the branch moved, and the local branch is left exactly where it started.
Importing the package
The package ships one entry point. Its named exports are the operations behind the subcommands —
validateTree, projectProduct, projectProductData, generateIds, resolveFold, diffFolds,
findLandingConflicts, runIncrementSession, and the backlog operations — with the types their signatures name.
Everything else under src/ is implementation and moves freely between versions.
An increment names itself with IncrementRef = number | string: a published number, or a draft
increment's directory name. FoldedClaim, OutOfForce, AddedClaim, and ClosedClaim carry it,
and a Fold reports label — the last increment folded — beside drafts, the draft directory
names folded in ordinal order. at remains the published increment the fold is taken at.
import { diffFolds, renderFoldDiff, resolveFold } from '@twin-digital/design-process'
const from = resolveFold('.', 'increment-process', { kind: 'increment', number: 9 })
const to = resolveFold('.', 'increment-process')
process.stdout.write(renderFoldDiff(diffFolds('increment-process', from.fold, to.fold)))