Minor because two things you may now type were errors before. Compared by
name: the eleven subcommands are unchanged, flags.go's seventeen long flags
are unchanged, and the fragment file's five keys are unchanged — so nothing new
was invented. What changed is where existing spellings are accepted:
resume now takes --verify-cmd and --verify-timeout, which it previously
rejected with flag provided but not defined; and a use: inside a fragment
file, previously a load error, now resolves.
The headline is closure: a fragment may cite a fragment, so a loop can
contain a loop, bounded by a citation chain and a depth of three files. ADR 0027
opened the unit; this closes it — a graph of loops is a loop.
Two of this release's entries exist because someone was stranded by them. #198
was found by the maintainer's own auto --verify-cmd run hitting a session
limit, and #200 by the same person reaching for resume --help at the moment it
mattered. Both are in the release that also changed how this project dogfoods:
291 runs, 284 of them run and 7 auto — the path the README leads with was
the path we exercised least, and that is where both bugs lived.
Added
-
A node inside a fragment file may carry
use:/with:— a fragment may
cite a fragment (ADR 0029). A loop can now hold a loop, and the shape a
graph reuses no longer has to be the shape whose halves happen to be inline.
Resolution became recursive descent through one code path used at every
depth, so a nesteduse:is judged by exactly the rules a top-level one is:# graphs/fragments/gated-lane.yaml — the shipped instance fragment: gated-lane exit: pr nodes: - { id: dev, prompt: "{{ with.task }}", … } # inline: no shape to cite - { id: e2e, use: e2e-verify, depends_on: [dev] } - { id: review, use: review-style, depends_on: [e2e], feedback: { rerun: dev, max: 1 } } - { id: pr, use: pr-publish, depends_on: [review] }
$ oh-my-graph lint graphs/backlog-batch.yaml fragment: node "lane-a" spliced from "gated-lane" (…) — nodes: lane-a/dev, lane-a/e2e, lane-a/review, lane-a/pr — allowed_tools resolved from with: lane-a/dev: Read, Edit, Write, Bash(git *), Bash(go *), Bash(make *) fragment: node "lane-a/e2e" spliced from "e2e-verify" (…) fragment: node "lane-a/review" spliced from "review-style" (…) — node overrides: success_check fragment: node "lane-a/pr" spliced from "pr-publish" (…)
The rules, each a decision the ADR argues rather than an implementation
detail:- A cycle is a repeat on the CURRENT citation chain — a load error naming
the cycle in order (top → mid → back → top), charged to the file whose
use:line closes it, because that is the one a reader can delete. Chain
membership, not a global visited set: a diamond — two loops citing one
leaf, or one loop citing a leaf twice — is exactly what fragments are for
and stays legal. - The chain is bounded at 3 citation hops, checked before the cited file
is read, so a runaway arrives as a message rather than as a hang or a stack
overflow. Hops are fragment FILES, not id segments: three multi-node hops
mint a four-segment id, three single-node hops mint none, and an alias hop
spends the budget anyway because what is bounded is how far the loader
walks. The number is projected need (2) plus one level of headroom, and it
is deliberately small enough to be reachable — a bound of 16 could never
be shown to be wrong. - Namespacing composes left-to-right by the same join —
top+core→
top/core, then+ make→top/core/make— and decomposition stays
unique because an atom cannot contain the delimiter.nodeIDPattern's
(?:/segment)?became*; it is a widening of the backstop only, and it
needs no new authorship refusal, because each of the three existing ones
tests for the PRESENCE of a/and never for how many. exit:is transitive, so a loop whose exit is itself a loop still
exposes exactly one value from outside: its transitive exit's artifact.
depends_oninheritance chains the same way, one level at a time, and
cwd/worktreepropagate by value through every level.- Parameter pass-through works, and falls out of the resolution order
rather than being added to it. Each level namespaces its own file's text,
then substitutes its own bindings, then descends — so a value bound at
any using site is never id-rewritten by any level, at any depth. That is
ADR 0027's guarantee extended, not a hole in it. - A nested
use:name must be a literal.use: "{{ with.which }}"is a
load error: the chain, the cycle check and the bound are all decided before
the cited file is read, so a citation whose target came from a binding would
make which files a graph pulls behavior from depend on data. - A single-node fragment may not cite a multi-node one — its body splices
onto the citing node and declares no id, so there is no namespace to mint
<id>/<internal>in. Citing another single-node fragment is an alias and is
fine — except that an alias may not write its ownprompt:: it relays the
cited fragment's behavior, and one that rewrites the prompt is claiming that
fragment's name while replacing what it does. A single-node body cited from
inside a fragment has its tokens namespaced against the citing fragment's
declared ids, and a token naming an id that fragment does not declare is a
load error charged to the citing site. - A fragment file's own
use:is judged against the file. The literal-name
rule, theprompt:-alongside-use:refusal above, and a deadwith:(a
binding with nouse:to bind) are facts about the file, so all three are
reported once, against it, naming the file the text is written in — never at
splice time charged to whichever node happened to cite it. - Lookup stays a pure function of the ENTRY file's path at every depth, so
a fragment that cites a fragment depends on a file its own author cannot
ship with it. No manifest, no pre-flight check — what is owed instead is in
the message: an error below depth 1 names the chain, so a reader who
never wrotee2e-verifyis told which fragment did, and that the citing
use:is in a fragment file rather than in their graph. - One disclosure line per resolution, parent line first. A nested line's
node id is the already-namespaced id of the node that cited the fragment, so
the ids alone say the shape of the tree without opening a file.Spliced
names only ids that exist in the resolved graph, so a parent line
deliberately undercounts a subtree containing a nested loop — the lines
below it are what answer "how big did this get". A resolution also carries
itsDepth(the chain length), because the id's slash count is a different
quantity: a single-node hop mints no segment, so an alias chain two files
deep is a nested resolution with no slash in its id at all.
No snapshot, feed or ledger change, which was the ADR's load-bearing
claim and its own falsification condition.internal/runstate,
internal/runfeedandinternal/ledgerhold a node id as an opaque string,
andhandoff.SanitizeNodeIDis injective at any depth for exactly the reason
it was at one:/and~are both outside the atom charset, soa/b/c → a~b~c.outcollides with nothing. The only edit outside the loader's own
resolution path is one regex character. What moves outside is output: ids
grow segments and oneuse:now yields several disclosure lines.What it does not claim: adoption. Nesting is a precondition for the
conversion below, and nothing measured says it is what unblocks the operator
lanes — see the retraction in the ADR entry further down. The falsification
condition is now a test rather than a paragraph
(TestNestingHasAShippedAdopter): if no shipped graph carries a chain of
length 2, the build fails and the ADR says to reopen. It asks the resolution's
Depthrather than counting slashes in its node id, so it measures the
quantity it names. - A cycle is a repeat on the CURRENT citation chain — a load error naming
-
ADR 0029 — a fragment may cite a fragment, bounded by a chain and a depth.
Opens the nesting non-goal ADR 0027 deferred, and settles what the deferral
named as its price: cycle detection over resolution (a repeat on the
current chain, not a global visit, so a diamond stays legal), a depth bound
of 3 citation hops with the argument for the number (projected need 2,
one level of headroom, deliberately falsifiable), and the composition rule
for namespacing an already-namespaced id. It also settles what the first
draft left open: the top-down resolution order and parameter pass-through,
the lookup rule at depth (entry-file-relative, and the file dependency a
citing fragment cannot declare), what the run's disclosure says about
Grantsand in what order, and what a single-node fragment's tokens mean
when a multi-node fragment cites it. Implemented in the same release —
see the two entries above.The ADR records that ADR 0027's registered falsification condition fired, and
— after review — retracts its own first explanation of why. The 28
operator lanes cite norepair-roundbecauserepair-round.yamlis not in
thefragments/directory they resolve against, souse: repair-roundis a
load error for every one of them; those same lanes carry 28use:
citations across 17 files, naming every fragment that is there. Neither
"nesting is the blocker" nor "the tool grants are the blocker" is measured;
the grant mismatch is real but untested, and the experiment that would settle
it is one file copy. Numbers, definitions and an asserting script:
docs/measurements/0029-repair-round-was-never-in-reach-of-the-lanes.md.
Changed
-
backlog-batch's lane A is one node citing a nested loop. Four nodes
(dev-a,e2e-a,review-a,pr-a) became oneuse: gated-lane, which
spliceslane-a/dev,lane-a/e2e,lane-a/review,lane-a/pr— the middle
three by citinge2e-verify,review-styleandpr-publishin turn. This is
the adopter ADR 0029 requires rather than offers, and lane A was the hardest
lane to convert precisely because three of its four nodes were already
citations.The gate PAIR moved into the fragment together — a narrowed review
success_checkand afeedback:arc back to the implementer are one
mechanism, and both now ride on an internal node citing a single-node
fragment, where ADR 0027 already allows both keys. The two paragraphs
explaining why each key is there moved with them. The implementer's tool grant
became a substitution point, because a grant is the knob lanes vary most and a
multi-nodeuse:may declare wiring only; PR #197 made a grant arriving
through a binding visible in the run log, which is what makes parameterizing
it honest rather than hidden.Lane B did not convert, and that is a finding rather than an omission. It
ADVISES where lane A gates, and the difference is the presence of two keys,
which no substitution point can bind into or out of existence — so one
fragment cannot serve both dispositions. The graph's header says so as rule 7.
The shipped pair (self-dev,dev-review-pr) is not converted either: ADR
0013's equivalence freeze is keyed on node id, the splice renames all four
masked nodes, and no mask entry can express a break whose cause is the key.
That evidence is one-time and impossible to reproduce, and a demonstration is
not worth spending it.Blast radius grows a hop: an edit to
e2e-verifynow moves a node in
backlog-batch, which never names it. The goldens under
internal/graph/testdata/golden/still put that in a reviewer's diff — two
files away from the file that changed.
Fixed
-
A subcommand's positional run-id slot no longer swallows a flag (#200).
resume --helpread--helpas the run id and reported a run that does not
exist, and every sibling that takes a positional before its flags did the
same:run,auto,lint,init,runs,show,watchandserve. The
worst of them had a filesystem effect —init --helpcreated a directory
named--help. One shared rule now decides slot 0 for all nine, resting on a
fact the CLI already guaranteed and a test now pins: a minted run id never
begins with a dash, so a dash there can never be a value.-h/-help/
--helpin the slot prints that subcommand's usage — the sameusageLines
synopsis the existing usage guards pin, plus its flag descriptions where it
has a FlagSet — on stdout, exit 0, because help is not a failure; any other
flag there is a named flag error rather than a value. Nothing else moved: a
valid run id, graph path, goal or directory takes the identical path, no flag
was registered or removed, and the error and exit code for a missing or
unknown dash-free run id are unchanged.runsintercepts only the help token,
soruns --purgekeeps its ownunknown subcommand "--purge" (want list).
Two neighbouring slots are untouched, each answering differently: help typed
AFTER the positional (resume <id> --help) still gets the flag package's own
defaults on stderr, exit 1, and top-leveloh-my-graph --help— which never
reaches a FlagSet, there being none before the subcommand — still answers
unknown command "--help"on stderr, exit 1. Both need their own fix. -
resumelearns--verify-cmd/--verify-timeout, so a run that paused on
a session limit can actually be resumed (#198, ADR 0016 §4 amended). A run
started withauto "<goal>" --verify-cmd '…'and paused on a session limit
could not be continued at all, and the tool said otherwise:$ oh-my-graph resume <id> --retry-failed … the saved graph carries success_check.verify on node(s) report, which auto mode never accepts from a run directory; RE-SUPPLY IT WITH --verify-cmd … $ oh-my-graph resume <id> --retry-failed --verify-cmd '…' flag provided but not defined: -verify-cmd
The refusal itself is the design — a resumed leg takes no engine-run shell
from a run directory, since a planned node holds bareWrite/Editand could
write one there — but its remedy named a flag onlyautoregistered. ADR 0016
§4 recorded that gap as a debt ("the flag lane owesresumethe same two
flags"), not as an exclusion, so this ships the half that was owed.What that cost was worth naming: ADR 0009's claim is that a session limit is a
pause, with the work banked for a later leg. For any auto run following ADR
0016's own advice that promise was not kept, and the user learned it only after
following an instruction that could not work.resumenow takes the same pairautodoes, and the ceiling is unchanged by
construction rather than by intention: the sameVerifyCommandvalue object
(one blank-command refusal, one 10-minute ceiling, pinned by a test that parses
the same flags through both subcommands and requires identical refusals), the
same trusted-code attachment at the same sinks after the same command
validation and the same graph re-parse, the
same run-wide serialization, the same engine-judged exit code, and no node
granted anything. There is deliberately no path onlyresumehas:run
has no--verify-cmd, soresume --verify-cmdagainst a hand-written
snapshot is an error rather than an attachment. A resume that supplies nothing
while the snapshot carried a verification is still refused — with a message
that now names a flag the command accepts, checked by a test againstresume's
own FlagSet. Pause hints for such a run print the pair back with the command in
it — POSIX-quoted, and carrying--verify-timeoutwhenever the bound is not
the default — so the copy-pasteable resume ADR 0009 promises stays
copy-pasteable, and pasting it runs what it says.
Added
-
The splice disclosure names a tool grant that was assembled across two
files (#196, ADR 0013 amended).FragmentResolution's own doc comment
states the principle — "a hollowed-outsuccess_checkor a widened
allowed_toolsis announced at every run, not only visible to whoever reads
the file" — and names two shapes: a key the using node overrides, and (ADR
0027) the ids a multi-node splice minted. A third escaped both. A fragment
may declare a substitution point inside its own grant:# fragments/tools.yaml substitutions: [extra] node: allowed_tools: [Read, "{{ with.extra }}"]
- { id: x, use: tools, with: { extra: "Bash(go *)" } }This loads clean today and the token really is substituted. It is not an
override — the citing node declares only wiring, exactly as the design
requires — so the override list was empty and nothing was announced. The
fragment file showed a slot, the citing graph showed a value, and the run log
showed neither: the one grant that needed two files to read was the one no run
could show.The line now carries the resolved grant, in both fragment forms:
fragment: node "x" spliced from "tools" (graphs/fragments/tools.yaml) — a parameterized gate — allowed_tools resolved from with: Read, Bash(go *) fragment: node "x" spliced from "lanes" (graphs/fragments/lanes.yaml) — two lanes — nodes: x/build, x/review — allowed_tools resolved from with: x/build: Read, Bash(go *)The multi-node form qualifies each grant by its minted id, because "which of
the five" is the question there; a splice that parameterized SEVERAL nodes'
grants gives each its own indented line instead, because inline they would
nest,inside;and the boundary between two nodes' grants would be the
weaker separator of the two:fragment: node "x" spliced from "lanes" (graphs/fragments/lanes.yaml) — two lanes — nodes: x/build, x/review allowed_tools resolved from with: x/build: Read, Bash(go *) x/review: Read, WriteWhat the line names is what the node runs with, down to the empty cases,
where YAML's null and its empty string part company: an element bound null is
dropped by the decode and so is dropped from the line, an element bound to
""is kept by both, and a whole grant bound null prints(none)— the node
has no grant. Read as text those look identical, and announcing one as the
other is the drift this clause exists to prevent.Two bounds keep it readable. It fires only
when substitution CONTRIBUTED to the field — a grant written verbatim in the
fragment is already readable in one file, and a line per grant per spliced
node is one nobody reads (the failure the Codex disclosure work documented:
one line per difference and no more). And the judgment is a before/after
comparison of that one field, never a scan of the fragment source for{{: a
token can arrive through a nested structure, and a whole-list binding
(allowed_tools: "{{ with.grant }}") replaces the field's type as well as its
text, so a source scan would drift from what substitution actually did.Disclosure only — nothing that loaded before now refuses. The two other
options the issue recorded (refuse awith:token insideallowed_tools;
amend the comment to claim less) were both declined. A grant the citing node
overrides keeps being announced as the override it is and adds no second
clause: the substituted one was discarded by the overlay, so naming it would
name a grant no node runs with. -
lint/run --dry-runwarn when a feedback loop's repair node never
quotes the feedback (ADR 0028).feedback: { rerun: R }and
{{ feedback.<declarer> }}are two halves of one mechanism, and until now
either half loaded clean alone: the arc's topology is validated, a token that
IS written is validated, the arc's aim is swept for — nothing asked whether
both halves are present. Run20260816-163759.091162000-1is what that costs.
A two-node loop declared its arc correctly, its build prompt said "if a
FEEDBACK section appears below, write alpha", and no token existed to put one
there. The engine wrote the payload, re-ran the build, got identical output,
and the check failed identically. Twice the money, one round's worth of
information, ledger readingfeedback round 1/1, andlintsilent.handoff.LintFeedbackQuotingis the sixth advisory sweep in that package,
wired into the onewarnAdvisorieshelperlintandrun --dry-runshare.
The rule: for every nodeDdeclaringfeedback: { rerun: R }, if no node in
the loop body other thanDitself quotes{{ feedback.D }}in its prompt,
warn — onR, namingD, becauseR's prompt is where the missing line
goes. A middle body node counts (build → refine → checkquoted atrefine
really does repair); the declarer's own quote does not (it is the judge, so
its re-run repairs nothing). Only prompts are read: a payload on a verify
command line is the fifth sweep's finding, and one in acwdis a path.
Matching uses the runtime's own placeholder pattern, so the sweep holds after
fragment splicing — the specimen's real token was
{{ feedback.qa-a/check }}, a namespaced id the loader wrote — and both
shapes are tested. Advisory for a hand-written graph, never a load error: an
absent token has one legitimate reading (a loop that repairs from the
repository rather than from the reply), where the misplaced token ADR 0010
made an error has none. -
Auto mode refuses a planned feedback arc nothing in its loop body quotes
(ADR 0028 §5). The planner is asked for both halves in one prompt sentence —
declare the arc on the reviewing node, and have the implementing node's prompt
read{{ feedback.<reviewing-node-id> }}— and until now only the arc half was
machine-checked (coordinator.validatePlannedNodesconstrains a planned
feedback:; it never refused one). So the blind loop's worst instance, the one
with no author to read a warning, was the case left uncovered.
coordinator.validatePlannedFeedbackQuotingescalates the sweep to a plan
refusal the same wayvalidatePlannedFeedbackReachescalates
graph.LintFeedbackReach, reading the same predicate rather than re-deciding
it. A refused plan buys one corrected re-plan carrying the refusal's text, and
the correction — one placeholder, empty on the first pass — is harmless even
when the refusal is wrong, which is why this one needs no
only-when-actionable weakening.It was measured before it shipped, and it has a control. Over the shipped
graphs/*.yaml(8 graphs, 2 declarers), a 26-lane operator corpus (2
declarers) and 288 local run snapshots deduplicated to 201 distinct resolved
graphs (11 declarers): 3 hits, all 3 real, 0 noise — with the caveat
attached to the number rather than to a later paragraph: the 3 are the three
lanes of one specimen graph in one run, so the precision evidence is one
distinct defective graph, not three independent ones. The same corpus holds
that graph's repair, three minutes later, and the sweep is correctly silent on
it. Of the 11 run-corpus declarers, 3 were planner-authored (auto mode'sgraph.json)
and all 3 quoted the payload correctly — the escalation above guards a shape
the planner can write, not one it has been measured getting wrong. No shipped
graph fires; nothing ingraphs/needed fixing, and a test now walks
graphs/*.yamlso that stays true. Full method, every number asserted rather
than reported:
docs/measurements/0028-feedback-quote-corpus.md.No runtime behaviour changed: feedback's semantics, payload file, round
accounting and exit codes are untouched. The auto-mode refusal changes what
autoaccepts as a plan, not how a graph runs — and note the reach of the
advisory half:lintandrun --dry-runprint it, a plainrundoes not, so
an operator who does not lint first still pays for a blind loop.
Fixed
-
run --dry-runprints the fragment disclosure at all. It shared the
advisory channel withlintandrunbut never called
printFragmentResolutions, so the one command a reader uses to check what a
graph WILL do before paying for it was the one that did not say which
fragments it spliced, from which files, or with which overrides. Same defect
class as #185's half-wired warning, and the reason the grant clause above is
tested at all three call sites rather than at the one that happened to be
found. -
A repair prompt no longer loses whole refusals to a silent cut. Auto mode
hands a refused plan's refusals back to one corrected planner call, quoted into
a fenced section bounded bymaxIssuesInPrompt. That bound was applied by
head-only truncation of the joined list, which on an over-long list left the
last refusal it kept ending mid-sentence and dropped every later one with no
trace — so the planner answered a prompt that never stated part of the fault,
the corrected reply re-committed it, and the plan the user paid for was gone.
The list is now packed in WHOLE refusals with the dropped count stated in the
prompt, and the budget is sized (3000, from 2000) rather than picked.The fault was reachable because two refusal families are graph-level and both
scale with the number of faulty arcs: a mis-aimed arc and a blind one can be
the same arc (ADR 0028 §Failure modes), and two such declarers rendered 2541
bytes into a 2000-byte budget before a single per-node refusal joined them.
coordinator.validatePlannedFeedbackQuotingnow compacts every blind arc into
one sentence naming each pair — four arcs cost 762 bytes instead of 2368 —
keeping the shared ~530-byte diagnosis out of the repeat, which brings the
same two declarers to 1998. Every one of those figures is measured on one
fixture and pinned by
TestGraphLevelRefusalFamiliesRenderTheirMeasuredSize, so a reworded refusal
fails a test instead of leaving a comment quietly false. -
{{ feedback.D | inline }}no longer counts as quoting a feedback payload.
handoff.LintFeedbackQuotingread the placeholder pattern's kind and reference
and ignored its filter group, where the runtime refuses a filtered feedback
token outright (graph.Validateat load,Handoff.Interpolateat run). No
graph that can be loaded today reaches it — both callers sweep a parsed
graph — so nothing observable changes; the guard and its test exist so the one
case where the sweep and the runtime could disagree cannot open up quietly. -
A release's page can no longer come out blank
(#193). v0.9.0 published
with an empty body — one newline — while every step reported success and the
artifacts uploaded fine. The notes file was built correctly (the same script
produces 143 lines under the runner's own ubuntu/dash/mawk, reproduced in a
container), so goreleaser's--release-noteswas not doing what it says
alongsidechangelog.disable. The body is no longer goreleaser's job:
gh release editsets it from the same file, and the step then reads it
back and fails under 200 bytes, because the failure that already happened
was a green workflow over an unreadable release.
Changed
- The "did you write a changelog entry?" check moved from a Go test on
main
to a CI job on the pull request
(#194). The test asked the
right question with the wrong trigger: it readgit log <lastTag>..HEAD, so a
PR's own merge commit did not exist while its CI was green and did exist the
instant it landed — every merge turnedmainred until somebody wrote the
entry afterwards. It caught five genuinely missing entries in v0.9.0, two of
them user-visible fixes, and then cost five round trips to its own timing.
Asked on the PR instead, it is answerable before the merge by the person who
knows what changed. Skipping stays allowed and stays loud:no-changelogin
the PR body.