Skip to content

v0.10.0

Latest

Choose a tag to compare

@github-actions github-actions released this 19 Aug 00:40
· 2 commits to main since this release
56e64fb

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 nested use: 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 jointop + core
      top/core, then + maketop/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_on inheritance chains the same way, one level at a time, and
      cwd/worktree propagate 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 own prompt:: 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, the prompt:-alongside-use: refusal above, and a dead with: (a
      binding with no use: 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 wrote e2e-verify is 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
      its Depth (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/runfeed and internal/ledger hold a node id as an opaque string,
    and handoff.SanitizeNodeID is injective at any depth for exactly the reason
    it was at one: / and ~ are both outside the atom charset, so a/b/c → a~b~c.out collides 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 one use: 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
    Depth rather than counting slashes in its node id, so it measures the
    quantity it names.

  • 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
    Grants and 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 no repair-round because repair-round.yaml is not in
    the fragments/ directory they resolve against, so use: repair-round is a
    load error for every one of them; those same lanes carry 28 use:
    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 one use: gated-lane, which
    splices lane-a/dev, lane-a/e2e, lane-a/review, lane-a/pr — the middle
    three by citing e2e-verify, review-style and pr-publish in 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_check and a feedback: 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-node use: 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-verify now 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 --help read --help as 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, watch and serve. The
    worst of them had a filesystem effect — init --help created 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/
    --help in the slot prints that subcommand's usage — the same usageLines
    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. runs intercepts only the help token,
    so runs --purge keeps its own unknown 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-level oh-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.

  • resume learns --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 with auto "<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 bare Write/Edit and could
    write one there — but its remedy named a flag only auto registered. ADR 0016
    §4 recorded that gap as a debt ("the flag lane owes resume the 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.

    resume now takes the same pair auto does, and the ceiling is unchanged by
    construction rather than by intention: the same VerifyCommand value 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 only resume has: run
    has no --verify-cmd, so resume --verify-cmd against 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 against resume's
    own FlagSet. Pause hints for such a run print the pair back with the command in
    it — POSIX-quoted, and carrying --verify-timeout whenever 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-out success_check or a widened
    allowed_tools is 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, Write
    

    What 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 a with: token inside allowed_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-run warn 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. Run 20260816-163759.091162000-1 is 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 reading feedback round 1/1, and lint silent.

    handoff.LintFeedbackQuoting is the sixth advisory sweep in that package,
    wired into the one warnAdvisories helper lint and run --dry-run share.
    The rule: for every node D declaring feedback: { rerun: R }, if no node in
    the loop body other than D itself quotes {{ feedback.D }} in its prompt,
    warn — on R, naming D, because R's prompt is where the missing line
    goes. A middle body node counts (build → refine → check quoted at refine
    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 a cwd is 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.validatePlannedNodes constrains 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.validatePlannedFeedbackQuoting escalates the sweep to a plan
    refusal the same way validatePlannedFeedbackReach escalates
    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's graph.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 in graphs/ needed fixing, and a test now walks
    graphs/*.yaml so 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
    auto accepts as a plan, not how a graph runs — and note the reach of the
    advisory half: lint and run --dry-run print it, a plain run does not, so
    an operator who does not lint first still pays for a blind loop.

Fixed

  • run --dry-run prints the fragment disclosure at all. It shared the
    advisory channel with lint and run but 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 by maxIssuesInPrompt. 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.validatePlannedFeedbackQuoting now 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.LintFeedbackQuoting read the placeholder pattern's kind and reference
    and ignored its filter group, where the runtime refuses a filtered feedback
    token outright (graph.Validate at load, Handoff.Interpolate at 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-notes was not doing what it says
    alongside changelog.disable. The body is no longer goreleaser's job:
    gh release edit sets 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 read git 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 turned main red 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-changelog in
    the PR body.